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:
1 parent
31604052ca
commit
c3764201b8
16 files changed
+2560
-349
No files matched your search
+207
-8
@@ -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
|
||||
|
||||
Reference in new issue
Block a user