feat: 5-frame button assets + timeline/sidebar polish

Button assets
- Re-introduce button-asset type with 4 content states (normal/hover/
  pressed/disabled) stored as keyframes at BUTTON_STATE_TIMES, plus a
  separate hit-area editor mode.
- useButtonRuntime composable for per-instance state/disabled flags on
  the main stage.
- Sidebar library lists button assets with live 80×80 thumbnails;
  renderButtonThumbnail now accepts an images map so image-kind layers
  inside a button render instead of producing a fully-transparent PNG.
- Compact (no-thumbnail) mode aligns button rows to the library-row
  height.

Scope footer
- Button-edit mode now uses the same sidebar scope-footer pattern as
  clip-edit (back button to "Main timeline" + active scope chip);
  removed the inline exit button from the timeline scrub-bar.

Timeline
- Track-name column default bumped to 260px to match the assets sidebar
  above it (storage key bumped to :v2 so stale values reset).
- scrub-bar z-index raised above sticky track-name columns so rows
  scroll under it instead of over.
- scrub-handle flips z-index based on horizontal scroll: above
  name-spacer when the playhead is in view (no half-clip at t=0),
  below when the playhead has scrolled off the left edge (so the
  toggle row cleanly covers it).
- name-spacer extends ±3/1px to fully cover the obscured handle.
- scrub-handle head pokes 3px above the ruler with rounded top corners
  via clip-path path().

Track context menu
- Added missing i18n keys (renameScriptTrack / deleteScriptTrack /
  renameScriptTrackDialog.{title,confirm}).
- Rename items show a visible pencil icon; empty .check slot removed
  so items line up flush-left; toggle items place their active ✓ on
  the right (kf-menu-trailing-check).

Dev HUD
- Test progress bar flips to --danger as soon as the first failure is
  recorded, instead of only at the end of the run.

Regression tests
- renderButtonThumbnail: null on empty button, renders shape layers,
  accepts custom images map.
- i18n: renameScriptTrack / deleteScriptTrack / dialog keys resolve.
- scrub-handle-obscured logic (visible at t=0, hidden when playhead is
  scrolled off-screen, still visible when playhead is far to the
  right of the scroll offset).
- Updated button state-scrubber expectations to match
  BUTTON_STATE_TIMES (normal=1, hover=2, pressed=3, disabled=4) and
  that hit-mode parks at normal=1 rather than 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-21 19:19:39 +02:00
1 parent 31604052ca
commit c3764201b8
16 files changed
+2560 -349

No files matched your search

+207 -8
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '7'
const RUNTIME_VERSION = '10'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -197,9 +197,109 @@ const ENGINE_SOURCE = `(function(){
jumpTo: null,
forcePause: false,
forcePlay: false,
buttonStates: {},
buttonDisabled: {},
warn: function(m){ if(window.console)window.console.warn('[animatr]',m); }
};
(scene.markers||[]).forEach(function(mk){ runtime.markers[mk.name]=mk.time; });
// Populated once per draw(); used for pointer hit-testing.
var buttonInstancesInFrame=[];
function resolveButtonState(layer){
var ov=runtime.buttonDisabled[layer.id];
var dis=ov===undefined?!!layer.buttonDisabled:ov;
if(dis) return 'disabled';
return runtime.buttonStates[layer.id] || 'normal';
}
function setButtonStateRt(layerId, st){
if(st==='normal') delete runtime.buttonStates[layerId];
else runtime.buttonStates[layerId]=st;
}
function pointInPolygon(px, py, pts){
var inside=false, n=pts.length;
for(var i=0,j=n-1;i<n;j=i++){
var xi=pts[i].x, yi=pts[i].y, xj=pts[j].x, yj=pts[j].y;
var intersect=((yi>py)!==(yj>py)) &&
(px<(xj-xi)*(py-yi)/((yj-yi)||1e-12)+xi);
if(intersect) inside=!inside;
}
return inside;
}
function hitTestShapes(x, y, shapes, w, h){
if(!shapes || !shapes.length){
return x>=0 && y>=0 && x<=w && y<=h;
}
for(var i=0;i<shapes.length;i++){
var s=shapes[i];
if(s.kind==='rect'){
if(x>=s.x && x<=s.x+s.width && y>=s.y && y<=s.y+s.height) return true;
} else if(s.kind==='circle'){
var dx=x-s.x, dy=y-s.y;
if(dx*dx+dy*dy <= s.radius*s.radius) return true;
} else if(s.kind==='polygon'){
if(s.points && s.points.length>=3 && pointInPolygon(x,y,s.points)) return true;
}
}
return false;
}
function pointerToCanvasPx(e){
var r=canvas.getBoundingClientRect();
if(!r.width || !r.height) return null;
return {
x:(e.clientX-r.left)*(canvas.width/r.width),
y:(e.clientY-r.top)*(canvas.height/r.height),
};
}
function buttonUnderPointer(dev){
for(var i=buttonInstancesInFrame.length-1;i>=0;i--){
var bi=buttonInstancesInFrame[i];
if(bi.layer.buttonDisabled && runtime.buttonDisabled[bi.layer.id]===undefined) continue;
if(runtime.buttonDisabled[bi.layer.id]===true) continue;
var inv=new DOMMatrix([bi.matrix.a,bi.matrix.b,bi.matrix.c,bi.matrix.d,bi.matrix.e,bi.matrix.f]).invertSelf();
var pt=inv.transformPoint(new DOMPoint(dev.x, dev.y));
if(hitTestShapes(pt.x, pt.y, bi.asset.hitArea, bi.asset.canvas.width, bi.asset.canvas.height)) return bi;
}
return null;
}
function onStageMove(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
var hit=buttonUnderPointer(dev);
for(var i=0;i<buttonInstancesInFrame.length;i++){
var bi=buttonInstancesInFrame[i];
if(resolveButtonState(bi.layer)==='disabled') continue;
var cur=resolveButtonState(bi.layer);
if(hit && hit.layer.id===bi.layer.id){
if(cur==='normal') setButtonStateRt(bi.layer.id,'hover');
} else {
if(cur==='hover' || cur==='pressed') setButtonStateRt(bi.layer.id,'normal');
}
}
}
function onStageDown(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
var hit=buttonUnderPointer(dev);
if(!hit) return;
if(resolveButtonState(hit.layer)==='disabled') return;
setButtonStateRt(hit.layer.id, 'pressed');
}
function onStageUp(e){
var dev=pointerToCanvasPx(e); if(!dev) return;
for(var i=0;i<buttonInstancesInFrame.length;i++){
var bi=buttonInstancesInFrame[i];
if(resolveButtonState(bi.layer)!=='pressed') continue;
var inv=new DOMMatrix([bi.matrix.a,bi.matrix.b,bi.matrix.c,bi.matrix.d,bi.matrix.e,bi.matrix.f]).invertSelf();
var pt=inv.transformPoint(new DOMPoint(dev.x, dev.y));
var inside=hitTestShapes(pt.x, pt.y, bi.asset.hitArea, bi.asset.canvas.width, bi.asset.canvas.height);
setButtonStateRt(bi.layer.id, inside?'hover':'normal');
if(inside){
var acts=bi.layer.onClick||[];
for(var ai=0;ai<acts.length;ai++) evalAction(acts[ai]);
}
}
}
canvas.addEventListener('mousemove', onStageMove);
canvas.addEventListener('mousedown', onStageDown);
canvas.addEventListener('mouseup', onStageUp);
function parseScriptSource(source){
var outActs=[];
var outErrs=[];
@@ -284,11 +384,16 @@ const ENGINE_SOURCE = `(function(){
var currentT=rangeIn;
var paused=false;
var lastNow=0;
var rafId=0;
var onFrame=opts.onFrame||null;
var destroyed=false;
function frame(now){
if(destroyed) return;
if(rangeLen<=0){
draw(currentT);
syncBgVideo(currentT);
requestAnimationFrame(frame);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
return;
}
if(!lastNow) lastNow=now;
@@ -304,13 +409,14 @@ const ENGINE_SOURCE = `(function(){
currentT=nextT;
fireFrames(prev, currentT);
}
if(runtime.jumpTo!=null){ currentT=runtime.jumpTo; runtime.jumpTo=null; }
if(runtime.forcePause){ paused=true; runtime.forcePause=false; }
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; }
}
if(runtime.jumpTo!=null){ currentT=runtime.jumpTo; runtime.jumpTo=null; }
if(runtime.forcePause){ paused=true; runtime.forcePause=false; }
if(runtime.forcePlay){ paused=false; runtime.forcePlay=false; lastNow=0; }
draw(currentT);
syncBgVideo(currentT);
requestAnimationFrame(frame);
if(onFrame) onFrame(currentT, paused);
rafId=requestAnimationFrame(frame);
}
function tracePolyPath(tctx,sp,ap,offX,offY,closed){
var n=sp.length;
@@ -342,6 +448,7 @@ const ENGINE_SOURCE = `(function(){
var asset=assetMap[layer.assetId];
if(!asset) return;
if(asset.kind!=='rect' && asset.kind!=='circle' && asset.kind!=='text' && asset.kind!=='polygon') return;
if(!layerActiveAt(layer,time)) return;
var p=sample(layer,time);
ctxm.save();
ctxm.translate(p.x,p.y);
@@ -376,9 +483,22 @@ const ENGINE_SOURCE = `(function(){
}
ctxm.restore();
}
function drawLayer(layer,time){
function layerActiveAt(layer,time){
var main=layer.keyframes||[];
var pos=layer.positionKeyframes||[];
var rot=layer.rotationKeyframes||[];
var total=main.length+pos.length+rot.length;
if(total<=1) return true;
var mn=Infinity, mx=-Infinity;
function scan(arr){ for(var i=0;i<arr.length;i++){ var t=arr[i].time; if(t<mn) mn=t; if(t>mx) mx=t; } }
scan(main); scan(pos); scan(rot);
var EPS=1e-6;
return time>=mn-EPS && time<=mx+EPS;
}
function drawLayer(layer,time,alwaysActive){
var asset=assetMap[layer.assetId];
if(!asset||!layer.visible)return;
if(!alwaysActive&&!layerActiveAt(layer,time))return;
var p=sample(layer,time);
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
@@ -483,6 +603,23 @@ const ENGINE_SOURCE = `(function(){
var iOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
ctx.drawImage(img,-iOffX,-iOffY,asset.width,asset.height);
}
}else if(asset.kind==='button'){
var bCanvas=asset.canvas||{width:0,height:0};
var bOffX=layer.offsetX!=null?layer.offsetX:bCanvas.width/2;
var bOffY=layer.offsetY!=null?layer.offsetY:bCanvas.height/2;
ctx.translate(-bOffX,-bOffY);
var bm=ctx.getTransform();
buttonInstancesInFrame.push({
layer:layer,
asset:asset,
matrix:{a:bm.a,b:bm.b,c:bm.c,d:bm.d,e:bm.e,f:bm.f}
});
var bState=resolveButtonState(layer);
var stateTime=bState==='hover'?2:(bState==='pressed'?3:(bState==='disabled'?4:1));
var bLayers=asset.layers||[];
for(var bli=bLayers.length-1;bli>=0;bli--){
drawLayer(bLayers[bli], stateTime, true);
}
}
ctx.shadowColor='transparent';
ctx.shadowBlur=0;
@@ -494,6 +631,7 @@ const ENGINE_SOURCE = `(function(){
var lw=scene.canvas.width,lh=scene.canvas.height;
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
@@ -613,8 +751,28 @@ const ENGINE_SOURCE = `(function(){
}
loadImages(scene.assets).then(function(loaded){
imgs=loaded;
requestAnimationFrame(frame);
rafId=requestAnimationFrame(frame);
});
return {
play: function(){ paused=false; lastNow=0; },
pause: function(){ paused=true; },
toggle: function(){ if(paused){ paused=false; lastNow=0; } else paused=true; },
isPlaying: function(){ return !paused; },
seek: function(t){ currentT=Math.max(rangeIn,Math.min(rangeOut,t)); lastNow=0; },
getTime: function(){ return currentT; },
getRangeIn: function(){ return rangeIn; },
getRangeOut: function(){ return rangeOut; },
destroy: function(){
destroyed=true;
cancelAnimationFrame(rafId);
canvas.removeEventListener('mousemove', onStageMove);
canvas.removeEventListener('mousedown', onStageDown);
canvas.removeEventListener('mouseup', onStageUp);
ro.disconnect();
canvas.remove();
if(bgVideo) bgVideo.remove();
}
};
}
var api={ __v: '${RUNTIME_VERSION}', mount: mount };
@@ -647,6 +805,47 @@ const SCENE_BOOT_SOURCE = `(function(){
})();
`
// ---- In-app mount: eval the engine once, return the same player the export uses ----
export interface PlayerHandle {
play(): void
pause(): void
toggle(): void
isPlaying(): boolean
seek(t: number): void
getTime(): number
getRangeIn(): number
getRangeOut(): number
destroy(): void
}
export interface MountOptions {
loop?: boolean
onFrame?: (time: number, paused: boolean) => void
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cachedApi: any = null
function ensureEngine() {
if (cachedApi) return
// eslint-disable-next-line no-eval
;(0, eval)(ENGINE_SOURCE)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cachedApi = (window as any).AnimatrPlayer
}
export function mountPlayer(
container: HTMLElement,
scene: Scene,
opts?: MountOptions,
): PlayerHandle {
ensureEngine()
return cachedApi.mount(container, JSON.parse(JSON.stringify(scene)), opts)
}
// ---- Export helpers ----
function jsonForExport(scene: Scene): string {
// Data-URL background videos are stripped from the export — local video
// files can't be embedded reasonably. The user needs to host the media