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:
1 parent
53c0410824
commit
d60684edd1
7 files changed
+512
-41
No files matched your search
+288
-5
@@ -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' })
|
||||
|
||||
Reference in new issue
Block a user