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
+33
View File
@@ -128,6 +128,39 @@ function sampleKeyframeSet(
return { ...base }
}
/**
* Union range of all keyframe times on a layer (main + position + rotation
* tracks). A layer is considered "alive" only within this range.
*
* Returns null (→ always visible) when the layer has zero or one total
* keyframes — a single keyframe is a static layer with no animation range.
*/
export function layerActiveRange(
layer: Layer,
): { min: number; max: number } | null {
const main = layer.keyframes || []
const pos = layer.positionKeyframes || []
const rot = layer.rotationKeyframes || []
const total = main.length + pos.length + rot.length
if (total <= 1) return null
let min = Infinity
let max = -Infinity
for (const arr of [main, pos, rot]) {
for (const k of arr) {
if (k.time < min) min = k.time
if (k.time > max) max = k.time
}
}
return { min, max }
}
export function isLayerActiveAt(layer: Layer, time: number): boolean {
const range = layerActiveRange(layer)
if (!range) return true
const EPSILON = 1e-6
return time >= range.min - EPSILON && time <= range.max + EPSILON
}
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
if (!layer.separated) return main
+44 -8
View File
@@ -1,5 +1,6 @@
import type { AnimatableProps, Asset, Layer, Scene } from '@/types/scene'
import { sampleLayer } from './interpolate'
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene } from '@/types/scene'
import { BUTTON_STATE_TIMES } from '@/types/scene'
import { isLayerActiveAt, sampleLayer } from './interpolate'
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
const result: Record<string, HTMLImageElement> = {}
@@ -129,10 +130,13 @@ function drawLayers(
assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>,
time: number,
buttonStates?: Map<string, ButtonState>,
alwaysActive?: boolean,
) {
for (let i = layers.length - 1; i >= 0; i--) {
const layer = layers[i]
if (!layer.visible) continue
if (!alwaysActive && !isLayerActiveAt(layer, time)) continue
const asset = assetMap.get(layer.assetId)
if (!asset) continue
const p = sampleLayer(layer, time)
@@ -232,6 +236,12 @@ function drawLayers(
const offY = layer.offsetY ?? asset.height / 2
ctx.drawImage(img, -offX, -offY, asset.width, asset.height)
}
} else if (asset.kind === 'button') {
const offX = layer.offsetX ?? asset.canvas.width / 2
const offY = layer.offsetY ?? asset.canvas.height / 2
ctx.translate(-offX, -offY)
const bs = buttonStates?.get(layer.id) ?? 'normal'
drawLayers(ctx, asset.layers, assetMap, images, BUTTON_STATE_TIMES[bs], undefined, true)
}
clearShadow(ctx)
@@ -254,6 +264,7 @@ function drawMaskShape(
asset.kind !== 'polygon'
)
return
if (!isLayerActiveAt(layer, time)) return
const p = sampleLayer(layer, time)
ctx.save()
ctx.translate(p.x, p.y)
@@ -295,11 +306,12 @@ function drawMaskedGroup(
assetMap: Map<string, Asset>,
images: Record<string, HTMLImageElement>,
time: number,
buttonStates?: Map<string, ButtonState>,
) {
const masks = children.filter((l) => l.isMask)
const content = children.filter((l) => !l.isMask)
if (masks.length === 0) {
drawLayers(ctx, children, assetMap, images, time)
drawLayers(ctx, children, assetMap, images, time, buttonStates)
return
}
@@ -310,12 +322,12 @@ function drawMaskedGroup(
off.height = h
const offCtx = off.getContext('2d')
if (!offCtx) {
drawLayers(ctx, content, assetMap, images, time)
drawLayers(ctx, content, assetMap, images, time, buttonStates)
return
}
const m = ctx.getTransform()
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
drawLayers(offCtx, content, assetMap, images, time)
drawLayers(offCtx, content, assetMap, images, time, buttonStates)
for (const mask of masks) {
offCtx.save()
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
@@ -333,11 +345,12 @@ export function drawSceneFrame(
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: { crop?: boolean },
options?: { crop?: boolean; buttonStates?: Map<string, ButtonState> },
) {
const lw = scene.canvas.width
const lh = scene.canvas.height
const crop = options?.crop !== false
const buttonStates = options?.buttonStates
ctx.save()
if (crop) {
@@ -410,7 +423,7 @@ export function drawSceneFrame(
drawMaskShape(drawCtx, layer, assetMap, time)
drawCtx.restore()
} else {
drawLayers(drawCtx, [layer], assetMap, images, time)
drawLayers(drawCtx, [layer], assetMap, images, time, buttonStates)
}
continue
}
@@ -435,7 +448,7 @@ export function drawSceneFrame(
const cl = layerById.get(group.childIds[k])
if (cl) childLayers.push(cl)
}
drawMaskedGroup(drawCtx, childLayers, assetMap, images, time)
drawMaskedGroup(drawCtx, childLayers, assetMap, images, time, buttonStates)
}
}
@@ -448,3 +461,26 @@ export function drawSceneFrame(
ctx.restore()
}
export function renderButtonThumbnail(
button: ButtonAsset,
scene: Scene,
maxSize = 80,
images: Record<string, HTMLImageElement> = {},
): string | null {
const { width: bw, height: bh } = button.canvas
if (bw <= 0 || bh <= 0 || !button.layers.length) return null
const scale = Math.min(maxSize / bw, maxSize / bh, 1)
const cw = Math.round(bw * scale)
const ch = Math.round(bh * scale)
const canvas = document.createElement('canvas')
canvas.width = cw
canvas.height = ch
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.scale(scale, scale)
const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
drawLayers(ctx, button.layers, assetMap, images, BUTTON_STATE_TIMES.normal, undefined, true)
return canvas.toDataURL('image/png')
}