New per-entity property that renders content N times around a pivot.
For clip instances each radial copy can also be staggered in time, so
arm i starts playing the clip i*offset frames later than the source.
Data model (src/types/scene.ts):
- Layer: kaleido?, kaleidoCount?
- ClipInstance: kaleido?, kaleidoCount?, kaleidoOffset?, kaleidoRotateCopies?,
kaleidoPivotX?, kaleidoPivotY?
All fields optional; legacy projects unchanged.
Math (src/lib/kaleido.ts):
- kaleidoCountFor / kaleidoAngleDegrees, clipKaleidoPivot, clipArmTranslate.
- clipArmLocalTime: returns the per-arm clip-local time. For arm i with
offset dt, the arm shows frame (offsetIntoBlock - i*dt) — visible only
when that falls inside [0, trimmedDuration]. Returns null otherwise so
the caller can hide pre-start and post-end arms.
Render parity:
- src/lib/render.ts: extracted renderClipInstance helper used by both the
top-level scene loop and drawTrackGroup. drawLayers + drawMaskShape now
loop the kaleido count and add the per-copy angle to the layer rotation
(Konva-style "rotate around (x,y)" comes for free).
- src/lib/exportPlayer.ts: mirror in JS (kalCount, kalAngle, clipKaleidoPivot,
clipArmLocalT, clipArmTrans, drawClipInstance). drawLayer + drawMaskShape
wrapped the same way. RUNTIME_VERSION 28 → 31 (3 fixes during landing).
- src/components/CanvasStage.vue: stackItems now emits one bundle per
kaleido arm with arm-specific items sampled at the arm's localT, so the
Konva live preview shows the staggered start in the editor — not just
in the fullscreen preview / export. Source arm (armIndex 0) keeps the
selection / drag / transform handlers; non-source arms are listening:
false. New clipInstanceKaleidoGroupConfig handles the per-arm transform
with rotateCopies on/off semantics. withK helper augments per-layer
configs with the kaleido rotation.
UI (src/components/PropertiesPanel.vue + i18n):
- New "Kaleidoscope" collapsible section. Layer mode: toggle + repeat.
Clip-instance mode (selected via canvas, panel banner explains the
partial editability): toggle, repeat, start offset (frames — converted
to seconds via scene.fps before storage), pivot X/Y, rotate-copies
toggle. Hidden for button-asset layers (hit-area routing breaks if
multiplied).
Store (src/stores/scene.ts):
- setLayerKaleido / setLayerKaleidoCount with [2, 64] integer clamp,
setClipInstanceKaleido with clear-semantics for offset=0 and
rotateCopies=true (deletes the field so JSON stays clean).
Tests (8 new under "kaleidoscope — store + render + engine"):
- Roundtrip + clamp for both Layer and ClipInstance setters.
- drawSceneFrame: 4 cardinal-pixel kaleido smoke for static layer.
- Button asset bypasses kaleido (hit-area routing intact).
- ClipInstance roundtrip + clear-semantics for offset=0.
- clipArmLocalTime: arm 0 honours bounds (returns null when source
itself is past), delayed arms outlive the source's window when
their shifted time is still in [0, dur].
- drawSceneFrame integration: 4-arm clip with 0.5s offset; sample at
t=0 / t=0.5 / t=1.5 verifies staircase visibility (1 → 2 → 3 arms).
- Engine source carries kalCount, kalAngle, drawClipInstance,
clipKaleidoPivot, clipArmLocalT; RUNTIME_VERSION '31'.
- layerActiveAt regression — kaleido fields don't break visibility gate.
Plus the prior export-runtime test updated to read drawClipInstance's
inst.{x,y,scaleX,scaleY,rotation} variables instead of the old inline
inst2.* names that the kaleido refactor removed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
4.2 KiB
TypeScript
110 lines
4.2 KiB
TypeScript
// Math helpers for the kaleidoscope feature. Kept as pure data so
|
|
// the canvas2d offline renderer (lib/render.ts), the Konva live
|
|
// stage (components/CanvasStage.vue), AND the exported runtime
|
|
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
|
|
// JS) all derive identical geometry from the same definitions.
|
|
|
|
import type { Layer, ClipInstance } from '@/types/scene'
|
|
|
|
/** Number of radial copies to render. Returns 1 when kaleido is off
|
|
* or the count is below the floor — the caller should treat 1 as
|
|
* "no kaleido, just render once unchanged". */
|
|
export function kaleidoCountFor(entity: { kaleido?: boolean; kaleidoCount?: number }): number {
|
|
if (!entity.kaleido) return 1
|
|
const n = Math.floor(entity.kaleidoCount ?? 0)
|
|
if (n < 2) return 1
|
|
return n
|
|
}
|
|
|
|
/** Per-copy radial angle in degrees. Index 0 → 0° (the source). */
|
|
export function kaleidoAngleDegrees(index: number, count: number): number {
|
|
if (count <= 1) return 0
|
|
return (index * 360) / count
|
|
}
|
|
|
|
/** ClipInstance kaleido pivot in world space. Defaults to the
|
|
* instance's own position when no explicit pivot is set. */
|
|
export function clipKaleidoPivot(inst: ClipInstance): { x: number; y: number } {
|
|
const ix = inst.x ?? 0
|
|
const iy = inst.y ?? 0
|
|
return {
|
|
x: inst.kaleidoPivotX ?? ix,
|
|
y: inst.kaleidoPivotY ?? iy,
|
|
}
|
|
}
|
|
|
|
/** Per-arm local clip time (seconds). Returns `null` when the arm is
|
|
* outside the clip's playable range — i.e. the arm hasn't started
|
|
* yet (its delayed start is still in the future) or it has already
|
|
* finished. Callers must skip arms where this returns null.
|
|
*
|
|
* Semantics: arm `i` is delayed by `i * kaleidoOffset` seconds
|
|
* relative to the source. So with offset = 0.5s and four arms, at
|
|
* scene time `t` arm 0 shows the source frame, arm 1 shows the
|
|
* frame from 0.5s earlier in the clip's playback (equivalent to
|
|
* "arm 1 starts 0.5s after arm 0"), arm 2 from 1s earlier, etc.
|
|
* Before its delayed start the arm is hidden; after the clip's end
|
|
* it stays gone.
|
|
*/
|
|
export function clipArmLocalTime(
|
|
inst: ClipInstance,
|
|
baseLocal: number,
|
|
index: number,
|
|
trimmedDuration: number,
|
|
): number | null {
|
|
if (trimmedDuration <= 0) return null
|
|
const offset = inst.kaleidoOffset ?? 0
|
|
const trimStart = inst.trimStart ?? 0
|
|
const offsetIntoBlock = baseLocal - trimStart
|
|
// Arm 0 (and the no-shift case) just uses the source's own clip-
|
|
// local time; arms i>0 with a non-zero offset are delayed.
|
|
const shifted = (index === 0 || offset === 0)
|
|
? offsetIntoBlock
|
|
: offsetIntoBlock - index * offset
|
|
const EPS = 1e-9
|
|
if (shifted < -EPS || shifted > trimmedDuration + EPS) return null
|
|
return trimStart + Math.max(0, Math.min(trimmedDuration, shifted))
|
|
}
|
|
|
|
/**
|
|
* For rotateCopies=false: compute the rotated *position* of the
|
|
* instance's anchor around the kaleido pivot. The caller then
|
|
* translates by this rotated position (instead of the original
|
|
* (ix, iy)) so the contents land in a circle around the pivot
|
|
* without rotating themselves.
|
|
*/
|
|
export function clipArmTranslate(
|
|
inst: ClipInstance,
|
|
index: number,
|
|
count: number,
|
|
): { x: number; y: number } {
|
|
const ix = inst.x ?? 0
|
|
const iy = inst.y ?? 0
|
|
if (index === 0 || count <= 1) return { x: ix, y: iy }
|
|
const rotateCopies = inst.kaleidoRotateCopies !== false
|
|
if (rotateCopies) return { x: ix, y: iy }
|
|
const { x: px, y: py } = clipKaleidoPivot(inst)
|
|
const a = (kaleidoAngleDegrees(index, count) * Math.PI) / 180
|
|
const dx = ix - px
|
|
const dy = iy - py
|
|
return {
|
|
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
|
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
|
}
|
|
}
|
|
|
|
/** Bool helper: should the per-arm transform also rotate the
|
|
* contents (true) or only translate them around the pivot (false)?
|
|
* Default true mirrors the natural kaleidoscope look. Layers don't
|
|
* expose a toggle — they always rotate copies. */
|
|
export function shouldRotateClipCopies(inst: ClipInstance): boolean {
|
|
return inst.kaleidoRotateCopies !== false
|
|
}
|
|
|
|
/** Convenience wrapper for the layer side, where the math is
|
|
* simpler (pivot is always the layer's own position, contents
|
|
* always rotate with the copy). */
|
|
export function layerKaleidoCount(layer: Layer): number {
|
|
return kaleidoCountFor(layer)
|
|
}
|