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

No files matched your search

+65 -34
View File
@@ -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<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);
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<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();
}
+178
View File
@@ -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<Asset, { kind: 'circle' }>) {
</li>
<li v-if="!store.scene.assets.length" class="empty">keine Assets</li>
</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>
</template>
@@ -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);
}
</style>
+199 -43
View File
@@ -126,21 +126,86 @@ interface RenderedLayer {
props: AnimatableProps
}
const rendered = computed<RenderedLayer[]>(() =>
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<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) 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<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),
},
})
}
})
.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<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 {
layerId: string
sampled: AnimatableProps
@@ -217,7 +346,7 @@ interface PivotInfo {
const pivotInfo = computed<PivotInfo | null>(() => {
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<MouseEvent>) {
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<DragEvent>) {
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<DragEvent>) {
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
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(() => {
<v-group :config="viewConfig">
<v-group :config="clipGroupConfig">
<v-rect :config="backgroundConfig" />
<template v-for="item in rendered" :key="item.layer.id">
<v-rect
v-if="item.asset.kind === 'rect'"
:config="rectConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="item.asset.kind === 'circle'"
:config="circleConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="item.asset.kind === 'text'"
:config="textConfig(item.layer, item.asset, item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
:config="imageConfig(item.layer, item.asset, item.props)!"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<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-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
@dragend="handleDragEnd"
@transformend="handleTransformEnd"
/>
</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-rect :config="canvasBorderConfig" />
+1 -1
View File
@@ -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<AnimatableProps | null>(() =>
+644 -142
View File
@@ -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<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 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<AnimatableProps> = {}
@@ -217,64 +252,63 @@ function onKeyframeDblClick(e: MouseEvent, layerId: string, keyframeId: string)
const tracks = computed<TimelineTrack[]>(() => {
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<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
})
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<string | null>(null)
const dragTrackId = ref<string | null>(null)
const dropIndex = ref<number | null>(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<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 trackMenuRef = ref<HTMLDivElement | null>(null)
@@ -659,87 +868,175 @@ onBeforeUnmount(() => {
</div>
</div>
<div class="tracks">
<div
v-for="(track, idx) in tracks"
:key="track.layerId + ':' + track.kind"
class="track"
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragLayerId === track.layerId,
}"
@dragover="track.isParent ? onTrackDragOver($event, track.layerId) : null"
@drop="track.isParent ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span
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"
>
<PhDotsSixVertical
v-if="track.isParent"
:size="12"
class="drag-handle"
/>
<PhArrowsSplit
v-if="track.isParent && track.layer.separated"
:size="11"
class="name-badge"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
{{ track.label }}
</span>
<template v-for="(track, idx) in tracks" :key="track.type === 'layer' ? `l:${track.layerId}:${track.kind}` : `c:${track.clipTrackId}`">
<!-- Layer track -->
<div
class="track-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onLaneDblClick($event, track)"
v-if="track.type === 'layer'"
class="track"
:class="{
'sub-track': !track.isParent,
inactive: store.selectedLayerId !== null && track.layerId !== store.selectedLayerId,
'drop-before': isDropBefore(track),
'drop-after': isDropAfter(track, tracks[idx + 1]),
dragging: track.isParent && dragTrackId === track.layerId,
}"
@dragover="track.isParent ? onTrackDragOver($event, track.orderIndex) : null"
@drop="track.isParent ? onTrackDrop($event) : null"
@dragleave="onTrackDragLeave"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(track.kfs)"
:key="seg.id"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
:title="
kf.hold
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@click="onKeyframeClick($event, track.layerId, kf.time)"
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
/>
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"
>
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
<PhArrowsSplit
v-if="track.isParent && track.layer.separated"
:size="11"
class="name-badge"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
{{ track.label }}
</span>
<div
class="track-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onLaneDblClick($event, track)"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(track.kfs)"
:key="seg.id"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in track.kfs"
:key="kf.id"
class="kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
:title="
kf.hold
? `Still Frame ${formatKfTime(kf.time)} — hält bis zum nächsten Keyframe`
: `Keyframe ${formatKfTime(kf.time)} — ziehen zum Verschieben, Rechtsklick für Optionen`
"
@pointerdown="onKeyframePointerDown($event, track.layerId, kf.id)"
@pointermove="onKeyframePointerMove"
@pointerup="onKeyframePointerUp"
@pointercancel="onKeyframePointerUp"
@click="onKeyframeClick($event, track.layerId, kf.time)"
@dblclick="onKeyframeDblClick($event, track.layerId, kf.id)"
@contextmenu="onKeyframeContextMenu($event, track.layerId, kf.id, track.kfs)"
/>
</div>
</div>
<!-- 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>
</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>
<p v-if="!tracks.length" class="empty">Noch keine Layer.</p>
</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
v-if="menu"
ref="menuRef"
@@ -1257,6 +1554,211 @@ onBeforeUnmount(() => {
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;
+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', () => {
test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy()
+81 -37
View File
@@ -89,6 +89,19 @@ const ENGINE_SOURCE = `(function(){
var kfs=scene.layers[i].keyframes;
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;
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<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();
}
+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'
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
}
export function drawSceneFrame(
function drawLayers(
ctx: CanvasRenderingContext2D,
scene: Scene,
time: number,
layers: Layer[],
assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>,
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<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]
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<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()
}
+231 -33
View File
@@ -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<AnimatableProps>): 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<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', {
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<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: {
@@ -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<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.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
+25
View File
@@ -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 = {
+14
View File
@@ -31,3 +31,17 @@ 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.
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