This commit is contained in:
th committed 2026-04-19 21:13:53 +02:00
1 parent cc6b11e61d
commit b977ee35a8
11 files changed
+1518 -187

No files matched your search

+42 -11
View File
@@ -86,17 +86,9 @@
var canvas=document.getElementById('c'); var canvas=document.getElementById('c');
var ctx=canvas.getContext('2d'); var ctx=canvas.getContext('2d');
var scene=null, images={}; var scene=null, images={};
function drawFrame(time){ function drawLayerNode(layer, am, time){
if(!scene) return; var asset=am[layer.assetId];
var lw=scene.canvas.width, lh=scene.canvas.height; if(!asset || !layer.visible) return;
ctx.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
for(var j=scene.layers.length-1;j>=0;j--){
var layer=scene.layers[j]; if(!layer.visible) continue;
var asset=am[layer.assetId]; if(!asset) continue;
var p=sample(layer,time); var p=sample(layer,time);
ctx.save(); ctx.save();
ctx.globalAlpha = ctx.globalAlpha * p.opacity; ctx.globalAlpha = ctx.globalAlpha * p.opacity;
@@ -131,6 +123,45 @@
} }
ctx.restore(); ctx.restore();
} }
function drawFrame(time){
if(!scene) return;
var lw=scene.canvas.width, 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);
var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
var layerById={};
for(var li=0;li<scene.layers.length;li++) layerById[scene.layers[li].id]=scene.layers[li];
var clipById={}; var clips=scene.clips||[];
for(var ci=0;ci<clips.length;ci++) clipById[clips[ci].id]=clips[ci];
var instByTrack={}; var instances=scene.clipInstances||[];
for(var ii=0;ii<instances.length;ii++){
var iE=instances[ii];
(instByTrack[iE.clipTrackId]=instByTrack[iE.clipTrackId]||[]).push(iE);
}
var order=scene.trackOrder||[];
if(!order.length){ for(var oj=0;oj<scene.layers.length;oj++) order.push(scene.layers[oj].id); }
for(var oi=order.length-1;oi>=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;ti<tInsts.length;ti++){
var inst=tInsts[ti];
var clip=clipById[inst.clipId];
if(!clip) continue;
var tS=inst.trimStart||0;
var tE=inst.trimEnd||0;
var visible=Math.max(0, clip.duration - tS - tE);
var offset=time - inst.startTime;
if(offset<0 || offset>visible) 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();
} }
window.__animatrInit=function(s){ window.__animatrInit=function(s){
+178
View File
@@ -2,12 +2,15 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { import {
PhCircle, PhCircle,
PhFilmStrip,
PhImage, PhImage,
PhImageSquare, PhImageSquare,
PhListBullets, PhListBullets,
PhPencilSimple,
PhPlus, PhPlus,
PhSquare, PhSquare,
PhTextT, PhTextT,
PhTrash,
PhUploadSimple, PhUploadSimple,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
@@ -65,6 +68,32 @@ function addSampleText() {
store.addLayer(asset.id) 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) { async function handleFileSelected(event: Event) {
uploadError.value = null uploadError.value = null
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
@@ -240,6 +269,47 @@ function circlePreviewStyle(asset: Extract<Asset, { kind: 'circle' }>) {
</li> </li>
<li v-if="!store.scene.assets.length" class="empty">keine Assets</li> <li v-if="!store.scene.assets.length" class="empty">keine Assets</li>
</ul> </ul>
<div class="lib-header clips-header">
<h4>Clips</h4>
<button class="thumb-toggle" title="Neuen Clip anlegen" @click="addClip">
<PhPlus :size="12" weight="bold" />
</button>
</div>
<ul class="clips">
<li
v-for="clip in clips"
:key="clip.id"
class="clip"
:class="{ editing: store.activeClipId === clip.id }"
draggable="true"
@dragstart="onClipDragStart($event, clip.id)"
@dblclick="store.enterClip(clip.id)"
>
<div class="clip-icon">
<PhFilmStrip :size="16" />
</div>
<div class="clip-meta">
<span class="clip-name" :title="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ clip.layers.length }} Layer · {{ clip.duration.toFixed(1) }}s
</span>
</div>
<div class="clip-actions">
<button
class="clip-btn"
title="Umbenennen"
@click.stop="renameClipPrompt(clip.id, clip.name)"
>
<PhPencilSimple :size="11" />
</button>
<button class="clip-btn danger" title="Löschen" @click.stop="deleteClip(clip.id)">
<PhTrash :size="11" />
</button>
</div>
</li>
<li v-if="!clips.length" class="empty">keine Clips</li>
</ul>
</div> </div>
</template> </template>
@@ -439,4 +509,112 @@ h4 {
font-style: italic; font-style: italic;
justify-content: center; 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);
}
</style> </style>
+183 -27
View File
@@ -126,21 +126,86 @@ interface RenderedLayer {
props: AnimatableProps props: AnimatableProps
} }
const rendered = computed<RenderedLayer[]>(() => interface RenderedClipLayer extends RenderedLayer {
store.scene.layers instanceId: string
.slice()
.reverse()
.map((layer) => {
const asset = assetMap.value.get(layer.assetId)
if (!asset) return null
return {
layer,
asset,
props: sampleLayer(layer, store.playhead),
} }
type StackItem =
| { kind: 'layer'; item: RenderedLayer }
| { kind: 'clip'; item: RenderedClipLayer }
const stackItems = computed<StackItem[]>(() => {
// 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) continue
out.push({
kind: 'layer',
item: { layer, asset, props: sampleLayer(layer, store.playhead) },
})
}
return out
}
const out: StackItem[] = []
const layerById = new Map<string, Layer>()
for (const l of store.scene.layers) layerById.set(l.id, l)
const clipById = new Map<string, NonNullable<typeof store.scene.clips>[number]>()
for (const c of store.scene.clips ?? []) clipById.set(c.id, c)
const instancesByTrack = new Map<string, NonNullable<typeof store.scene.clipInstances>>()
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),
},
})
}
}
}
return out
}) })
.filter((x): x is RenderedLayer => x !== null),
)
function baseNodeConfig(layer: Layer, props: AnimatableProps) { function baseNodeConfig(layer: Layer, props: AnimatableProps) {
return { 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<Asset, { kind: 'rect' }>
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<Asset, { kind: 'circle' }>
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<Asset, { kind: 'text' }>
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<Asset, { kind: 'image' }>
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 { interface PivotInfo {
layerId: string layerId: string
sampled: AnimatableProps sampled: AnimatableProps
@@ -217,7 +346,7 @@ interface PivotInfo {
const pivotInfo = computed<PivotInfo | null>(() => { const pivotInfo = computed<PivotInfo | null>(() => {
const id = store.selectedLayerId const id = store.selectedLayerId
if (!id) return null 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 if (!layer) return null
const asset = assetMap.value.get(layer.assetId) const asset = assetMap.value.get(layer.assetId)
if (!asset) return null if (!asset) return null
@@ -303,7 +432,7 @@ function attachTransformer() {
} }
watch( watch(
() => [store.selectedLayerId, rendered.value.length, store.playhead] as const, () => [store.selectedLayerId, stackItems.value.length, store.playhead] as const,
async () => { async () => {
await nextTick() await nextTick()
attachTransformer() attachTransformer()
@@ -332,14 +461,14 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
store.selectLayer(null) store.selectLayer(null)
return 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) store.selectLayer(match ? match.id : null)
} }
function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) { function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
const node = e.target const node = e.target
const id = node.id() 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, { store.upsertKeyframe(id, store.playhead, {
x: node.x(), x: node.x(),
y: node.y(), y: node.y(),
@@ -349,7 +478,7 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) { function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
const node = e.target const node = e.target
const id = node.id() 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, { store.upsertKeyframe(id, store.playhead, {
x: node.x(), x: node.x(),
y: node.y(), y: node.y(),
@@ -530,32 +659,59 @@ onBeforeUnmount(() => {
<v-group :config="viewConfig"> <v-group :config="viewConfig">
<v-group :config="clipGroupConfig"> <v-group :config="clipGroupConfig">
<v-rect :config="backgroundConfig" /> <v-rect :config="backgroundConfig" />
<template v-for="item in rendered" :key="item.layer.id"> <template
v-for="(entry, i) in stackItems"
:key="
entry.kind === 'layer'
? `l:${entry.item.layer.id}`
: `ci:${entry.item.instanceId}:${entry.item.layer.id}:${i}`
"
>
<template v-if="entry.kind === 'layer'">
<v-rect <v-rect
v-if="item.asset.kind === 'rect'" v-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(item.layer, item.asset, item.props)" :config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transformend="handleTransformEnd" @transformend="handleTransformEnd"
/> />
<v-circle <v-circle
v-else-if="item.asset.kind === 'circle'" v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(item.layer, item.asset, item.props)" :config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transformend="handleTransformEnd" @transformend="handleTransformEnd"
/> />
<v-text <v-text
v-else-if="item.asset.kind === 'text'" v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(item.layer, item.asset, item.props)" :config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transformend="handleTransformEnd" @transformend="handleTransformEnd"
/> />
<v-image <v-image
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)" v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(item.layer, item.asset, item.props)!" :config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
@dragend="handleDragEnd" @dragend="handleDragEnd"
@transformend="handleTransformEnd" @transformend="handleTransformEnd"
/> />
</template> </template>
<template v-else>
<v-rect
v-if="entry.item.asset.kind === 'rect'"
:config="clipRectConfig(entry.item)"
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="clipCircleConfig(entry.item)"
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="clipTextConfig(entry.item)"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)"
:config="clipImageConfig(entry.item)!"
/>
</template>
</template>
</v-group> </v-group>
<v-rect :config="canvasBorderConfig" /> <v-rect :config="canvasBorderConfig" />
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" /> <v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
+1 -1
View File
@@ -11,7 +11,7 @@ const formatPercent = (v: number) => `${Math.round(v * 100)}%`
const store = useSceneStore() const store = useSceneStore()
const selected = computed(() => 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<AnimatableProps | null>(() => const sampled = computed<AnimatableProps | null>(() =>
+579 -77
View File
@@ -12,21 +12,56 @@ import {
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap' 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 layerId: string
layer: Layer layer: Layer
kfs: Keyframe[] kfs: Keyframe[]
kind: 'main' | 'position' | 'rotation' kind: 'main' | 'position' | 'rotation'
label: string label: string
isParent: boolean isParent: boolean
layerIndex: number orderIndex: number
} }
interface TimelineClipTrack {
type: 'clip'
clipTrackId: string
name: string
orderIndex: number
}
type TimelineTrack = TimelineLayerTrack | TimelineClipTrack
const store = useSceneStore() const store = useSceneStore()
const duration = computed(() => store.scene.duration) const duration = computed(() => store.activeDuration)
const inClipEdit = computed(() => store.activeClipId !== null)
const clipInstancesByTrack = computed<Record<string, ClipInstance[]>>(() => {
const out: Record<string, ClipInstance[]> = {}
for (const ci of store.scene.clipInstances ?? []) {
;(out[ci.clipTrackId] ||= []).push(ci)
}
return out
})
const clipById = computed(() => {
const m = new Map<string, Clip>()
for (const c of store.scene.clips ?? []) m.set(c.id, c)
return m
})
const clipTrackById = computed(() => {
const m = new Map<string, NonNullable<typeof store.scene.clipTracks>[number]>()
for (const t of store.scene.clipTracks ?? []) m.set(t.id, t)
return m
})
const NAME_WIDTH = 140 const NAME_WIDTH = 140
const MIN_PX_PER_SEC = 2 const MIN_PX_PER_SEC = 2
@@ -163,14 +198,14 @@ function onLaneClick(e: MouseEvent) {
store.setPlayhead(snapTime(ratio * duration.value)) store.setPlayhead(snapTime(ratio * duration.value))
} }
function onLaneDblClick(e: MouseEvent, track: TimelineTrack) { function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
e.stopPropagation() e.stopPropagation()
const el = e.currentTarget as HTMLElement const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
if (rect.width <= 0) return if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value) 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 if (!layer) return
const sampled = sampleLayer(layer, time) const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {} const props: Partial<AnimatableProps> = {}
@@ -217,64 +252,63 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
const tracks = computed<TimelineTrack[]>(() => { const tracks = computed<TimelineTrack[]>(() => {
const out: TimelineTrack[] = [] const out: TimelineTrack[] = []
store.scene.layers.forEach((layer, 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) { if (!layer.separated) {
out.push({ out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
layerId: layer.id,
layer,
kfs: layer.keyframes,
kind: 'main',
label: layer.name,
isParent: true,
layerIndex,
})
} else { } else {
out.push({ out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
layerId: layer.id, out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.positionKeyframes ?? [], kind: 'position', label: 'Position', isParent: false, orderIndex })
layer, out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.rotationKeyframes ?? [], kind: 'rotation', label: 'Rotation', isParent: false, orderIndex })
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,
}) })
return out
}
const order = store.scene.trackOrder ?? []
const layerById = new Map<string, Layer>()
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 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 { function isDropBefore(track: TimelineTrack): boolean {
return ( if (dropIndex.value === null) return false
dropIndex.value !== null && if (!isTrackHead(track)) return false
track.isParent && if (dragTrackId.value === trackDndId(track)) return false
dropIndex.value === track.layerIndex && return dropIndex.value === track.orderIndex
dragLayerId.value !== track.layerId
)
} }
function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean { function isDropAfter(track: TimelineTrack, nextTrack: TimelineTrack | undefined): boolean {
if (dropIndex.value === null) return false if (dropIndex.value === null) return false
if (dragLayerId.value === track.layerId) return false if (dragTrackId.value === trackDndId(track)) return false
if (dropIndex.value !== track.layerIndex + 1) return false if (dropIndex.value !== track.orderIndex + 1) return false
if (nextTrack && nextTrack.layerIndex === track.layerIndex) return false if (nextTrack && nextTrack.orderIndex === track.orderIndex) return false
return true return true
} }
@@ -454,55 +488,230 @@ interface TrackMenuState {
separated: boolean separated: boolean
} }
const dragLayerId = ref<string | null>(null) const dragTrackId = ref<string | null>(null)
const dropIndex = ref<number | null>(null) const dropIndex = ref<number | null>(null)
function onTrackDragStart(e: DragEvent, layerId: string) { function onTrackDragStart(e: DragEvent, trackId: string) {
if (!e.dataTransfer) return if (!e.dataTransfer) return
dragLayerId.value = layerId dragTrackId.value = trackId
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
try { try {
e.dataTransfer.setData('text/plain', layerId) e.dataTransfer.setData('text/plain', trackId)
} catch { } catch {
/* Safari older versions */ /* ignore */
} }
} }
function onTrackDragOver(e: DragEvent, layerId: string) { function onTrackDragOver(e: DragEvent, orderIndex: number) {
if (!dragLayerId.value) return if (!dragTrackId.value) return
if (!e.dataTransfer) return if (!e.dataTransfer) return
e.preventDefault() e.preventDefault()
e.dataTransfer.dropEffect = 'move' e.dataTransfer.dropEffect = 'move'
const el = e.currentTarget as HTMLElement const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect() const rect = el.getBoundingClientRect()
const midY = rect.top + rect.height / 2 const midY = rect.top + rect.height / 2
const index = store.scene.layers.findIndex((l) => l.id === layerId) dropIndex.value = e.clientY < midY ? orderIndex : orderIndex + 1
if (index < 0) return
dropIndex.value = e.clientY < midY ? index : index + 1
} }
function onTrackDragLeave() { function onTrackDragLeave() {
/* dropIndex updates on next dragover, keep as-is to avoid flicker */ /* keep dropIndex to avoid flicker */
} }
function onTrackDrop(e: DragEvent) { function onTrackDrop(e: DragEvent) {
e.preventDefault() e.preventDefault()
const srcId = dragLayerId.value const srcId = dragTrackId.value
const target = dropIndex.value const target = dropIndex.value
dragLayerId.value = null dragTrackId.value = null
dropIndex.value = null dropIndex.value = null
if (!srcId || target === null) return 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 if (from < 0) return
let to = target let to = target
if (from < to) to -= 1 if (from < to) to -= 1
store.moveLayer(from, to) store.moveTrack(from, to)
} }
function onTrackDragEnd() { function onTrackDragEnd() {
dragLayerId.value = null dragTrackId.value = null
dropIndex.value = null dropIndex.value = null
} }
interface ClipDrag {
kind: 'new' | 'move'
clipId: string
instanceId?: string
grabOffsetSec: number
}
const clipDrag = ref<ClipDrag | null>(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<ClipTrimState | null>(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<TrackMenuState | null>(null) const trackMenu = ref<TrackMenuState | null>(null)
const trackMenuRef = ref<HTMLDivElement | null>(null) const trackMenuRef = ref<HTMLDivElement | null>(null)
@@ -659,18 +868,19 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<div class="tracks"> <div class="tracks">
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
<!-- Layer track -->
<div <div
v-for="(track, idx) in tracks" v-if="track.type === 'layer'"
:key="track.layerId + ':' + track.kind"
class="track" class="track"
:class="{ :class="{
'sub-track': !track.isParent, 'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId, inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': isDropBefore(track), 'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]), 'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragLayerId === track.layerId, dragging: track.isParent && dragTrackId === track.layerId,
}" }"
@dragover="track.isParent ? onTrackDragOver($event, track.layerId) : null" @dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent ? onTrackDrop($event) : null" @drop="track.isParent ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave" @dragleave="onTrackDragLeave"
> >
@@ -687,11 +897,7 @@ onBeforeUnmount(() => {
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null" @dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
@dragend="onTrackDragEnd" @dragend="onTrackDragEnd"
> >
<PhDotsSixVertical <PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
v-if="track.isParent"
:size="12"
class="drag-handle"
/>
<PhArrowsSplit <PhArrowsSplit
v-if="track.isParent && track.layer.separated" v-if="track.isParent && track.layer.separated"
:size="11" :size="11"
@@ -735,9 +941,100 @@ onBeforeUnmount(() => {
/> />
</div> </div>
</div> </div>
<p v-if="!tracks.length" class="empty">Noch keine Layer.</p>
<!-- Clip track -->
<div
v-else
class="track clip-track"
:class="{
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: dragTrackId === track.clipTrackId,
}"
@dragover="onTrackDragOver($event, track.orderIndex)"
@drop="onTrackDrop($event)"
@dragleave="onTrackDragLeave"
>
<span
class="track-name clip-track-name"
draggable="true"
@dragstart="onTrackDragStart($event, track.clipTrackId)"
@dragend="onTrackDragEnd"
>
<PhDotsSixVertical :size="12" class="drag-handle" />
<PhFilmStrip :size="11" class="name-badge" />
{{ track.name }}
</span>
<div
class="track-lane clip-lane"
:style="{ width: laneWidth + 'px' }"
@dragover.stop="onClipLaneDragOver($event, track.clipTrackId)"
@drop.stop="onClipLaneDrop($event, track.clipTrackId)"
>
<span
v-for="ci in clipInstancesByTrack[track.clipTrackId] ?? []"
:key="ci.id"
class="clip-block"
:class="{
'trimmed-start': (ci.trimStart ?? 0) > 0,
'trimmed-end': (ci.trimEnd ?? 0) > 0,
trimming: clipTrim?.instanceId === ci.id,
}"
:style="clipBlockStyle(ci)"
:title="trimTooltip(ci)"
draggable="true"
@dragstart.stop="onClipBlockDragStart($event, ci)"
@dragend="onClipBlockDragEnd"
@dblclick="onClipBlockDblClick(ci)"
@contextmenu="onClipBlockContextMenu($event, ci)"
>
<span
class="clip-handle start"
draggable="false"
@pointerdown="onClipHandlePointerDown($event, ci, 'start')"
@pointermove="onClipHandlePointerMove"
@pointerup="onClipHandlePointerUp"
@pointercancel="onClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
<PhFilmStrip :size="11" class="clip-block-icon" />
<span class="clip-block-name">{{ clipById.get(ci.clipId)?.name ?? '?' }}</span>
<span
class="clip-handle end"
draggable="false"
@pointerdown="onClipHandlePointerDown($event, ci, 'end')"
@pointermove="onClipHandlePointerMove"
@pointerup="onClipHandlePointerUp"
@pointercancel="onClipHandlePointerUp"
@dragstart.prevent.stop
@dblclick.stop
@click.stop
/>
</span>
</div> </div>
</div> </div>
</template>
<p v-if="!tracks.length" class="empty">Noch keine Tracks.</p>
<div v-if="!inClipEdit" class="track clip-track add-clip-track">
<button class="add-clip-btn" title="Weitere Clip-Spur" @click="addClipTrack">
<PhPlus :size="11" weight="bold" />
<span>Clip-Spur hinzufügen</span>
</button>
</div>
</div>
</div>
</div>
<div v-if="inClipEdit" class="clip-breadcrumb">
<button class="breadcrumb-btn" @click="exitClip">
← Haupt-Timeline
</button>
<span class="breadcrumb-current">
<PhFilmStrip :size="11" />
{{ store.activeClip?.name ?? '' }}
</span>
</div> </div>
<div <div
@@ -1257,6 +1554,211 @@ onBeforeUnmount(() => {
font-size: 12px; 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 { .kf-menu {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
+143
View File
@@ -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', () => { suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => { test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy() expect(document.querySelector('.app')).toBeTruthy()
+65 -21
View File
@@ -89,6 +89,19 @@ const ENGINE_SOURCE = `(function(){
var kfs=scene.layers[i].keyframes; var kfs=scene.layers[i].keyframes;
for(var j=0;j<kfs.length;j++){ if(kfs[j].time>max) max=kfs[j].time; } for(var j=0;j<kfs.length;j++){ if(kfs[j].time>max) max=kfs[j].time; }
} }
var instances=scene.clipInstances||[];
var clips=scene.clips||[];
var clipById={};
for(var ci=0;ci<clips.length;ci++) clipById[clips[ci].id]=clips[ci];
for(var ii=0;ii<instances.length;ii++){
var inst=instances[ii];
var cl=clipById[inst.clipId];
if(!cl) continue;
var trimS=inst.trimStart||0;
var trimE=inst.trimEnd||0;
var end=inst.startTime+Math.max(0,cl.duration-trimS-trimE);
if(end>max) max=end;
}
if(max<=0) return scene.duration; if(max<=0) return scene.duration;
return Math.min(max,scene.duration); return Math.min(max,scene.duration);
} }
@@ -127,28 +140,9 @@ const ENGINE_SOURCE = `(function(){
draw(t); draw(t);
requestAnimationFrame(frame); requestAnimationFrame(frame);
} }
function draw(time){ function drawLayer(layer,time){
var lw=scene.canvas.width,lh=scene.canvas.height;
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch);
ctx.save();
ctx.translate(ox,oy);
ctx.scale(s,s);
ctx.beginPath();
ctx.rect(0,0,lw,lh);
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]; var asset=assetMap[layer.assetId];
if(!asset)continue; if(!asset||!layer.visible)return;
var p=sample(layer,time); var p=sample(layer,time);
ctx.save(); ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity; ctx.globalAlpha=ctx.globalAlpha*p.opacity;
@@ -184,6 +178,56 @@ const ENGINE_SOURCE = `(function(){
} }
ctx.restore(); ctx.restore();
} }
function draw(time){
var lw=scene.canvas.width,lh=scene.canvas.height;
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch);
ctx.save();
ctx.translate(ox,oy);
ctx.scale(s,s);
ctx.beginPath();
ctx.rect(0,0,lw,lh);
ctx.clip();
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
var clipById2={};
var clips2=scene.clips||[];
for(var ci2=0;ci2<clips2.length;ci2++)clipById2[clips2[ci2].id]=clips2[ci2];
var instByTrack={};
var instances=scene.clipInstances||[];
for(var iInst=0;iInst<instances.length;iInst++){
var iE=instances[iInst];
(instByTrack[iE.clipTrackId]=instByTrack[iE.clipTrackId]||[]).push(iE);
}
var order=scene.trackOrder||[];
if(!order.length){for(var lj=0;lj<scene.layers.length;lj++)order.push(scene.layers[lj].id);}
for(var oi=order.length-1;oi>=0;oi--){
var id=order[oi];
var lyr=layerById[id];
if(lyr){ drawLayer(lyr,time); continue; }
var tInsts=instByTrack[id]||[];
for(var ti=0;ti<tInsts.length;ti++){
var inst2=tInsts[ti];
var clip2=clipById2[inst2.clipId];
if(!clip2)continue;
var tS=inst2.trimStart||0;
var tE=inst2.trimEnd||0;
var visible=Math.max(0,clip2.duration-tS-tE);
var offset=time-inst2.startTime;
if(offset<0||offset>visible)continue;
var localT=tS+offset;
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
drawLayer(clip2.layers[cl2],localT);
}
}
}
ctx.restore(); ctx.restore();
} }
loadImages(scene.assets).then(function(loaded){ loadImages(scene.assets).then(function(loaded){
+59 -19
View File
@@ -1,4 +1,4 @@
import type { Asset, Scene } from '@/types/scene' import type { Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate' import { sampleLayer } from './interpolate'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> { export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
@@ -24,27 +24,15 @@ export async function loadSceneImages(scene: Scene): Promise<Record<string, HTML
return result return result
} }
export function drawSceneFrame( function drawLayers(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
scene: Scene, layers: Layer[],
time: number, assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>, images: Record<string, HTMLImageElement>,
time: number,
) { ) {
const lw = scene.canvas.width for (let i = layers.length - 1; i >= 0; i--) {
const lh = scene.canvas.height const layer = layers[i]
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<string, Asset>()
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]
if (!layer.visible) continue if (!layer.visible) continue
const asset = assetMap.get(layer.assetId) const asset = assetMap.get(layer.assetId)
if (!asset) continue if (!asset) continue
@@ -86,6 +74,58 @@ export function drawSceneFrame(
ctx.restore() ctx.restore()
} }
}
export function drawSceneFrame(
ctx: CanvasRenderingContext2D,
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
) {
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<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
const layerById = new Map<string, Layer>()
for (const l of scene.layers) layerById.set(l.id, l)
const instancesByTrack = new Map<string, typeof scene.clipInstances>()
for (const ci of scene.clipInstances ?? []) {
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
arr.push(ci)
instancesByTrack.set(ci.clipTrackId, arr)
}
const clipById = new Map<string, NonNullable<typeof scene.clips>[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() ctx.restore()
} }
+229 -31
View File
@@ -1,5 +1,15 @@
import { defineStore } from 'pinia' 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 { DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
@@ -14,6 +24,7 @@ interface State {
selectedLayerId: string | null selectedLayerId: string | null
playhead: number playhead: number
playing: boolean playing: boolean
activeClipId: string | null
} }
function allKeyframes(layer: Layer): Keyframe[] { function allKeyframes(layer: Layer): Keyframe[] {
@@ -90,39 +101,86 @@ function hasKeys(obj: Partial<AnimatableProps>): boolean {
return false 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<string>()
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', { export const useSceneStore = defineStore('scene', {
state: (): State => ({ state: (): State => ({
scene: { scene: normalizeScene({
version: 1, version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' }, canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10, duration: 10,
fps: 60, fps: 60,
assets: [], assets: [],
layers: [], layers: [],
}, }),
selectedLayerId: null, selectedLayerId: null,
playhead: 0, playhead: 0,
playing: false, playing: false,
activeClipId: null,
}), }),
getters: { getters: {
lastKeyframeTime(state): number { activeClip(state): Clip | null {
let max = 0 if (!state.activeClipId) return null
for (const layer of state.scene.layers) { const clips = state.scene.clips ?? []
for (const kf of allKeyframes(layer)) { return clips.find((c) => c.id === state.activeClipId) ?? null
if (kf.time > max) max = kf.time
}
}
return Math.min(state.scene.duration, max)
}, },
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 let max = 0
for (const layer of state.scene.layers) { for (const layer of this.activeLayers) {
for (const kf of allKeyframes(layer)) { for (const kf of allKeyframes(layer)) {
if (kf.time > max) max = kf.time if (kf.time > max) max = kf.time
} }
} }
if (max <= 0) return state.scene.duration return Math.min(this.activeDuration, max)
return Math.min(max, state.scene.duration) },
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<string, Clip>()
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: { actions: {
@@ -163,16 +221,25 @@ export const useSceneStore = defineStore('scene', {
layer.offsetY = 0 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 this.selectedLayerId = layer.id
return layer return layer
}, },
removeLayer(id: string) { 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 if (this.selectedLayerId === id) this.selectedLayerId = null
}, },
moveLayer(fromIndex: number, toIndex: number) { moveLayer(fromIndex: number, toIndex: number) {
const arr = this.scene.layers const arr = this.activeLayers
if (fromIndex < 0 || fromIndex >= arr.length) return if (fromIndex < 0 || fromIndex >= arr.length) return
if (toIndex < 0 || toIndex > arr.length) return if (toIndex < 0 || toIndex > arr.length) return
const clamped = Math.min(toIndex, arr.length - 1) const clamped = Math.min(toIndex, arr.length - 1)
@@ -180,15 +247,30 @@ export const useSceneStore = defineStore('scene', {
const [moved] = arr.splice(fromIndex, 1) const [moved] = arr.splice(fromIndex, 1)
arr.splice(clamped, 0, moved) 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) { selectLayer(id: string | null) {
this.selectedLayerId = id this.selectedLayerId = id
}, },
setPlayhead(t: number) { 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) { setDuration(seconds: number) {
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) this.scene.duration = Math.max(0.1, seconds)
if (this.playhead > this.scene.duration) this.playhead = this.scene.duration if (this.playhead > this.scene.duration) this.playhead = this.scene.duration
}
}, },
setFps(fps: number) { setFps(fps: number) {
this.scene.fps = Math.max(1, Math.round(fps)) this.scene.fps = Math.max(1, Math.round(fps))
@@ -201,7 +283,7 @@ export const useSceneStore = defineStore('scene', {
this.scene.canvas.background = color this.scene.canvas.background = color
}, },
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) { upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.scene.layers.find((l) => l.id === layerId) const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return if (!layer) return
if (!layer.separated) { if (!layer.separated) {
const snapshot = sampleLayer(layer, time) const snapshot = sampleLayer(layer, time)
@@ -224,7 +306,7 @@ export const useSceneStore = defineStore('scene', {
} }
}, },
removeKeyframeAt(layerId: string, time: number) { 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 if (!layer) return
const filter = (kfs?: Keyframe[]) => const filter = (kfs?: Keyframe[]) =>
kfs ? kfs.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) : kfs 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) if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes)
}, },
removeKeyframe(layerId: string, keyframeId: string) { 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 if (!layer) return
const found = findKf(layer, keyframeId) const found = findKf(layer, keyframeId)
if (!found) return if (!found) return
@@ -241,13 +323,13 @@ export const useSceneStore = defineStore('scene', {
if (idx >= 0) found.arr.splice(idx, 1) if (idx >= 0) found.arr.splice(idx, 1)
}, },
setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) { 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 if (!layer) return
const found = findKf(layer, keyframeId) const found = findKf(layer, keyframeId)
if (found) found.kf.easing = easing if (found) found.kf.easing = easing
}, },
setKeyframeHold(layerId: string, keyframeId: string, hold: boolean) { 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 if (!layer) return
const found = findKf(layer, keyframeId) const found = findKf(layer, keyframeId)
if (!found) return if (!found) return
@@ -255,11 +337,11 @@ export const useSceneStore = defineStore('scene', {
else delete found.kf.hold else delete found.kf.hold
}, },
setKeyframeTime(layerId: string, keyframeId: string, newTime: number) { 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 if (!layer) return
const found = findKf(layer, keyframeId) const found = findKf(layer, keyframeId)
if (!found) return 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) sortKfs(found.arr)
}, },
shiftLayerPivot( shiftLayerPivot(
@@ -269,7 +351,7 @@ export const useSceneStore = defineStore('scene', {
dLocalX: number, dLocalX: number,
dLocalY: 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 if (!layer) return
layer.offsetX = (layer.offsetX ?? 0) + dLocalX layer.offsetX = (layer.offsetX ?? 0) + dLocalX
layer.offsetY = (layer.offsetY ?? 0) + dLocalY layer.offsetY = (layer.offsetY ?? 0) + dLocalY
@@ -286,14 +368,14 @@ export const useSceneStore = defineStore('scene', {
shift(layer.positionKeyframes) shift(layer.positionKeyframes)
}, },
duplicateKeyframe(layerId: string, keyframeId: string): string | null { 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 if (!layer) return null
const found = findKf(layer, keyframeId) const found = findKf(layer, keyframeId)
if (!found) return null if (!found) return null
const { kf: source, arr } = found const { kf: source, arr } = found
const fps = this.scene.fps > 0 ? this.scene.fps : 60 const fps = this.scene.fps > 0 ? this.scene.fps : 60
const offset = Math.max(3 / fps, 0.1) 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) const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
let target = source.time + offset let target = source.time + offset
while (occupied(target) && target < duration) target += offset while (occupied(target) && target < duration) target += offset
@@ -315,7 +397,7 @@ export const useSceneStore = defineStore('scene', {
return newKf.id return newKf.id
}, },
setLayerSeparated(layerId: string, separated: boolean) { 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) return
if (!!layer.separated === separated) return if (!!layer.separated === separated) return
if (separated) { if (separated) {
@@ -381,7 +463,123 @@ export const useSceneStore = defineStore('scene', {
} }
}, },
loadScene(scene: 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.selectedLayerId = null
this.playhead = 0 this.playhead = 0
this.playing = false this.playing = false
+25
View File
@@ -71,6 +71,27 @@ export interface Layer {
offsetY?: number 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 { export interface Scene {
version: 1 version: 1
canvas: { canvas: {
@@ -82,6 +103,10 @@ export interface Scene {
fps: number fps: number
assets: Asset[] assets: Asset[]
layers: Layer[] layers: Layer[]
clips?: Clip[]
clipTracks?: ClipTrack[]
clipInstances?: ClipInstance[]
trackOrder?: string[]
} }
export const DEFAULT_BASE_PROPS: AnimatableProps = { export const DEFAULT_BASE_PROPS: AnimatableProps = {
+14
View File
@@ -31,3 +31,17 @@ ich möchte ein fullscreen preview auf shift-space
bitte fehlende tests ergänzen 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. 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
<button data-v-228c0a9d="" class="breadcrumb-btn"> ← Haupt-Timeline </button> 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