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(() => { - - - - - + + + + + + + + + + + + + diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index c4fe1f4..31e849e 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -11,7 +11,7 @@ const formatPercent = (v: number) => `${Math.round(v * 100)}%` const store = useSceneStore() const selected = computed(() => - store.scene.layers.find((l) => l.id === store.selectedLayerId) ?? null, + store.activeLayers.find((l) => l.id === store.selectedLayerId) ?? null, ) const sampled = computed(() => diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index b2c495f..611a6f1 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -12,21 +12,56 @@ import { import { useSceneStore } from '@/stores/scene' import { sampleLayer } from '@/lib/interpolate' import { snapToCandidates } from '@/lib/timelineSnap' -import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene' +import type { + AnimatableProps, + Clip, + ClipInstance, + Easing, + Keyframe, + Layer, +} from '@/types/scene' -interface TimelineTrack { +interface TimelineLayerTrack { + type: 'layer' layerId: string layer: Layer kfs: Keyframe[] kind: 'main' | 'position' | 'rotation' label: string isParent: boolean - layerIndex: number + orderIndex: number } +interface TimelineClipTrack { + type: 'clip' + clipTrackId: string + name: string + orderIndex: number +} + +type TimelineTrack = TimelineLayerTrack | TimelineClipTrack + const store = useSceneStore() -const duration = computed(() => store.scene.duration) +const duration = computed(() => store.activeDuration) +const inClipEdit = computed(() => store.activeClipId !== null) +const clipInstancesByTrack = computed>(() => { + const out: Record = {} + for (const ci of store.scene.clipInstances ?? []) { + ;(out[ci.clipTrackId] ||= []).push(ci) + } + return out +}) +const clipById = computed(() => { + const m = new Map() + for (const c of store.scene.clips ?? []) m.set(c.id, c) + return m +}) +const clipTrackById = computed(() => { + const m = new Map[number]>() + for (const t of store.scene.clipTracks ?? []) m.set(t.id, t) + return m +}) const NAME_WIDTH = 140 const MIN_PX_PER_SEC = 2 @@ -163,14 +198,14 @@ function onLaneClick(e: MouseEvent) { store.setPlayhead(snapTime(ratio * duration.value)) } -function onLaneDblClick(e: MouseEvent, track: TimelineTrack) { +function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) { e.stopPropagation() const el = e.currentTarget as HTMLElement const rect = el.getBoundingClientRect() if (rect.width <= 0) return const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) const time = snapTime(ratio * duration.value) - const layer = store.scene.layers.find((l) => l.id === track.layerId) + const layer = store.activeLayers.find((l) => l.id === track.layerId) if (!layer) return const sampled = sampleLayer(layer, time) const props: Partial = {} @@ -217,64 +252,63 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string) const tracks = computed(() => { const out: TimelineTrack[] = [] - store.scene.layers.forEach((layer, layerIndex) => { - if (!layer.separated) { - out.push({ - layerId: layer.id, - layer, - kfs: layer.keyframes, - kind: 'main', - label: layer.name, - isParent: true, - layerIndex, - }) - } else { - out.push({ - layerId: layer.id, - layer, - kfs: layer.keyframes, - kind: 'main', - label: layer.name, - isParent: true, - layerIndex, - }) - out.push({ - layerId: layer.id, - layer, - kfs: layer.positionKeyframes ?? [], - kind: 'position', - label: 'Position', - isParent: false, - layerIndex, - }) - out.push({ - layerId: layer.id, - layer, - kfs: layer.rotationKeyframes ?? [], - kind: 'rotation', - label: 'Rotation', - isParent: false, - layerIndex, - }) + if (store.activeClipId) { + // Clip-edit scope: only show clip's layers (no clip tracks inside a clip) + store.activeLayers.forEach((layer, orderIndex) => { + if (!layer.separated) { + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex }) + } else { + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex }) + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex }) + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex }) + } + }) + return out + } + const order = store.scene.trackOrder ?? [] + const layerById = new Map() + for (const l of store.scene.layers) layerById.set(l.id, l) + order.forEach((id, orderIndex) => { + const layer = layerById.get(id) + if (layer) { + if (!layer.separated) { + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex }) + } else { + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex }) + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex }) + out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex }) + } + return + } + const ct = clipTrackById.value.get(id) + if (ct) { + out.push({ type: 'clip', clipTrackId: ct.id, name: ct.name ?? 'Clip-Spur', orderIndex }) } }) return out }) +function trackDndId(track: TimelineTrack): string { + return track.type === 'layer' ? track.layerId : track.clipTrackId +} + +function isTrackHead(track: TimelineTrack): boolean { + if (track.type === 'clip') return true + return track.isParent +} + function isDropBefore(track: TimelineTrack): boolean { - return ( - dropIndex.value !== null && - track.isParent && - dropIndex.value === track.layerIndex && - dragLayerId.value !== track.layerId - ) + if (dropIndex.value === null) return false + if (!isTrackHead(track)) return false + if (dragTrackId.value === trackDndId(track)) return false + return dropIndex.value === track.orderIndex } function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean { if (dropIndex.value === null) return false - if (dragLayerId.value === track.layerId) return false - if (dropIndex.value !== track.layerIndex + 1) return false - if (nextTrack && nextTrack.layerIndex === track.layerIndex) return false + if (dragTrackId.value === trackDndId(track)) return false + if (dropIndex.value !== track.orderIndex + 1) return false + if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false return true } @@ -454,55 +488,230 @@ interface TrackMenuState { separated: boolean } -const dragLayerId = ref(null) +const dragTrackId = ref(null) const dropIndex = ref(null) -function onTrackDragStart(e: DragEvent, layerId: string) { +function onTrackDragStart(e: DragEvent, trackId: string) { if (!e.dataTransfer) return - dragLayerId.value = layerId + dragTrackId.value = trackId e.dataTransfer.effectAllowed = 'move' try { - e.dataTransfer.setData('text/plain', layerId) + e.dataTransfer.setData('text/plain', trackId) } catch { - /* Safari older versions */ + /* ignore */ } } -function onTrackDragOver(e: DragEvent, layerId: string) { - if (!dragLayerId.value) return +function onTrackDragOver(e: DragEvent, orderIndex: number) { + if (!dragTrackId.value) return if (!e.dataTransfer) return e.preventDefault() e.dataTransfer.dropEffect = 'move' const el = e.currentTarget as HTMLElement const rect = el.getBoundingClientRect() const midY = rect.top + rect.height / 2 - const index = store.scene.layers.findIndex((l) => l.id === layerId) - if (index < 0) return - dropIndex.value = e.clientY < midY ? index : index + 1 + dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1 } function onTrackDragLeave() { - /* dropIndex updates on next dragover, keep as-is to avoid flicker */ + /* keep dropIndex to avoid flicker */ } function onTrackDrop(e: DragEvent) { e.preventDefault() - const srcId = dragLayerId.value + const srcId = dragTrackId.value const target = dropIndex.value - dragLayerId.value = null + dragTrackId.value = null dropIndex.value = null if (!srcId || target === null) return - const from = store.scene.layers.findIndex((l) => l.id === srcId) + const order = store.scene.trackOrder ?? [] + const from = order.indexOf(srcId) if (from < 0) return let to = target if (from < to) to -= 1 - store.moveLayer(from, to) + store.moveTrack(from, to) } function onTrackDragEnd() { - dragLayerId.value = null + dragTrackId.value = null dropIndex.value = null } + +interface ClipDrag { + kind: 'new' | 'move' + clipId: string + instanceId?: string + grabOffsetSec: number +} + +const clipDrag = ref(null) + +function pixelTimeFromEvent(e: DragEvent, el: HTMLElement): number { + const rect = el.getBoundingClientRect() + if (rect.width <= 0) return 0 + const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + return ratio * duration.value +} + +function onClipLaneDragOver(e: DragEvent, _clipTrackId: string) { + if (!clipDrag.value) { + if (e.dataTransfer?.types.includes('application/x-animatr-clip')) { + e.preventDefault() + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' + } + return + } + e.preventDefault() + if (e.dataTransfer) e.dataTransfer.dropEffect = clipDrag.value.kind === 'new' ? 'copy' : 'move' +} + +function onClipLaneDrop(e: DragEvent, clipTrackId: string) { + e.preventDefault() + const el = e.currentTarget as HTMLElement + const time = pixelTimeFromEvent(e, el) + const cd = clipDrag.value + if (cd) { + const start = Math.max(0, time - cd.grabOffsetSec) + if (cd.kind === 'move' && cd.instanceId) { + store.moveClipInstance(cd.instanceId, start, clipTrackId) + } else { + store.addClipInstance(cd.clipId, start, clipTrackId) + } + clipDrag.value = null + return + } + const clipId = e.dataTransfer?.getData('application/x-animatr-clip') + if (clipId) { + store.addClipInstance(clipId, time, clipTrackId) + } +} + +function onClipBlockDragStart(e: DragEvent, ci: ClipInstance) { + if (clipTrim.value) { + e.preventDefault() + return + } + if (!e.dataTransfer) return + const el = e.currentTarget as HTMLElement + const rect = el.getBoundingClientRect() + const visible = visibleDurationOf(ci) + const grabOffsetSec = + rect.width > 0 ? ((e.clientX - rect.left) / rect.width) * visible : 0 + clipDrag.value = { kind: 'move', clipId: ci.clipId, instanceId: ci.id, grabOffsetSec } + e.dataTransfer.effectAllowed = 'move' + try { + e.dataTransfer.setData('application/x-animatr-clip-move', ci.id) + } catch { + /* ignore */ + } +} + +function onClipBlockDragEnd() { + clipDrag.value = null +} + +function addClipTrack() { + store.addClipTrack() +} + +function clipDurationOf(ci: ClipInstance): number { + return clipById.value.get(ci.clipId)?.duration ?? 1 +} + +function visibleDurationOf(ci: ClipInstance): number { + return Math.max( + 0.05, + clipDurationOf(ci) - (ci.trimStart ?? 0) - (ci.trimEnd ?? 0), + ) +} + +function clipBlockStyle(ci: ClipInstance) { + return { + left: pxFromTime(ci.startTime) + 'px', + width: Math.max(6, pxFromTime(visibleDurationOf(ci))) + 'px', + } +} + +function trimTooltip(ci: ClipInstance): string { + const name = clipById.value.get(ci.clipId)?.name ?? '?' + const dur = visibleDurationOf(ci).toFixed(2) + const trims: string[] = [] + if ((ci.trimStart ?? 0) > 0) trims.push(`Start -${(ci.trimStart ?? 0).toFixed(2)}s`) + if ((ci.trimEnd ?? 0) > 0) trims.push(`Ende -${(ci.trimEnd ?? 0).toFixed(2)}s`) + const suffix = trims.length ? ` · ${trims.join(' · ')}` : '' + return `${name} · ${dur}s${suffix} · Kanten ziehen zum Verkürzen, Doppelklick zum Öffnen` +} + +interface ClipTrimState { + instanceId: string + side: 'start' | 'end' + pointerId: number + startX: number + initialStartTime: number + initialTrimStart: number + initialTrimEnd: number +} + +const clipTrim = ref(null) + +function onClipHandlePointerDown(e: PointerEvent, ci: ClipInstance, side: 'start' | 'end') { + if (e.button !== 0) return + e.preventDefault() + e.stopPropagation() + const el = e.currentTarget as HTMLElement + el.setPointerCapture(e.pointerId) + clipTrim.value = { + instanceId: ci.id, + side, + pointerId: e.pointerId, + startX: e.clientX, + initialStartTime: ci.startTime, + initialTrimStart: ci.trimStart ?? 0, + initialTrimEnd: ci.trimEnd ?? 0, + } +} + +function onClipHandlePointerMove(e: PointerEvent) { + const t = clipTrim.value + if (!t || t.pointerId !== e.pointerId) return + const pps = effectivePxPerSec.value + if (pps <= 0) return + const deltaSec = (e.clientX - t.startX) / pps + if (t.side === 'start') { + // shift block right = trim more from start + shift startTime + store.trimClipInstance(t.instanceId, { + trimStart: t.initialTrimStart + deltaSec, + startTime: t.initialStartTime + deltaSec, + }) + } else { + // shift right edge right = less trimEnd (reveal more) + store.trimClipInstance(t.instanceId, { + trimEnd: t.initialTrimEnd - deltaSec, + }) + } +} + +function onClipHandlePointerUp(e: PointerEvent) { + const t = clipTrim.value + if (!t || t.pointerId !== e.pointerId) return + const el = e.currentTarget as HTMLElement + if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId) + clipTrim.value = null +} + +function onClipBlockDblClick(ci: ClipInstance) { + store.enterClip(ci.clipId) +} + +function onClipBlockContextMenu(e: MouseEvent, ci: ClipInstance) { + e.preventDefault() + if (!confirm('Clip-Instanz entfernen?')) return + store.removeClipInstance(ci.id) +} + +function exitClip() { + store.exitClip() +} const trackMenu = ref(null) const trackMenuRef = ref(null) @@ -659,87 +868,175 @@ onBeforeUnmount(() => { - - - - - ↳ - {{ track.label }} - + + - - + class="track-name" + :class="{ + selected: track.layerId === store.selectedLayerId && track.isParent, + parent: track.isParent, + sub: !track.isParent, + }" + :draggable="track.isParent" + @click="store.selectLayer(track.layerId)" + @contextmenu="track.isParent ? onTrackContextMenu($event, track.layerId) : null" + @dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null" + @dragend="onTrackDragEnd" + > + + + ↳ + {{ track.label }} + + + + + + + + + + + + + {{ track.name }} + + + + + + {{ clipById.get(ci.clipId)?.name ?? '?' }} + + + + + + Noch keine Tracks. + + + + Clip-Spur hinzufügen + - Noch keine Layer. + + + ← Haupt-Timeline + + + + {{ store.activeClip?.name ?? '' }} + + + { font-size: 12px; } +.track.clip-track { + height: 34px; + background: linear-gradient( + to bottom, + transparent 0%, + rgba(110, 168, 255, 0.04) 50%, + transparent 100% + ); +} + +.clip-track .track-name { + background: var(--panel-alt); + font-size: 11px; + color: var(--text-dim); +} + +.clip-track-name { + gap: 6px; +} + +.clip-lane { + position: relative; +} + +.clip-block { + position: absolute; + top: 4px; + bottom: 4px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 8px; + background: var(--accent-soft); + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + color: var(--accent); + font-size: 11px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + cursor: grab; + touch-action: none; + z-index: 2; + transition: background 140ms var(--ease), transform 140ms var(--ease); +} + +.clip-block:hover { + background: var(--accent-soft-strong); +} + +.clip-block:active { + cursor: grabbing; +} + +.clip-block-icon { + flex-shrink: 0; + opacity: 0.9; +} + +.clip-block-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.clip-handle { + position: absolute; + top: 0; + bottom: 0; + width: 8px; + cursor: ew-resize; + z-index: 3; + touch-action: none; + background: linear-gradient( + to right, + rgba(255, 255, 255, 0.35), + rgba(255, 255, 255, 0) + ); + opacity: 0; + transition: opacity 120ms var(--ease), background 120ms var(--ease); +} + +.clip-handle.start { + left: 0; + border-top-left-radius: var(--radius-sm); + border-bottom-left-radius: var(--radius-sm); +} + +.clip-handle.end { + right: 0; + background: linear-gradient( + to left, + rgba(255, 255, 255, 0.35), + rgba(255, 255, 255, 0) + ); + border-top-right-radius: var(--radius-sm); + border-bottom-right-radius: var(--radius-sm); +} + +.clip-handle::after { + content: ''; + position: absolute; + top: 20%; + bottom: 20%; + width: 2px; + border-radius: 2px; + background: var(--accent); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3); +} + +.clip-handle.start::after { + left: 2px; +} + +.clip-handle.end::after { + right: 2px; +} + +.clip-block:hover .clip-handle { + opacity: 1; +} + +.clip-block.trimmed-start .clip-handle.start, +.clip-block.trimmed-end .clip-handle.end { + opacity: 0.75; +} + +.clip-block.trimmed-start .clip-handle.start::after, +.clip-block.trimmed-end .clip-handle.end::after { + background: #ffc857; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); +} + +.clip-block.trimming .clip-handle { + opacity: 1; +} + +.clip-block.trimming { + box-shadow: 0 0 0 1px #ffc857; +} + +.clip-handle:hover, +.clip-handle:active { + background: rgba(255, 255, 255, 0.25) !important; + opacity: 1 !important; +} + +.add-clip-track { + height: 26px; + background: transparent; + border-bottom: 1px dashed var(--border); +} + +.add-clip-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 100%; + padding: 0 14px; + background: transparent; + border: none; + color: var(--text-dim); + font-size: 11px; +} + +.add-clip-btn:hover { + color: var(--accent); +} + +.clip-breadcrumb { + position: absolute; + top: 4px; + left: 12px; + z-index: 15; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 4px 10px 4px 8px; + background: var(--panel); + border: 1px solid var(--border-strong); + border-radius: 999px; + box-shadow: var(--shadow-sm); + font-size: 11px; +} + +.breadcrumb-btn { + padding: 2px 8px; + background: transparent; + border: none; + color: var(--text-dim); + font-size: 11px; + border-radius: 999px; +} + +.breadcrumb-btn:hover { + color: var(--accent); +} + +.breadcrumb-current { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--accent); + font-weight: 500; +} + .kf-menu { position: fixed; z-index: 1000; diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index aa0ed6a..f31100b 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -986,6 +986,149 @@ export function registerAllTests(): void { }) }) + suite('clips — library & scope', () => { + test('addClip creates empty clip in library', () => { + const s = makeFreshStore() + const c = s.addClip('Intro') + expect(s.scene.clips?.length).toBe(1) + expect(c.name).toBe('Intro') + expect(c.layers).toHaveLength(0) + expect(c.duration).toBeGreaterThan(0) + }) + + test('enterClip switches activeLayers + activeDuration to clip scope', async () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'SceneL', width: 20, height: 20, fill: '#aaa' }) + s.addLayer(a.id) + const c = s.addClip('C1') + c.duration = 3.5 + expect(s.activeLayers).toHaveLength(1) + expect(s.activeDuration).toBe(s.scene.duration) + s.enterClip(c.id) + await tick() + expect(s.activeLayers).toHaveLength(0) + expect(s.activeDuration).toBe(3.5) + expect(s.playhead).toBe(0) + s.exitClip() + expect(s.activeLayers).toHaveLength(1) + expect(s.activeDuration).toBe(s.scene.duration) + }) + + test('addLayer while in clip scope targets clip layers, not scene layers', async () => { + const s = makeFreshStore() + const c = s.addClip('C2') + const a = s.addAsset({ kind: 'rect', name: 'X', width: 20, height: 20, fill: '#888' }) + s.enterClip(c.id) + await tick() + s.addLayer(a.id) + expect(c.layers).toHaveLength(1) + expect(s.scene.layers).toHaveLength(0) + }) + + test('addClip auto-creates a first clip track if none exist', () => { + const s = makeFreshStore() + expect(s.scene.clipTracks ?? []).toHaveLength(0) + s.addClip('Auto') + expect((s.scene.clipTracks ?? []).length).toBeGreaterThan(0) + expect((s.scene.trackOrder ?? []).length).toBeGreaterThan(0) + }) + + test('addClipInstance anchors to a specific clipTrackId', () => { + const s = makeFreshStore() + const c = s.addClip('C3') + const t = s.scene.clipTracks![0] + const ci = s.addClipInstance(c.id, 2.5, t.id) + expect(ci).toBeTruthy() + expect(s.scene.clipInstances).toHaveLength(1) + expect(ci!.startTime).toBe(2.5) + expect(ci!.clipTrackId).toBe(t.id) + }) + + test('moveClipInstance updates position + can switch track', () => { + const s = makeFreshStore() + const c = s.addClip('C4') + const t1 = s.scene.clipTracks![0] + const t2 = s.addClipTrack() + const ci = s.addClipInstance(c.id, 0, t1.id)! + s.moveClipInstance(ci.id, 4, t2.id) + expect(ci.startTime).toBe(4) + expect(ci.clipTrackId).toBe(t2.id) + }) + + test('removeClip removes all instances and exits if active', () => { + const s = makeFreshStore() + const c = s.addClip('C5') + const t = s.scene.clipTracks![0] + s.addClipInstance(c.id, 0, t.id) + s.addClipInstance(c.id, 1, t.id) + s.enterClip(c.id) + s.removeClip(c.id) + expect(s.scene.clips).toHaveLength(0) + expect(s.scene.clipInstances).toHaveLength(0) + expect(s.activeClipId).toBeNull() + }) + + test('negative startTime is clamped to 0', () => { + const s = makeFreshStore() + const c = s.addClip('C6') + const t = s.scene.clipTracks![0] + const ci = s.addClipInstance(c.id, -5, t.id)! + expect(ci.startTime).toBe(0) + }) + + test('trimClipInstance clamps trimStart/trimEnd and preserves min visible duration', () => { + const s = makeFreshStore() + const c = s.addClip('Trim') + c.duration = 4 + const t = s.scene.clipTracks![0] + const ci = s.addClipInstance(c.id, 0, t.id)! + s.trimClipInstance(ci.id, { trimStart: 1 }) + expect(ci.trimStart).toBe(1) + s.trimClipInstance(ci.id, { trimEnd: 1 }) + expect(ci.trimEnd).toBe(1) + // try to over-trim: start + end > duration - MIN_VISIBLE + s.trimClipInstance(ci.id, { trimStart: 10 }) + const total = (ci.trimStart ?? 0) + (ci.trimEnd ?? 0) + expect(total).toBeLessThan(c.duration) + }) + + test('trimClipInstance accepts startTime shift (left-handle drag)', () => { + const s = makeFreshStore() + const c = s.addClip('TrimLeft') + c.duration = 4 + const t = s.scene.clipTracks![0] + const ci = s.addClipInstance(c.id, 2, t.id)! + s.trimClipInstance(ci.id, { startTime: 2.5, trimStart: 0.5 }) + expect(ci.startTime).toBe(2.5) + expect(ci.trimStart).toBe(0.5) + }) + + test('negative trim values are clamped to zero', () => { + const s = makeFreshStore() + const c = s.addClip('Neg') + c.duration = 3 + const t = s.scene.clipTracks![0] + const ci = s.addClipInstance(c.id, 0, t.id)! + s.trimClipInstance(ci.id, { trimStart: -1, trimEnd: -2 }) + expect(ci.trimStart).toBe(0) + expect(ci.trimEnd).toBe(0) + }) + + test('moveTrack reorders the unified stack (layer above clip track)', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'M', width: 10, height: 10, fill: '#000' }) + const l = s.addLayer(a.id) + s.addClip('X') + const track = s.scene.clipTracks![0] + // order is [layer, clipTrack] + expect(s.scene.trackOrder![0]).toBe(l.id) + expect(s.scene.trackOrder![1]).toBe(track.id) + s.moveTrack(0, 2) + expect(s.scene.trackOrder![0]).toBe(track.id) + expect(s.scene.trackOrder![1]).toBe(l.id) + }) + }) + suite('app smoke — live DOM', () => { test('app root, topbar, timeline, stage are mounted', () => { expect(document.querySelector('.app')).toBeTruthy() diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index d545311..8ba39d2 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -89,6 +89,19 @@ const ENGINE_SOURCE = `(function(){ var kfs=scene.layers[i].keyframes; for(var j=0;jmax) max=kfs[j].time; } } + var instances=scene.clipInstances||[]; + var clips=scene.clips||[]; + var clipById={}; + for(var ci=0;cimax) max=end; + } if(max<=0) return scene.duration; return Math.min(max,scene.duration); } @@ -127,6 +140,44 @@ const ENGINE_SOURCE = `(function(){ draw(t); requestAnimationFrame(frame); } + function drawLayer(layer,time){ + var asset=assetMap[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); + 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=p.fill||asset.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=p.fill||asset.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=p.fill||asset.fill; + ctx.font=asset.fontSize+'px sans-serif'; + ctx.textBaseline='top'; + ctx.fillText(asset.text,-tOffX,-tOffY); + }else if(asset.kind==='image'){ + var img=imgs[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 draw(time){ var lw=scene.canvas.width,lh=scene.canvas.height; var cw=size.w,ch=size.h; @@ -144,45 +195,38 @@ const ENGINE_SOURCE = `(function(){ ctx.clip(); ctx.fillStyle=scene.canvas.background; ctx.fillRect(0,0,lw,lh); - for(var i=scene.layers.length-1;i>=0;i--){ - var layer=scene.layers[i]; - if(!layer.visible)continue; - var asset=assetMap[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); - 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=p.fill||asset.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=p.fill||asset.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=p.fill||asset.fill; - ctx.font=asset.fontSize+'px sans-serif'; - ctx.textBaseline='top'; - ctx.fillText(asset.text,-tOffX,-tOffY); - }else if(asset.kind==='image'){ - var img=imgs[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){ drawLayer(lyr,time); continue; } + var tInsts=instByTrack[id]||[]; + for(var ti=0;tivisible)continue; + var localT=tS+offset; + for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){ + drawLayer(clip2.layers[cl2],localT); } } - ctx.restore(); } ctx.restore(); } diff --git a/src/lib/render.ts b/src/lib/render.ts index 3f8f66d..f225e7c 100644 --- a/src/lib/render.ts +++ b/src/lib/render.ts @@ -1,4 +1,4 @@ -import type { Asset, Scene } from '@/types/scene' +import type { Asset, Layer, Scene } from '@/types/scene' import { sampleLayer } from './interpolate' export async function loadSceneImages(scene: Scene): Promise> { @@ -24,27 +24,15 @@ export async function loadSceneImages(scene: Scene): Promise, images: Record, + time: number, ) { - const lw = scene.canvas.width - const lh = scene.canvas.height - - ctx.save() - ctx.beginPath() - ctx.rect(0, 0, lw, lh) - ctx.clip() - ctx.fillStyle = scene.canvas.background - ctx.fillRect(0, 0, lw, lh) - - const assetMap = new Map() - for (const a of scene.assets) assetMap.set(a.id, a) - - for (let i = scene.layers.length - 1; i >= 0; i--) { - const layer = scene.layers[i] + for (let i = layers.length - 1; i >= 0; i--) { + const layer = layers[i] if (!layer.visible) continue const asset = assetMap.get(layer.assetId) if (!asset) continue @@ -86,6 +74,58 @@ export function drawSceneFrame( ctx.restore() } +} + +export function drawSceneFrame( + ctx: CanvasRenderingContext2D, + scene: Scene, + time: number, + images: Record, +) { + const lw = scene.canvas.width + const lh = scene.canvas.height + + ctx.save() + ctx.beginPath() + ctx.rect(0, 0, lw, lh) + ctx.clip() + ctx.fillStyle = scene.canvas.background + ctx.fillRect(0, 0, lw, lh) + + const assetMap = new Map() + for (const a of scene.assets) assetMap.set(a.id, a) + + const layerById = new Map() + for (const l of scene.layers) layerById.set(l.id, l) + const instancesByTrack = new Map() + for (const ci of scene.clipInstances ?? []) { + const arr = instancesByTrack.get(ci.clipTrackId) ?? [] + arr.push(ci) + instancesByTrack.set(ci.clipTrackId, arr) + } + const clipById = new Map[number]>() + for (const c of scene.clips ?? []) clipById.set(c.id, c) + + const order = scene.trackOrder ?? scene.layers.map((l) => l.id) + for (let i = order.length - 1; i >= 0; i--) { + const id = order[i] + const layer = layerById.get(id) + if (layer) { + drawLayers(ctx, [layer], assetMap, images, time) + 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 = time - inst.startTime + if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue + drawLayers(ctx, clip.layers, assetMap, images, trimStart + offsetIntoBlock) + } + } ctx.restore() } diff --git a/src/stores/scene.ts b/src/stores/scene.ts index d00d3e2..9bfed3f 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -1,5 +1,15 @@ import { defineStore } from 'pinia' -import type { AnimatableProps, Asset, Easing, Keyframe, Layer, Scene } from '@/types/scene' +import type { + AnimatableProps, + Asset, + Clip, + ClipInstance, + ClipTrack, + Easing, + Keyframe, + Layer, + Scene, +} from '@/types/scene' import { DEFAULT_BASE_PROPS } from '@/types/scene' import { sampleLayer } from '@/lib/interpolate' @@ -14,6 +24,7 @@ interface State { selectedLayerId: string | null playhead: number playing: boolean + activeClipId: string | null } function allKeyframes(layer: Layer): Keyframe[] { @@ -90,39 +101,86 @@ function hasKeys(obj: Partial): boolean { return false } +function normalizeScene(scene: Scene): Scene { + scene.clips ||= [] + scene.clipTracks ||= [] + scene.clipInstances ||= [] + if (!scene.trackOrder) { + scene.trackOrder = [] + for (const l of scene.layers) scene.trackOrder.push(l.id) + for (const t of scene.clipTracks) scene.trackOrder.push(t.id) + } else { + const known = new Set() + for (const l of scene.layers) known.add(l.id) + for (const t of scene.clipTracks) known.add(t.id) + scene.trackOrder = scene.trackOrder.filter((id) => known.has(id)) + for (const l of scene.layers) if (!scene.trackOrder.includes(l.id)) scene.trackOrder.unshift(l.id) + for (const t of scene.clipTracks) if (!scene.trackOrder.includes(t.id)) scene.trackOrder.push(t.id) + } + return scene +} + export const useSceneStore = defineStore('scene', { state: (): State => ({ - scene: { + scene: normalizeScene({ version: 1, canvas: { width: 1280, height: 720, background: '#ffffff' }, duration: 10, fps: 60, assets: [], layers: [], - }, + }), selectedLayerId: null, playhead: 0, playing: false, + activeClipId: null, }), getters: { - lastKeyframeTime(state): number { - let max = 0 - for (const layer of state.scene.layers) { - for (const kf of allKeyframes(layer)) { - if (kf.time > max) max = kf.time - } - } - return Math.min(state.scene.duration, max) + activeClip(state): Clip | null { + if (!state.activeClipId) return null + const clips = state.scene.clips ?? [] + return clips.find((c) => c.id === state.activeClipId) ?? null }, - effectiveEnd(state): number { + activeLayers(): Layer[] { + const clip = this.activeClip + return clip ? clip.layers : this.scene.layers + }, + activeDuration(): number { + const clip = this.activeClip + return clip ? clip.duration : this.scene.duration + }, + lastKeyframeTime(): number { let max = 0 - for (const layer of state.scene.layers) { + for (const layer of this.activeLayers) { for (const kf of allKeyframes(layer)) { if (kf.time > max) max = kf.time } } - if (max <= 0) return state.scene.duration - return Math.min(max, state.scene.duration) + return Math.min(this.activeDuration, max) + }, + effectiveEnd(): number { + let max = 0 + for (const layer of this.activeLayers) { + for (const kf of allKeyframes(layer)) { + if (kf.time > max) max = kf.time + } + } + if (!this.activeClipId) { + const clipById = new Map() + for (const c of this.scene.clips ?? []) clipById.set(c.id, c) + for (const inst of this.scene.clipInstances ?? []) { + const clip = clipById.get(inst.clipId) + if (!clip) continue + const visible = Math.max( + 0, + clip.duration - (inst.trimStart ?? 0) - (inst.trimEnd ?? 0), + ) + const end = inst.startTime + visible + if (end > max) max = end + } + } + if (max <= 0) return this.activeDuration + return Math.min(max, this.activeDuration) }, }, actions: { @@ -163,16 +221,25 @@ export const useSceneStore = defineStore('scene', { layer.offsetY = 0 } } - this.scene.layers.unshift(layer) + this.activeLayers.unshift(layer) + if (!this.activeClipId) { + const order = (this.scene.trackOrder ||= []) + if (!order.includes(layer.id)) order.unshift(layer.id) + } this.selectedLayerId = layer.id return layer }, removeLayer(id: string) { - this.scene.layers = this.scene.layers.filter((l) => l.id !== id) + const layers = this.activeLayers + const idx = layers.findIndex((l) => l.id === id) + if (idx >= 0) layers.splice(idx, 1) + if (!this.activeClipId && this.scene.trackOrder) { + this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id) + } if (this.selectedLayerId === id) this.selectedLayerId = null }, moveLayer(fromIndex: number, toIndex: number) { - const arr = this.scene.layers + const arr = this.activeLayers if (fromIndex < 0 || fromIndex >= arr.length) return if (toIndex < 0 || toIndex > arr.length) return const clamped = Math.min(toIndex, arr.length - 1) @@ -180,15 +247,30 @@ export const useSceneStore = defineStore('scene', { const [moved] = arr.splice(fromIndex, 1) arr.splice(clamped, 0, moved) }, + moveTrack(fromIndex: number, toIndex: number) { + const order = this.scene.trackOrder ?? [] + if (fromIndex < 0 || fromIndex >= order.length) return + if (toIndex < 0 || toIndex > order.length) return + const clamped = Math.min(toIndex, order.length - 1) + if (fromIndex === clamped) return + const [moved] = order.splice(fromIndex, 1) + order.splice(clamped, 0, moved) + }, selectLayer(id: string | null) { this.selectedLayerId = id }, setPlayhead(t: number) { - this.playhead = Math.max(0, Math.min(this.scene.duration, t)) + this.playhead = Math.max(0, Math.min(this.activeDuration, t)) }, setDuration(seconds: number) { - this.scene.duration = Math.max(0.1, seconds) - if (this.playhead > this.scene.duration) this.playhead = this.scene.duration + const clip = this.activeClip + if (clip) { + clip.duration = Math.max(0.1, seconds) + if (this.playhead > clip.duration) this.playhead = clip.duration + } else { + this.scene.duration = Math.max(0.1, seconds) + if (this.playhead > this.scene.duration) this.playhead = this.scene.duration + } }, setFps(fps: number) { this.scene.fps = Math.max(1, Math.round(fps)) @@ -201,7 +283,7 @@ export const useSceneStore = defineStore('scene', { this.scene.canvas.background = color }, upsertKeyframe(layerId: string, time: number, props: Partial) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return if (!layer.separated) { const snapshot = sampleLayer(layer, time) @@ -224,7 +306,7 @@ export const useSceneStore = defineStore('scene', { } }, removeKeyframeAt(layerId: string, time: number) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const filter = (kfs?: Keyframe[]) => kfs ? kfs.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) : kfs @@ -233,7 +315,7 @@ export const useSceneStore = defineStore('scene', { if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes) }, removeKeyframe(layerId: string, keyframeId: string) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return @@ -241,13 +323,13 @@ export const useSceneStore = defineStore('scene', { if (idx >= 0) found.arr.splice(idx, 1) }, setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (found) found.kf.easing = easing }, setKeyframeHold(layerId: string, keyframeId: string, hold: boolean) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return @@ -255,11 +337,11 @@ export const useSceneStore = defineStore('scene', { else delete found.kf.hold }, setKeyframeTime(layerId: string, keyframeId: string, newTime: number) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return const found = findKf(layer, keyframeId) if (!found) return - found.kf.time = Math.max(0, Math.min(this.scene.duration, newTime)) + found.kf.time = Math.max(0, Math.min(this.activeDuration, newTime)) sortKfs(found.arr) }, shiftLayerPivot( @@ -269,7 +351,7 @@ export const useSceneStore = defineStore('scene', { dLocalX: number, dLocalY: number, ) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return layer.offsetX = (layer.offsetX ?? 0) + dLocalX layer.offsetY = (layer.offsetY ?? 0) + dLocalY @@ -286,14 +368,14 @@ export const useSceneStore = defineStore('scene', { shift(layer.positionKeyframes) }, duplicateKeyframe(layerId: string, keyframeId: string): string | null { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return null const found = findKf(layer, keyframeId) if (!found) return null const { kf: source, arr } = found const fps = this.scene.fps > 0 ? this.scene.fps : 60 const offset = Math.max(3 / fps, 0.1) - const duration = this.scene.duration + const duration = this.activeDuration const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON) let target = source.time + offset while (occupied(target) && target < duration) target += offset @@ -315,7 +397,7 @@ export const useSceneStore = defineStore('scene', { return newKf.id }, setLayerSeparated(layerId: string, separated: boolean) { - const layer = this.scene.layers.find((l) => l.id === layerId) + const layer = this.activeLayers.find((l) => l.id === layerId) if (!layer) return if (!!layer.separated === separated) return if (separated) { @@ -381,7 +463,123 @@ export const useSceneStore = defineStore('scene', { } }, loadScene(scene: Scene) { - this.scene = scene + this.scene = normalizeScene(scene) + this.selectedLayerId = null + this.playhead = 0 + this.playing = false + this.activeClipId = null + }, + addClip(name?: string): Clip { + const clip: Clip = { + id: uid(), + name: name?.trim() || 'Clip', + duration: Math.max(1, Math.min(5, this.scene.duration)), + layers: [], + } + ;(this.scene.clips ||= []).push(clip) + if (!this.scene.clipTracks?.length) this.addClipTrack() + return clip + }, + removeClip(id: string) { + if (this.scene.clips) { + this.scene.clips = this.scene.clips.filter((c) => c.id !== id) + } + if (this.scene.clipInstances) { + this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipId !== id) + } + if (this.activeClipId === id) { + this.activeClipId = null + this.selectedLayerId = null + this.playhead = 0 + } + }, + renameClip(id: string, name: string) { + const clip = this.scene.clips?.find((c) => c.id === id) + if (clip) clip.name = name.trim() || clip.name + }, + addClipTrack(name?: string): ClipTrack { + const track: ClipTrack = { id: uid(), name } + ;(this.scene.clipTracks ||= []).push(track) + ;(this.scene.trackOrder ||= []).push(track.id) + return track + }, + removeClipTrack(id: string) { + if (this.scene.clipTracks) { + this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id) + } + if (this.scene.clipInstances) { + this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipTrackId !== id) + } + if (this.scene.trackOrder) { + this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id) + } + }, + addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null { + const clip = this.scene.clips?.find((c) => c.id === clipId) + if (!clip) return null + const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId) + if (!track) return null + const instance: ClipInstance = { + id: uid(), + clipId, + startTime: Math.max(0, startTime), + clipTrackId, + } + ;(this.scene.clipInstances ||= []).push(instance) + return instance + }, + moveClipInstance(id: string, startTime: number, clipTrackId?: string) { + const inst = this.scene.clipInstances?.find((ci) => ci.id === id) + if (!inst) return + inst.startTime = Math.max(0, startTime) + if (clipTrackId) { + const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId) + if (track) inst.clipTrackId = clipTrackId + } + }, + trimClipInstance( + id: string, + patch: { startTime?: number; trimStart?: number; trimEnd?: number }, + ) { + const inst = this.scene.clipInstances?.find((ci) => ci.id === id) + if (!inst) return + const clip = this.scene.clips?.find((c) => c.id === inst.clipId) + if (!clip) return + const MIN_VISIBLE = 0.05 + let trimStart = patch.trimStart ?? inst.trimStart ?? 0 + let trimEnd = patch.trimEnd ?? inst.trimEnd ?? 0 + let startTime = patch.startTime ?? inst.startTime + trimStart = Math.max(0, trimStart) + trimEnd = Math.max(0, trimEnd) + const maxBudget = clip.duration - MIN_VISIBLE + if (trimStart + trimEnd > maxBudget) { + if (patch.trimStart !== undefined && patch.trimEnd === undefined) { + trimStart = Math.max(0, maxBudget - trimEnd) + } else if (patch.trimEnd !== undefined && patch.trimStart === undefined) { + trimEnd = Math.max(0, maxBudget - trimStart) + } else { + trimStart = Math.min(trimStart, maxBudget) + trimEnd = Math.max(0, maxBudget - trimStart) + } + } + inst.trimStart = trimStart + inst.trimEnd = trimEnd + inst.startTime = Math.max(0, startTime) + }, + removeClipInstance(id: string) { + if (!this.scene.clipInstances) return + this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id) + }, + enterClip(id: string) { + const clip = this.scene.clips?.find((c) => c.id === id) + if (!clip) return + this.activeClipId = id + this.selectedLayerId = null + this.playhead = 0 + this.playing = false + }, + exitClip() { + this.activeClipId = null this.selectedLayerId = null this.playhead = 0 this.playing = false diff --git a/src/types/scene.ts b/src/types/scene.ts index 0435d06..9e14540 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -71,6 +71,27 @@ export interface Layer { offsetY?: number } +export interface Clip { + id: string + name: string + duration: number + layers: Layer[] +} + +export interface ClipTrack { + id: string + name?: string +} + +export interface ClipInstance { + id: string + clipId: string + startTime: number + clipTrackId: string + trimStart?: number + trimEnd?: number +} + export interface Scene { version: 1 canvas: { @@ -82,6 +103,10 @@ export interface Scene { fps: number assets: Asset[] layers: Layer[] + clips?: Clip[] + clipTracks?: ClipTrack[] + clipInstances?: ClipInstance[] + trackOrder?: string[] } export const DEFAULT_BASE_PROPS: AnimatableProps = { diff --git a/todo b/todo index 2878d33..516e447 100644 --- a/todo +++ b/todo @@ -30,4 +30,18 @@ ich möchte ein fullscreen preview auf shift-space bitte fehlende tests ergänzen -neues feature: clips. ein clip ist eine eigene timeline. diese timelines sind bibliotheks-objekte und können in die haupt-timeline gezogen werden. sie landen auf einer oder mehreren clip-spuren. ein clip wird dort nur als block angezeigt, nicht als keyframes. man kann den clip durch doppelklick aber öffnen wie die haupt-timeline. durch DnD kann man die position des clips in der timeline verschieben. \ No newline at end of file +neues feature: clips. ein clip ist eine eigene timeline. diese timelines sind bibliotheks-objekte und können in die haupt-timeline gezogen werden. sie landen auf einer oder mehreren clip-spuren. ein clip wird dort nur als block angezeigt, nicht als keyframes. man kann den clip durch doppelklick aber öffnen wie die haupt-timeline. durch DnD kann man die position des clips in der timeline verschieben. + +die clips muss man am anfang und ende durch ziehen verkürzen können. verkürzungen müssen in der UI sichtbar sein durch anfasser die man auch wieder anders justieren kann + +für user-interaktionen (wie neues text-objekt) brauchen wir eigene popups. keine browser stock confirms + + ← Haupt-Timeline verdeckt die timeline switches. das muss etwas weiter oben am bottom des asset asides angezeigt werden + + + + + +spuren muss man einfärben können. wir bieten die grundfarben als color tiles bei rechtsklick auf die spur an gilt auch für clipspuren + +clips draggen darf nur auf der x-achse gehen. y verschiebung darf nicht möglich sein - das ist irreführend \ No newline at end of file
Noch keine Tracks.
Noch keine Layer.