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
@@ -6,6 +6,7 @@ import {
|
||||
PhDiamond,
|
||||
PhEye,
|
||||
PhEyeSlash,
|
||||
PhFlower,
|
||||
PhMaskHappy,
|
||||
PhMinus,
|
||||
PhPolygon,
|
||||
@@ -23,7 +24,7 @@ import { t } from '@/i18n'
|
||||
|
||||
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> = {
|
||||
transform: true,
|
||||
@@ -33,6 +34,7 @@ const defaultOpen: Record<SectionKey, boolean> = {
|
||||
shadow: false,
|
||||
mask: false,
|
||||
button: true,
|
||||
kaleido: false,
|
||||
}
|
||||
|
||||
function readStoredOpen(): Record<SectionKey, boolean> {
|
||||
@@ -159,6 +161,62 @@ const buttonAsset = computed(() => {
|
||||
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>({
|
||||
get: () => polygonAsset.value?.points.length ?? 0,
|
||||
set: (v: number) => {
|
||||
@@ -714,6 +772,39 @@ function deleteSelection() {
|
||||
</div>
|
||||
</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">
|
||||
<button class="group-header" @click="toggleSection('button')">
|
||||
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
@@ -736,6 +827,79 @@ function deleteSelection() {
|
||||
}}
|
||||
</button>
|
||||
</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">
|
||||
{{ t('properties.lockedLayer') }}
|
||||
</p>
|
||||
|
||||
Reference in new issue
Block a user