0.8.0: group colour cascade, still-frame past end, project rename, UX polish

Headline:

- Group colour cascades to every non-overridden child (layer or clip
  track). New `colorOverridden` flag on Layer / ClipTrack / ScriptTrack
  / TrackGroup; setTrackColor on a non-group pins the track, clearing
  re-inherits from the parent group. moveTrackIntoGroup also inherits.
  normalizeScene migrates legacy scenes (existing colour ⇒ overridden).
- Keyframes inside a button-asset timeline snap to the 4 state slots
  (normal/hover/pressed/disabled). upsertKeyframe / setKeyframeTime /
  duplicateKeyframe all enforce it; colliding drags refuse the move,
  duplicates walk to the nearest free slot.
- Backspace / Delete on a selected layer removes it (respecting the
  layer's own lock and any enclosing group lock).
- "Still frame" allowed on the last keyframe: the layer stays on stage
  past its final kf. Timeline draws a solid dim tail line from the
  last kf to the end, and single-keyframe tracks now show a full-span
  line so on-stage/off-stage reads at a glance. Height stays 3px.
- Project name moved next to the logo, turns into a rename button on
  hover (pencil fades in). Rename syncs the stored record when the
  project is already persisted.
- Shadow + blur keyframes animate in the preview and exported runtime:
  the runtime's lerpProps used to drop them, so every preview rendered
  shadow=0 between keyframes. RUNTIME_VERSION 11 → 13 (covers the
  button-mask + shadow fixes). Held-last-kf also taught to the runtime.
- Button masks render across every path: edit-mode mask-preview,
  nested buttons on the main stage, sidebar thumbnails, fullscreen
  preview + exports. drawButtonFrame new in render.ts.

Smaller fixes:

- Dragging layer A on the stage with a stale selection on layer B used
  to move B and snap A back. Now the shift-selected branch only runs
  when the dragged layer is itself in the selection.
- Clip-track row was missing its icon — PhFilmStrip was referenced but
  never imported. Added, plus dropped the bespoke `gap` on clip / script
  name rows so their icon+label spacing matches layer rows.
- Clip-art drawer pre-lays-out its 400px grid behind `overflow: hidden`
  so slide-in reveals a finished grid instead of reflowing auto-fill
  columns over 220ms.
- Timeline clip-track dropzone hides until the library has a clip; its
  "drop a clip here" hint stays centred in the visible ruler area.
- Keyframe context menu: check-mark and icons share a single 12px column
  so the ✓ on an easing option lines up with PhPauseCircle / PhCopy /
  PhTrash on the rows below. "Still frame"'s active indicator moved to
  the trailing-check slot.
- Button editor's mask-preview now shows the button's own stage instead
  of leaking through to the main scene. drawButtonFrame drives it.

Regression tests cover all of the above: group-color cascade, pin,
clear-re-inherit, migration, button-kf snap and collision rules,
backspace-deletes-selected-layer (incl. lock guards + editable-target
guard), still-frame-past-end visibility, clipart drawer rigid width,
mask rendering in drawButtonFrame / drawSceneFrame, shadow + blur
interpolation in both canvas and runtime paths.

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 21:26:06 +02:00
1 parent ac89f6762d
commit 1e89ea86e0
15 files changed
+849 -74

No files matched your search

+25 -3
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '11'
const RUNTIME_VERSION = '13'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -70,6 +70,20 @@ const ENGINE_SOURCE = `(function(){
if(a.strokeWidth!=null&&b.strokeWidth!=null) out.strokeWidth=lerp(a.strokeWidth,b.strokeWidth,t);
else if(b.strokeWidth!=null) out.strokeWidth=b.strokeWidth;
else if(a.strokeWidth!=null) out.strokeWidth=a.strokeWidth;
if(a.blur!=null && b.blur!=null) out.blur=lerp(a.blur,b.blur,t);
else if(b.blur!=null) out.blur=b.blur;
else if(a.blur!=null) out.blur=a.blur;
var shadowNumKeys=['shadowBlur','shadowOpacity','shadowAngle','shadowDistance'];
for(var sk=0;sk<shadowNumKeys.length;sk++){
var skName=shadowNumKeys[sk];
var sav=a[skName], sbv=b[skName];
if(sav!=null && sbv!=null) out[skName]=lerp(sav,sbv,t);
else if(sbv!=null) out[skName]=sbv;
else if(sav!=null) out[skName]=sav;
}
if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
else if(b.shadowColor) out.shadowColor=b.shadowColor;
else if(a.shadowColor) out.shadowColor=a.shadowColor;
return out;
}
function sample(layer,time){
@@ -489,9 +503,17 @@ const ENGINE_SOURCE = `(function(){
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; } }
var mn=Infinity, mx=-Infinity, lastHold=false;
function scan(arr){
for(var i=0;i<arr.length;i++){
var k=arr[i], t=k.time;
if(t<mn) mn=t;
if(t>mx){ mx=t; lastHold=!!k.hold; }
else if(t===mx && k.hold){ lastHold=true; }
}
}
scan(main); scan(pos); scan(rot);
if(lastHold) mx=Infinity;
var EPS=1e-6;
return time>=mn-EPS && time<=mx+EPS;
}
+14 -1
View File
@@ -134,6 +134,12 @@ function sampleKeyframeSet(
*
* Returns null (→ always visible) when the layer has zero or one total
* keyframes — a single keyframe is a static layer with no animation range.
*
* Special case: when the keyframe with the latest time has `hold: true`,
* the max is extended to Infinity — the layer stays alive past its last
* keyframe, pinned to that keyframe's sampled value. That's how users
* turn the final keyframe of a short anim into a still frame so the
* layer stays on-screen for the remainder of the timeline.
*/
export function layerActiveRange(
layer: Layer,
@@ -145,12 +151,19 @@ export function layerActiveRange(
if (total <= 1) return null
let min = Infinity
let max = -Infinity
let lastHold = false
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
if (k.time > max) {
max = k.time
lastHold = !!k.hold
} else if (k.time === max && k.hold) {
lastHold = true
}
}
}
if (lastHold) max = Infinity
return { min, max }
}