1.2.8: kaleidoscope — radial repeat for layers + clips with delayed-start arms

New per-entity property that renders content N times around a pivot.
For clip instances each radial copy can also be staggered in time, so
arm i starts playing the clip i*offset frames later than the source.

Data model (src/types/scene.ts):
- Layer: kaleido?, kaleidoCount?
- ClipInstance: kaleido?, kaleidoCount?, kaleidoOffset?, kaleidoRotateCopies?,
  kaleidoPivotX?, kaleidoPivotY?
All fields optional; legacy projects unchanged.

Math (src/lib/kaleido.ts):
- kaleidoCountFor / kaleidoAngleDegrees, clipKaleidoPivot, clipArmTranslate.
- clipArmLocalTime: returns the per-arm clip-local time. For arm i with
  offset dt, the arm shows frame (offsetIntoBlock - i*dt) — visible only
  when that falls inside [0, trimmedDuration]. Returns null otherwise so
  the caller can hide pre-start and post-end arms.

Render parity:
- src/lib/render.ts: extracted renderClipInstance helper used by both the
  top-level scene loop and drawTrackGroup. drawLayers + drawMaskShape now
  loop the kaleido count and add the per-copy angle to the layer rotation
  (Konva-style "rotate around (x,y)" comes for free).
- src/lib/exportPlayer.ts: mirror in JS (kalCount, kalAngle, clipKaleidoPivot,
  clipArmLocalT, clipArmTrans, drawClipInstance). drawLayer + drawMaskShape
  wrapped the same way. RUNTIME_VERSION 28 → 31 (3 fixes during landing).
- src/components/CanvasStage.vue: stackItems now emits one bundle per
  kaleido arm with arm-specific items sampled at the arm's localT, so the
  Konva live preview shows the staggered start in the editor — not just
  in the fullscreen preview / export. Source arm (armIndex 0) keeps the
  selection / drag / transform handlers; non-source arms are listening:
  false. New clipInstanceKaleidoGroupConfig handles the per-arm transform
  with rotateCopies on/off semantics. withK helper augments per-layer
  configs with the kaleido rotation.

UI (src/components/PropertiesPanel.vue + i18n):
- New "Kaleidoscope" collapsible section. Layer mode: toggle + repeat.
  Clip-instance mode (selected via canvas, panel banner explains the
  partial editability): toggle, repeat, start offset (frames — converted
  to seconds via scene.fps before storage), pivot X/Y, rotate-copies
  toggle. Hidden for button-asset layers (hit-area routing breaks if
  multiplied).

Store (src/stores/scene.ts):
- setLayerKaleido / setLayerKaleidoCount with [2, 64] integer clamp,
  setClipInstanceKaleido with clear-semantics for offset=0 and
  rotateCopies=true (deletes the field so JSON stays clean).

Tests (8 new under "kaleidoscope — store + render + engine"):
- Roundtrip + clamp for both Layer and ClipInstance setters.
- drawSceneFrame: 4 cardinal-pixel kaleido smoke for static layer.
- Button asset bypasses kaleido (hit-area routing intact).
- ClipInstance roundtrip + clear-semantics for offset=0.
- clipArmLocalTime: arm 0 honours bounds (returns null when source
  itself is past), delayed arms outlive the source's window when
  their shifted time is still in [0, dur].
- drawSceneFrame integration: 4-arm clip with 0.5s offset; sample at
  t=0 / t=0.5 / t=1.5 verifies staircase visibility (1 → 2 → 3 arms).
- Engine source carries kalCount, kalAngle, drawClipInstance,
  clipKaleidoPivot, clipArmLocalT; RUNTIME_VERSION '31'.
- layerActiveAt regression — kaleido fields don't break visibility gate.

Plus the prior export-runtime test updated to read drawClipInstance's
inst.{x,y,scaleX,scaleY,rotation} variables instead of the old inline
inst2.* names that the kaleido refactor removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 19:18:16 +02:00
1 parent 27d6a47b72
commit 80e1510b53
11 files changed
+1001 -212

No files matched your search

+122 -73
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
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
* call so user-uploaded custom fonts (which live in localStorage
@@ -689,6 +689,85 @@ const ENGINE_TEMPLATE = `(function(){
}
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){
var asset=assetMap[layer.assetId];
if(!asset) return;
@@ -697,38 +776,42 @@ const ENGINE_TEMPLATE = `(function(){
if(!buttonLayerActiveAt(layer,time)) return;
} else if(!layerActiveAt(layer,time)) return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
ctxm.rotate(p.rotation*Math.PI/180);
ctxm.scale(p.scaleX,p.scaleY);
ctxm.fillStyle='#000000';
if(asset.kind==='rect'){
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcOff=layer.offsetX!=null?layer.offsetX:0;
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath();
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
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);
var kn=kalCount(layer);
for(var ki=0;ki<kn;ki++){
var ka=kalAngle(ki,kn);
ctxm.save();
ctxm.translate(p.x,p.y);
ctxm.rotate((p.rotation+ka)*Math.PI/180);
ctxm.scale(p.scaleX,p.scaleY);
ctxm.fillStyle='#000000';
if(asset.kind==='rect'){
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
} else if(asset.kind==='circle'){
var mcOff=layer.offsetX!=null?layer.offsetX:0;
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
ctxm.beginPath();
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
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.
// A layer is "alive" from its earliest NORMAL kf onward, ending
@@ -790,10 +873,15 @@ const ENGINE_TEMPLATE = `(function(){
if(!buttonLayerActiveAt(layer,time)) return;
} else if(!layerActiveAt(layer,time)) return;
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.globalAlpha=ctx.globalAlpha*p.opacity;
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);
var sOp=p.shadowOpacity||0;
var sBl=p.shadowBlur||0;
@@ -945,6 +1033,7 @@ const ENGINE_TEMPLATE = `(function(){
ctx.shadowOffsetX=0;
ctx.shadowOffsetY=0;
ctx.restore();
}
}
function draw(time){
var lw=scene.canvas.width,lh=scene.canvas.height;
@@ -1013,27 +1102,7 @@ const ENGINE_TEMPLATE = `(function(){
var inst2=tInsts[ti];
var clip2=clipById2[inst2.clipId];
if(!clip2)continue;
var tS=inst2.trimStart||0;
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();
drawClipInstance(inst2, clip2, time);
}
continue;
}
@@ -1061,27 +1130,7 @@ const ENGINE_TEMPLATE = `(function(){
var inst4=insts3[ti3];
var clip4=clipById2[inst4.clipId];
if(!clip4) continue;
var tS4=inst4.trimStart||0;
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();
drawClipInstance(inst4, clip4, time);
}
};
if(!gMasks.length){