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
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.7",
|
"version": "1.2.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+138
-41
@@ -21,6 +21,13 @@ import { importImageFile, loadImage } from '@/lib/fileLoader'
|
|||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
||||||
import { fontCssFamily, fontsReady } from '@/lib/fonts'
|
import { fontCssFamily, fontsReady } from '@/lib/fonts'
|
||||||
|
import {
|
||||||
|
clipArmLocalTime,
|
||||||
|
clipArmTranslate,
|
||||||
|
kaleidoAngleDegrees,
|
||||||
|
kaleidoCountFor,
|
||||||
|
shouldRotateClipCopies,
|
||||||
|
} from '@/lib/kaleido'
|
||||||
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||||
import type {
|
import type {
|
||||||
Action,
|
Action,
|
||||||
@@ -541,6 +548,33 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
|||||||
return { ...cfg, draggable: false, listening: false }
|
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
|
/** SVG-asset child wrapper. Same listening/drag config as button
|
||||||
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
|
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
|
||||||
* pre-multiplies each child's strokeWidth by the outer placement
|
* 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). */
|
* no measurable layers (template falls back to a placeholder). */
|
||||||
bounds: Rect | null
|
bounds: Rect | null
|
||||||
items: RenderedClipLayer[]
|
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 =
|
type StackItem =
|
||||||
@@ -1052,39 +1090,50 @@ const stackItems = computed<StackItem[]>(() => {
|
|||||||
if (!clip) continue
|
if (!clip) continue
|
||||||
const trimStart = inst.trimStart ?? 0
|
const trimStart = inst.trimStart ?? 0
|
||||||
const trimEnd = inst.trimEnd ?? 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
|
const offsetIntoBlock = store.playhead - inst.startTime
|
||||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
// For kaleido instances delayed arms can outlive the source's
|
||||||
const localTime = trimStart + offsetIntoBlock
|
// window; widen the early-out so they survive into the loop.
|
||||||
// Bundle the clip's layers under a single clip-instance entry
|
const armCount = kaleidoCountFor(inst)
|
||||||
// so the template can wrap them in a draggable Konva group that
|
const offsetSec = inst.kaleidoOffset ?? 0
|
||||||
// carries the instance's x / y / scale transform.
|
const maxArmDelay = armCount > 1 && offsetSec !== 0
|
||||||
const items: RenderedClipLayer[] = []
|
? Math.max(0, (armCount - 1) * offsetSec)
|
||||||
for (let k = clip.layers.length - 1; k >= 0; k--) {
|
: 0
|
||||||
const cl = clip.layers[k]
|
if (offsetIntoBlock < 0 || offsetIntoBlock > visibleDur + maxArmDelay) continue
|
||||||
if (!cl.visible) continue
|
const baseLocal = trimStart + offsetIntoBlock
|
||||||
if (!isLayerActiveAt(cl, localTime)) continue
|
const bounds = computeClipBounds(clip, assetMap.value)
|
||||||
const ca = assetMap.value.get(cl.assetId)
|
// Bundle each kaleido arm separately. Each arm samples its own
|
||||||
if (!ca) continue
|
// localT so a delayed arm shows the right clip frame for *its*
|
||||||
items.push({
|
// playback offset (not the source's). Static (non-kaleido)
|
||||||
instanceId: inst.id,
|
// instances run this loop once with armIndex 0.
|
||||||
layer: cl,
|
for (let armIndex = 0; armIndex < armCount; armIndex++) {
|
||||||
asset: ca,
|
const localT = clipArmLocalTime(inst, baseLocal, armIndex, visibleDur)
|
||||||
props: sampleLayer(cl, localTime),
|
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
|
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
|
/** Invisible frame rect sized to the clip's union bbox. Sits as the
|
||||||
* first child of the ci-group so:
|
* first child of the ci-group so:
|
||||||
* - the group's client rect is dominated by this stable rectangle,
|
* - 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-if="entry.kind === 'layer'">
|
||||||
|
<template
|
||||||
|
v-for="ki in kaleidoIndicesForLayer(entry.item.layer)"
|
||||||
|
:key="`${entry.item.layer.id}:k${ki}`"
|
||||||
|
>
|
||||||
<v-group
|
<v-group
|
||||||
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
|
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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4204,6 +4298,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
<v-group
|
<v-group
|
||||||
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
|
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
|
||||||
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
|
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
|
||||||
|
v-show="ki === 0"
|
||||||
@mouseenter="onButtonEnter(entry.item.layer)"
|
@mouseenter="onButtonEnter(entry.item.layer)"
|
||||||
@mouseleave="onButtonLeave(entry.item.layer)"
|
@mouseleave="onButtonLeave(entry.item.layer)"
|
||||||
@mousedown="onButtonDown(entry.item.layer)"
|
@mousedown="onButtonDown(entry.item.layer)"
|
||||||
@@ -4244,7 +4339,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
</v-group>
|
</v-group>
|
||||||
<v-rect
|
<v-rect
|
||||||
v-else-if="entry.item.asset.kind === '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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4252,7 +4347,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-circle
|
<v-circle
|
||||||
v-else-if="entry.item.asset.kind === '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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4260,7 +4355,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-text
|
<v-text
|
||||||
v-else-if="entry.item.asset.kind === '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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4268,7 +4363,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-line
|
<v-line
|
||||||
v-else-if="entry.item.asset.kind === 'polygon'"
|
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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@@ -4276,21 +4371,23 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<v-image
|
<v-image
|
||||||
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
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"
|
@dragmove="handleDragMove"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@transform="handleTransform"
|
@transform="handleTransform"
|
||||||
@transformend="handleTransformEnd"
|
@transformend="handleTransformEnd"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-group
|
<v-group
|
||||||
:config="clipInstanceGroupConfig(entry.bundle.instance)"
|
:config="clipInstanceKaleidoGroupConfig(entry.bundle.instance, entry.bundle.armIndex)"
|
||||||
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
|
@mousedown="entry.bundle.armIndex === 0 && store.selectClipInstance(entry.bundle.instance.id)"
|
||||||
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
|
@dragend="entry.bundle.armIndex === 0 && onClipInstanceDragEnd($event, entry.bundle.instance.id)"
|
||||||
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
|
@transformend="entry.bundle.armIndex === 0 && onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
|
||||||
>
|
>
|
||||||
<v-rect
|
<v-rect
|
||||||
|
v-if="entry.bundle.armIndex === 0"
|
||||||
:config="clipFrameConfig(
|
:config="clipFrameConfig(
|
||||||
entry.bundle.instance.id,
|
entry.bundle.instance.id,
|
||||||
entry.bundle.bounds,
|
entry.bundle.bounds,
|
||||||
@@ -4298,7 +4395,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
|||||||
/>
|
/>
|
||||||
<template
|
<template
|
||||||
v-for="sub in entry.bundle.items"
|
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-rect
|
||||||
v-if="sub.asset.kind === 'rect'"
|
v-if="sub.asset.kind === 'rect'"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
PhDiamond,
|
PhDiamond,
|
||||||
PhEye,
|
PhEye,
|
||||||
PhEyeSlash,
|
PhEyeSlash,
|
||||||
|
PhFlower,
|
||||||
PhMaskHappy,
|
PhMaskHappy,
|
||||||
PhMinus,
|
PhMinus,
|
||||||
PhPolygon,
|
PhPolygon,
|
||||||
@@ -23,7 +24,7 @@ import { t } from '@/i18n'
|
|||||||
|
|
||||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||||
|
|
||||||
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button'
|
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'kaleido'
|
||||||
|
|
||||||
const defaultOpen: Record<SectionKey, boolean> = {
|
const defaultOpen: Record<SectionKey, boolean> = {
|
||||||
transform: true,
|
transform: true,
|
||||||
@@ -33,6 +34,7 @@ const defaultOpen: Record<SectionKey, boolean> = {
|
|||||||
shadow: false,
|
shadow: false,
|
||||||
mask: false,
|
mask: false,
|
||||||
button: true,
|
button: true,
|
||||||
|
kaleido: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStoredOpen(): Record<SectionKey, boolean> {
|
function readStoredOpen(): Record<SectionKey, boolean> {
|
||||||
@@ -159,6 +161,62 @@ const buttonAsset = computed(() => {
|
|||||||
return a && a.kind === 'button' ? a : null
|
return a && a.kind === 'button' ? a : null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Kaleido is allowed on any non-button layer. Buttons are excluded
|
||||||
|
// because radial copies multiply hit areas and break click routing.
|
||||||
|
const canKaleido = computed(() => {
|
||||||
|
const a = selectedAsset.value
|
||||||
|
if (!a) return false
|
||||||
|
return a.kind !== 'button'
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedClipInstance = computed(() => {
|
||||||
|
const id = store.selectedClipInstanceId
|
||||||
|
if (!id) return null
|
||||||
|
return store.scene.clipInstances?.find((c) => c.id === id) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
|
||||||
|
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
|
||||||
|
// Offset is stored as seconds (the renderer's unit) but presented to
|
||||||
|
// the user as frames — that's how animators reason about staggered
|
||||||
|
// playback. Conversion uses the scene's fps.
|
||||||
|
const ciKaleidoOffsetFrames = computed(() => {
|
||||||
|
const sec = selectedClipInstance.value?.kaleidoOffset ?? 0
|
||||||
|
const fps = Math.max(1, store.scene.fps || 30)
|
||||||
|
return Math.round(sec * fps)
|
||||||
|
})
|
||||||
|
const ciPivotX = computed(() => selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0)
|
||||||
|
const ciPivotY = computed(() => selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0)
|
||||||
|
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
|
||||||
|
|
||||||
|
function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1]) {
|
||||||
|
const inst = selectedClipInstance.value
|
||||||
|
if (!inst) return
|
||||||
|
store.setClipInstanceKaleido(inst.id, patch)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCiKaleidoOffsetFrames(frames: number) {
|
||||||
|
if (!Number.isFinite(frames)) return
|
||||||
|
const fps = Math.max(1, store.scene.fps || 30)
|
||||||
|
setCiKaleido({ kaleidoOffset: Math.round(frames) / fps })
|
||||||
|
}
|
||||||
|
|
||||||
|
const kaleidoOn = computed(() => !!selected.value?.kaleido)
|
||||||
|
|
||||||
|
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6)
|
||||||
|
|
||||||
|
function setKaleidoOn(on: boolean) {
|
||||||
|
if (!selected.value) return
|
||||||
|
store.setLayerKaleido(selected.value.id, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKaleidoCountInput(e: Event) {
|
||||||
|
if (!selected.value) return
|
||||||
|
const v = Number((e.target as HTMLInputElement).value)
|
||||||
|
if (!Number.isFinite(v)) return
|
||||||
|
store.setLayerKaleidoCount(selected.value.id, v)
|
||||||
|
}
|
||||||
|
|
||||||
const polygonPointCount = computed<number>({
|
const polygonPointCount = computed<number>({
|
||||||
get: () => polygonAsset.value?.points.length ?? 0,
|
get: () => polygonAsset.value?.points.length ?? 0,
|
||||||
set: (v: number) => {
|
set: (v: number) => {
|
||||||
@@ -714,6 +772,39 @@ function deleteSelection() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="canKaleido && !multiSelected" class="prop-group">
|
||||||
|
<button class="group-header" @click="toggleSection('kaleido')">
|
||||||
|
<component :is="sectionOpen.kaleido ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
|
<span>{{ t('properties.sections.kaleido') }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="sectionOpen.kaleido" class="group-body">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-toggle"
|
||||||
|
:class="{ active: kaleidoOn }"
|
||||||
|
v-tooltip="t('properties.kaleido.toggle')"
|
||||||
|
@click="setKaleidoOn(!kaleidoOn)"
|
||||||
|
>
|
||||||
|
<PhFlower :size="14" :weight="kaleidoOn ? 'fill' : 'regular'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label v-if="kaleidoOn">
|
||||||
|
{{ t('properties.kaleido.repeat') }}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
max="64"
|
||||||
|
step="1"
|
||||||
|
:value="kaleidoCountValue"
|
||||||
|
@change="onKaleidoCountInput($event)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p v-if="kaleidoOn" class="hint">{{ t('properties.kaleido.hint') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="buttonAsset && !multiSelected" class="prop-group">
|
<section v-if="buttonAsset && !multiSelected" class="prop-group">
|
||||||
<button class="group-header" @click="toggleSection('button')">
|
<button class="group-header" @click="toggleSection('button')">
|
||||||
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
|
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
@@ -736,6 +827,79 @@ function deleteSelection() {
|
|||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="selectedClipInstance">
|
||||||
|
<div class="multi-banner">{{ t('properties.clipInstance.banner') }}</div>
|
||||||
|
<section class="prop-group">
|
||||||
|
<button class="group-header" @click="toggleSection('kaleido')">
|
||||||
|
<component :is="sectionOpen.kaleido ? PhCaretDown : PhCaretRight" :size="11" />
|
||||||
|
<span>{{ t('properties.sections.kaleido') }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="sectionOpen.kaleido" class="group-body">
|
||||||
|
<div class="toggle-row">
|
||||||
|
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-toggle"
|
||||||
|
:class="{ active: ciKaleidoOn }"
|
||||||
|
v-tooltip="t('properties.kaleido.toggle')"
|
||||||
|
@click="setCiKaleido({ kaleido: !ciKaleidoOn })"
|
||||||
|
>
|
||||||
|
<PhFlower :size="14" :weight="ciKaleidoOn ? 'fill' : 'regular'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label v-if="ciKaleidoOn">
|
||||||
|
{{ t('properties.kaleido.repeat') }}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="2"
|
||||||
|
max="64"
|
||||||
|
step="1"
|
||||||
|
:value="ciKaleidoCount"
|
||||||
|
@change="setCiKaleido({ kaleidoCount: Number(($event.target as HTMLInputElement).value) })"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label v-if="ciKaleidoOn">
|
||||||
|
{{ t('properties.kaleido.offset') }}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
:value="ciKaleidoOffsetFrames"
|
||||||
|
@change="setCiKaleidoOffsetFrames(Number(($event.target as HTMLInputElement).value))"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div v-if="ciKaleidoOn" class="row">
|
||||||
|
<label>
|
||||||
|
{{ t('properties.kaleido.pivotX') }}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="ciPivotX"
|
||||||
|
@change="setCiKaleido({ kaleidoPivotX: Number(($event.target as HTMLInputElement).value) })"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{{ t('properties.kaleido.pivotY') }}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="ciPivotY"
|
||||||
|
@change="setCiKaleido({ kaleidoPivotY: Number(($event.target as HTMLInputElement).value) })"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div v-if="ciKaleidoOn" class="toggle-row">
|
||||||
|
<span class="toggle-label">{{ t('properties.kaleido.rotateCopies') }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-toggle"
|
||||||
|
:class="{ active: ciRotateCopies }"
|
||||||
|
@click="setCiKaleido({ kaleidoRotateCopies: !ciRotateCopies })"
|
||||||
|
>
|
||||||
|
<PhFlower :size="14" :weight="ciRotateCopies ? 'fill' : 'regular'" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="ciKaleidoOn" class="hint">{{ t('properties.kaleido.hintClip') }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
<p v-else-if="selected && editableLayers.length === 0" class="hint">
|
<p v-else-if="selected && editableLayers.length === 0" class="hint">
|
||||||
{{ t('properties.lockedLayer') }}
|
{{ t('properties.lockedLayer') }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+253
-11
@@ -1257,18 +1257,20 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('runtime wraps each clip instance in translate + rotate + scale', () => {
|
test('runtime wraps each clip instance in translate + rotate + scale', () => {
|
||||||
// Pin that the exported runtime's per-clip draw loop honours
|
// Pin that the exported runtime's per-clip draw honours
|
||||||
// ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor
|
// ClipInstance.x/y/scaleX/scaleY/rotation. Refactored into a
|
||||||
// and the exported HTML stay visually identical for any
|
// `drawClipInstance(inst, clip, time)` helper for the kaleido
|
||||||
// clip-instance transform.
|
// feature, so we now look at that function's body rather than
|
||||||
|
// the inline `inst2.*` variables.
|
||||||
const r = generateRuntimeScript()
|
const r = generateRuntimeScript()
|
||||||
expect(r).toContain('inst2.x')
|
expect(r).toContain('function drawClipInstance(')
|
||||||
expect(r).toContain('inst2.y')
|
expect(r).toContain('inst.x')
|
||||||
expect(r).toContain('inst2.scaleX')
|
expect(r).toContain('inst.y')
|
||||||
expect(r).toContain('inst2.scaleY')
|
expect(r).toContain('inst.scaleX')
|
||||||
expect(r).toContain('inst2.rotation')
|
expect(r).toContain('inst.scaleY')
|
||||||
expect(r).toContain('ctx.rotate(irot2*Math.PI/180)')
|
expect(r).toContain('inst.rotation')
|
||||||
expect(r).toContain('ctx.scale(isx2,isy2)')
|
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', () => {
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -201,12 +201,26 @@ export const en = {
|
|||||||
polygon: 'Polygon',
|
polygon: 'Polygon',
|
||||||
appearance: 'Appearance',
|
appearance: 'Appearance',
|
||||||
button: 'On Click',
|
button: 'On Click',
|
||||||
|
kaleido: 'Kaleidoscope',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
fontSize: 'Font Size',
|
fontSize: 'Font Size',
|
||||||
font: 'Font',
|
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',
|
polygonPoints: 'Points',
|
||||||
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
|
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
|
||||||
polygonClosed: 'Closed path (fill)',
|
polygonClosed: 'Closed path (fill)',
|
||||||
|
|||||||
+122
-73
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
|
|||||||
return Array.isArray((p as Partial<Project>).scenes)
|
return Array.isArray((p as Partial<Project>).scenes)
|
||||||
}
|
}
|
||||||
|
|
||||||
const RUNTIME_VERSION = '28'
|
const RUNTIME_VERSION = '31'
|
||||||
|
|
||||||
/** Materialize the engine source. The font map is rebuilt on every
|
/** Materialize the engine source. The font map is rebuilt on every
|
||||||
* call so user-uploaded custom fonts (which live in localStorage
|
* call so user-uploaded custom fonts (which live in localStorage
|
||||||
@@ -689,6 +689,85 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
}
|
}
|
||||||
if(closed) tctx.closePath();
|
if(closed) tctx.closePath();
|
||||||
}
|
}
|
||||||
|
// Kaleidoscope: number of radial copies for an entity (Layer or
|
||||||
|
// ClipInstance). 1 = no kaleido. Mirrors lib/kaleido.ts.
|
||||||
|
function kalCount(e){
|
||||||
|
if(!e || !e.kaleido) return 1;
|
||||||
|
var n=Math.floor(e.kaleidoCount||0);
|
||||||
|
return n<2?1:n;
|
||||||
|
}
|
||||||
|
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
|
||||||
|
function clipKaleidoPivot(inst){
|
||||||
|
var ix=inst.x||0, iy=inst.y||0;
|
||||||
|
return {
|
||||||
|
x:(inst.kaleidoPivotX==null?ix:inst.kaleidoPivotX),
|
||||||
|
y:(inst.kaleidoPivotY==null?iy:inst.kaleidoPivotY)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function clipArmLocalT(inst, baseLocal, i, trimmedDur){
|
||||||
|
if(trimmedDur<=0) return null;
|
||||||
|
var off=inst.kaleidoOffset||0;
|
||||||
|
var trimS=inst.trimStart||0;
|
||||||
|
var offsetIntoBlock=baseLocal-trimS;
|
||||||
|
// Arm 0 (and the no-shift case) → source's own time. Arms i>0
|
||||||
|
// with a non-zero offset start later.
|
||||||
|
var shifted=(i===0||off===0)?offsetIntoBlock:(offsetIntoBlock-i*off);
|
||||||
|
var EPS=1e-9;
|
||||||
|
if(shifted < -EPS || shifted > trimmedDur+EPS) return null;
|
||||||
|
if(shifted<0) shifted=0;
|
||||||
|
if(shifted>trimmedDur) shifted=trimmedDur;
|
||||||
|
return trimS+shifted;
|
||||||
|
}
|
||||||
|
function clipArmTrans(inst, i, n){
|
||||||
|
var ix=inst.x||0, iy=inst.y||0;
|
||||||
|
if(i===0||n<=1) return {x:ix,y:iy};
|
||||||
|
var rot=inst.kaleidoRotateCopies!==false;
|
||||||
|
if(rot) return {x:ix,y:iy};
|
||||||
|
var pv=clipKaleidoPivot(inst);
|
||||||
|
var a=kalAngle(i,n)*Math.PI/180;
|
||||||
|
var dx=ix-pv.x, dy=iy-pv.y;
|
||||||
|
return {
|
||||||
|
x:pv.x+dx*Math.cos(a)-dy*Math.sin(a),
|
||||||
|
y:pv.y+dx*Math.sin(a)+dy*Math.cos(a)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function drawClipInstance(inst, clip, time){
|
||||||
|
var trimS=inst.trimStart||0;
|
||||||
|
var trimE=inst.trimEnd||0;
|
||||||
|
var visibleClip=Math.max(0,clip.duration-trimS-trimE);
|
||||||
|
var offsetIntoBlock=time-inst.startTime;
|
||||||
|
var isx=(inst.scaleX==null?1:inst.scaleX);
|
||||||
|
var isy=(inst.scaleY==null?1:inst.scaleY);
|
||||||
|
var irot=inst.rotation||0;
|
||||||
|
var n=kalCount(inst);
|
||||||
|
var rotateCopies=inst.kaleidoRotateCopies!==false;
|
||||||
|
var pv=clipKaleidoPivot(inst);
|
||||||
|
var baseLocal=trimS+offsetIntoBlock;
|
||||||
|
// Delayed arms can outlive the source's own window — keep
|
||||||
|
// rendering until the LAST arm has finished playing.
|
||||||
|
var offSec=inst.kaleidoOffset||0;
|
||||||
|
var maxArmDelay=(n>1 && offSec!==0)?Math.max(0,(n-1)*offSec):0;
|
||||||
|
if(offsetIntoBlock<0||offsetIntoBlock>visibleClip+maxArmDelay) return;
|
||||||
|
for(var i=0;i<n;i++){
|
||||||
|
var ka=kalAngle(i,n);
|
||||||
|
var lt=clipArmLocalT(inst, baseLocal, i, visibleClip);
|
||||||
|
if(lt===null) continue;
|
||||||
|
var tr=clipArmTrans(inst, i, n);
|
||||||
|
ctx.save();
|
||||||
|
if(n>1 && i>0 && rotateCopies){
|
||||||
|
ctx.translate(pv.x,pv.y);
|
||||||
|
ctx.rotate(ka*Math.PI/180);
|
||||||
|
ctx.translate(-pv.x,-pv.y);
|
||||||
|
}
|
||||||
|
ctx.translate(tr.x,tr.y);
|
||||||
|
if(irot!==0) ctx.rotate(irot*Math.PI/180);
|
||||||
|
ctx.scale(isx,isy);
|
||||||
|
for(var li=clip.layers.length-1;li>=0;li--){
|
||||||
|
drawLayer(clip.layers[li], lt);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
function drawMaskShape(ctxm,layer,time,buttonMode){
|
function drawMaskShape(ctxm,layer,time,buttonMode){
|
||||||
var asset=assetMap[layer.assetId];
|
var asset=assetMap[layer.assetId];
|
||||||
if(!asset) return;
|
if(!asset) return;
|
||||||
@@ -697,38 +776,42 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
if(!buttonLayerActiveAt(layer,time)) return;
|
if(!buttonLayerActiveAt(layer,time)) return;
|
||||||
} else if(!layerActiveAt(layer,time)) return;
|
} else if(!layerActiveAt(layer,time)) return;
|
||||||
var p=sample(layer,time);
|
var p=sample(layer,time);
|
||||||
ctxm.save();
|
var kn=kalCount(layer);
|
||||||
ctxm.translate(p.x,p.y);
|
for(var ki=0;ki<kn;ki++){
|
||||||
ctxm.rotate(p.rotation*Math.PI/180);
|
var ka=kalAngle(ki,kn);
|
||||||
ctxm.scale(p.scaleX,p.scaleY);
|
ctxm.save();
|
||||||
ctxm.fillStyle='#000000';
|
ctxm.translate(p.x,p.y);
|
||||||
if(asset.kind==='rect'){
|
ctxm.rotate((p.rotation+ka)*Math.PI/180);
|
||||||
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
ctxm.scale(p.scaleX,p.scaleY);
|
||||||
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
ctxm.fillStyle='#000000';
|
||||||
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
|
if(asset.kind==='rect'){
|
||||||
} else if(asset.kind==='circle'){
|
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||||
var mcOff=layer.offsetX!=null?layer.offsetX:0;
|
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||||
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
|
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
|
||||||
ctxm.beginPath();
|
} else if(asset.kind==='circle'){
|
||||||
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
|
var mcOff=layer.offsetX!=null?layer.offsetX:0;
|
||||||
ctxm.fill();
|
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
} else if(asset.kind==='text'){
|
ctxm.beginPath();
|
||||||
var mtOff=layer.offsetX!=null?layer.offsetX:0;
|
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
|
||||||
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
|
|
||||||
ctxm.font=asset.fontSize+'px '+fontFam(asset);
|
|
||||||
ctxm.textBaseline='top';
|
|
||||||
ctxm.fillText(asset.text,-mtOff,-mtOffY);
|
|
||||||
} else {
|
|
||||||
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
|
|
||||||
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
|
|
||||||
var mpPts=p.points||asset.points||[];
|
|
||||||
var mpClosed=asset.closed!==false;
|
|
||||||
if(mpClosed && mpPts.length>=3){
|
|
||||||
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
|
|
||||||
ctxm.fill();
|
ctxm.fill();
|
||||||
|
} else if(asset.kind==='text'){
|
||||||
|
var mtOff=layer.offsetX!=null?layer.offsetX:0;
|
||||||
|
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
|
ctxm.font=asset.fontSize+'px '+fontFam(asset);
|
||||||
|
ctxm.textBaseline='top';
|
||||||
|
ctxm.fillText(asset.text,-mtOff,-mtOffY);
|
||||||
|
} else {
|
||||||
|
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||||
|
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||||
|
var mpPts=p.points||asset.points||[];
|
||||||
|
var mpClosed=asset.closed!==false;
|
||||||
|
if(mpClosed && mpPts.length>=3){
|
||||||
|
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
|
||||||
|
ctxm.fill();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ctxm.restore();
|
||||||
}
|
}
|
||||||
ctxm.restore();
|
|
||||||
}
|
}
|
||||||
// Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts.
|
// Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts.
|
||||||
// A layer is "alive" from its earliest NORMAL kf onward, ending
|
// A layer is "alive" from its earliest NORMAL kf onward, ending
|
||||||
@@ -790,10 +873,15 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
if(!buttonLayerActiveAt(layer,time)) return;
|
if(!buttonLayerActiveAt(layer,time)) return;
|
||||||
} else if(!layerActiveAt(layer,time)) return;
|
} else if(!layerActiveAt(layer,time)) return;
|
||||||
var p=sample(layer,time);
|
var p=sample(layer,time);
|
||||||
|
// Kaleido off for button-asset layers — multiplying their hit
|
||||||
|
// areas would break click routing.
|
||||||
|
var kn=(asset.kind==='button')?1:kalCount(layer);
|
||||||
|
for(var ki=0;ki<kn;ki++){
|
||||||
|
var ka=kalAngle(ki,kn);
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||||
ctx.translate(p.x,p.y);
|
ctx.translate(p.x,p.y);
|
||||||
ctx.rotate(p.rotation*Math.PI/180);
|
ctx.rotate((p.rotation+ka)*Math.PI/180);
|
||||||
ctx.scale(p.scaleX,p.scaleY);
|
ctx.scale(p.scaleX,p.scaleY);
|
||||||
var sOp=p.shadowOpacity||0;
|
var sOp=p.shadowOpacity||0;
|
||||||
var sBl=p.shadowBlur||0;
|
var sBl=p.shadowBlur||0;
|
||||||
@@ -945,6 +1033,7 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
ctx.shadowOffsetX=0;
|
ctx.shadowOffsetX=0;
|
||||||
ctx.shadowOffsetY=0;
|
ctx.shadowOffsetY=0;
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function draw(time){
|
function draw(time){
|
||||||
var lw=scene.canvas.width,lh=scene.canvas.height;
|
var lw=scene.canvas.width,lh=scene.canvas.height;
|
||||||
@@ -1013,27 +1102,7 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
var inst2=tInsts[ti];
|
var inst2=tInsts[ti];
|
||||||
var clip2=clipById2[inst2.clipId];
|
var clip2=clipById2[inst2.clipId];
|
||||||
if(!clip2)continue;
|
if(!clip2)continue;
|
||||||
var tS=inst2.trimStart||0;
|
drawClipInstance(inst2, clip2, time);
|
||||||
var tE=inst2.trimEnd||0;
|
|
||||||
var visibleClip=Math.max(0,clip2.duration-tS-tE);
|
|
||||||
var offset=time-inst2.startTime;
|
|
||||||
if(offset<0||offset>visibleClip)continue;
|
|
||||||
var localT=tS+offset;
|
|
||||||
var ix2=inst2.x||0, iy2=inst2.y||0;
|
|
||||||
var isx2=(inst2.scaleX==null?1:inst2.scaleX);
|
|
||||||
var isy2=(inst2.scaleY==null?1:inst2.scaleY);
|
|
||||||
var irot2=inst2.rotation||0;
|
|
||||||
var needsXf=ix2!==0||iy2!==0||isx2!==1||isy2!==1||irot2!==0;
|
|
||||||
if(needsXf){
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(ix2,iy2);
|
|
||||||
if(irot2!==0) ctx.rotate(irot2*Math.PI/180);
|
|
||||||
ctx.scale(isx2,isy2);
|
|
||||||
}
|
|
||||||
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
|
|
||||||
drawLayer(clip2.layers[cl2],localT);
|
|
||||||
}
|
|
||||||
if(needsXf) ctx.restore();
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1061,27 +1130,7 @@ const ENGINE_TEMPLATE = `(function(){
|
|||||||
var inst4=insts3[ti3];
|
var inst4=insts3[ti3];
|
||||||
var clip4=clipById2[inst4.clipId];
|
var clip4=clipById2[inst4.clipId];
|
||||||
if(!clip4) continue;
|
if(!clip4) continue;
|
||||||
var tS4=inst4.trimStart||0;
|
drawClipInstance(inst4, clip4, time);
|
||||||
var tE4=inst4.trimEnd||0;
|
|
||||||
var vis4=Math.max(0,clip4.duration-tS4-tE4);
|
|
||||||
var off4=time-inst4.startTime;
|
|
||||||
if(off4<0||off4>vis4) continue;
|
|
||||||
var lt4=tS4+off4;
|
|
||||||
var ix4=inst4.x||0, iy4=inst4.y||0;
|
|
||||||
var isx4=(inst4.scaleX==null?1:inst4.scaleX);
|
|
||||||
var isy4=(inst4.scaleY==null?1:inst4.scaleY);
|
|
||||||
var irot4=inst4.rotation||0;
|
|
||||||
var needsXf4=ix4!==0||iy4!==0||isx4!==1||isy4!==1||irot4!==0;
|
|
||||||
if(needsXf4){
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(ix4,iy4);
|
|
||||||
if(irot4!==0) ctx.rotate(irot4*Math.PI/180);
|
|
||||||
ctx.scale(isx4,isy4);
|
|
||||||
}
|
|
||||||
for(var cl4=clip4.layers.length-1;cl4>=0;cl4--){
|
|
||||||
drawLayer(clip4.layers[cl4],lt4);
|
|
||||||
}
|
|
||||||
if(needsXf4) ctx.restore();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if(!gMasks.length){
|
if(!gMasks.length){
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
+114
-84
@@ -2,6 +2,15 @@ import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, Sv
|
|||||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||||
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
|
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
|
||||||
import { fontCssFamily } from './fonts'
|
import { fontCssFamily } from './fonts'
|
||||||
|
import {
|
||||||
|
clipArmLocalTime,
|
||||||
|
clipArmTranslate,
|
||||||
|
clipKaleidoPivot,
|
||||||
|
kaleidoAngleDegrees,
|
||||||
|
kaleidoCountFor,
|
||||||
|
layerKaleidoCount,
|
||||||
|
shouldRotateClipCopies,
|
||||||
|
} from './kaleido'
|
||||||
|
|
||||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||||
const result: Record<string, HTMLImageElement> = {}
|
const result: Record<string, HTMLImageElement> = {}
|
||||||
@@ -150,11 +159,16 @@ function drawLayers(
|
|||||||
if (!asset) continue
|
if (!asset) continue
|
||||||
const p = sampleLayer(layer, time)
|
const p = sampleLayer(layer, time)
|
||||||
|
|
||||||
ctx.save()
|
// Kaleido is disabled on button-asset layers (button assets render
|
||||||
ctx.globalAlpha *= p.opacity
|
// their own internal layers and click routing must stay 1:1).
|
||||||
ctx.translate(p.x, p.y)
|
const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer)
|
||||||
ctx.rotate((p.rotation * Math.PI) / 180)
|
for (let kf = 0; kf < kCount; kf++) {
|
||||||
ctx.scale(p.scaleX, p.scaleY)
|
const kAngle = kaleidoAngleDegrees(kf, kCount)
|
||||||
|
ctx.save()
|
||||||
|
ctx.globalAlpha *= p.opacity
|
||||||
|
ctx.translate(p.x, p.y)
|
||||||
|
ctx.rotate(((p.rotation + kAngle) * Math.PI) / 180)
|
||||||
|
ctx.scale(p.scaleX, p.scaleY)
|
||||||
|
|
||||||
const shadow = computeShadow(p)
|
const shadow = computeShadow(p)
|
||||||
applyShadow(ctx, shadow)
|
applyShadow(ctx, shadow)
|
||||||
@@ -261,8 +275,63 @@ function drawLayers(
|
|||||||
drawMaskedGroup(ctx, asset.layers, assetMap, images, 0, undefined, true)
|
drawMaskedGroup(ctx, asset.layers, assetMap, images, 0, undefined, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearShadow(ctx)
|
clearShadow(ctx)
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders one clip instance — including kaleidoscope replication
|
||||||
|
* when configured. Shared by the top-level scene loop and the
|
||||||
|
* in-group child-drawing path so both honour the same kaleido math. */
|
||||||
|
function renderClipInstance(
|
||||||
|
target: CanvasRenderingContext2D,
|
||||||
|
inst: import('@/types/scene').ClipInstance,
|
||||||
|
clip: import('@/types/scene').Clip,
|
||||||
|
time: number,
|
||||||
|
assetMap: Map<string, Asset>,
|
||||||
|
images: Record<string, HTMLImageElement>,
|
||||||
|
) {
|
||||||
|
const trimStart = inst.trimStart ?? 0
|
||||||
|
const trimEnd = inst.trimEnd ?? 0
|
||||||
|
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||||
|
const offsetIntoBlock = time - inst.startTime
|
||||||
|
const isx = inst.scaleX ?? 1
|
||||||
|
const isy = inst.scaleY ?? 1
|
||||||
|
const irot = inst.rotation ?? 0
|
||||||
|
|
||||||
|
const kCount = kaleidoCountFor(inst)
|
||||||
|
const rotateCopies = shouldRotateClipCopies(inst)
|
||||||
|
const pivot = clipKaleidoPivot(inst)
|
||||||
|
const baseLocal = trimStart + offsetIntoBlock
|
||||||
|
|
||||||
|
// With kaleido + offset, delayed arms can still be valid after the
|
||||||
|
// source's window has ended (they started later, so they finish
|
||||||
|
// later). The early-out for the source's window only applies when
|
||||||
|
// there's no delay-staggered arm to render.
|
||||||
|
const offsetSec = inst.kaleidoOffset ?? 0
|
||||||
|
const maxArmDelay = kCount > 1 && offsetSec !== 0 ? Math.max(0, (kCount - 1) * offsetSec) : 0
|
||||||
|
if (offsetIntoBlock < 0 || offsetIntoBlock > visible + maxArmDelay) return
|
||||||
|
|
||||||
|
for (let kf = 0; kf < kCount; kf++) {
|
||||||
|
const kAngle = kaleidoAngleDegrees(kf, kCount)
|
||||||
|
const localT = clipArmLocalTime(inst, baseLocal, kf, visible)
|
||||||
|
if (localT === null) continue
|
||||||
|
const trans = clipArmTranslate(inst, kf, kCount)
|
||||||
|
|
||||||
|
target.save()
|
||||||
|
if (kCount > 1 && kf > 0 && rotateCopies) {
|
||||||
|
// Rotate the entire local space around the pivot — contents
|
||||||
|
// follow the rotation, giving the natural kaleido look.
|
||||||
|
target.translate(pivot.x, pivot.y)
|
||||||
|
target.rotate((kAngle * Math.PI) / 180)
|
||||||
|
target.translate(-pivot.x, -pivot.y)
|
||||||
|
}
|
||||||
|
target.translate(trans.x, trans.y)
|
||||||
|
if (irot !== 0) target.rotate((irot * Math.PI) / 180)
|
||||||
|
target.scale(isx, isy)
|
||||||
|
drawMaskedGroup(target, clip.layers, assetMap, images, localT)
|
||||||
|
target.restore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,38 +355,45 @@ function drawMaskShape(
|
|||||||
if (!isButtonLayerActiveAt(layer, time)) return
|
if (!isButtonLayerActiveAt(layer, time)) return
|
||||||
} else if (!isLayerActiveAt(layer, time)) return
|
} else if (!isLayerActiveAt(layer, time)) return
|
||||||
const p = sampleLayer(layer, time)
|
const p = sampleLayer(layer, time)
|
||||||
ctx.save()
|
// Mask layers honour kaleido — every radial copy contributes to the
|
||||||
ctx.translate(p.x, p.y)
|
// composite mask buffer, so the cumulative mask is the union of the
|
||||||
ctx.rotate((p.rotation * Math.PI) / 180)
|
// N rotated shapes.
|
||||||
ctx.scale(p.scaleX, p.scaleY)
|
const kCount = layerKaleidoCount(layer)
|
||||||
ctx.fillStyle = '#000000'
|
for (let kf = 0; kf < kCount; kf++) {
|
||||||
if (asset.kind === 'rect') {
|
const kAngle = kaleidoAngleDegrees(kf, kCount)
|
||||||
const offX = layer.offsetX ?? asset.width / 2
|
ctx.save()
|
||||||
const offY = layer.offsetY ?? asset.height / 2
|
ctx.translate(p.x, p.y)
|
||||||
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
ctx.rotate(((p.rotation + kAngle) * Math.PI) / 180)
|
||||||
} else if (asset.kind === 'circle') {
|
ctx.scale(p.scaleX, p.scaleY)
|
||||||
const offX = layer.offsetX ?? 0
|
ctx.fillStyle = '#000000'
|
||||||
const offY = layer.offsetY ?? 0
|
if (asset.kind === 'rect') {
|
||||||
ctx.beginPath()
|
const offX = layer.offsetX ?? asset.width / 2
|
||||||
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
const offY = layer.offsetY ?? asset.height / 2
|
||||||
ctx.fill()
|
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
||||||
} else if (asset.kind === 'text') {
|
} else if (asset.kind === 'circle') {
|
||||||
const offX = layer.offsetX ?? 0
|
const offX = layer.offsetX ?? 0
|
||||||
const offY = layer.offsetY ?? 0
|
const offY = layer.offsetY ?? 0
|
||||||
ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
|
ctx.beginPath()
|
||||||
ctx.textBaseline = 'top'
|
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
||||||
ctx.fillText(asset.text, -offX, -offY)
|
|
||||||
} else {
|
|
||||||
const offX = layer.offsetX ?? 0
|
|
||||||
const offY = layer.offsetY ?? 0
|
|
||||||
const pts = p.points ?? asset.points
|
|
||||||
const closed = asset.closed !== false
|
|
||||||
if (closed && pts.length >= 3) {
|
|
||||||
tracePolygonPath(ctx, pts, asset.points, offX, offY, true)
|
|
||||||
ctx.fill()
|
ctx.fill()
|
||||||
|
} else if (asset.kind === 'text') {
|
||||||
|
const offX = layer.offsetX ?? 0
|
||||||
|
const offY = layer.offsetY ?? 0
|
||||||
|
ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
|
||||||
|
ctx.textBaseline = 'top'
|
||||||
|
ctx.fillText(asset.text, -offX, -offY)
|
||||||
|
} else {
|
||||||
|
const offX = layer.offsetX ?? 0
|
||||||
|
const offY = layer.offsetY ?? 0
|
||||||
|
const pts = p.points ?? asset.points
|
||||||
|
const closed = asset.closed !== false
|
||||||
|
if (closed && pts.length >= 3) {
|
||||||
|
tracePolygonPath(ctx, pts, asset.points, offX, offY, true)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ctx.restore()
|
||||||
}
|
}
|
||||||
ctx.restore()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the contents of a scene-level TrackGroup. Unlike
|
/** Render the contents of a scene-level TrackGroup. Unlike
|
||||||
@@ -379,32 +455,7 @@ function drawTrackGroup(
|
|||||||
for (const inst of instances) {
|
for (const inst of instances) {
|
||||||
const clip = clipById.get(inst.clipId)
|
const clip = clipById.get(inst.clipId)
|
||||||
if (!clip) continue
|
if (!clip) continue
|
||||||
const trimStart = inst.trimStart ?? 0
|
renderClipInstance(target, inst, clip, time, assetMap, images)
|
||||||
const trimEnd = inst.trimEnd ?? 0
|
|
||||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
|
||||||
const offsetIntoBlock = time - inst.startTime
|
|
||||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
|
||||||
const ix = inst.x ?? 0
|
|
||||||
const iy = inst.y ?? 0
|
|
||||||
const isx = inst.scaleX ?? 1
|
|
||||||
const isy = inst.scaleY ?? 1
|
|
||||||
const irot = inst.rotation ?? 0
|
|
||||||
const needsTransform =
|
|
||||||
ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
|
|
||||||
if (needsTransform) {
|
|
||||||
target.save()
|
|
||||||
target.translate(ix, iy)
|
|
||||||
if (irot !== 0) target.rotate((irot * Math.PI) / 180)
|
|
||||||
target.scale(isx, isy)
|
|
||||||
}
|
|
||||||
// Clip-internal masks: same scoping as the main timeline,
|
|
||||||
// confined to the clip. drawMaskedGroup partitions the clip's
|
|
||||||
// layers into masks vs. content and composites the result via
|
|
||||||
// destination-in / destination-out, so a mask placed above
|
|
||||||
// content layers in the clip masks the content but doesn't
|
|
||||||
// bleed out to anything else on the canvas.
|
|
||||||
drawMaskedGroup(target, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
|
||||||
if (needsTransform) target.restore()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,28 +667,7 @@ export function drawSceneFrame(
|
|||||||
for (const inst of instances) {
|
for (const inst of instances) {
|
||||||
const clip = clipById.get(inst.clipId)
|
const clip = clipById.get(inst.clipId)
|
||||||
if (!clip) continue
|
if (!clip) continue
|
||||||
const trimStart = inst.trimStart ?? 0
|
renderClipInstance(drawCtx, inst, clip, time, assetMap, images)
|
||||||
const trimEnd = inst.trimEnd ?? 0
|
|
||||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
|
||||||
const offsetIntoBlock = time - inst.startTime
|
|
||||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
|
||||||
const ix = inst.x ?? 0
|
|
||||||
const iy = inst.y ?? 0
|
|
||||||
const isx = inst.scaleX ?? 1
|
|
||||||
const isy = inst.scaleY ?? 1
|
|
||||||
const irot = inst.rotation ?? 0
|
|
||||||
const needsTransform =
|
|
||||||
ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
|
|
||||||
if (needsTransform) {
|
|
||||||
drawCtx.save()
|
|
||||||
drawCtx.translate(ix, iy)
|
|
||||||
if (irot !== 0) drawCtx.rotate((irot * Math.PI) / 180)
|
|
||||||
drawCtx.scale(isx, isy)
|
|
||||||
}
|
|
||||||
// Same as the in-group clip path above: drawMaskedGroup so
|
|
||||||
// clip-local masks apply to layers within this clip only.
|
|
||||||
drawMaskedGroup(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
|
||||||
if (needsTransform) drawCtx.restore()
|
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2163,6 +2163,23 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
const n = name.trim()
|
const n = name.trim()
|
||||||
if (n) layer.name = n
|
if (n) layer.name = n
|
||||||
},
|
},
|
||||||
|
setLayerKaleido(id: string, on: boolean) {
|
||||||
|
const layer = this.activeLayers.find(l => l.id === id)
|
||||||
|
if (!layer) return
|
||||||
|
if (on) {
|
||||||
|
layer.kaleido = true
|
||||||
|
if (!layer.kaleidoCount || layer.kaleidoCount < 2) layer.kaleidoCount = 6
|
||||||
|
} else {
|
||||||
|
// Keep kaleidoCount so re-toggling restores the user's choice.
|
||||||
|
delete layer.kaleido
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setLayerKaleidoCount(id: string, count: number) {
|
||||||
|
const layer = this.activeLayers.find(l => l.id === id)
|
||||||
|
if (!layer) return
|
||||||
|
const n = Math.max(2, Math.min(64, Math.floor(count)))
|
||||||
|
layer.kaleidoCount = n
|
||||||
|
},
|
||||||
setClipTrackName(id: string, name: string) {
|
setClipTrackName(id: string, name: string) {
|
||||||
const track = this.scene.clipTracks?.find((t) => t.id === id)
|
const track = this.scene.clipTracks?.find((t) => t.id === id)
|
||||||
if (track) track.name = name.trim() || undefined
|
if (track) track.name = name.trim() || undefined
|
||||||
@@ -3245,6 +3262,46 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
else inst.rotation = r
|
else inst.rotation = r
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setClipInstanceKaleido(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{
|
||||||
|
kaleido: boolean
|
||||||
|
kaleidoCount: number
|
||||||
|
kaleidoOffset: number
|
||||||
|
kaleidoRotateCopies: boolean
|
||||||
|
kaleidoPivotX: number
|
||||||
|
kaleidoPivotY: number
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
|
||||||
|
if (!inst) return
|
||||||
|
if (patch.kaleido !== undefined) {
|
||||||
|
if (patch.kaleido) {
|
||||||
|
inst.kaleido = true
|
||||||
|
if (!inst.kaleidoCount || inst.kaleidoCount < 2) inst.kaleidoCount = 6
|
||||||
|
} else {
|
||||||
|
delete inst.kaleido
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.kaleidoCount !== undefined) {
|
||||||
|
const n = Math.max(2, Math.min(64, Math.floor(patch.kaleidoCount)))
|
||||||
|
inst.kaleidoCount = n
|
||||||
|
}
|
||||||
|
if (patch.kaleidoOffset !== undefined) {
|
||||||
|
if (patch.kaleidoOffset === 0) delete inst.kaleidoOffset
|
||||||
|
else inst.kaleidoOffset = patch.kaleidoOffset
|
||||||
|
}
|
||||||
|
if (patch.kaleidoRotateCopies !== undefined) {
|
||||||
|
if (patch.kaleidoRotateCopies) delete inst.kaleidoRotateCopies
|
||||||
|
else inst.kaleidoRotateCopies = false
|
||||||
|
}
|
||||||
|
if (patch.kaleidoPivotX !== undefined) {
|
||||||
|
inst.kaleidoPivotX = patch.kaleidoPivotX
|
||||||
|
}
|
||||||
|
if (patch.kaleidoPivotY !== undefined) {
|
||||||
|
inst.kaleidoPivotY = patch.kaleidoPivotY
|
||||||
|
}
|
||||||
|
},
|
||||||
/** Create an empty group. When `anchorTrackId` is given the group
|
/** Create an empty group. When `anchorTrackId` is given the group
|
||||||
* lands directly above that track in trackOrder — resolving to
|
* lands directly above that track in trackOrder — resolving to
|
||||||
* the parent group's position if the anchor sits inside one. Falls
|
* the parent group's position if the anchor sits inside one. Falls
|
||||||
|
|||||||
@@ -230,6 +230,13 @@ export interface Layer {
|
|||||||
onClick?: Action[]
|
onClick?: Action[]
|
||||||
/** Only meaningful for button layers: initial disabled flag at runtime. */
|
/** Only meaningful for button layers: initial disabled flag at runtime. */
|
||||||
buttonDisabled?: boolean
|
buttonDisabled?: boolean
|
||||||
|
/** Render N radial copies around the layer's pivot. Spatial only —
|
||||||
|
* layers don't get a per-copy time offset (that lives on
|
||||||
|
* ClipInstance, where it's the headline use case). */
|
||||||
|
kaleido?: boolean
|
||||||
|
/** ≥ 2, integer. Total visible copies = kaleidoCount. Disabled
|
||||||
|
* when `kaleido` is false. */
|
||||||
|
kaleidoCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Clip {
|
export interface Clip {
|
||||||
@@ -287,6 +294,26 @@ export interface ClipInstance {
|
|||||||
scaleY?: number
|
scaleY?: number
|
||||||
/** Degrees clockwise; applied around the group's origin. */
|
/** Degrees clockwise; applied around the group's origin. */
|
||||||
rotation?: number
|
rotation?: number
|
||||||
|
/** Render the clip N times around `kaleidoPivotX/Y`. Each copy can
|
||||||
|
* be time-shifted via `kaleidoOffset` so the radial arms show
|
||||||
|
* different points of the clip's playback simultaneously. */
|
||||||
|
kaleido?: boolean
|
||||||
|
/** ≥ 2, integer. */
|
||||||
|
kaleidoCount?: number
|
||||||
|
/** Seconds. Local clip time for arm i is
|
||||||
|
* `(visibleOffsetIntoBlock - i * kaleidoOffset)` wrapped into the
|
||||||
|
* trimmed sub-window. Default 0 → all arms show the same frame. */
|
||||||
|
kaleidoOffset?: number
|
||||||
|
/** When true (default), each copy rotates so its content follows
|
||||||
|
* the radial direction. When false, copies are translated around
|
||||||
|
* the pivot but keep the source's orientation — a ring of identical
|
||||||
|
* un-rotated duplicates. */
|
||||||
|
kaleidoRotateCopies?: boolean
|
||||||
|
/** World-space pivot for the radial arrangement. Defaults to the
|
||||||
|
* instance's own (x, y). Setting it elsewhere lets the user place
|
||||||
|
* the kaleido centre off the clip. */
|
||||||
|
kaleidoPivotX?: number
|
||||||
|
kaleidoPivotY?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.2.7'
|
export const APP_VERSION = '1.2.8'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user