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>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 19:18:16 +02:00
1 parent 27d6a47b72
commit 80e1510b53
11 files changed
+1001 -212

No files matched your search

+138 -41
View File
@@ -21,6 +21,13 @@ import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { fontCssFamily, fontsReady } from '@/lib/fonts'
import {
clipArmLocalTime,
clipArmTranslate,
kaleidoAngleDegrees,
kaleidoCountFor,
shouldRotateClipCopies,
} from '@/lib/kaleido'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import type {
Action,
@@ -541,6 +548,33 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
return { ...cfg, draggable: false, listening: false }
}
/** Returns [0..N-1] when kaleido is on, else [0]. The Konva templates
* iterate this and call `withK(cfg, ki, n)` to add the per-copy
* rotation. For ki>0 the copy is non-listening so only the source
* receives clicks/drag/transform events. */
function kaleidoIndicesForLayer(layer: Layer): number[] {
const n = kaleidoCountFor(layer)
if (n <= 1) return [0]
return Array.from({ length: n }, (_, i) => i)
}
/** Augments a layer's Konva config with the kaleido per-copy rotation.
* Konva rotates around the node's `(x, y)` world position (offset is
* the local-space anchor), so adding `kAngle` to `rotation` gives the
* desired rotation around the layer's pivot for free. Non-source
* copies are listening:false to keep events on the source. */
function withK<T extends Record<string, unknown>>(cfg: T, ki: number, n: number): T {
if (n <= 1 || ki === 0) return cfg
const kAngle = kaleidoAngleDegrees(ki, n)
const baseRot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0
return {
...cfg,
rotation: baseRot + kAngle,
listening: false,
draggable: false,
} as T
}
/** SVG-asset child wrapper. Same listening/drag config as button
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
* pre-multiplies each child's strokeWidth by the outer placement
@@ -907,6 +941,10 @@ interface RenderedClipInstance {
* no measurable layers (template falls back to a placeholder). */
bounds: Rect | null
items: RenderedClipLayer[]
/** Kaleido arm index (0 = source). Static (non-kaleido) instances
* always emit a single bundle with armIndex 0, armCount 1. */
armIndex: number
armCount: number
}
type StackItem =
@@ -1052,39 +1090,50 @@ const stackItems = computed<StackItem[]>(() => {
if (!clip) continue
const trimStart = inst.trimStart ?? 0
const trimEnd = inst.trimEnd ?? 0
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const visibleDur = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = store.playhead - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
const localTime = trimStart + offsetIntoBlock
// Bundle the clip's layers under a single clip-instance entry
// so the template can wrap them in a draggable Konva group that
// carries the instance's x / y / scale transform.
const items: RenderedClipLayer[] = []
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
if (!isLayerActiveAt(cl, localTime)) continue
const ca = assetMap.value.get(cl.assetId)
if (!ca) continue
items.push({
instanceId: inst.id,
layer: cl,
asset: ca,
props: sampleLayer(cl, localTime),
// For kaleido instances delayed arms can outlive the source's
// window; widen the early-out so they survive into the loop.
const armCount = kaleidoCountFor(inst)
const offsetSec = inst.kaleidoOffset ?? 0
const maxArmDelay = armCount > 1 && offsetSec !== 0
? Math.max(0, (armCount - 1) * offsetSec)
: 0
if (offsetIntoBlock < 0 || offsetIntoBlock > visibleDur + maxArmDelay) continue
const baseLocal = trimStart + offsetIntoBlock
const bounds = computeClipBounds(clip, assetMap.value)
// Bundle each kaleido arm separately. Each arm samples its own
// localT so a delayed arm shows the right clip frame for *its*
// playback offset (not the source's). Static (non-kaleido)
// instances run this loop once with armIndex 0.
for (let armIndex = 0; armIndex < armCount; armIndex++) {
const localT = clipArmLocalTime(inst, baseLocal, armIndex, visibleDur)
if (localT === null) continue
const items: RenderedClipLayer[] = []
for (let k = clip.layers.length - 1; k >= 0; k--) {
const cl = clip.layers[k]
if (!cl.visible) continue
if (!isLayerActiveAt(cl, localT)) continue
const ca = assetMap.value.get(cl.assetId)
if (!ca) continue
items.push({
instanceId: inst.id,
layer: cl,
asset: ca,
props: sampleLayer(cl, localT),
})
}
out.push({
kind: 'clipInstance',
bundle: {
instance: inst,
bounds,
items,
armIndex,
armCount,
},
})
}
// Always push the bundle when the instance is in time-range,
// even if no items are renderable at this playhead: the frame
// rect still needs to render so the clip can be selected (and
// the transformer framed) on empty frames.
out.push({
kind: 'clipInstance',
bundle: {
instance: inst,
bounds: computeClipBounds(clip, assetMap.value),
items,
},
})
}
}
return out
@@ -1516,6 +1565,47 @@ function clipInstanceGroupConfig(inst: ClipInstance) {
}
}
/** Per-arm Konva Group config for a kaleido'd clip instance. ki=0 is
* the source (events go here), ki>0 are decorative non-listening
* copies. Spatial layout mirrors lib/kaleido.ts: rotateCopies=true
* rotates the entire local space around the pivot (contents follow);
* rotateCopies=false places the instance translated around the pivot
* without rotating its contents. */
function clipInstanceKaleidoGroupConfig(inst: ClipInstance, ki: number) {
const base = clipInstanceGroupConfig(inst)
const n = kaleidoCountFor(inst)
if (n <= 1 || ki === 0) return base
const trans = clipArmTranslate(inst, ki, n)
if (shouldRotateClipCopies(inst)) {
// Rotate the source's local space around the pivot. Konva doesn't
// expose a "rotate around an arbitrary point" knob in a single
// node, but we can replicate it by translating to the pivot,
// rotating, and translating back — folded into x / y / rotation.
const px = inst.kaleidoPivotX ?? (inst.x ?? 0)
const py = inst.kaleidoPivotY ?? (inst.y ?? 0)
const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180
const dx = (inst.x ?? 0) - px
const dy = (inst.y ?? 0) - py
const rotatedX = px + dx * Math.cos(a) - dy * Math.sin(a)
const rotatedY = py + dx * Math.sin(a) + dy * Math.cos(a)
return {
...base,
x: rotatedX,
y: rotatedY,
rotation: (inst.rotation ?? 0) + kaleidoAngleDegrees(ki, n),
listening: false,
draggable: false,
}
}
return {
...base,
x: trans.x,
y: trans.y,
listening: false,
draggable: false,
}
}
/** Invisible frame rect sized to the clip's union bbox. Sits as the
* first child of the ci-group so:
* - the group's client rect is dominated by this stable rectangle,
@@ -4158,9 +4248,13 @@ function onResizeHandlePointerUp(e: PointerEvent) {
"
>
<template v-if="entry.kind === 'layer'">
<template
v-for="ki in kaleidoIndicesForLayer(entry.item.layer)"
:key="`${entry.item.layer.id}:k${ki}`"
>
<v-group
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4204,6 +4298,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
<v-group
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
v-show="ki === 0"
@mouseenter="onButtonEnter(entry.item.layer)"
@mouseleave="onButtonLeave(entry.item.layer)"
@mousedown="onButtonDown(entry.item.layer)"
@@ -4244,7 +4339,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
</v-group>
<v-rect
v-else-if="entry.item.asset.kind === 'rect'"
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4252,7 +4347,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4260,7 +4355,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4268,7 +4363,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="polygonConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@@ -4276,21 +4371,23 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-image
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, ki, kaleidoCountFor(entry.item.layer))"
@dragmove="handleDragMove"
@dragend="handleDragEnd"
@transform="handleTransform"
@transformend="handleTransformEnd"
/>
</template>
</template>
<template v-else>
<v-group
:config="clipInstanceGroupConfig(entry.bundle.instance)"
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
:config="clipInstanceKaleidoGroupConfig(entry.bundle.instance, entry.bundle.armIndex)"
@mousedown="entry.bundle.armIndex === 0 && store.selectClipInstance(entry.bundle.instance.id)"
@dragend="entry.bundle.armIndex === 0 && onClipInstanceDragEnd($event, entry.bundle.instance.id)"
@transformend="entry.bundle.armIndex === 0 && onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
>
<v-rect
v-if="entry.bundle.armIndex === 0"
:config="clipFrameConfig(
entry.bundle.instance.id,
entry.bundle.bounds,
@@ -4298,7 +4395,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<template
v-for="sub in entry.bundle.items"
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}:k${entry.bundle.armIndex}`"
>
<v-rect
v-if="sub.asset.kind === 'rect'"