From b977ee35a896b93dfd75be1e8dc199210587d230 Mon Sep 17 00:00:00 2001 From: th Date: Sun, 19 Apr 2026 21:13:53 +0200 Subject: [PATCH] bak --- server/render.html | 99 ++-- src/components/AssetSidebar.vue | 178 +++++++ src/components/CanvasStage.vue | 242 +++++++-- src/components/PropertiesPanel.vue | 2 +- src/components/Timeline.vue | 786 +++++++++++++++++++++++------ src/devtools/tests.ts | 143 ++++++ src/lib/exportPlayer.ts | 118 +++-- src/lib/render.ts | 78 ++- src/stores/scene.ts | 264 ++++++++-- src/types/scene.ts | 25 + todo | 16 +- 11 files changed, 1641 insertions(+), 310 deletions(-) diff --git a/server/render.html b/server/render.html index 6caa871..024fa66 100644 --- a/server/render.html +++ b/server/render.html @@ -86,6 +86,43 @@ var canvas=document.getElementById('c'); var ctx=canvas.getContext('2d'); var scene=null, images={}; + function drawLayerNode(layer, am, time){ + var asset=am[layer.assetId]; + if(!asset || !layer.visible) return; + var p=sample(layer,time); + ctx.save(); + ctx.globalAlpha = ctx.globalAlpha * p.opacity; + ctx.translate(p.x,p.y); + ctx.rotate(p.rotation*Math.PI/180); + ctx.scale(p.scaleX,p.scaleY); + var fill = p.fill || asset.fill; + if(asset.kind==='rect'){ + var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2; + var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2; + ctx.fillStyle=fill; + ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height); + } else if(asset.kind==='circle'){ + var cOffX=layer.offsetX!=null?layer.offsetX:0; + var cOffY=layer.offsetY!=null?layer.offsetY:0; + ctx.fillStyle=fill; + ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill(); + } else if(asset.kind==='text'){ + var tOffX=layer.offsetX!=null?layer.offsetX:0; + var tOffY=layer.offsetY!=null?layer.offsetY:0; + ctx.fillStyle=fill; + ctx.font=asset.fontSize+'px sans-serif'; + ctx.textBaseline='top'; + ctx.fillText(asset.text,-tOffX,-tOffY); + } else if(asset.kind==='image'){ + var img=images[asset.id]; + if(img){ + var iOffX=layer.offsetX!=null?layer.offsetX:asset.width/2; + var iOffY=layer.offsetY!=null?layer.offsetY:asset.height/2; + ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height); + } + } + ctx.restore(); + } function drawFrame(time){ if(!scene) return; var lw=scene.canvas.width, lh=scene.canvas.height; @@ -94,42 +131,36 @@ ctx.fillStyle=scene.canvas.background; ctx.fillRect(0,0,lw,lh); var am={}; for(var i=0;i=0;j--){ - var layer=scene.layers[j]; if(!layer.visible) continue; - var asset=am[layer.assetId]; if(!asset) continue; - var p=sample(layer,time); - ctx.save(); - ctx.globalAlpha = ctx.globalAlpha * p.opacity; - ctx.translate(p.x,p.y); - ctx.rotate(p.rotation*Math.PI/180); - ctx.scale(p.scaleX,p.scaleY); - var fill = p.fill || asset.fill; - if(asset.kind==='rect'){ - var rOffX=layer.offsetX!=null?layer.offsetX:asset.width/2; - var rOffY=layer.offsetY!=null?layer.offsetY:asset.height/2; - ctx.fillStyle=fill; - ctx.fillRect(-rOffX,-rOffY,asset.width,asset.height); - } else if(asset.kind==='circle'){ - var cOffX=layer.offsetX!=null?layer.offsetX:0; - var cOffY=layer.offsetY!=null?layer.offsetY:0; - ctx.fillStyle=fill; - ctx.beginPath(); ctx.arc(-cOffX,-cOffY,asset.radius,0,Math.PI*2); ctx.fill(); - } else if(asset.kind==='text'){ - var tOffX=layer.offsetX!=null?layer.offsetX:0; - var tOffY=layer.offsetY!=null?layer.offsetY:0; - ctx.fillStyle=fill; - ctx.font=asset.fontSize+'px sans-serif'; - ctx.textBaseline='top'; - ctx.fillText(asset.text,-tOffX,-tOffY); - } else if(asset.kind==='image'){ - var img=images[asset.id]; - if(img){ - var iOffX=layer.offsetX!=null?layer.offsetX:asset.width/2; - var iOffY=layer.offsetY!=null?layer.offsetY:asset.height/2; - ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height); + var layerById={}; + for(var li=0;li=0;oi--){ + var id=order[oi]; + var lyr=layerById[id]; + if(lyr){ drawLayerNode(lyr, am, time); continue; } + var tInsts=instByTrack[id]||[]; + for(var ti=0;tivisible) continue; + var localT = tS + offset; + for(var cl=clip.layers.length-1; cl>=0; cl--){ + drawLayerNode(clip.layers[cl], am, localT); } } - ctx.restore(); } ctx.restore(); } diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue index c393790..209ea18 100644 --- a/src/components/AssetSidebar.vue +++ b/src/components/AssetSidebar.vue @@ -2,12 +2,15 @@ import { computed, ref, watch } from 'vue' import { PhCircle, + PhFilmStrip, PhImage, PhImageSquare, PhListBullets, + PhPencilSimple, PhPlus, PhSquare, PhTextT, + PhTrash, PhUploadSimple, } from '@phosphor-icons/vue' import { useSceneStore } from '@/stores/scene' @@ -65,6 +68,32 @@ function addSampleText() { store.addLayer(asset.id) } +const clips = computed(() => store.scene.clips ?? []) + +function addClip() { + const input = prompt('Name des Clips?', 'Clip') + if (input === null) return + store.addClip(input) +} + +function renameClipPrompt(clipId: string, current: string) { + const input = prompt('Neuer Name?', current) + if (input === null) return + store.renameClip(clipId, input) +} + +function deleteClip(clipId: string) { + if (!confirm('Clip und alle Instanzen wirklich löschen?')) return + store.removeClip(clipId) +} + +function onClipDragStart(e: DragEvent, clipId: string) { + if (!e.dataTransfer) return + e.dataTransfer.effectAllowed = 'copy' + e.dataTransfer.setData('application/x-animatr-clip', clipId) + e.dataTransfer.setData('text/plain', clipId) +} + async function handleFileSelected(event: Event) { uploadError.value = null const input = event.target as HTMLInputElement @@ -240,6 +269,47 @@ function circlePreviewStyle(asset: Extract) {
  • keine Assets
  • + +
    +

    Clips

    + +
    +
      +
    • +
      + +
      +
      + {{ clip.name }} + + {{ clip.layers.length }} Layer · {{ clip.duration.toFixed(1) }}s + +
      +
      + + +
      +
    • +
    • keine Clips
    • +
    @@ -439,4 +509,112 @@ h4 { font-style: italic; justify-content: center; } + +.clips-header { + margin-top: 14px; +} + +.clips { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.clip { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: var(--panel-alt); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: grab; + transition: background 140ms var(--ease), border-color 140ms var(--ease); +} + +.clip:hover { + background: var(--panel-hover); + border-color: var(--border-strong); +} + +.clip:active { + cursor: grabbing; +} + +.clip.editing { + border-color: var(--accent); + background: var(--accent-soft); +} + +.clip-icon { + width: 26px; + height: 26px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); + background: var(--bg); + border-radius: var(--radius-sm); +} + +.clip-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +.clip-name { + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.clip-sub { + font-size: 10px; + color: var(--text-dim); +} + +.clip-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 140ms var(--ease); +} + +.clip:hover .clip-actions, +.clip.editing .clip-actions { + opacity: 1; +} + +.clip-btn { + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + background: transparent; + border: 1px solid transparent; + color: var(--text-dim); + border-radius: 4px; +} + +.clip-btn:hover { + background: var(--panel); + color: var(--text); +} + +.clip-btn.danger:hover { + background: var(--danger-soft); + color: var(--danger); + border-color: var(--danger); +} diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue index 486b442..871cade 100644 --- a/src/components/CanvasStage.vue +++ b/src/components/CanvasStage.vue @@ -126,21 +126,86 @@ interface RenderedLayer { props: AnimatableProps } -const rendered = computed(() => - store.scene.layers - .slice() - .reverse() - .map((layer) => { +interface RenderedClipLayer extends RenderedLayer { + instanceId: string +} + +type StackItem = + | { kind: 'layer'; item: RenderedLayer } + | { kind: 'clip'; item: RenderedClipLayer } + +const stackItems = computed(() => { + // Clip-edit scope: just render active clip's layers (top of timeline = top of canvas) + if (store.activeClipId) { + const out: StackItem[] = [] + const layers = store.activeLayers.slice().reverse() + for (const layer of layers) { const asset = assetMap.value.get(layer.assetId) - if (!asset) return null - return { - layer, - asset, - props: sampleLayer(layer, store.playhead), + if (!asset) continue + out.push({ + kind: 'layer', + item: { layer, asset, props: sampleLayer(layer, store.playhead) }, + }) + } + return out + } + + const out: StackItem[] = [] + const layerById = new Map() + for (const l of store.scene.layers) layerById.set(l.id, l) + const clipById = new Map[number]>() + for (const c of store.scene.clips ?? []) clipById.set(c.id, c) + const instancesByTrack = new Map>() + for (const ci of store.scene.clipInstances ?? []) { + const arr = instancesByTrack.get(ci.clipTrackId) ?? [] + arr.push(ci) + instancesByTrack.set(ci.clipTrackId, arr) + } + + const order = store.scene.trackOrder ?? store.scene.layers.map((l) => l.id) + // iterate bottom → top: reverse the array + for (let i = order.length - 1; i >= 0; i--) { + const id = order[i] + const layer = layerById.get(id) + if (layer) { + const asset = assetMap.value.get(layer.assetId) + if (!asset) continue + out.push({ + kind: 'layer', + item: { layer, asset, props: sampleLayer(layer, store.playhead) }, + }) + continue + } + const instances = instancesByTrack.get(id) ?? [] + for (const inst of instances) { + const clip = clipById.get(inst.clipId) + if (!clip) continue + const trimStart = inst.trimStart ?? 0 + const trimEnd = inst.trimEnd ?? 0 + const visible = Math.max(0, clip.duration - trimStart - trimEnd) + const offsetIntoBlock = store.playhead - inst.startTime + if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue + const localTime = trimStart + offsetIntoBlock + // Clip's own layers: reverse too so layer[0] ends up on top + for (let k = clip.layers.length - 1; k >= 0; k--) { + const cl = clip.layers[k] + if (!cl.visible) continue + const ca = assetMap.value.get(cl.assetId) + if (!ca) continue + out.push({ + kind: 'clip', + item: { + instanceId: inst.id, + layer: cl, + asset: ca, + props: sampleLayer(cl, localTime), + }, + }) } - }) - .filter((x): x is RenderedLayer => x !== null), -) + } + } + return out +}) function baseNodeConfig(layer: Layer, props: AnimatableProps) { return { @@ -209,6 +274,70 @@ function imageConfig( } } +function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) { + return { + id: `ci:${instanceId}:${layer.id}`, + x: props.x, + y: props.y, + scaleX: props.scaleX, + scaleY: props.scaleY, + rotation: props.rotation, + opacity: props.opacity, + visible: layer.visible, + draggable: false, + listening: false, + } +} + +function clipRectConfig(item: RenderedClipLayer) { + const asset = item.asset as Extract + return { + ...clipBaseConfig(item.instanceId, item.layer, item.props), + width: asset.width, + height: asset.height, + offsetX: item.layer.offsetX ?? asset.width / 2, + offsetY: item.layer.offsetY ?? asset.height / 2, + fill: item.props.fill ?? asset.fill, + } +} + +function clipCircleConfig(item: RenderedClipLayer) { + const asset = item.asset as Extract + return { + ...clipBaseConfig(item.instanceId, item.layer, item.props), + radius: asset.radius, + offsetX: item.layer.offsetX ?? 0, + offsetY: item.layer.offsetY ?? 0, + fill: item.props.fill ?? asset.fill, + } +} + +function clipTextConfig(item: RenderedClipLayer) { + const asset = item.asset as Extract + return { + ...clipBaseConfig(item.instanceId, item.layer, item.props), + text: asset.text, + fontSize: asset.fontSize, + offsetX: item.layer.offsetX ?? 0, + offsetY: item.layer.offsetY ?? 0, + fill: item.props.fill ?? asset.fill, + } +} + +function clipImageConfig(item: RenderedClipLayer) { + const asset = item.asset as Extract + const img = imageCache.value[asset.id] + if (!img) return null + return { + ...clipBaseConfig(item.instanceId, item.layer, item.props), + image: img, + width: asset.width, + height: asset.height, + offsetX: item.layer.offsetX ?? asset.width / 2, + offsetY: item.layer.offsetY ?? asset.height / 2, + } +} + interface PivotInfo { layerId: string sampled: AnimatableProps @@ -217,7 +346,7 @@ interface PivotInfo { const pivotInfo = computed(() => { const id = store.selectedLayerId if (!id) return null - const layer = store.scene.layers.find((l) => l.id === id) + const layer = store.activeLayers.find((l) => l.id === id) if (!layer) return null const asset = assetMap.value.get(layer.assetId) if (!asset) return null @@ -303,7 +432,7 @@ function attachTransformer() { } watch( - () => [store.selectedLayerId, rendered.value.length, store.playhead] as const, + () => [store.selectedLayerId, stackItems.value.length, store.playhead] as const, async () => { await nextTick() attachTransformer() @@ -332,14 +461,14 @@ function handleStageMouseDown(e: Konva.KonvaEventObject) { store.selectLayer(null) return } - const match = store.scene.layers.find((l) => l.id === id) + const match = store.activeLayers.find((l) => l.id === id) store.selectLayer(match ? match.id : null) } function handleDragEnd(e: Konva.KonvaEventObject) { const node = e.target const id = node.id() - if (!store.scene.layers.some((l) => l.id === id)) return + if (!store.activeLayers.some((l) => l.id === id)) return store.upsertKeyframe(id, store.playhead, { x: node.x(), y: node.y(), @@ -349,7 +478,7 @@ function handleDragEnd(e: Konva.KonvaEventObject) { function handleTransformEnd(e: Konva.KonvaEventObject) { const node = e.target const id = node.id() - if (!store.scene.layers.some((l) => l.id === id)) return + if (!store.activeLayers.some((l) => l.id === id)) return store.upsertKeyframe(id, store.playhead, { x: node.x(), y: node.y(), @@ -530,31 +659,58 @@ onBeforeUnmount(() => { -