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
+122
-73
@@ -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){
|
||||
|
||||
Reference in new issue
Block a user