0.8.7: duplicate track via right-click (layer / clip / script)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-22 06:22:32 +02:00
1 parent d60684edd1
commit 517705bf55
4 files changed
+254 -1

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "animatr", "name": "animatr",
"private": true, "private": true,
"version": "0.8.6", "version": "0.8.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+22
View File
@@ -1998,6 +1998,17 @@ function deleteTrack() {
trackMenu.value = null trackMenu.value = null
} }
function duplicateTrackFromMenu() {
if (!trackMenu.value) return
const newId = store.duplicateTrack(trackMenu.value.trackId)
trackMenu.value = null
if (newId && store.scene.layers.some((l) => l.id === newId)) {
// Only layers have a meaningful per-track selection model; clip /
// script tracks aren't selected in the same sense.
store.selectLayer(newId)
}
}
async function renameFromMenu() { async function renameFromMenu() {
if (!trackMenu.value) return if (!trackMenu.value) return
if (trackMenu.value.kind === 'group') { if (trackMenu.value.kind === 'group') {
@@ -3043,6 +3054,17 @@ onBeforeUnmount(() => {
</button> </button>
</template> </template>
</template> </template>
<template v-if="trackMenu.kind !== 'group'">
<div class="kf-menu-divider"></div>
<button
class="kf-menu-item"
:disabled="trackMenu.locked"
@click="duplicateTrackFromMenu"
>
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
</template>
<div class="kf-menu-divider"></div> <div class="kf-menu-divider"></div>
<button class="kf-menu-item danger" @click="deleteTrack"> <button class="kf-menu-item danger" @click="deleteTrack">
<PhTrash :size="12" class="kf-menu-icon" /> <PhTrash :size="12" class="kf-menu-icon" />
+137
View File
@@ -2345,6 +2345,143 @@ export function registerAllTests(): void {
}) })
}) })
suite('scene store — duplicateTrack', () => {
test('duplicates a layer with fresh keyframe ids right below the original', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D1', width: 10, height: 10, fill: '#abc' })
const l = s.addLayer(a.id, 'Orig')!
s.upsertKeyframe(l.id, 1, { x: 10 })
s.upsertKeyframe(l.id, 2, { x: 20 })
const origKfIds = new Set(l.keyframes.map((k) => k.id))
const newId = s.duplicateTrack(l.id)
expect(newId).toBeTruthy()
const dup = s.scene.layers.find((x) => x.id === newId)!
expect(dup).toBeTruthy()
expect(dup.id === l.id).toBe(false)
expect(dup.name).toBe('Orig')
expect(dup.assetId).toBe(a.id)
expect(dup.keyframes.length).toBe(l.keyframes.length)
// Fresh ids for every keyframe so history/undo never aliases.
for (const k of dup.keyframes) expect(origKfIds.has(k.id)).toBe(false)
// Clone sits immediately after the original in trackOrder.
const order = s.scene.trackOrder!
expect(order.indexOf(newId!)).toBe(order.indexOf(l.id) + 1)
})
test('layer duplicate is deeply independent of the original', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D2', width: 10, height: 10, fill: '#bcd' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 10 })
const newId = s.duplicateTrack(l.id)!
const dup = s.scene.layers.find((x) => x.id === newId)!
// Mutate the clone — the original must not see the change.
dup.base.x = 999
dup.keyframes[0].props.x = 999
const origAfter = s.scene.layers.find((x) => x.id === l.id)!
expect(origAfter.base.x === 999).toBe(false)
expect(origAfter.keyframes[0].props.x === 999).toBe(false)
})
test('layer duplicate copies separated position/rotation kf arrays with fresh ids', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D3', width: 10, height: 10, fill: '#cde' })
const l = s.addLayer(a.id)!
s.upsertKeyframe(l.id, 1, { x: 5, y: 5, rotation: 45 })
s.setLayerSeparated(l.id, true)
const before = s.scene.layers.find((x) => x.id === l.id)!
const origPosIds = new Set((before.positionKeyframes ?? []).map((k) => k.id))
const origRotIds = new Set((before.rotationKeyframes ?? []).map((k) => k.id))
const newId = s.duplicateTrack(l.id)!
const dup = s.scene.layers.find((x) => x.id === newId)!
expect(dup.separated).toBe(true)
expect((dup.positionKeyframes ?? []).length).toBe((before.positionKeyframes ?? []).length)
expect((dup.rotationKeyframes ?? []).length).toBe((before.rotationKeyframes ?? []).length)
for (const k of dup.positionKeyframes ?? []) expect(origPosIds.has(k.id)).toBe(false)
for (const k of dup.rotationKeyframes ?? []) expect(origRotIds.has(k.id)).toBe(false)
})
test('duplicating a grouped layer keeps the clone in the same group', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'D4', width: 10, height: 10, fill: '#def' })
const l = s.addLayer(a.id)!
const g = s.addTrackGroup('G')
s.moveTrackIntoGroup(l.id, g.id)
const newId = s.duplicateTrack(l.id)!
const group = s.scene.groups!.find((x) => x.id === g.id)!
// Duplicate lands in the group, directly after the original.
expect(group.childIds.indexOf(newId)).toBe(group.childIds.indexOf(l.id) + 1)
// Grouped children never leak into trackOrder.
expect((s.scene.trackOrder ?? []).includes(newId)).toBe(false)
})
test('duplicates a clip track along with its clip instances', () => {
const s = makeFreshStore()
const clip = s.addClip('C')
clip.duration = 5
const origTrack = s.addClipTrack('track-A')
const ci1 = s.addClipInstance(clip.id, 0, origTrack.id)!
const ci2 = s.addClipInstance(clip.id, 2, origTrack.id)!
const newId = s.duplicateTrack(origTrack.id)
expect(newId).toBeTruthy()
expect(newId === origTrack.id).toBe(false)
const newTrack = s.scene.clipTracks!.find((t) => t.id === newId)!
expect(newTrack.name).toBe('track-A')
// Original instances untouched.
expect(s.scene.clipInstances!.find((ci) => ci.id === ci1.id)!.clipTrackId).toBe(origTrack.id)
expect(s.scene.clipInstances!.find((ci) => ci.id === ci2.id)!.clipTrackId).toBe(origTrack.id)
// Clone track carries copies with fresh ids and same clipId/startTime.
const cloneInstances = s.scene.clipInstances!.filter((ci) => ci.clipTrackId === newId)
expect(cloneInstances).toHaveLength(2)
const startTimes = cloneInstances.map((ci) => ci.startTime).sort((a, b) => a - b)
expect(startTimes).toEqual([0, 2])
for (const ci of cloneInstances) {
expect(ci.clipId).toBe(clip.id)
expect(ci.id === ci1.id).toBe(false)
expect(ci.id === ci2.id).toBe(false)
}
// Slotted right after the original in trackOrder.
const order = s.scene.trackOrder!
expect(order.indexOf(newId!)).toBe(order.indexOf(origTrack.id) + 1)
})
test('duplicates a script track along with its frames', () => {
const s = makeFreshStore()
s.setDuration(10)
const origTrack = s.addScriptTrack('triggers')
const f1 = s.addScriptFrame(origTrack.id, 1, [{ type: 'pause' }])!
const f2 = s.addScriptFrame(origTrack.id, 3, [{ type: 'play' }])!
const newId = s.duplicateTrack(origTrack.id)!
expect(newId === origTrack.id).toBe(false)
const newTrack = s.scene.scriptTracks!.find((t) => t.id === newId)!
expect(newTrack.name).toBe('triggers')
// Original frames untouched.
expect(s.scene.scriptFrames!.find((f) => f.id === f1.id)!.trackId).toBe(origTrack.id)
expect(s.scene.scriptFrames!.find((f) => f.id === f2.id)!.trackId).toBe(origTrack.id)
// Clone has its own frames at the same times with matching actions.
const cloneFrames = s.scene.scriptFrames!.filter((f) => f.trackId === newId)
expect(cloneFrames).toHaveLength(2)
const times = cloneFrames.map((f) => f.time).sort((a, b) => a - b)
expect(times).toEqual([1, 3])
for (const f of cloneFrames) {
expect(f.id === f1.id).toBe(false)
expect(f.id === f2.id).toBe(false)
}
})
test('returns null for unknown id and for group ids', () => {
const s = makeFreshStore()
expect(s.duplicateTrack('nope-no-such-track')).toBe(null)
const g = s.addTrackGroup('G')
expect(s.duplicateTrack(g.id)).toBe(null)
})
})
suite('app smoke — live DOM', () => { suite('app smoke — live DOM', () => {
test('app root, topbar, timeline, stage are mounted', () => { test('app root, topbar, timeline, stage are mounted', () => {
expect(document.querySelector('.app')).toBeTruthy() expect(document.querySelector('.app')).toBeTruthy()
+94
View File
@@ -1756,6 +1756,100 @@ export const useSceneStore = defineStore('scene', {
;(this.scene.trackOrder ||= []).push(track.id) ;(this.scene.trackOrder ||= []).push(track.id)
return track return track
}, },
/**
* Duplicate a top-level track by id. Handles layer / clipTrack /
* scriptTrack — clip instances and script frames belonging to the
* original are cloned too. The duplicate lands directly after the
* original in trackOrder (or in the same group's childIds if the
* original lived inside one). Groups themselves are not supported
* here; returns null for unknown ids, clip-edit / button-edit
* contexts, and groups.
*/
duplicateTrack(id: string): string | null {
if (this.activeClipId || this.activeButtonAssetId) return null
const scene = this.scene
// Grouped tracks live in TrackGroup.childIds and are absent from
// scene.trackOrder entirely (only the group's own id appears at
// top level). Duplicate stays inside whichever container held the
// original.
const placeAfter = (afterId: string, newId: string) => {
const order = (scene.trackOrder ||= [])
const idx = order.indexOf(afterId)
if (idx >= 0) {
order.splice(idx + 1, 0, newId)
return
}
for (const g of scene.groups ?? []) {
const gi = g.childIds.indexOf(afterId)
if (gi >= 0) {
g.childIds.splice(gi + 1, 0, newId)
return
}
}
order.push(newId)
}
const layer = scene.layers.find((l) => l.id === id)
if (layer) {
const cloned = structuredClone(toRaw(layer)) as Layer
cloned.id = uid()
cloned.keyframes = cloned.keyframes.map((k) => ({ ...k, id: uid() }))
if (cloned.positionKeyframes) {
cloned.positionKeyframes = cloned.positionKeyframes.map((k) => ({ ...k, id: uid() }))
}
if (cloned.rotationKeyframes) {
cloned.rotationKeyframes = cloned.rotationKeyframes.map((k) => ({ ...k, id: uid() }))
}
const origIdx = scene.layers.findIndex((l) => l.id === id)
scene.layers.splice(origIdx + 1, 0, cloned)
placeAfter(id, cloned.id)
return cloned.id
}
const clipTrack = scene.clipTracks?.find((t) => t.id === id)
if (clipTrack) {
const newTrack: ClipTrack = {
...(structuredClone(toRaw(clipTrack)) as ClipTrack),
id: uid(),
}
;(scene.clipTracks ||= []).push(newTrack)
const instances = scene.clipInstances?.filter((ci) => ci.clipTrackId === id) ?? []
for (const ci of instances) {
const clonedInst: ClipInstance = {
...(structuredClone(toRaw(ci)) as ClipInstance),
id: uid(),
clipTrackId: newTrack.id,
}
;(scene.clipInstances ||= []).push(clonedInst)
}
placeAfter(id, newTrack.id)
return newTrack.id
}
const scriptTrack = scene.scriptTracks?.find((t) => t.id === id)
if (scriptTrack) {
const newTrack: ScriptTrack = {
...(structuredClone(toRaw(scriptTrack)) as ScriptTrack),
id: uid(),
}
;(scene.scriptTracks ||= []).push(newTrack)
const frames = scene.scriptFrames?.filter((f) => f.trackId === id) ?? []
for (const f of frames) {
const clonedFrame: ScriptFrame = {
...(structuredClone(toRaw(f)) as ScriptFrame),
id: uid(),
trackId: newTrack.id,
}
;(scene.scriptFrames ||= []).push(clonedFrame)
}
if (scene.scriptFrames) scene.scriptFrames.sort((a, b) => a.time - b.time)
placeAfter(id, newTrack.id)
return newTrack.id
}
return null
},
removeClipTrack(id: string) { removeClipTrack(id: string) {
if (this.scene.clipTracks) { if (this.scene.clipTracks) {
this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id) this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id)