ui i like most
This commit is contained in:
+175
-30
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, markRaw, watch } from 'vue'
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import type { Node, Edge, Connection } from '@vue-flow/core'
|
||||
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||
import type { Node, Edge, Connection, XYPosition, EdgeMouseEvent } from '@vue-flow/core'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
|
||||
@@ -13,15 +13,18 @@ import { buildGraph } from './graphBuilder'
|
||||
import { defaultParams } from './nodeTypes'
|
||||
import type { StudioNodeData } from './types'
|
||||
|
||||
const FLOW_ID = 'dmf-studio'
|
||||
const { viewportHelper } = useVueFlow(FLOW_ID)
|
||||
|
||||
const studio = useStudioStore()
|
||||
const nodeTypes = { studio: markRaw(StudioNode) }
|
||||
|
||||
const nodes = ref<Node[]>([])
|
||||
const edges = ref<Edge[]>([])
|
||||
const flowWrapperRef = ref<HTMLElement>()
|
||||
|
||||
let nodeCounter = 0
|
||||
|
||||
// Sync server-push state into Vue Flow node data
|
||||
watch(
|
||||
() => studio.serverStatus,
|
||||
(status) => {
|
||||
@@ -36,31 +39,64 @@ watch(
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function addNode(nodeType: string) {
|
||||
const DROP_OFFSET_X = 100
|
||||
const DROP_OFFSET_Y = 20
|
||||
|
||||
function createNodeAt(nodeType: string, pos: XYPosition) {
|
||||
nodeCounter++
|
||||
const id = `${nodeType}-${nodeCounter}`
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const newNode: Node = {
|
||||
id,
|
||||
type: 'studio',
|
||||
position: { x: 160 + (nodeCounter % 6) * 220, y: 80 + Math.floor(nodeCounter / 6) * 200 },
|
||||
position: pos,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: { nodeType, params: defaultParams(nodeType), serverState: undefined } as any,
|
||||
}
|
||||
// @ts-ignore TS2589: Vue Flow Node<T> generic is too deep for vue-tsc's instantiation limit
|
||||
// @ts-ignore TS2589
|
||||
nodes.value = [...nodes.value, newNode]
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
const nodeType = event.dataTransfer?.getData('application/dmf-node-type')
|
||||
if (!nodeType) return
|
||||
const vp = viewportHelper.value
|
||||
if (!vp) return
|
||||
const flowPos = vp.screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
|
||||
createNodeAt(nodeType, { x: flowPos.x - DROP_OFFSET_X, y: flowPos.y - DROP_OFFSET_Y })
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
function addNode(nodeType: string) {
|
||||
const vp = viewportHelper.value
|
||||
if (vp && flowWrapperRef.value) {
|
||||
const rect = flowWrapperRef.value.getBoundingClientRect()
|
||||
const center = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||
const flowPos = vp.screenToFlowCoordinate(center)
|
||||
createNodeAt(nodeType, { x: flowPos.x - DROP_OFFSET_X, y: flowPos.y - DROP_OFFSET_Y })
|
||||
} else {
|
||||
createNodeAt(nodeType, { x: 200, y: 80 })
|
||||
}
|
||||
}
|
||||
|
||||
function onConnect(connection: Connection) {
|
||||
const srcHandle = connection.sourceHandle ?? ''
|
||||
const tgtHandle = connection.targetHandle ?? ''
|
||||
if (srcHandle.startsWith('video') !== tgtHandle.startsWith('video')) return
|
||||
const kind = srcHandle.startsWith('video') ? 'video' : 'audio'
|
||||
edges.value = [
|
||||
...edges.value,
|
||||
{ ...connection, id: `e-${Date.now()}`, animated: true } as Edge,
|
||||
{ ...connection, id: `e-${Date.now()}`, animated: true, class: `edge-${kind}` } as Edge,
|
||||
]
|
||||
}
|
||||
|
||||
function onEdgeClick(event: EdgeMouseEvent) {
|
||||
edges.value = edges.value.filter(e => e.id !== event.edge.id)
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (nodes.value.length === 0) { alert('Add at least one node first.'); return }
|
||||
if (edges.value.length === 0) { alert('Connect the nodes with at least one wire first.'); return }
|
||||
@@ -84,16 +120,29 @@ function clear() {
|
||||
<Toolbar @run="run" @stop="stop" @clear="clear" />
|
||||
<div class="canvas-area">
|
||||
<NodePalette @add-node="addNode" />
|
||||
<VueFlow
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
class="flow-canvas"
|
||||
:min-zoom="0.3"
|
||||
:max-zoom="2"
|
||||
fit-view-on-init
|
||||
@connect="onConnect"
|
||||
/>
|
||||
<div
|
||||
class="flow-wrapper"
|
||||
ref="flowWrapperRef"
|
||||
@dragover.prevent="onDragOver"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<VueFlow
|
||||
:id="FLOW_ID"
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
:node-types="nodeTypes"
|
||||
class="flow-canvas"
|
||||
:min-zoom="0.3"
|
||||
:max-zoom="2"
|
||||
fit-view-on-init
|
||||
@connect="onConnect"
|
||||
@edge-click="onEdgeClick"
|
||||
/>
|
||||
<div v-if="nodes.length === 0" class="canvas-hint">
|
||||
<div class="hint-label">EMPTY WORKSPACE</div>
|
||||
<div class="hint-text">DRAG NODES FROM LEFT PANEL</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -104,35 +153,96 @@ html, body, #app {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #11111b;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #cdd6f4;
|
||||
background: var(--bg-base);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Vue Flow: Canvas ── */
|
||||
.vue-flow {
|
||||
background: #11111b !important;
|
||||
background-color: var(--bg-base) !important;
|
||||
background-image:
|
||||
radial-gradient(circle, #1a1a1a 1px, transparent 1px) !important;
|
||||
background-size: 24px 24px !important;
|
||||
}
|
||||
|
||||
.vue-flow__background {
|
||||
background: #11111b !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ── Vue Flow: Edges — thin white wires ── */
|
||||
.vue-flow__edge {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
stroke: #89b4fa !important;
|
||||
stroke-width: 2 !important;
|
||||
stroke: #E6E6E6 !important;
|
||||
stroke-width: 1.5 !important;
|
||||
}
|
||||
|
||||
.vue-flow__edge.edge-audio .vue-flow__edge-path {
|
||||
stroke-dasharray: 4 3 !important;
|
||||
}
|
||||
|
||||
.vue-flow__edge.animated .vue-flow__edge-path {
|
||||
stroke-dasharray: 5;
|
||||
animation: dashdraw 0.5s linear infinite;
|
||||
stroke-dasharray: 5 4;
|
||||
animation: dashflow 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.vue-flow__edge:hover .vue-flow__edge-path {
|
||||
stroke: var(--accent) !important;
|
||||
stroke-width: 2 !important;
|
||||
}
|
||||
|
||||
@keyframes dashflow {
|
||||
to { stroke-dashoffset: -18; }
|
||||
}
|
||||
|
||||
/* ── Vue Flow: Handles ── */
|
||||
.vue-flow__handle {
|
||||
border-radius: 50% !important;
|
||||
border: 1px solid var(--border-medium) !important;
|
||||
}
|
||||
|
||||
@keyframes dashdraw {
|
||||
to { stroke-dashoffset: -10; }
|
||||
/* ── Vue Flow: Selection — red dot indicator ── */
|
||||
.vue-flow__node.selectable:focus,
|
||||
.vue-flow__node.selectable:active {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
.vue-flow__node.selected .studio-node {
|
||||
border-color: var(--accent) !important;
|
||||
}
|
||||
|
||||
/* ── Vue Flow: Controls ── */
|
||||
.vue-flow__controls {
|
||||
background: var(--bg-panel-1) !important;
|
||||
border: 1px solid var(--border-thin) !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
margin: 8px !important;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button {
|
||||
background: transparent !important;
|
||||
border-bottom: 1px solid var(--border-thin) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button:hover {
|
||||
background: var(--bg-elevated) !important;
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button svg {
|
||||
fill: currentColor !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,15 +253,50 @@ html, body, #app {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.flow-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flow-canvas {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
.canvas-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-hint);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hint-label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 10px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button type="button" class="counter" @click="count++">
|
||||
Count is {{ count }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="dmf-icon"
|
||||
>
|
||||
<template v-if="name === 'play'">
|
||||
<polygon points="6 3 20 12 6 21 6 3" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'stop'">
|
||||
<rect x="5" y="5" width="14" height="14" rx="2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'trash'">
|
||||
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m-1 0v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V6" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</template>
|
||||
<template v-else-if="name === 'x'">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'plus'">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</template>
|
||||
<template v-else-if="name === 'zap'">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'grid'">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</template>
|
||||
<template v-else-if="name === 'wifi'">
|
||||
<path d="M5 12.55a11 11 0 0 1 11 0M8.53 16.11a6 6 0 0 1 6.95 0M2 8.82a15 15 0 0 1 20 0" />
|
||||
<circle cx="12" cy="20" r="0.5" fill="currentColor" />
|
||||
</template>
|
||||
<template v-else-if="name === 'video'">
|
||||
<rect x="2" y="5" width="14" height="14" rx="2" />
|
||||
<path d="M22 8 16 12 22 16 22 8" />
|
||||
</template>
|
||||
<template v-else-if="name === 'file-video'">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="M9 12 14 15 9 18 9 12" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'cast'">
|
||||
<path d="M2 8.5a20 20 0 0 1 20 0M5 12.1a14 14 0 0 1 14 0M8.5 15.6a8 8 0 0 1 7 0" />
|
||||
<circle cx="12" cy="20" r="1" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'monitor'">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<polyline points="8 21 12 17 16 21" />
|
||||
</template>
|
||||
<template v-else-if="name === 'eye-off'">
|
||||
<path d="M9.88 9.88a3 3 0 0 0 4.24 4.24" />
|
||||
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68" />
|
||||
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61" />
|
||||
<path d="m2 2 20 20" />
|
||||
</template>
|
||||
<template v-else-if="name === 'waveform'">
|
||||
<path d="M2 12 5 12 7 6 9 18 11 9 13 15 15 4 17 20 19 10 22 12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'signal'">
|
||||
<path d="M2 20h.01M6 20v-4M10 20v-8M14 20V8M18 20V4M22 20v-2" />
|
||||
</template>
|
||||
<template v-else-if="name === 'power'">
|
||||
<path d="M12 2v10M18.4 6.6a9 9 0 1 1-12.8 0" />
|
||||
</template>
|
||||
<template v-else-if="name === 'arrow-right'">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check'">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'dot'">
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<template v-else-if="name === 'folder'">
|
||||
<path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.93a2 2 0 0 1-1.66-.9l-.82-1.2A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}>(), {
|
||||
size: 18,
|
||||
strokeWidth: 2,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dmf-icon {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
+113
-44
@@ -5,77 +5,146 @@ const emit = defineEmits<{
|
||||
addNode: [nodeType: string]
|
||||
}>()
|
||||
|
||||
const groups = [
|
||||
{ label: 'Sources', types: ['testpattern', 'ndiin', 'decklinkin'] },
|
||||
{ label: 'Sinks', types: ['ndiout', 'decklinkout', 'fakesink'] },
|
||||
interface PaletteGroup {
|
||||
label: string
|
||||
types: string[]
|
||||
}
|
||||
|
||||
const groups: PaletteGroup[] = [
|
||||
{ label: 'SOURCES', types: ['testpattern', 'ndiin', 'decklinkin', 'videoin'] },
|
||||
{ label: 'SINKS', types: ['ndiout', 'decklinkout', 'fakesink'] },
|
||||
]
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
testpattern: '▦',
|
||||
ndiin: '◬',
|
||||
decklinkin: '◧',
|
||||
videoin: '▣',
|
||||
ndiout: '◬',
|
||||
decklinkout: '◧',
|
||||
fakesink: '◯',
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, nodeType: string) {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('application/dmf-node-type', nodeType)
|
||||
event.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="palette">
|
||||
<div class="palette-title">Nodes</div>
|
||||
<div v-for="group in groups" :key="group.label" class="palette-group">
|
||||
<div class="group-label">{{ group.label }}</div>
|
||||
<button
|
||||
v-for="type in group.types"
|
||||
:key="type"
|
||||
class="palette-btn"
|
||||
@click="emit('addNode', type)"
|
||||
>
|
||||
+ {{ NODE_TYPES[type]?.label ?? type }}
|
||||
</button>
|
||||
<div class="palette-label">NODES</div>
|
||||
<div class="palette-body">
|
||||
<div v-for="group in groups" :key="group.label" class="palette-group">
|
||||
<div class="group-label">{{ group.label }}</div>
|
||||
<div class="group-items">
|
||||
<button
|
||||
v-for="type in group.types"
|
||||
:key="type"
|
||||
class="node-btn"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, type)"
|
||||
@click="emit('addNode', type)"
|
||||
>
|
||||
<span class="node-icon">{{ iconMap[type] ?? '◯' }}</span>
|
||||
<span class="node-name">{{ NODE_TYPES[type]?.label ?? type }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="palette-footer">DRAG OR CLICK TO ADD</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.palette {
|
||||
width: 140px;
|
||||
background: #181825;
|
||||
border-right: 1px solid #313244;
|
||||
width: var(--palette-width);
|
||||
background: var(--bg-panel-0);
|
||||
border-right: 1px solid var(--border-thin);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.palette-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #6c7086;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 12px 12px 6px;
|
||||
text-transform: uppercase;
|
||||
.palette-label {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-thin);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 3px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.palette-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.palette-body::-webkit-scrollbar { width: 3px; }
|
||||
.palette-body::-webkit-scrollbar-track { background: transparent; }
|
||||
.palette-body::-webkit-scrollbar-thumb { background: var(--border-medium); }
|
||||
|
||||
.palette-group {
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px dotted var(--border-thin);
|
||||
}
|
||||
|
||||
.group-label {
|
||||
font-size: 10px;
|
||||
color: #45475a;
|
||||
padding: 4px 12px 2px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
padding: 10px var(--sp-4) 4px;
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.palette-btn {
|
||||
display: block;
|
||||
.node-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: #bac2de;
|
||||
padding: 6px var(--sp-4);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
border-radius: 0;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.palette-btn:hover {
|
||||
background: #2a2b3d;
|
||||
color: #cdd6f4;
|
||||
.node-btn:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
.node-icon {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-btn:hover .node-icon {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.palette-footer {
|
||||
padding: 8px var(--sp-4);
|
||||
border-top: 1px solid var(--border-thin);
|
||||
font-size: 8px;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
+66
-40
@@ -11,22 +11,24 @@ const emit = defineEmits<{
|
||||
const studio = useStudioStore()
|
||||
|
||||
const statusLabel = computed(() =>
|
||||
studio.connected ? 'Connected' : 'Disconnected'
|
||||
studio.connected ? 'ONLINE' : 'OFFLINE'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar">
|
||||
<span class="app-title">DMF Studio</span>
|
||||
<div class="brand">DMF STUDIO</div>
|
||||
|
||||
<div class="toolbar-center">
|
||||
<button class="btn btn-run" @click="emit('run')">▶ Run</button>
|
||||
<button class="btn btn-stop" @click="emit('stop')">■ Stop</button>
|
||||
<button class="btn btn-clear" @click="emit('clear')">Clear</button>
|
||||
<div class="separator" />
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn" @click="emit('run')">▶ RUN</button>
|
||||
<button class="btn" @click="emit('stop')">■ STOP</button>
|
||||
<button class="btn btn-danger" @click="emit('clear')">CLEAR</button>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<span :class="['status-dot', studio.connected ? 'dot-ok' : 'dot-err']" />
|
||||
<span :class="['dot', studio.connected ? 'dot-on' : 'dot-off']" />
|
||||
<span class="status-text">{{ statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,57 +38,81 @@ const statusLabel = computed(() =>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 44px;
|
||||
background: #181825;
|
||||
border-bottom: 1px solid #313244;
|
||||
height: var(--toolbar-height);
|
||||
padding: 0 var(--sp-4);
|
||||
gap: var(--sp-4);
|
||||
background: var(--bg-panel-0);
|
||||
border-bottom: 1px solid var(--border-thin);
|
||||
flex-shrink: 0;
|
||||
z-index: var(--z-toolbar);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 4px;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--border-thin);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #89b4fa;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 5px 14px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-thin);
|
||||
color: var(--text-secondary);
|
||||
padding: 5px 12px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
letter-spacing: 1.5px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
border-radius: 0;
|
||||
text-transform: uppercase;
|
||||
transition: border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.btn:hover { opacity: 0.85; }
|
||||
.btn:hover {
|
||||
border-color: var(--border-medium);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-run { background: #a6e3a1; color: #1e2030; }
|
||||
.btn-stop { background: #f38ba8; color: #1e2030; }
|
||||
.btn-clear { background: #313244; color: #9399b2; }
|
||||
.btn-danger:hover {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #9399b2;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-ok { background: #a6e3a1; box-shadow: 0 0 4px #a6e3a1; }
|
||||
.dot-err { background: #f38ba8; }
|
||||
</style>
|
||||
.dot-on { background: var(--text-primary); }
|
||||
.dot-off { background: var(--accent); }
|
||||
|
||||
.status-text {
|
||||
font-size: 9px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 2px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
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 stateBadge = computed(() => {
|
||||
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 { text: 'RUN', cls: 'badge-running' }
|
||||
case 'stopped': return { text: 'STOP', cls: 'badge-stopped' }
|
||||
case 'crashed': return { text: 'ERR', cls: 'badge-crashed' }
|
||||
default: return null
|
||||
case 'running': return 'RUN'
|
||||
case 'stopped': return 'STOP'
|
||||
case 'crashed': return 'ERR'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Fixed layout constants (keep in sync with CSS)
|
||||
const HEADER_H = 36 // px — node-header height
|
||||
const PORT_H = 26 // px — port-row height
|
||||
const HEADER_H = 38
|
||||
const PORT_H = 28
|
||||
|
||||
// Handle is centered on its port row
|
||||
function handleTopPx(index: number): string {
|
||||
return `${HEADER_H + PORT_H * index + PORT_H / 2}px`
|
||||
}
|
||||
@@ -32,6 +45,19 @@ function handleTopPx(index: number): string {
|
||||
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>
|
||||
@@ -42,7 +68,7 @@ function setParam(key: string, value: string | number) {
|
||||
'node-crashed': data.serverState === 'crashed',
|
||||
}"
|
||||
>
|
||||
<!-- Input handles (left) — positioned to match port rows -->
|
||||
<!-- Input handles -->
|
||||
<Handle
|
||||
v-for="(port, i) in inputs"
|
||||
:key="port.id"
|
||||
@@ -55,22 +81,22 @@ function setParam(key: string, value: string | number) {
|
||||
|
||||
<!-- Header -->
|
||||
<div class="node-header">
|
||||
<span class="node-icon">{{ nodeIcon }}</span>
|
||||
<span class="node-label">{{ typeDef?.label ?? data.nodeType }}</span>
|
||||
<span v-if="stateBadge" :class="['badge', stateBadge.cls]">{{ stateBadge.text }}</span>
|
||||
<span v-if="stateText" :class="['state', `state-${data.serverState}`]">{{ stateText }}</span>
|
||||
<button class="node-close" @click.stop="onDeleteNode">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Port rows -->
|
||||
<div class="node-ports">
|
||||
<!-- 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-dot dot-${port.kind}`" />
|
||||
<span class="port-name">{{ port.kind }}</span>
|
||||
<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.kind }}</span>
|
||||
<span :class="`port-dot dot-${port.kind}`" />
|
||||
<span :class="['port-name', `port-${port.kind}`]">{{ port.kind }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +104,7 @@ function setParam(key: string, value: string | number) {
|
||||
<!-- 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 }}</label>
|
||||
<label class="param-label">{{ param.label.toUpperCase() }}</label>
|
||||
<select
|
||||
v-if="param.type === 'select'"
|
||||
class="param-input"
|
||||
@@ -98,13 +124,19 @@ function setParam(key: string, value: string | number) {
|
||||
: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: node id -->
|
||||
<!-- Footer -->
|
||||
<div class="node-id">{{ id }}</div>
|
||||
|
||||
<!-- Output handles (right) — positioned to match port rows -->
|
||||
<!-- Output handles -->
|
||||
<Handle
|
||||
v-for="(port, i) in outputs"
|
||||
:key="port.id"
|
||||
@@ -118,62 +150,90 @@ function setParam(key: string, value: string | number) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Keep HEADER_H and PORT_H in sync with the JS constants above */
|
||||
.studio-node {
|
||||
background: #1e2030;
|
||||
border: 1px solid #383a4e;
|
||||
border-radius: 8px;
|
||||
min-width: 190px;
|
||||
font-size: 12px;
|
||||
color: #cdd6f4;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
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;
|
||||
}
|
||||
|
||||
.studio-node.node-running {
|
||||
border-color: #a6e3a1;
|
||||
box-shadow: 0 0 0 1px #a6e3a1, 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
.node-running {
|
||||
border-color: var(--text-primary);
|
||||
}
|
||||
|
||||
.studio-node.node-crashed {
|
||||
border-color: #f38ba8;
|
||||
box-shadow: 0 0 0 1px #f38ba8, 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
.node-crashed {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* HEADER_H = 36px */
|
||||
/* ── Header ── */
|
||||
.node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: var(--node-header-h);
|
||||
padding: 0 10px;
|
||||
height: 36px;
|
||||
border-bottom: 1px solid #383a4e;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid var(--border-thin);
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #89b4fa;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
letter-spacing: 0.5px;
|
||||
.state {
|
||||
font-size: 8px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
.badge-running { background: #a6e3a1; color: #1e2030; }
|
||||
.badge-stopped { background: #6c7086; color: #cdd6f4; }
|
||||
.badge-crashed { background: #f38ba8; color: #1e2030; }
|
||||
|
||||
/* PORT_H = 26px per row */
|
||||
.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 #2a2b3d;
|
||||
border-bottom: 1px solid var(--border-thin);
|
||||
}
|
||||
|
||||
.ports-side {
|
||||
@@ -184,9 +244,8 @@ function setParam(key: string, value: string | number) {
|
||||
.port-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
height: var(--node-port-h);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.port-row-right {
|
||||
@@ -194,30 +253,26 @@ function setParam(key: string, value: string | number) {
|
||||
}
|
||||
|
||||
.port-name {
|
||||
font-size: 10px;
|
||||
color: #6c7086;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
letter-spacing: 1.5px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.port-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
.port-video { color: var(--text-primary); }
|
||||
.port-audio {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dot-video { background: #89b4fa; }
|
||||
.dot-audio { background: #fab387; }
|
||||
|
||||
/* Params */
|
||||
/* ── Params ── */
|
||||
.node-body {
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border-bottom: 1px solid #2a2b3d;
|
||||
border-bottom: 1px solid var(--border-thin);
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
.param-row {
|
||||
@@ -227,46 +282,70 @@ function setParam(key: string, value: string | number) {
|
||||
}
|
||||
|
||||
.param-label {
|
||||
color: #9399b2;
|
||||
min-width: 70px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 68px;
|
||||
font-size: 8px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 1px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.param-input {
|
||||
flex: 1;
|
||||
background: #11111b;
|
||||
border: 1px solid #383a4e;
|
||||
border-radius: 4px;
|
||||
color: #cdd6f4;
|
||||
font-size: 11px;
|
||||
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: #89b4fa;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.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: 10px;
|
||||
color: #45475a;
|
||||
font-size: 8px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Handles — use :deep() to pierce Vue Flow's DOM */
|
||||
/* ── Handles — circular sockets ── */
|
||||
:deep(.handle-video) {
|
||||
background: #89b4fa !important;
|
||||
border-color: #89b4fa !important;
|
||||
width: 11px !important;
|
||||
height: 11px !important;
|
||||
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: #fab387 !important;
|
||||
border-color: #fab387 !important;
|
||||
width: 11px !important;
|
||||
height: 11px !important;
|
||||
background: transparent !important;
|
||||
border: 1px solid var(--text-secondary) !important;
|
||||
width: var(--handle-size) !important;
|
||||
height: var(--handle-size) !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import './tokens.css'
|
||||
|
||||
createApp(App).use(createPinia()).mount('#app')
|
||||
createApp(App).use(createPinia()).mount('#app')
|
||||
@@ -92,6 +92,17 @@ export const NODE_TYPES: Record<string, NodeTypeDef> = {
|
||||
],
|
||||
params: [],
|
||||
},
|
||||
videoin: {
|
||||
type: 'videoin',
|
||||
label: 'Multimedia file in',
|
||||
ports: [
|
||||
{ id: 'video-out', kind: 'video', direction: 'out' },
|
||||
{ id: 'audio-out', kind: 'audio', direction: 'out' },
|
||||
],
|
||||
params: [
|
||||
{ key: 'file', label: 'File', type: 'input', default: 0, min: 0, max: 15 }
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// Default params for a new node of the given type
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
DMF Studio — Cyber-Industrial Design Tokens
|
||||
Monochrome palette, single red accent, monospace typography
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* ── Color Palette ── */
|
||||
--bg-base: #060606;
|
||||
--bg-panel-0: #0B0B0B;
|
||||
--bg-panel-1: #101010;
|
||||
--bg-elevated: #141414;
|
||||
|
||||
--border-thin: #2A2A2A;
|
||||
--border-medium: #353535;
|
||||
|
||||
--text-primary: #E6E6E6;
|
||||
--text-secondary: #8B8B8B;
|
||||
--text-muted: #666666;
|
||||
--text-dim: #444444;
|
||||
|
||||
--accent: #FF4040;
|
||||
--accent-dim: rgba(255, 64, 64, 0.15);
|
||||
--accent-border: rgba(255, 64, 64, 0.40);
|
||||
--accent-glow: 0 0 8px rgba(255, 64, 64, 0.30);
|
||||
|
||||
--status-ok: #E6E6E6;
|
||||
--status-err: #FF4040;
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-mono: 'JetBrains Mono', 'IBM Plex Mono', 'SF Mono', monospace;
|
||||
|
||||
/* ── Radii — flat, no rounded corners ── */
|
||||
--radius: 0px;
|
||||
|
||||
/* ── Shadows — none ── */
|
||||
--shadow: none;
|
||||
|
||||
/* ── Transitions ── */
|
||||
--ease: linear;
|
||||
--dur-fast: 100ms;
|
||||
--dur-base: 150ms;
|
||||
--dur-slow: 250ms;
|
||||
|
||||
/* ── Spacing — strict 8px grid ── */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 20px;
|
||||
--sp-6: 24px;
|
||||
--sp-8: 32px;
|
||||
|
||||
/* ── Borders ── */
|
||||
--border-width: 1px;
|
||||
|
||||
/* ── App-specific ── */
|
||||
--toolbar-height: 40px;
|
||||
--palette-width: 200px;
|
||||
--node-header-h: 38px;
|
||||
--node-port-h: 28px;
|
||||
--node-min-width: 200px;
|
||||
--handle-size: 12px;
|
||||
|
||||
/* ── Z-index ── */
|
||||
--z-toolbar: 200;
|
||||
--z-hint: 5;
|
||||
}
|
||||
+2
-2
@@ -9,7 +9,7 @@ export interface PortDef {
|
||||
export interface ParamDef {
|
||||
key: string
|
||||
label: string
|
||||
type: 'select' | 'number'
|
||||
type: 'select' | 'number' | 'input'
|
||||
options?: { label: string; value: string | number }[]
|
||||
default: string | number
|
||||
min?: number
|
||||
@@ -27,7 +27,7 @@ export interface NodeTypeDef {
|
||||
// All config as a flat record; format keys (res, fps) handled separately in graphBuilder
|
||||
export interface StudioNodeData {
|
||||
nodeType: string
|
||||
params: Record<string, string | number>
|
||||
params: Record<string, string | number | File>
|
||||
serverState?: 'running' | 'stopped' | 'crashed'
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user