0.8.6: clip instances get stage transforms (x/y/scale/rotation)

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:13:09 +02:00
1 parent 53c0410824
commit d60684edd1
7 files changed
+512 -41

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "animatr", "name": "animatr",
"private": true, "private": true,
"version": "0.8.5", "version": "0.8.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+127 -34
View File
@@ -23,6 +23,7 @@ import type {
Asset, Asset,
ButtonAsset, ButtonAsset,
ButtonState, ButtonState,
ClipInstance,
HitShape, HitShape,
Keyframe, Keyframe,
Layer, Layer,
@@ -728,9 +729,14 @@ interface RenderedClipLayer extends RenderedLayer {
instanceId: string instanceId: string
} }
interface RenderedClipInstance {
instance: ClipInstance
items: RenderedClipLayer[]
}
type StackItem = type StackItem =
| { kind: 'layer'; item: RenderedLayer } | { kind: 'layer'; item: RenderedLayer }
| { kind: 'clip'; item: RenderedClipLayer } | { kind: 'clipInstance'; bundle: RenderedClipInstance }
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps { function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
const live = liveLayerPose.value const live = liveLayerPose.value
@@ -833,23 +839,26 @@ const stackItems = computed<StackItem[]>(() => {
const offsetIntoBlock = store.playhead - inst.startTime const offsetIntoBlock = store.playhead - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
const localTime = trimStart + offsetIntoBlock 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--) { for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k] const cl = clip.layers[k]
if (!cl.visible) continue if (!cl.visible) continue
if (!isLayerActiveAt(cl, localTime)) continue if (!isLayerActiveAt(cl, localTime)) continue
const ca = assetMap.value.get(cl.assetId) const ca = assetMap.value.get(cl.assetId)
if (!ca) continue if (!ca) continue
out.push({ items.push({
kind: 'clip', instanceId: inst.id,
item: { layer: cl,
instanceId: inst.id, asset: ca,
layer: cl, props: sampleLayer(cl, localTime),
asset: ca,
props: sampleLayer(cl, localTime),
},
}) })
} }
if (items.length) {
out.push({ kind: 'clipInstance', bundle: { instance: inst, items } })
}
} }
} }
return out 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<DragEvent>,
instanceId: string,
) {
const node = e.target
store.setClipInstanceTransform(instanceId, {
x: node.x(),
y: node.y(),
})
}
function onClipInstanceTransformEnd(
e: Konva.KonvaEventObject<Event>,
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) { function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) {
return { return {
id: `ci:${instanceId}:${layer.id}`, id: `ci:${instanceId}:${layer.id}`,
@@ -1235,7 +1288,11 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
opacity: props.opacity, opacity: props.opacity,
visible: layer.visible, visible: layer.visible,
draggable: false, 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), ...shadowCfg(props),
} }
} }
@@ -1808,7 +1865,9 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
} }
const transformerConfig = { const transformerConfig = {
rotateEnabled: false, rotateEnabled: true,
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
rotationSnapTolerance: 4,
enabledAnchors: [ enabledAnchors: [
'top-left', 'top-left',
'top-center', 'top-center',
@@ -1837,6 +1896,22 @@ function attachTransformer(retriesLeft = 3) {
const stage = stageRef.value?.getStage() const stage = stageRef.value?.getStage()
const tr = transformerRef.value?.getNode() const tr = transformerRef.value?.getNode()
if (!stage || !tr) return 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<Konva.Node>(`#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 const id = store.selectedLayerId
if (!id) { if (!id) {
tr.nodes([]) tr.nodes([])
@@ -1865,7 +1940,13 @@ function attachTransformer(retriesLeft = 3) {
} }
watch( watch(
() => [store.selectedLayerId, stackItems.value, store.playhead] as const, () =>
[
store.selectedLayerId,
store.selectedClipInstanceId,
stackItems.value,
store.playhead,
] as const,
async () => { async () => {
await nextTick() await nextTick()
attachTransformer() attachTransformer()
@@ -2902,7 +2983,7 @@ onBeforeUnmount(() => {
:key=" :key="
entry.kind === 'layer' entry.kind === 'layer'
? `l:${entry.item.layer.id}` ? `l:${entry.item.layer.id}`
: `ci:${entry.item.instanceId}:${entry.item.layer.id}:${i}` : `ci:${entry.bundle.instance.id}:${i}`
" "
> >
<template v-if="entry.kind === 'layer'"> <template v-if="entry.kind === 'layer'">
@@ -2989,26 +3070,38 @@ onBeforeUnmount(() => {
/> />
</template> </template>
<template v-else> <template v-else>
<v-rect <v-group
v-if="entry.item.asset.kind === 'rect'" :config="clipInstanceGroupConfig(entry.bundle.instance)"
:config="clipRectConfig(entry.item)" @mousedown="store.selectClipInstance(entry.bundle.instance.id)"
/> @dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
<v-circle @transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
v-else-if="entry.item.asset.kind === 'circle'" >
:config="clipCircleConfig(entry.item)" <template
/> v-for="sub in entry.bundle.items"
<v-text :key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
v-else-if="entry.item.asset.kind === 'text'" >
:config="clipTextConfig(entry.item)" <v-rect
/> v-if="sub.asset.kind === 'rect'"
<v-line :config="clipRectConfig(sub)"
v-else-if="entry.item.asset.kind === 'polygon'" />
:config="clipPolygonConfig(entry.item)" <v-circle
/> v-else-if="sub.asset.kind === 'circle'"
<v-image :config="clipCircleConfig(sub)"
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)" />
:config="clipImageConfig(entry.item)!" <v-text
/> v-else-if="sub.asset.kind === 'text'"
:config="clipTextConfig(sub)"
/>
<v-line
v-else-if="sub.asset.kind === 'polygon'"
:config="clipPolygonConfig(sub)"
/>
<v-image
v-else-if="sub.asset.kind === 'image' && clipImageConfig(sub)"
:config="clipImageConfig(sub)!"
/>
</template>
</v-group>
</template> </template>
</template> </template>
<template <template
+288 -5
View File
@@ -986,6 +986,21 @@ export function registerAllTests(): void {
expect(r).toContain('out.blur') expect(r).toContain('out.blur')
}) })
test('runtime wraps each clip instance in translate + rotate + scale', () => {
// 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)', () => { test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
// Regression: the runtime used to fall back to `effectiveEnd(scene)` // Regression: the runtime used to fall back to `effectiveEnd(scene)`
// — the time of the last keyframe — when scene.outTime was unset. // — the time of the last keyframe — when scene.outTime was unset.
@@ -1298,19 +1313,35 @@ export function registerAllTests(): void {
}) })
suite('ui — Modal', () => { 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<Element>) =>
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
(el) => !baseline.has(el),
)
test('renders nothing when modelValue is false', async () => { test('renders nothing when modelValue is false', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(Modal, { modelValue: false, title: 'X' }) const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
await nextTick() await nextTick()
expect(document.querySelector('[role="dialog"]')).toBeNull() expect(dialogsAddedSince(baseline).length).toBe(0)
handle.unmount() handle.unmount()
}) })
test('teleports backdrop + dialog to body when open, renders title', async () => { test('teleports backdrop + dialog to body when open, renders title', async () => {
const baseline = snapshotDialogs()
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' }) const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
await nextTick() await nextTick()
const dialog = document.querySelector('[role="dialog"]') as HTMLElement | null const added = dialogsAddedSince(baseline)
expect(dialog).toBeTruthy() expect(added.length).toBe(1)
expect(dialog!.textContent?.includes('Hallo Modal')).toBe(true) const dialog = added[0] as HTMLElement
expect(dialog.textContent?.includes('Hallo Modal')).toBe(true)
handle.unmount() handle.unmount()
}) })
@@ -1349,6 +1380,7 @@ export function registerAllTests(): void {
test('dismissable=false hides close button and swallows Esc', async () => { test('dismissable=false hides close button and swallows Esc', async () => {
let latest: boolean | null = null let latest: boolean | null = null
const baseline = snapshotDialogs()
const handle = mountComponent(Modal, { const handle = mountComponent(Modal, {
modelValue: true, modelValue: true,
title: 'Busy', title: 'Busy',
@@ -1358,7 +1390,10 @@ export function registerAllTests(): void {
}, },
}) })
await nextTick() 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() expect(btn).toBeNull()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await nextTick() await nextTick()
@@ -1689,6 +1724,254 @@ export function registerAllTests(): void {
expect(ci.trimEnd).toBe(0) 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', () => { test('setTrackColor applies to layer and can be cleared with null', () => {
const s = makeFreshStore() const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' }) const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
+13 -1
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene' import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '15' const RUNTIME_VERSION = '17'
const ENGINE_SOURCE = `(function(){ const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return; if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -732,9 +732,21 @@ const ENGINE_SOURCE = `(function(){
var offset=time-inst2.startTime; var offset=time-inst2.startTime;
if(offset<0||offset>visibleClip)continue; if(offset<0||offset>visibleClip)continue;
var localT=tS+offset; 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--){ for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
drawLayer(clip2.layers[cl2],localT); drawLayer(clip2.layers[cl2],localT);
} }
if(needsXf) ctx.restore();
} }
continue; continue;
} }
+14
View File
@@ -446,7 +446,21 @@ export function drawSceneFrame(
const visible = Math.max(0, clip.duration - trimStart - trimEnd) const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime const offsetIntoBlock = time - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue 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) drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
if (needsTransform) drawCtx.restore()
} }
continue continue
} }
+57
View File
@@ -73,6 +73,11 @@ interface State {
* anchor. Used by group-selected / delete-selected and any * anchor. Used by group-selected / delete-selected and any
* operation that needs the whole set. */ * operation that needs the whole set. */
selectedLayerIds: string[] 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 playhead: number
playing: boolean playing: boolean
activeClipId: string | null activeClipId: string | null
@@ -268,6 +273,7 @@ export const useSceneStore = defineStore('scene', {
scene: createBlankScene(), scene: createBlankScene(),
selectedLayerId: null, selectedLayerId: null,
selectedLayerIds: [], selectedLayerIds: [],
selectedClipInstanceId: null,
playhead: 0, playhead: 0,
playing: false, playing: false,
activeClipId: null, activeClipId: null,
@@ -454,6 +460,14 @@ export const useSceneStore = defineStore('scene', {
selectLayer(id: string | null) { selectLayer(id: string | null) {
this.selectedLayerId = id this.selectedLayerId = id
this.selectedLayerIds = id ? [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 /** Shift-click behaviour: add the layer to the selection if it's
* not already there; otherwise remove it. The anchor moves to * not already there; otherwise remove it. The anchor moves to
@@ -1808,6 +1822,49 @@ export const useSceneStore = defineStore('scene', {
removeClipInstance(id: string) { removeClipInstance(id: string) {
if (!this.scene.clipInstances) return if (!this.scene.clipInstances) return
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id) 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 { addTrackGroup(name?: string): TrackGroup {
const group: TrackGroup = { const group: TrackGroup = {
+12
View File
@@ -215,6 +215,18 @@ export interface ClipInstance {
clipTrackId: string clipTrackId: string
trimStart?: number trimStart?: number
trimEnd?: 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 { export interface BackgroundVideo {