bak
This commit is contained in:
1 parent
cc6b11e61d
commit
b977ee35a8
11 files changed
+1518
-187
No files matched your search
+42
-11
@@ -86,17 +86,9 @@
|
||||
var canvas=document.getElementById('c');
|
||||
var ctx=canvas.getContext('2d');
|
||||
var scene=null, images={};
|
||||
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];
|
||||
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;
|
||||
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;
|
||||
@@ -131,6 +123,45 @@
|
||||
}
|
||||
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();
|
||||
}
|
||||
window.__animatrInit=function(s){
|
||||
|
||||
@@ -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>
|
||||
+183
-27
@@ -126,21 +126,86 @@ interface RenderedLayer {
|
||||
props: AnimatableProps
|
||||
}
|
||||
|
||||
const rendered = computed<RenderedLayer[]>(() =>
|
||||
store.scene.layers
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((layer) => {
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (!asset) return null
|
||||
return {
|
||||
layer,
|
||||
asset,
|
||||
props: sampleLayer(layer, store.playhead),
|
||||
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) 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) {
|
||||
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,32 +659,59 @@ onBeforeUnmount(() => {
|
||||
<v-group :config="viewConfig">
|
||||
<v-group :config="clipGroupConfig">
|
||||
<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-if="item.asset.kind === 'rect'"
|
||||
:config="rectConfig(item.layer, item.asset, item.props)"
|
||||
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="item.asset.kind === 'circle'"
|
||||
:config="circleConfig(item.layer, item.asset, item.props)"
|
||||
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="item.asset.kind === 'text'"
|
||||
:config="textConfig(item.layer, item.asset, item.props)"
|
||||
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="item.asset.kind === 'image' && imageConfig(item.layer, item.asset, item.props)"
|
||||
:config="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(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" />
|
||||
<v-line v-if="rotationArmConfig" :config="rotationArmConfig" />
|
||||
|
||||
@@ -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>(() =>
|
||||
|
||||
+579
-77
@@ -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 (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({
|
||||
layerId: layer.id,
|
||||
layer,
|
||||
kfs: layer.keyframes,
|
||||
kind: 'main',
|
||||
label: layer.name,
|
||||
isParent: true,
|
||||
layerIndex,
|
||||
})
|
||||
out.push({ type: 'layer', layerId: layer.id, layer, kfs: layer.keyframes, kind: 'main', label: layer.name, isParent: true, orderIndex })
|
||||
} 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,
|
||||
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,18 +868,19 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
v-for="(track, idx) in tracks"
|
||||
:key="track.layerId + ':' + track.kind"
|
||||
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 && 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"
|
||||
@dragleave="onTrackDragLeave"
|
||||
>
|
||||
@@ -687,11 +897,7 @@ onBeforeUnmount(() => {
|
||||
@dragstart="track.isParent ? onTrackDragStart($event, track.layerId) : null"
|
||||
@dragend="onTrackDragEnd"
|
||||
>
|
||||
<PhDotsSixVertical
|
||||
v-if="track.isParent"
|
||||
:size="12"
|
||||
class="drag-handle"
|
||||
/>
|
||||
<PhDotsSixVertical v-if="track.isParent" :size="12" class="drag-handle" />
|
||||
<PhArrowsSplit
|
||||
v-if="track.isParent && track.layer.separated"
|
||||
:size="11"
|
||||
@@ -735,9 +941,100 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</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>
|
||||
</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
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
+65
-21
@@ -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,28 +140,9 @@ const ENGINE_SOURCE = `(function(){
|
||||
draw(t);
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
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);
|
||||
for(var i=scene.layers.length-1;i>=0;i--){
|
||||
var layer=scene.layers[i];
|
||||
if(!layer.visible)continue;
|
||||
function drawLayer(layer,time){
|
||||
var asset=assetMap[layer.assetId];
|
||||
if(!asset)continue;
|
||||
if(!asset||!layer.visible)return;
|
||||
var p=sample(layer,time);
|
||||
ctx.save();
|
||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||
@@ -184,6 +178,56 @@ const ENGINE_SOURCE = `(function(){
|
||||
}
|
||||
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();
|
||||
}
|
||||
loadImages(scene.assets).then(function(loaded){
|
||||
|
||||
+59
-19
@@ -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()
|
||||
}
|
||||
+229
-31
@@ -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) {
|
||||
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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user