Files
dmf-studio-ui/src/components/nodes/StudioNode.vue
T
2026-07-09 01:27:35 +03:00

351 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue'
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import type { NodeProps } from '@vue-flow/core'
import type { StudioNodeData } from '../../types'
import { NODE_TYPES } from '../../nodeTypes'
const props = defineProps<NodeProps<StudioNodeData>>()
const { removeNodes } = useVueFlow()
const typeDef = computed(() => NODE_TYPES[props.data.nodeType] ?? null)
const inputs = computed(() => typeDef.value?.ports.filter(p => p.direction === 'in') ?? [])
const outputs = computed(() => typeDef.value?.ports.filter(p => p.direction === 'out') ?? [])
const isSource = computed(() => outputs.value.length > 0)
const iconMap: Record<string, string> = {
testpattern: '▦',
ndiin: '◬',
decklinkin: '◧',
videoin: '▣',
ndiout: '◬',
decklinkout: '◧',
fakesink: '◯',
}
const nodeIcon = computed(() => iconMap[props.data.nodeType] ?? '◯')
const stateText = computed(() => {
switch (props.data.serverState) {
case 'running': return 'RUN'
case 'stopped': return 'STOP'
case 'crashed': return 'ERR'
default: return ''
}
})
const HEADER_H = 38
const PORT_H = 28
function handleTopPx(index: number): string {
return `${HEADER_H + PORT_H * index + PORT_H / 2}px`
}
function setParam(key: string, value: string | number) {
props.data.params[key] = value
}
const handleFileChange = (key: string, event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const file = target.files[0].name
const basePath = '/home/itten/test-vid/'
setParam(key, basePath + file)
}
}
function onDeleteNode() {
removeNodes([props.id])
}
</script>
<template>
<div
class="studio-node"
:class="{
'node-running': data.serverState === 'running',
'node-crashed': data.serverState === 'crashed',
}"
>
<!-- Input handles -->
<Handle
v-for="(port, i) in inputs"
:key="port.id"
:id="port.id"
type="target"
:position="Position.Left"
:class="`handle-${port.kind}`"
:style="{ top: handleTopPx(i) }"
/>
<!-- Header -->
<div class="node-header">
<span class="node-icon">{{ nodeIcon }}</span>
<span class="node-label">{{ typeDef?.label ?? data.nodeType }}</span>
<span v-if="stateText" :class="['state', `state-${data.serverState}`]">{{ stateText }}</span>
<button class="node-close" @click.stop="onDeleteNode">×</button>
</div>
<!-- Ports -->
<div v-if="inputs.length || outputs.length" class="node-ports">
<div class="ports-side ports-left">
<div v-for="port in inputs" :key="port.id" class="port-row">
<span :class="['port-name', `port-${port.kind}`]">{{ port.kind }}</span>
</div>
</div>
<div class="ports-side ports-right">
<div v-for="port in outputs" :key="port.id" class="port-row port-row-right">
<span :class="['port-name', `port-${port.kind}`]">{{ port.kind }}</span>
</div>
</div>
</div>
<!-- Params -->
<div v-if="typeDef && typeDef.params.length" class="node-body">
<div v-for="param in typeDef.params" :key="param.key" class="param-row">
<label class="param-label">{{ param.label.toUpperCase() }}</label>
<select
v-if="param.type === 'select'"
class="param-input"
:value="data.params[param.key] ?? param.default"
@change="setParam(param.key, ($event.target as HTMLSelectElement).value)"
>
<option v-for="opt in param.options" :key="String(opt.value)" :value="opt.value">
{{ opt.label }}
</option>
</select>
<input
v-else-if="param.type === 'number'"
type="number"
class="param-input"
:value="data.params[param.key] ?? param.default"
:min="param.min"
:max="param.max"
@change="setParam(param.key, Number(($event.target as HTMLInputElement).value))"
/>
<input
v-else-if="param.type === 'input'"
type="file"
class="param-input-file"
@change="handleFileChange(param.key, $event)"
/>
</div>
</div>
<!-- Footer -->
<div class="node-id">{{ id }}</div>
<!-- Output handles -->
<Handle
v-for="(port, i) in outputs"
:key="port.id"
:id="port.id"
type="source"
:position="Position.Right"
:class="`handle-${port.kind}`"
:style="{ top: handleTopPx(i) }"
/>
</div>
</template>
<style scoped>
.studio-node {
background: var(--bg-panel-1);
border: 1px solid var(--border-thin);
border-radius: 0;
min-width: var(--node-min-width);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-primary);
box-shadow: none;
transition: border-color var(--dur-base) var(--ease);
position: relative;
overflow: visible;
}
.node-running {
border-color: var(--text-primary);
}
.node-crashed {
border-color: var(--accent);
}
/* ── Header ── */
.node-header {
display: flex;
align-items: center;
gap: 8px;
height: var(--node-header-h);
padding: 0 10px;
border-bottom: 1px solid var(--border-thin);
}
.node-icon {
font-size: 13px;
color: var(--text-muted);
}
.node-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 1px;
color: var(--text-primary);
text-transform: uppercase;
flex: 1;
}
.state {
font-size: 8px;
font-weight: 500;
letter-spacing: 1px;
padding: 1px 5px;
border: 1px solid currentColor;
}
.state-running { color: var(--text-primary); }
.state-stopped { color: var(--text-muted); }
.state-crashed { color: var(--accent); }
.node-close {
background: transparent;
border: 1px solid var(--border-thin);
color: var(--text-muted);
width: 18px;
height: 18px;
font-size: 12px;
cursor: pointer;
border-radius: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: border-color var(--dur-fast), color var(--dur-fast);
}
.node-close:hover {
border-color: var(--accent);
color: var(--accent);
}
/* ── Port rows ── */
.node-ports {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--border-thin);
}
.ports-side {
display: flex;
flex-direction: column;
}
.port-row {
display: flex;
align-items: center;
height: var(--node-port-h);
padding: 0 10px;
}
.port-row-right {
flex-direction: row-reverse;
}
.port-name {
font-size: 9px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-secondary);
}
.port-video { color: var(--text-primary); }
.port-audio {
color: var(--text-secondary);
opacity: 0.7;
}
/* ── Params ── */
.node-body {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
border-bottom: 1px solid var(--border-thin);
background: var(--bg-base);
}
.param-row {
display: flex;
align-items: center;
gap: 6px;
}
.param-label {
color: var(--text-muted);
min-width: 68px;
font-size: 8px;
font-weight: 400;
letter-spacing: 1px;
flex-shrink: 0;
}
.param-input {
flex: 1;
background: var(--bg-base);
border: 1px solid var(--border-thin);
border-radius: 0;
color: var(--text-primary);
font-size: 10px;
font-family: var(--font-mono);
padding: 3px 6px;
outline: none;
cursor: pointer;
min-width: 0;
}
.param-input:focus {
border-color: var(--accent);
}
.param-input option {
background: var(--bg-panel-1);
}
.param-input-file {
flex: 1;
background: var(--bg-base);
border: 1px solid var(--border-thin);
border-radius: 0;
color: var(--text-primary);
font-size: 9px;
font-family: var(--font-mono);
padding: 2px 4px;
outline: none;
cursor: pointer;
max-width: 100px;
}
/* ── Footer ── */
.node-id {
padding: 4px 10px;
font-size: 8px;
color: var(--text-dim);
letter-spacing: 0.5px;
}
/* ── Handles — circular sockets ── */
:deep(.handle-video) {
background: var(--text-primary) !important;
border-color: var(--bg-base) !important;
width: var(--handle-size) !important;
height: var(--handle-size) !important;
}
:deep(.handle-audio) {
background: transparent !important;
border: 1px solid var(--text-secondary) !important;
width: var(--handle-size) !important;
height: var(--handle-size) !important;
}
</style>