From 80e1510b5331024f57a2808a4cb5c1b62ed2f8f7 Mon Sep 17 00:00:00 2001 From: th
{{ t('properties.kaleido.hint') }}
+{{ t('properties.kaleido.hintClip') }}
+{{ t('properties.lockedLayer') }}
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 2fc6445..a3ba43a 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -1257,18 +1257,20 @@ export function registerAllTests(): void { }) 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. + // Pin that the exported runtime's per-clip draw honours + // ClipInstance.x/y/scaleX/scaleY/rotation. Refactored into a + // `drawClipInstance(inst, clip, time)` helper for the kaleido + // feature, so we now look at that function's body rather than + // the inline `inst2.*` variables. 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)') + expect(r).toContain('function drawClipInstance(') + expect(r).toContain('inst.x') + expect(r).toContain('inst.y') + expect(r).toContain('inst.scaleX') + expect(r).toContain('inst.scaleY') + expect(r).toContain('inst.rotation') + expect(r).toContain('ctx.rotate(irot*Math.PI/180)') + expect(r).toContain('ctx.scale(isx,isy)') }) test('runtime embeds a preloader (spinner + Promise.all) before starting the frame loop', () => { @@ -15323,4 +15325,244 @@ export function registerAllTests(): void { } }) }) + + suite('kaleidoscope — store + render + engine', () => { + test('Layer kaleido toggle / count roundtrip + clamp', () => { + const s = makeFreshStore() + const a = s.addAsset({ kind: 'rect', name: 'R', width: 20, height: 20, fill: '#f00' }) + const layer = s.addLayer(a.id)! + // Toggle on → defaults applied. + s.setLayerKaleido(layer.id, true) + expect(layer.kaleido).toBe(true) + expect(layer.kaleidoCount).toBe(6) + // Setter clamps to [2, 64] integer. + s.setLayerKaleidoCount(layer.id, 1.7) + expect(layer.kaleidoCount).toBe(2) + s.setLayerKaleidoCount(layer.id, 999) + expect(layer.kaleidoCount).toBe(64) + s.setLayerKaleidoCount(layer.id, 8) + expect(layer.kaleidoCount).toBe(8) + // Toggle off keeps the count so re-toggling restores it. + s.setLayerKaleido(layer.id, false) + expect(layer.kaleido === undefined).toBe(true) + expect(layer.kaleidoCount).toBe(8) + }) + + test('drawSceneFrame replicates a layer at four cardinal angles when kaleidoCount=4', () => { + const s = makeFreshStore() + s.scene.canvas.width = 200 + s.scene.canvas.height = 200 + s.scene.canvas.background = 'transparent' + // 20×20 red rect with its pivot 60px to the left of its centre, + // so the source copy lands at world rect (150,90)-(170,110) and + // 4 kaleido copies fan out at 0°/90°/180°/270° around (100,100). + const a = s.addAsset({ + kind: 'rect', name: 'K', width: 20, height: 20, fill: '#ff0000', + }) + const layer = s.addLayer(a.id)! + layer.offsetX = -50 + layer.offsetY = 10 + s.upsertKeyframe(layer.id, 0, { x: 100, y: 100 }) + s.setLayerKaleido(layer.id, true) + s.setLayerKaleidoCount(layer.id, 4) + + const cv = document.createElement('canvas') + cv.width = 200 + cv.height = 200 + const ctx = cv.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}) + + // Sample one pixel inside each expected rotated copy. The rect + // body sits ~60px off the pivot in its source orientation; the + // four cardinal positions land on (160,100), (100,160), + // (40,100), (100,40). + const p = (x: number, y: number) => ctx.getImageData(x, y, 1, 1).data + const samples = [p(160, 100), p(100, 160), p(40, 100), p(100, 40)] + for (const px of samples) { + // Red dominant, alpha non-zero. + expect(px[0]).toBeGreaterThan(200) + expect(px[3]).toBeGreaterThan(200) + } + }) + + test('drawSceneFrame leaves a button-asset layer single-rendered even with kaleido on', () => { + // Buttons must keep their hit-area count at 1. The render path + // explicitly bypasses kaleido for asset.kind === 'button'. + const s = makeFreshStore() + s.scene.canvas.width = 200 + s.scene.canvas.height = 200 + s.scene.canvas.background = 'transparent' + const btn = s.addButtonAsset('B', 40, 40) + const layer = s.addLayer(btn.id)! + layer.offsetX = -50 + layer.offsetY = 10 + s.upsertKeyframe(layer.id, 0, { x: 100, y: 100 }) + // Force the kaleido flags on; renderer must still ignore them. + layer.kaleido = true + layer.kaleidoCount = 4 + + const cv = document.createElement('canvas') + cv.width = 200 + cv.height = 200 + const ctx = cv.getContext('2d')! + drawSceneFrame(ctx, s.scene, 0, {}) + // No assertion on pixels (button assets paint via their inner + // layer stack — empty here). The smoke test is that rendering + // completes without throwing and the engine path doesn't loop + // on the button. Reaching this line means we're good. + expect(true).toBe(true) + }) + + test('ClipInstance kaleido fields roundtrip + clear semantics', () => { + const s = makeFreshStore() + const clip = s.addClip('Spinner') + const track = s.addClipTrack() + const inst = s.addClipInstance(clip.id, 0, track.id)! + s.setClipInstanceKaleido(inst.id, { + kaleido: true, + kaleidoCount: 6, + kaleidoOffset: 0.5, + kaleidoPivotX: 100, + kaleidoPivotY: 200, + kaleidoRotateCopies: false, + }) + const stored = s.scene.clipInstances!.find((c) => c.id === inst.id)! + expect(stored.kaleido).toBe(true) + expect(stored.kaleidoCount).toBe(6) + expect(stored.kaleidoOffset).toBe(0.5) + expect(stored.kaleidoPivotX).toBe(100) + expect(stored.kaleidoPivotY).toBe(200) + expect(stored.kaleidoRotateCopies).toBe(false) + // Setting offset back to 0 deletes the field so the object stays + // clean across saves; same for rotateCopies=true. + s.setClipInstanceKaleido(inst.id, { kaleidoOffset: 0, kaleidoRotateCopies: true }) + expect(stored.kaleidoOffset === undefined).toBe(true) + expect(stored.kaleidoRotateCopies === undefined).toBe(true) + }) + + test('clipArmLocalTime delays each arm by i*offset and hides arms outside the clip window', async () => { + const { clipArmLocalTime } = await import('@/lib/kaleido') + // 4-arm clip, 2s trimmed window, dt = 0.5s. Arm i is delayed + // by i*0.5 s relative to the source — at scene time = source's + // offsetIntoBlock t, arm i sees clip-time (t - i*0.5). + const inst = { + kaleidoOffset: 0.5, + trimStart: 0, + } as import('@/types/scene').ClipInstance + // baseLocal = 0.25 (= source playing at t=0.25). + // i=0 → 0.25 (visible) + // i=1 → 0.25 - 0.5 = -0.25 → null (hasn't started yet) + // i=2 → 0.25 - 1.0 = -0.75 → null + // i=3 → 0.25 - 1.5 = -1.25 → null + expect(clipArmLocalTime(inst, 0.25, 0, 2)).toBeCloseTo(0.25, 5) + expect(clipArmLocalTime(inst, 0.25, 1, 2)).toBe(null) + expect(clipArmLocalTime(inst, 0.25, 2, 2)).toBe(null) + expect(clipArmLocalTime(inst, 0.25, 3, 2)).toBe(null) + // baseLocal = 1.5 — late enough for several arms to be in range. + expect(clipArmLocalTime(inst, 1.5, 0, 2)).toBeCloseTo(1.5, 5) + expect(clipArmLocalTime(inst, 1.5, 1, 2)).toBeCloseTo(1.0, 5) + expect(clipArmLocalTime(inst, 1.5, 2, 2)).toBeCloseTo(0.5, 5) + expect(clipArmLocalTime(inst, 1.5, 3, 2)).toBeCloseTo(0, 5) + // baseLocal = 2.4 — past the clip's end. Arm 0 (and any arm + // with `shifted` outside [0, trimmedDur]) returns null; delayed + // arms whose shifted falls in range are still valid. + expect(clipArmLocalTime(inst, 2.4, 0, 2)).toBe(null) + expect(clipArmLocalTime(inst, 2.4, 1, 2)).toBeCloseTo(1.9, 5) + expect(clipArmLocalTime(inst, 2.4, 4, 2)).toBeCloseTo(0.4, 5) + // trimStart shifts the returned value but not the math. + const inst2 = { kaleidoOffset: 0.5, trimStart: 0.4 } as import('@/types/scene').ClipInstance + expect(clipArmLocalTime(inst2, 0.4 + 1.5, 2, 2)).toBeCloseTo(0.4 + 0.5, 5) + }) + + test('drawSceneFrame: delayed kaleido arms appear only after their stagger', () => { + // Build a clip that draws a single red square. Stagger 4 arms by + // 0.5s each, place the kaleido pivot off-canvas so arms 0-3 land + // at four distinct pixel positions, then sample those positions + // at scene-times that should expose 1, then 2, then 3 arms. + const s = makeFreshStore() + s.scene.canvas.width = 200 + s.scene.canvas.height = 200 + s.scene.canvas.background = 'transparent' + s.scene.duration = 5 + const sq = s.addAsset({ + kind: 'rect', name: 'Sq', width: 20, height: 20, fill: '#ff0000', + }) + const clip = s.addClip('SquareClip') + clip.duration = 1 + clip.layers = [ + { + id: 'cl-l1', + name: 'sq', + assetId: sq.id, + visible: true, + base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 }, + keyframes: [], + offsetX: -50, + offsetY: 10, + }, + ] + const track = s.addClipTrack() + const inst = s.addClipInstance(clip.id, 0, track.id)! + s.setClipInstanceTransform(inst.id, { x: 100, y: 100 }) + s.setClipInstanceKaleido(inst.id, { + kaleido: true, + kaleidoCount: 4, + kaleidoOffset: 0.5, + }) + const draw = (t: number) => { + const cv = document.createElement('canvas') + cv.width = 200 + cv.height = 200 + const ctx = cv.getContext('2d')! + drawSceneFrame(ctx, s.scene, t, {}) + return ctx + } + const isRed = (ctx: CanvasRenderingContext2D, x: number, y: number) => { + const px = ctx.getImageData(x, y, 1, 1).data + return px[0] > 200 && px[3] > 200 + } + // At t=0: only arm 0 visible (160, 100). + let ctx = draw(0) + expect(isRed(ctx, 160, 100)).toBe(true) + expect(isRed(ctx, 100, 160)).toBe(false) + expect(isRed(ctx, 40, 100)).toBe(false) + expect(isRed(ctx, 100, 40)).toBe(false) + // At t=0.5: arms 0 and 1 visible. + ctx = draw(0.5) + expect(isRed(ctx, 160, 100)).toBe(true) + expect(isRed(ctx, 100, 160)).toBe(true) + expect(isRed(ctx, 40, 100)).toBe(false) + // At t=1.5: source (arm 0) is past its 1s clip so gone, but arms + // 1 (in clip-time 1.0 — at end), 2 (0.5), 3 (0) still visible. + ctx = draw(1.5) + expect(isRed(ctx, 160, 100)).toBe(false) + expect(isRed(ctx, 100, 160)).toBe(true) + expect(isRed(ctx, 40, 100)).toBe(true) + expect(isRed(ctx, 100, 40)).toBe(true) + }) + + test('exported runtime engine carries kaleido helpers + drawClipInstance', () => { + const src = generateRuntimeScript() + expect(src).toContain('function kalCount(') + expect(src).toContain('function kalAngle(') + expect(src).toContain('function drawClipInstance(') + expect(src).toContain('clipKaleidoPivot') + expect(src).toContain('clipArmLocalT') + // Runtime version got bumped through this feature's iterations. + expect(src).toContain("'31'") + }) + + test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => { + // Regression guard: the kaleido wrapping must not change the + // visibility gate for layers without kaleido — same fixtures as + // the layerActiveAt parity test above. + const src = generateRuntimeScript() + const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/) + expect(!!match).toBe(true) + const layerActiveAt = new Function('layer', 'time', match![1]) as (layer: unknown, time: number) => boolean + const a = { keyframes: [{ time: 2, kind: 'normal' }], kaleido: true, kaleidoCount: 4 } + expect(layerActiveAt(a, 0)).toBe(false) + expect(layerActiveAt(a, 2)).toBe(true) + }) + }) } diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 1d85d33..a3589f7 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -201,12 +201,26 @@ export const en = { polygon: 'Polygon', appearance: 'Appearance', button: 'On Click', + kaleido: 'Kaleidoscope', }, text: { text: 'Text', fontSize: 'Font Size', font: 'Font', }, + kaleido: { + toggle: 'Kaleido', + repeat: 'Repeat', + offset: 'Start offset (frames)', + rotateCopies: 'Rotate copies outward', + pivotX: 'Pivot X', + pivotY: 'Pivot Y', + hint: 'Renders the layer N times around its pivot.', + hintClip: 'Renders the clip N times around the pivot. Each copy starts at a different point in the clip when "Start offset" is set.', + }, + clipInstance: { + banner: 'Clip instance — only kaleidoscope is editable here. Use the canvas to move/resize.', + }, polygonPoints: 'Points', polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.', polygonClosed: 'Closed path (fill)', diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index 07f0df6..0774e9a 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project { return Array.isArray((p as Partial