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

+288 -5
View File
@@ -986,6 +986,21 @@ export function registerAllTests(): void {
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)', () => {
// 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<Element>) =>
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' })