Files
RUWIO/src/lib/kaleido.ts
T
thandClaude Opus 4.7 80e1510b53 1.2.8: kaleidoscope — radial repeat for layers + clips with delayed-start arms
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>
2026-04-26 19:18:16 +02:00

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)
}