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:
1 parent
27d6a47b72
commit
80e1510b53
11 files changed
+1001
-212
No files matched your search
+138
-41
@@ -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'"
|
||||
|
||||
Reference in new issue
Block a user