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 StudioNode from './components/nodes/StudioNode.vue'
|
||||||
import { useStudioStore } from './stores/studio'
|
import { useStudioStore } from './stores/studio'
|
||||||
import { buildGraph } from './graphBuilder'
|
import { buildGraph } from './graphBuilder'
|
||||||
import { defaultParams } from './nodeTypes'
|
import { defaultParams, NODE_TYPES } from './nodeTypes'
|
||||||
import type { StudioNodeData } from './types'
|
import type { StudioNodeData } from './types'
|
||||||
|
|
||||||
const FLOW_ID = 'dmf-studio'
|
const FLOW_ID = 'dmf-studio'
|
||||||
@@ -23,6 +23,16 @@ const nodes = ref<Node[]>([])
|
|||||||
const edges = ref<Edge[]>([])
|
const edges = ref<Edge[]>([])
|
||||||
const flowWrapperRef = ref<HTMLElement>()
|
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
|
let nodeCounter = 0
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -85,12 +95,13 @@ function addNode(nodeType: string) {
|
|||||||
function onConnect(connection: Connection) {
|
function onConnect(connection: Connection) {
|
||||||
const srcHandle = connection.sourceHandle ?? ''
|
const srcHandle = connection.sourceHandle ?? ''
|
||||||
const tgtHandle = connection.targetHandle ?? ''
|
const tgtHandle = connection.targetHandle ?? ''
|
||||||
if (srcHandle.startsWith('video') !== tgtHandle.startsWith('video')) return
|
|
||||||
if (!srcHandle.includes('-out') || !tgtHandle.includes('-in')) 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 = [
|
||||||
...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[] = [
|
const groups: PaletteGroup[] = [
|
||||||
{ label: 'SOURCES', types: ['testpattern', 'ndiin', 'decklinkin', 'videoin'] },
|
{ label: 'SOURCES', types: ['testpattern', 'ndiin', 'decklinkin', 'videoin'] },
|
||||||
{ label: 'PROCESS', types: ['mix'] },
|
{ label: 'PROCESS', types: ['mix', 'pip', 'gaindb'] },
|
||||||
{ label: 'SINKS', types: ['ndiout', 'decklinkout', 'fakesink'] },
|
{ label: 'SINKS', types: ['ndiout', 'decklinkout', 'fakesink'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -25,6 +25,8 @@ const iconMap: Record<string, string> = {
|
|||||||
decklinkout: '◧',
|
decklinkout: '◧',
|
||||||
fakesink: '◯',
|
fakesink: '◯',
|
||||||
mix: '⊕',
|
mix: '⊕',
|
||||||
|
pip: '⊞',
|
||||||
|
gaindb: '◎',
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragStart(event: DragEvent, nodeType: string) {
|
function onDragStart(event: DragEvent, nodeType: string) {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const iconMap: Record<string, string> = {
|
|||||||
decklinkout: '◧',
|
decklinkout: '◧',
|
||||||
fakesink: '◯',
|
fakesink: '◯',
|
||||||
mix: '⊕',
|
mix: '⊕',
|
||||||
|
pip: '⊞',
|
||||||
|
gaindb: '◎',
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeIcon = computed(() => iconMap[props.data.nodeType] ?? '◯')
|
const nodeIcon = computed(() => iconMap[props.data.nodeType] ?? '◯')
|
||||||
@@ -107,7 +109,15 @@ function onDeleteNode() {
|
|||||||
|
|
||||||
<!-- Params -->
|
<!-- Params -->
|
||||||
<div v-if="typeDef && typeDef.params.length" class="node-body">
|
<div v-if="typeDef && typeDef.params.length" class="node-body">
|
||||||
<div v-for="param in typeDef.params" :key="param.key" class="param-row">
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
<label class="param-label">{{ param.label.toUpperCase() }}</label>
|
||||||
<select
|
<select
|
||||||
v-if="param.type === 'select'"
|
v-if="param.type === 'select'"
|
||||||
@@ -135,6 +145,7 @@ function onDeleteNode() {
|
|||||||
@change="handleFileChange(param.key, $event)"
|
@change="handleFileChange(param.key, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
@@ -285,6 +296,31 @@ function onDeleteNode() {
|
|||||||
gap: 6px;
|
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 {
|
.param-label {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 68px;
|
min-width: 68px;
|
||||||
|
|||||||
+28
-6
@@ -23,11 +23,26 @@ function parseRes(res: string): { width: number; height: number } {
|
|||||||
return { width: w || 1920, height: h || 1080 }
|
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
|
// Converts a Vue Flow handle id to the NODE_CONFIG port key used in graph JSON
|
||||||
function handleToPort(handle: string): string {
|
function handleToPort(handle: string): string {
|
||||||
return handle.replace(/-(?:in|out)(?:-\d+)?$/, '_flow_id')
|
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 {
|
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 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 srcData = nodeMap.get(e.source)
|
||||||
const srcHandle = e.sourceHandle ?? 'video-out'
|
const srcHandle = e.sourceHandle ?? 'video-out'
|
||||||
const tgtHandle = e.targetHandle ?? 'video-in'
|
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 }
|
let format: Record<string, unknown> = { kind }
|
||||||
if (kind === 'video') {
|
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'))
|
const { fps_num, fps_den } = parseFps(String(srcData?.params.fps ?? '25'))
|
||||||
format = { kind, width, height, fps_num, fps_den }
|
format = { kind, width, height, fps_num, fps_den }
|
||||||
} else {
|
} 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 {
|
return {
|
||||||
from: e.source, from_port: handleToPort(srcHandle),
|
from: e.source, from_port: portKeyFor(srcNode, srcHandle),
|
||||||
to: e.target, to_port: handleToPort(tgtHandle),
|
to: e.target, to_port: portKeyFor(tgtNode, tgtHandle),
|
||||||
format,
|
format,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+47
-7
@@ -45,10 +45,22 @@ export const TRANSITION_OPTIONS = [
|
|||||||
{ label: 'Push', value: 'push' },
|
{ 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)
|
// Format params shown on source nodes; values go into edge format (not node params)
|
||||||
const FORMAT_PARAMS: ParamDef[] = [
|
export const FORMAT_OPTIONS_VIDEO: ParamDef[] = [
|
||||||
{ key: 'res', label: 'Resolution', type: 'select', options: RESOLUTION_OPTIONS, default: '1920x1080', isFormat: true },
|
{ 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 },
|
{ 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> = {
|
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' },
|
{ id: 'audio-out', kind: 'audio', direction: 'out' },
|
||||||
],
|
],
|
||||||
params: [
|
params: [
|
||||||
...FORMAT_PARAMS,
|
...FORMAT_OPTIONS_VIDEO,
|
||||||
|
...FORMAT_OPTIONS_AUDIO,
|
||||||
{ key: 'pattern', 'label': 'Pattern', type: 'select', options: PATTERN_OPTIONS, default: 'bars' },
|
{ key: 'pattern', 'label': 'Pattern', type: 'select', options: PATTERN_OPTIONS, default: 'bars' },
|
||||||
{ key: 'channels', label: 'Channels', type: 'number', default: 0, min: 0, max: 16 }
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ndiin: {
|
ndiin: {
|
||||||
@@ -74,7 +86,8 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
|||||||
],
|
],
|
||||||
params: [
|
params: [
|
||||||
{ key: 'source_num', label: 'NDI Source #', type: 'number', default: 0, min: 0, max: 15 },
|
{ key: 'source_num', label: 'NDI Source #', type: 'number', default: 0, min: 0, max: 15 },
|
||||||
...FORMAT_PARAMS,
|
...FORMAT_OPTIONS_VIDEO,
|
||||||
|
...FORMAT_OPTIONS_AUDIO,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
decklinkin: {
|
decklinkin: {
|
||||||
@@ -86,7 +99,8 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
|||||||
],
|
],
|
||||||
params: [
|
params: [
|
||||||
{ key: 'device_index', label: 'Port', type: 'select', options: DEVICE_OPTIONS, default: 0 },
|
{ key: 'device_index', label: 'Port', type: 'select', options: DEVICE_OPTIONS, default: 0 },
|
||||||
...FORMAT_PARAMS,
|
...FORMAT_OPTIONS_VIDEO,
|
||||||
|
...FORMAT_OPTIONS_AUDIO,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ndiout: {
|
ndiout: {
|
||||||
@@ -145,6 +159,32 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
|||||||
{ key: 'duration', label: 'Duration', type: 'number', default: 500, min: 0, max: 10000 },
|
{ 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
|
// Default params for a new node of the given type
|
||||||
|
|||||||
+3
-1
@@ -1,9 +1,10 @@
|
|||||||
export type PortKind = 'video' | 'audio'
|
export type PortKind = 'video' | 'audio'
|
||||||
|
|
||||||
export interface PortDef {
|
export interface PortDef {
|
||||||
id: string // 'video-out', 'audio-in', etc.
|
id: string // 'video-out', 'audio-in', etc. (UI handle id)
|
||||||
kind: PortKind
|
kind: PortKind
|
||||||
direction: 'in' | 'out'
|
direction: 'in' | 'out'
|
||||||
|
portKey?: string // backend graph JSON key; defaults to handleToPort(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParamDef {
|
export interface ParamDef {
|
||||||
@@ -15,6 +16,7 @@ export interface ParamDef {
|
|||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
isFormat?: boolean // true → goes into edge format (not node params)
|
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 {
|
export interface NodeTypeDef {
|
||||||
|
|||||||
Reference in New Issue
Block a user