frontend almost work
This commit is contained in:
+15
-4
@@ -10,7 +10,7 @@ import NodePalette from './components/NodePalette.vue'
|
||||
import StudioNode from './components/nodes/StudioNode.vue'
|
||||
import { useStudioStore } from './stores/studio'
|
||||
import { buildGraph } from './graphBuilder'
|
||||
import { defaultParams } from './nodeTypes'
|
||||
import { defaultParams, NODE_TYPES } from './nodeTypes'
|
||||
import type { StudioNodeData } from './types'
|
||||
|
||||
const FLOW_ID = 'dmf-studio'
|
||||
@@ -23,6 +23,16 @@ const nodes = ref<Node[]>([])
|
||||
const edges = ref<Edge[]>([])
|
||||
const flowWrapperRef = ref<HTMLElement>()
|
||||
|
||||
function portKind(nodeId: string, handleId: string): string | undefined {
|
||||
for (const n of nodes.value) {
|
||||
if (n.id === nodeId) {
|
||||
const def = NODE_TYPES[(n.data as StudioNodeData).nodeType]
|
||||
return def?.ports.find(p => p.id === handleId)?.kind
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
let nodeCounter = 0
|
||||
|
||||
watch(
|
||||
@@ -85,12 +95,13 @@ function addNode(nodeType: string) {
|
||||
function onConnect(connection: Connection) {
|
||||
const srcHandle = connection.sourceHandle ?? ''
|
||||
const tgtHandle = connection.targetHandle ?? ''
|
||||
if (srcHandle.startsWith('video') !== tgtHandle.startsWith('video')) return
|
||||
if (!srcHandle.includes('-out') || !tgtHandle.includes('-in')) return
|
||||
const kind = srcHandle.startsWith('video') ? 'video' : 'audio'
|
||||
const srcKind = portKind(connection.source, srcHandle)
|
||||
const tgtKind = portKind(connection.target, tgtHandle)
|
||||
if (!srcKind || !tgtKind || srcKind !== tgtKind) return
|
||||
edges.value = [
|
||||
...edges.value,
|
||||
{ ...connection, id: `e-${Date.now()}`, animated: true, class: `edge-${kind}` } as Edge,
|
||||
{ ...connection, id: `e-${Date.now()}`, animated: true, class: `edge-${srcKind}` } as Edge,
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ interface PaletteGroup {
|
||||
|
||||
const groups: PaletteGroup[] = [
|
||||
{ label: 'SOURCES', types: ['testpattern', 'ndiin', 'decklinkin', 'videoin'] },
|
||||
{ label: 'PROCESS', types: ['mix'] },
|
||||
{ label: 'PROCESS', types: ['mix', 'pip', 'gaindb'] },
|
||||
{ label: 'SINKS', types: ['ndiout', 'decklinkout', 'fakesink'] },
|
||||
]
|
||||
|
||||
@@ -25,6 +25,8 @@ const iconMap: Record<string, string> = {
|
||||
decklinkout: '◧',
|
||||
fakesink: '◯',
|
||||
mix: '⊕',
|
||||
pip: '⊞',
|
||||
gaindb: '◎',
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, nodeType: string) {
|
||||
|
||||
@@ -26,6 +26,8 @@ const iconMap: Record<string, string> = {
|
||||
decklinkout: '◧',
|
||||
fakesink: '◯',
|
||||
mix: '⊕',
|
||||
pip: '⊞',
|
||||
gaindb: '◎',
|
||||
}
|
||||
|
||||
const nodeIcon = computed(() => iconMap[props.data.nodeType] ?? '◯')
|
||||
@@ -107,34 +109,43 @@ function onDeleteNode() {
|
||||
|
||||
<!-- 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)"
|
||||
<template v-for="(param, i) in typeDef.params" :key="param.key">
|
||||
<div
|
||||
v-if="param.group && (i === 0 || typeDef.params[i - 1].group !== param.group)"
|
||||
class="param-group"
|
||||
>
|
||||
<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>
|
||||
<span class="param-group-label">{{ param.group }}</span>
|
||||
<span class="param-group-line"></span>
|
||||
</div>
|
||||
<div 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>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -285,6 +296,31 @@ function onDeleteNode() {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.param-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.param-group:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.param-group-label {
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.param-group-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-thin);
|
||||
}
|
||||
|
||||
.param-label {
|
||||
color: var(--text-muted);
|
||||
min-width: 68px;
|
||||
|
||||
+28
-6
@@ -23,11 +23,26 @@ function parseRes(res: string): { width: number; height: number } {
|
||||
return { width: w || 1920, height: h || 1080 }
|
||||
}
|
||||
|
||||
// Looks up the port kind from the node type definition
|
||||
function portKindFor(node: Node | undefined, handleId: string): string | undefined {
|
||||
if (!node) return undefined
|
||||
const def = NODE_TYPES[(node.data as StudioNodeData).nodeType]
|
||||
return def?.ports.find(p => p.id === handleId)?.kind
|
||||
}
|
||||
|
||||
// Looks up the backend port key from the node type definition, falls back to handleToPort
|
||||
function portKeyFor(node: Node | undefined, handleId: string): string {
|
||||
if (node) {
|
||||
const def = NODE_TYPES[(node.data as StudioNodeData).nodeType]
|
||||
const portKey = def?.ports.find(p => p.id === handleId)?.portKey
|
||||
if (portKey) return portKey
|
||||
}
|
||||
return handleToPort(handleId)
|
||||
}
|
||||
|
||||
// Converts a Vue Flow handle id to the NODE_CONFIG port key used in graph JSON
|
||||
function handleToPort(handle: string): string {
|
||||
return handle.replace(/-(?:in|out)(?:-\d+)?$/, '_flow_id')
|
||||
// 'video-out' → 'video_flow_id', 'audio-in' → 'audio_flow_id'
|
||||
// 'video-in-1' → 'video_flow_id', 'audio-in-2' → 'audio_flow_id'
|
||||
}
|
||||
|
||||
export function buildGraph(vfNodes: Node[], vfEdges: Edge[]): GraphJson {
|
||||
@@ -49,10 +64,12 @@ export function buildGraph(vfNodes: Node[], vfEdges: Edge[]): GraphJson {
|
||||
})
|
||||
|
||||
const edges = vfEdges.map((e) => {
|
||||
const srcNode = vfNodes.find(n => n.id === e.source)
|
||||
const tgtNode = vfNodes.find(n => n.id === e.target)
|
||||
const srcData = nodeMap.get(e.source)
|
||||
const srcHandle = e.sourceHandle ?? 'video-out'
|
||||
const tgtHandle = e.targetHandle ?? 'video-in'
|
||||
const kind = srcHandle.startsWith('video') ? 'video' : 'audio'
|
||||
const kind = portKindFor(srcNode, srcHandle) ?? 'video'
|
||||
|
||||
let format: Record<string, unknown> = { kind }
|
||||
if (kind === 'video') {
|
||||
@@ -60,12 +77,17 @@ export function buildGraph(vfNodes: Node[], vfEdges: Edge[]): GraphJson {
|
||||
const { fps_num, fps_den } = parseFps(String(srcData?.params.fps ?? '25'))
|
||||
format = { kind, width, height, fps_num, fps_den }
|
||||
} else {
|
||||
format = { kind, sample_rate: 48000, channels: 2, bit_depth: 32 }
|
||||
format = {
|
||||
kind,
|
||||
sample_rate: Number(srcData?.params.sample_rate ?? 48000),
|
||||
channels: Number(srcData?.params.channels ?? 2),
|
||||
bit_depth: 32,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: e.source, from_port: handleToPort(srcHandle),
|
||||
to: e.target, to_port: handleToPort(tgtHandle),
|
||||
from: e.source, from_port: portKeyFor(srcNode, srcHandle),
|
||||
to: e.target, to_port: portKeyFor(tgtNode, tgtHandle),
|
||||
format,
|
||||
}
|
||||
})
|
||||
|
||||
+47
-7
@@ -45,10 +45,22 @@ export const TRANSITION_OPTIONS = [
|
||||
{ label: 'Push', value: 'push' },
|
||||
]
|
||||
|
||||
export const SAMPLE_RATE_OPTIONS = [
|
||||
{ label: '44100', value: 44100 },
|
||||
{ label: '48000', value: 48000 },
|
||||
{ label: '96000', value: 96000 },
|
||||
]
|
||||
|
||||
// Format params shown on source nodes; values go into edge format (not node params)
|
||||
const FORMAT_PARAMS: ParamDef[] = [
|
||||
{ key: 'res', label: 'Resolution', type: 'select', options: RESOLUTION_OPTIONS, default: '1920x1080', isFormat: true },
|
||||
{ key: 'fps', label: 'FPS', type: 'select', options: FPS_OPTIONS, default: '25', isFormat: true },
|
||||
export const FORMAT_OPTIONS_VIDEO: ParamDef[] = [
|
||||
{ key: 'res', label: 'Resolution', type: 'select', options: RESOLUTION_OPTIONS, default: '1920x1080', isFormat: true, group: 'VIDEO' },
|
||||
{ key: 'fps', label: 'FPS', type: 'select', options: FPS_OPTIONS, default: '25', isFormat: true, group: 'VIDEO' },
|
||||
]
|
||||
|
||||
export const FORMAT_OPTIONS_AUDIO: ParamDef[] = [
|
||||
{ key: 'channels', label: 'Channels', type: 'number', default: 0, min: 0, max: 16, isFormat: true, group: 'AUDIO' },
|
||||
{ key: 'sample_rate', label: 'Sample Rate', type: 'select', options: SAMPLE_RATE_OPTIONS, default: 48000, isFormat: true, group: 'AUDIO' },
|
||||
{ key: 'amplitude', label: 'Amplitude (dB)', type: 'number', default: -18, min: -60, max: 0, isFormat: true },
|
||||
]
|
||||
|
||||
export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
||||
@@ -60,9 +72,9 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
||||
{ id: 'audio-out', kind: 'audio', direction: 'out' },
|
||||
],
|
||||
params: [
|
||||
...FORMAT_PARAMS,
|
||||
...FORMAT_OPTIONS_VIDEO,
|
||||
...FORMAT_OPTIONS_AUDIO,
|
||||
{ key: 'pattern', 'label': 'Pattern', type: 'select', options: PATTERN_OPTIONS, default: 'bars' },
|
||||
{ key: 'channels', label: 'Channels', type: 'number', default: 0, min: 0, max: 16 }
|
||||
],
|
||||
},
|
||||
ndiin: {
|
||||
@@ -74,7 +86,8 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
||||
],
|
||||
params: [
|
||||
{ key: 'source_num', label: 'NDI Source #', type: 'number', default: 0, min: 0, max: 15 },
|
||||
...FORMAT_PARAMS,
|
||||
...FORMAT_OPTIONS_VIDEO,
|
||||
...FORMAT_OPTIONS_AUDIO,
|
||||
],
|
||||
},
|
||||
decklinkin: {
|
||||
@@ -86,7 +99,8 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
||||
],
|
||||
params: [
|
||||
{ key: 'device_index', label: 'Port', type: 'select', options: DEVICE_OPTIONS, default: 0 },
|
||||
...FORMAT_PARAMS,
|
||||
...FORMAT_OPTIONS_VIDEO,
|
||||
...FORMAT_OPTIONS_AUDIO,
|
||||
],
|
||||
},
|
||||
ndiout: {
|
||||
@@ -145,6 +159,32 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
||||
{ key: 'duration', label: 'Duration', type: 'number', default: 500, min: 0, max: 10000 },
|
||||
],
|
||||
},
|
||||
pip: {
|
||||
type: 'pip',
|
||||
label: 'PiP',
|
||||
ports: [
|
||||
{ id: 'background-in', kind: 'video', direction: 'in' },
|
||||
{ id: 'inset-in', kind: 'video', direction: 'in' },
|
||||
{ id: 'video-out', kind: 'video', direction: 'out' },
|
||||
],
|
||||
params: [
|
||||
{ key: 'x', label: 'X', type: 'number', default: 0 },
|
||||
{ key: 'y', label: 'Y', type: 'number', default: 0 },
|
||||
{ key: 'width', label: 'Width', type: 'number', default: 480 },
|
||||
{ key: 'height', label: 'Height', type: 'number', default: 270 },
|
||||
],
|
||||
},
|
||||
gaindb: {
|
||||
type: 'gaindb',
|
||||
label: 'Gain/dB',
|
||||
ports: [
|
||||
{ id: 'audio-in', kind: 'audio', direction: 'in', portKey: 'audio_in_flow_id' },
|
||||
{ id: 'audio-out', kind: 'audio', direction: 'out', portKey: 'audio_out_flow_id' },
|
||||
],
|
||||
params: [
|
||||
{ key: 'gain_db', label: 'Gain (dB)', type: 'number', default: 0, min: -60, max: 20 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Default params for a new node of the given type
|
||||
|
||||
+3
-1
@@ -1,9 +1,10 @@
|
||||
export type PortKind = 'video' | 'audio'
|
||||
|
||||
export interface PortDef {
|
||||
id: string // 'video-out', 'audio-in', etc.
|
||||
id: string // 'video-out', 'audio-in', etc. (UI handle id)
|
||||
kind: PortKind
|
||||
direction: 'in' | 'out'
|
||||
portKey?: string // backend graph JSON key; defaults to handleToPort(id)
|
||||
}
|
||||
|
||||
export interface ParamDef {
|
||||
@@ -15,6 +16,7 @@ export interface ParamDef {
|
||||
min?: number
|
||||
max?: number
|
||||
isFormat?: boolean // true → goes into edge format (not node params)
|
||||
group?: string // section label e.g. 'VIDEO', 'AUDIO' — renders a divider header
|
||||
}
|
||||
|
||||
export interface NodeTypeDef {
|
||||
|
||||
Reference in New Issue
Block a user