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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
Reference in new issue
Block a user