From d60684edd121576ab588a9ba1f654c0a2a62637f Mon Sep 17 00:00:00 2001
From: th
Date: Wed, 22 Apr 2026 06:13:09 +0200
Subject: [PATCH] 0.8.6: clip instances get stage transforms
(x/y/scale/rotation)
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
src/components/CanvasStage.vue | 161 ++++++++++++++----
src/devtools/tests.ts | 293 ++++++++++++++++++++++++++++++++-
src/lib/exportPlayer.ts | 14 +-
src/lib/render.ts | 14 ++
src/stores/scene.ts | 57 +++++++
src/types/scene.ts | 12 ++
7 files changed, 512 insertions(+), 41 deletions(-)
diff --git a/package.json b/package.json
index 6ee23a3..579adcc 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
- "version": "0.8.5",
+ "version": "0.8.6",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index 3cb9760..fbb6dcd 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -23,6 +23,7 @@ import type {
Asset,
ButtonAsset,
ButtonState,
+ ClipInstance,
HitShape,
Keyframe,
Layer,
@@ -728,9 +729,14 @@ interface RenderedClipLayer extends RenderedLayer {
instanceId: string
}
+interface RenderedClipInstance {
+ instance: ClipInstance
+ items: RenderedClipLayer[]
+}
+
type StackItem =
| { kind: 'layer'; item: RenderedLayer }
- | { kind: 'clip'; item: RenderedClipLayer }
+ | { kind: 'clipInstance'; bundle: RenderedClipInstance }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value
@@ -833,23 +839,26 @@ const stackItems = computed(() => {
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
+ // Bundle the clip's layers under a single clip-instance entry
+ // so the template can wrap them in a draggable Konva group that
+ // carries the instance's x / y / scale transform.
+ const items: RenderedClipLayer[] = []
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
if (!isLayerActiveAt(cl, localTime)) 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),
- },
+ items.push({
+ instanceId: inst.id,
+ layer: cl,
+ asset: ca,
+ props: sampleLayer(cl, localTime),
})
}
+ if (items.length) {
+ out.push({ kind: 'clipInstance', bundle: { instance: inst, items } })
+ }
}
}
return out
@@ -1224,6 +1233,50 @@ function imageConfig(
}
}
+/** Per-clip-instance wrapper group. Carries the instance's static
+ * x / y / scale so the user can reposition the packed clip on the
+ * stage without touching its inner keyframes. `rotation` is
+ * deliberately absent — the user asked for move + scale only.
+ * The inner child configs keep `listening: false`, so pointer
+ * events funnel to the outer group and drag/transform apply as a
+ * unit. */
+function clipInstanceGroupConfig(inst: ClipInstance) {
+ return {
+ id: `ci-group:${inst.id}`,
+ x: inst.x ?? 0,
+ y: inst.y ?? 0,
+ scaleX: inst.scaleX ?? 1,
+ scaleY: inst.scaleY ?? 1,
+ rotation: inst.rotation ?? 0,
+ draggable: true,
+ }
+}
+
+function onClipInstanceDragEnd(
+ e: Konva.KonvaEventObject,
+ instanceId: string,
+) {
+ const node = e.target
+ store.setClipInstanceTransform(instanceId, {
+ x: node.x(),
+ y: node.y(),
+ })
+}
+
+function onClipInstanceTransformEnd(
+ e: Konva.KonvaEventObject,
+ instanceId: string,
+) {
+ const node = e.target
+ store.setClipInstanceTransform(instanceId, {
+ scaleX: node.scaleX(),
+ scaleY: node.scaleY(),
+ rotation: node.rotation(),
+ x: node.x(),
+ y: node.y(),
+ })
+}
+
function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) {
return {
id: `ci:${instanceId}:${layer.id}`,
@@ -1235,7 +1288,11 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
opacity: props.opacity,
visible: layer.visible,
draggable: false,
- listening: false,
+ // Keep clip children hit-testable so a click on the clip's
+ // visible shape bubbles up to the parent group's @mousedown
+ // selection handler. They don't carry their own handlers, so
+ // the event bubbles straight up.
+ listening: true,
...shadowCfg(props),
}
}
@@ -1808,7 +1865,9 @@ function onPivotDragEnd(e: Konva.KonvaEventObject) {
}
const transformerConfig = {
- rotateEnabled: false,
+ rotateEnabled: true,
+ rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
+ rotationSnapTolerance: 4,
enabledAnchors: [
'top-left',
'top-center',
@@ -1837,6 +1896,22 @@ function attachTransformer(retriesLeft = 3) {
const stage = stageRef.value?.getStage()
const tr = transformerRef.value?.getNode()
if (!stage || !tr) return
+
+ // Clip-instance selection takes precedence — the transformer wraps
+ // the group so its move + scale handles update the instance's
+ // static x / y / scale.
+ const clipInstId = store.selectedClipInstanceId
+ if (clipInstId) {
+ const groupNode = stage.findOne(`#ci-group:${clipInstId}`)
+ if (!groupNode && retriesLeft > 0) {
+ attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
+ return
+ }
+ tr.nodes(groupNode ? [groupNode] : [])
+ tr.getLayer()?.batchDraw()
+ return
+ }
+
const id = store.selectedLayerId
if (!id) {
tr.nodes([])
@@ -1865,7 +1940,13 @@ function attachTransformer(retriesLeft = 3) {
}
watch(
- () => [store.selectedLayerId, stackItems.value, store.playhead] as const,
+ () =>
+ [
+ store.selectedLayerId,
+ store.selectedClipInstanceId,
+ stackItems.value,
+ store.playhead,
+ ] as const,
async () => {
await nextTick()
attachTransformer()
@@ -2902,7 +2983,7 @@ onBeforeUnmount(() => {
:key="
entry.kind === 'layer'
? `l:${entry.item.layer.id}`
- : `ci:${entry.item.instanceId}:${entry.item.layer.id}:${i}`
+ : `ci:${entry.bundle.instance.id}:${i}`
"
>
@@ -2989,26 +3070,38 @@ onBeforeUnmount(() => {
/>
-
-
-
-
-
+
+
+
+
+
+
+
+
+
{
+ // Pin that the exported runtime's per-clip draw loop honours
+ // ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor
+ // and the exported HTML stay visually identical for any
+ // clip-instance transform.
+ const r = generateRuntimeScript()
+ expect(r).toContain('inst2.x')
+ expect(r).toContain('inst2.y')
+ expect(r).toContain('inst2.scaleX')
+ expect(r).toContain('inst2.scaleY')
+ expect(r).toContain('inst2.rotation')
+ expect(r).toContain('ctx.rotate(irot2*Math.PI/180)')
+ expect(r).toContain('ctx.scale(isx2,isy2)')
+ })
+
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
// — the time of the last keyframe — when scene.outTime was unset.
@@ -1298,19 +1313,35 @@ export function registerAllTests(): void {
})
suite('ui — Modal', () => {
+ // Modal teleports to document.body. If the running app has a
+ // dialog open (welcome / export / settings), a plain
+ // document.querySelector('[role="dialog"]') would match that
+ // instead of what the test just mounted. Snapshot the live
+ // dialogs first and diff after mount so each assertion only
+ // considers the nodes this test created.
+ const snapshotDialogs = () =>
+ new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
+ const dialogsAddedSince = (baseline: Set) =>
+ Array.from(document.querySelectorAll('[role="dialog"]')).filter(
+ (el) => !baseline.has(el),
+ )
+
test('renders nothing when modelValue is false', async () => {
+ const baseline = snapshotDialogs()
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
await nextTick()
- expect(document.querySelector('[role="dialog"]')).toBeNull()
+ expect(dialogsAddedSince(baseline).length).toBe(0)
handle.unmount()
})
test('teleports backdrop + dialog to body when open, renders title', async () => {
+ const baseline = snapshotDialogs()
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
await nextTick()
- const dialog = document.querySelector('[role="dialog"]') as HTMLElement | null
- expect(dialog).toBeTruthy()
- expect(dialog!.textContent?.includes('Hallo Modal')).toBe(true)
+ const added = dialogsAddedSince(baseline)
+ expect(added.length).toBe(1)
+ const dialog = added[0] as HTMLElement
+ expect(dialog.textContent?.includes('Hallo Modal')).toBe(true)
handle.unmount()
})
@@ -1349,6 +1380,7 @@ export function registerAllTests(): void {
test('dismissable=false hides close button and swallows Esc', async () => {
let latest: boolean | null = null
+ const baseline = snapshotDialogs()
const handle = mountComponent(Modal, {
modelValue: true,
title: 'Busy',
@@ -1358,7 +1390,10 @@ export function registerAllTests(): void {
},
})
await nextTick()
- const btn = document.querySelector('[role="dialog"] header button')
+ const added = dialogsAddedSince(baseline)
+ expect(added.length).toBe(1)
+ const dialog = added[0] as HTMLElement
+ const btn = dialog.querySelector('header button')
expect(btn).toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick()
@@ -1689,6 +1724,254 @@ export function registerAllTests(): void {
expect(ci.trimEnd).toBe(0)
})
+ test('setClipInstanceTransform stores x / y / scale / rotation', () => {
+ const s = makeFreshStore()
+ const c = s.addClip('Xf')
+ const t = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(c.id, 0, t.id)!
+ s.setClipInstanceTransform(ci.id, {
+ x: 80,
+ y: -30,
+ scaleX: 2,
+ scaleY: 0.5,
+ rotation: 45,
+ })
+ expect(ci.x).toBe(80)
+ expect(ci.y).toBe(-30)
+ expect(ci.scaleX).toBe(2)
+ expect(ci.scaleY).toBe(0.5)
+ expect(ci.rotation).toBe(45)
+ })
+
+ test('setClipInstanceTransform normalises rotation into (-180, 180]', () => {
+ // Values outside the canonical range are wrapped so the stored
+ // number stays human-readable across multiple transform
+ // sessions (Konva's transformer accumulates past 360).
+ const s = makeFreshStore()
+ const c = s.addClip('Xf')
+ const t = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(c.id, 0, t.id)!
+ s.setClipInstanceTransform(ci.id, { rotation: 370 })
+ expect(ci.rotation).toBe(10)
+ s.setClipInstanceTransform(ci.id, { rotation: -190 })
+ expect(ci.rotation).toBe(170)
+ s.setClipInstanceTransform(ci.id, { rotation: 360 })
+ // 360 % 360 = 0 → pruned back out of the object.
+ expect('rotation' in ci).toBe(false)
+ })
+
+ test('setClipInstanceTransform prunes defaults so JSON stays clean', () => {
+ // Defaults (x=0, y=0, scale=1) are absent on a freshly-added
+ // instance. Writing them back explicitly mustn't leave the
+ // field around — serialized scenes stay minimal and diffable.
+ const s = makeFreshStore()
+ const c = s.addClip('Xf')
+ const t = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(c.id, 0, t.id)!
+ s.setClipInstanceTransform(ci.id, { x: 50, scaleX: 2 })
+ expect(ci.x).toBe(50)
+ expect(ci.scaleX).toBe(2)
+ // Clear back to defaults — the keys should get deleted.
+ s.setClipInstanceTransform(ci.id, { x: 0, scaleX: 1 })
+ expect('x' in ci).toBe(false)
+ expect('scaleX' in ci).toBe(false)
+ })
+
+ test('setClipInstanceTransform leaves unspecified fields alone', () => {
+ const s = makeFreshStore()
+ const c = s.addClip('Xf')
+ const t = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(c.id, 0, t.id)!
+ s.setClipInstanceTransform(ci.id, { x: 10, y: 20, scaleX: 3, scaleY: 3 })
+ // Partial patch: only x. y / scale must survive.
+ s.setClipInstanceTransform(ci.id, { x: 99 })
+ expect(ci.x).toBe(99)
+ expect(ci.y).toBe(20)
+ expect(ci.scaleX).toBe(3)
+ expect(ci.scaleY).toBe(3)
+ })
+
+ test('setClipInstanceTransform on an unknown id is a no-op', () => {
+ const s = makeFreshStore()
+ const c = s.addClip('Xf')
+ const t = s.scene.clipTracks![0]
+ s.addClipInstance(c.id, 0, t.id)!
+ s.setClipInstanceTransform('not-an-id', { x: 5 })
+ // No-op; no side effects or throw.
+ expect(s.scene.clipInstances!.every((ci) => ci.x === undefined)).toBe(true)
+ })
+
+ test('selectClipInstance clears the layer selection and vice versa', () => {
+ // Selection is mutually exclusive — stage transformer only
+ // targets one thing at a time.
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ const c = s.addClip('Xf')
+ const t = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(c.id, 0, t.id)!
+
+ s.selectLayer(l.id)
+ expect(s.selectedLayerId).toBe(l.id)
+ expect(s.selectedClipInstanceId).toBe(null)
+
+ s.selectClipInstance(ci.id)
+ expect(s.selectedClipInstanceId).toBe(ci.id)
+ expect(s.selectedLayerId).toBe(null)
+ expect(s.selectedLayerIds).toEqual([])
+
+ s.selectLayer(l.id)
+ expect(s.selectedLayerId).toBe(l.id)
+ expect(s.selectedClipInstanceId).toBe(null)
+
+ s.selectClipInstance(null)
+ expect(s.selectedClipInstanceId).toBe(null)
+ // selecting null on the clip branch doesn't touch the layer
+ // selection — the user just cleared the clip-side pointer.
+ expect(s.selectedLayerId).toBe(l.id)
+ })
+
+ test('removeClipInstance clears selectedClipInstanceId when it matched', () => {
+ const s = makeFreshStore()
+ const c = s.addClip('Xf')
+ const t = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(c.id, 0, t.id)!
+ s.selectClipInstance(ci.id)
+ s.removeClipInstance(ci.id)
+ expect(s.selectedClipInstanceId).toBe(null)
+ })
+
+ test('drawSceneFrame rotates the clip-instance group on the canvas', () => {
+ // Geometry that keeps the rect on-canvas through a 180° flip:
+ // - 20×10 rect anchored at layer origin (0,0) with no offset.
+ // - Clip instance at x=50, y=50 → unrotated, rect lives at
+ // world (50..70, 50..60).
+ // - Rotate 180° → rect flips to world (30..50, 40..50).
+ // Both pre and post positions sit comfortably on the 100×100
+ // canvas so we can assert concrete pixels on either side.
+ const s = makeFreshStore()
+ s.scene.canvas.width = 100
+ s.scene.canvas.height = 100
+ s.scene.canvas.background = 'transparent'
+ s.scene.duration = 5
+
+ const rect = s.addAsset({
+ kind: 'rect',
+ name: 'R',
+ width: 20,
+ height: 10,
+ fill: '#ff0000',
+ })
+ const clip = s.addClip('C')
+ clip.duration = 5
+ clip.layers = [
+ {
+ id: 'clip-layer-r',
+ name: 'R',
+ assetId: rect.id,
+ visible: true,
+ base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
+ keyframes: [],
+ offsetX: 0,
+ offsetY: 0,
+ },
+ ]
+ const track = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(clip.id, 0, track.id)!
+ s.setClipInstanceTransform(ci.id, { x: 50, y: 50 })
+
+ // Baseline: unrotated rect covers world (50..70, 50..60). A
+ // pixel at (60, 55) must be red; (40, 45) must be transparent.
+ const unrotated = document.createElement('canvas')
+ unrotated.width = 100
+ unrotated.height = 100
+ const uCtx = unrotated.getContext('2d')!
+ drawSceneFrame(uCtx, s.scene, 0, {})
+ const beforeInside = uCtx.getImageData(60, 55, 1, 1).data
+ const beforeOutside = uCtx.getImageData(40, 45, 1, 1).data
+ expect(beforeInside[0] > 200).toBe(true)
+ expect(beforeInside[3] > 200).toBe(true)
+ expect(beforeOutside[3]).toBe(0)
+
+ // Rotate 180°: rect flips around (50, 50) to world (30..50,
+ // 40..50). The two probe pixels swap roles.
+ s.setClipInstanceTransform(ci.id, { rotation: 180 })
+ const rotated = document.createElement('canvas')
+ rotated.width = 100
+ rotated.height = 100
+ const rCtx = rotated.getContext('2d')!
+ drawSceneFrame(rCtx, s.scene, 0, {})
+ const afterInside = rCtx.getImageData(40, 45, 1, 1).data
+ const afterOutside = rCtx.getImageData(60, 55, 1, 1).data
+ expect(afterInside[0] > 200).toBe(true)
+ expect(afterInside[3] > 200).toBe(true)
+ expect(afterOutside[3]).toBe(0)
+ })
+
+ test('drawSceneFrame translates + scales the clip-instance group on the canvas', () => {
+ // Seeds a 100×100 scene with a tiny red rect clip; places one
+ // instance at the top-left (0,0) with 2× scale so the 10×10
+ // rect renders as a 20×20 block. Reads pixels to confirm the
+ // transform actually landed on the canvas-path renderer
+ // (mask-preview, thumbnails, etc. all go through drawSceneFrame).
+ const s = makeFreshStore()
+ s.scene.canvas.width = 100
+ s.scene.canvas.height = 100
+ s.scene.canvas.background = 'transparent'
+ s.scene.duration = 5
+
+ const rect = s.addAsset({
+ kind: 'rect',
+ name: 'R',
+ width: 10,
+ height: 10,
+ fill: '#ff0000',
+ })
+ const clip = s.addClip('C')
+ clip.duration = 5
+ // Build the clip's sole layer directly — addLayer would target
+ // the scene, not the clip library.
+ clip.layers = [
+ {
+ id: 'clip-layer-1',
+ name: 'RL',
+ assetId: rect.id,
+ visible: true,
+ base: {
+ x: 5,
+ y: 5,
+ scaleX: 1,
+ scaleY: 1,
+ rotation: 0,
+ opacity: 1,
+ },
+ keyframes: [],
+ offsetX: 5,
+ offsetY: 5,
+ },
+ ]
+ const track = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(clip.id, 0, track.id)!
+ // Transform: nudge to (0,0) origin for the top-left and scale 2×.
+ s.setClipInstanceTransform(ci.id, { x: 0, y: 0, scaleX: 2, scaleY: 2 })
+
+ const canvas = document.createElement('canvas')
+ canvas.width = 100
+ canvas.height = 100
+ const ctx = canvas.getContext('2d')!
+ drawSceneFrame(ctx, s.scene, 0, {})
+ // Without transform the 10×10 rect at (5,5) covers x=0..10, y=0..10
+ // (layer's own offset centres it on its base pos). With scale 2×
+ // it covers 0..20 in both axes. A pixel at (15, 15) should be red.
+ const insideAfterScale = ctx.getImageData(15, 15, 1, 1).data
+ expect(insideAfterScale[0] > 200).toBe(true)
+ expect(insideAfterScale[3] > 200).toBe(true)
+ // A pixel at (25, 25) should still be blank (outside the
+ // scaled rect + stage-render still clips to canvas).
+ const outsideAfterScale = ctx.getImageData(25, 25, 1, 1).data
+ expect(outsideAfterScale[3]).toBe(0)
+ })
+
test('setTrackColor applies to layer and can be cleared with null', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts
index 08a6e3b..fab6efb 100644
--- a/src/lib/exportPlayer.ts
+++ b/src/lib/exportPlayer.ts
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
-const RUNTIME_VERSION = '15'
+const RUNTIME_VERSION = '17'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -732,9 +732,21 @@ const ENGINE_SOURCE = `(function(){
var offset=time-inst2.startTime;
if(offset<0||offset>visibleClip)continue;
var localT=tS+offset;
+ var ix2=inst2.x||0, iy2=inst2.y||0;
+ var isx2=(inst2.scaleX==null?1:inst2.scaleX);
+ var isy2=(inst2.scaleY==null?1:inst2.scaleY);
+ var irot2=inst2.rotation||0;
+ var needsXf=ix2!==0||iy2!==0||isx2!==1||isy2!==1||irot2!==0;
+ if(needsXf){
+ ctx.save();
+ ctx.translate(ix2,iy2);
+ if(irot2!==0) ctx.rotate(irot2*Math.PI/180);
+ ctx.scale(isx2,isy2);
+ }
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
drawLayer(clip2.layers[cl2],localT);
}
+ if(needsXf) ctx.restore();
}
continue;
}
diff --git a/src/lib/render.ts b/src/lib/render.ts
index e001f76..5daa46d 100644
--- a/src/lib/render.ts
+++ b/src/lib/render.ts
@@ -446,7 +446,21 @@ export function drawSceneFrame(
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
+ const ix = inst.x ?? 0
+ const iy = inst.y ?? 0
+ const isx = inst.scaleX ?? 1
+ const isy = inst.scaleY ?? 1
+ const irot = inst.rotation ?? 0
+ const needsTransform =
+ ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
+ if (needsTransform) {
+ drawCtx.save()
+ drawCtx.translate(ix, iy)
+ if (irot !== 0) drawCtx.rotate((irot * Math.PI) / 180)
+ drawCtx.scale(isx, isy)
+ }
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
+ if (needsTransform) drawCtx.restore()
}
continue
}
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
index 34bf373..46e55e9 100644
--- a/src/stores/scene.ts
+++ b/src/stores/scene.ts
@@ -73,6 +73,11 @@ interface State {
* anchor. Used by group-selected / delete-selected and any
* operation that needs the whole set. */
selectedLayerIds: string[]
+ /** Selected clip-instance id — drives the canvas transformer when a
+ * packed clip is clicked on the stage. Mutually exclusive with
+ * `selectedLayerId`: selecting a clip clears the layer selection
+ * and vice versa. */
+ selectedClipInstanceId: string | null
playhead: number
playing: boolean
activeClipId: string | null
@@ -268,6 +273,7 @@ export const useSceneStore = defineStore('scene', {
scene: createBlankScene(),
selectedLayerId: null,
selectedLayerIds: [],
+ selectedClipInstanceId: null,
playhead: 0,
playing: false,
activeClipId: null,
@@ -454,6 +460,14 @@ export const useSceneStore = defineStore('scene', {
selectLayer(id: string | null) {
this.selectedLayerId = id
this.selectedLayerIds = id ? [id] : []
+ if (id) this.selectedClipInstanceId = null
+ },
+ selectClipInstance(id: string | null) {
+ this.selectedClipInstanceId = id
+ if (id) {
+ this.selectedLayerId = null
+ this.selectedLayerIds = []
+ }
},
/** Shift-click behaviour: add the layer to the selection if it's
* not already there; otherwise remove it. The anchor moves to
@@ -1808,6 +1822,49 @@ export const useSceneStore = defineStore('scene', {
removeClipInstance(id: string) {
if (!this.scene.clipInstances) return
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
+ if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
+ },
+ /** Update the per-instance x / y / scaleX / scaleY. Missing fields
+ * on the patch are left alone; clearing a field (setting it to
+ * the default 0 / 1) prunes it from the stored object so the
+ * JSON stays clean. The clip's own keyframes are untouched. */
+ setClipInstanceTransform(
+ id: string,
+ patch: Partial<{
+ x: number
+ y: number
+ scaleX: number
+ scaleY: number
+ rotation: number
+ }>,
+ ) {
+ const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
+ if (!inst) return
+ if (patch.x !== undefined) {
+ if (patch.x === 0) delete inst.x
+ else inst.x = patch.x
+ }
+ if (patch.y !== undefined) {
+ if (patch.y === 0) delete inst.y
+ else inst.y = patch.y
+ }
+ if (patch.scaleX !== undefined) {
+ if (patch.scaleX === 1) delete inst.scaleX
+ else inst.scaleX = patch.scaleX
+ }
+ if (patch.scaleY !== undefined) {
+ if (patch.scaleY === 1) delete inst.scaleY
+ else inst.scaleY = patch.scaleY
+ }
+ if (patch.rotation !== undefined) {
+ // Normalise to (-180, 180] so the stored value stays human-
+ // readable and idempotent across multiple transform sessions.
+ let r = patch.rotation % 360
+ if (r > 180) r -= 360
+ if (r <= -180) r += 360
+ if (r === 0) delete inst.rotation
+ else inst.rotation = r
+ }
},
addTrackGroup(name?: string): TrackGroup {
const group: TrackGroup = {
diff --git a/src/types/scene.ts b/src/types/scene.ts
index b659079..c204a1f 100644
--- a/src/types/scene.ts
+++ b/src/types/scene.ts
@@ -215,6 +215,18 @@ export interface ClipInstance {
clipTrackId: string
trimStart?: number
trimEnd?: number
+ /**
+ * Static per-instance transform applied as a group wrapper over the
+ * clip's layers on the stage. The clip itself is NOT animatable —
+ * these values are constants for this particular placement. Missing
+ * fields default to 0 (position / rotation) or 1 (scale).
+ */
+ x?: number
+ y?: number
+ scaleX?: number
+ scaleY?: number
+ /** Degrees clockwise; applied around the group's origin. */
+ rotation?: number
}
export interface BackgroundVideo {
|