1.2.7: 6 more bundled fonts + custom-font upload + layerActiveAt parity

Built-in additions: Montserrat, Nunito, Figtree, Gabarito (sans),
Archivo Black (heavy display), Press Start 2P (pixel/retro).

Custom font upload (lib/fonts.ts):
- uploadCustomFont(file) reads .woff2/.woff/.ttf/.otf via FileReader,
  registers a FontFace early so a corrupt file fails before storage,
  then persists {id, label, dataUrl} to localStorage under
  'animatr:customFonts'. Quota errors surface a friendly message.
- removeCustomFont(id) drops the FontFace + storage entry. If the
  asset still pointed at the deleted font, the picker resets it to
  System Sans on the calling side.
- AVAILABLE_FONTS becomes a ComputedRef combining BUILTIN_FONTS with
  the reactive customFontsState. fontFamilyMapSnapshot() rebuilds the
  {id → CSS family} map per export so user-uploaded fonts make it
  into the exported runtime.

FontPicker UI:
- Two sections divided by a thin rule — built-ins on top, "Your fonts"
  below — plus an "Upload font…" entry at the bottom that opens a
  hidden file input. Custom rows show a trash button on hover.
- Auto-selects a freshly uploaded font.

Engine source becomes a function (engineSource()) instead of a const,
so each export materializes the latest FONT_FAMILY map. ENGINE_TEMPLATE
holds the static skeleton with a __FONT_FAMILY_MAP__ placeholder.

Example HTML hint:
- Built-in fonts list their .woff2 path; custom fonts get a
  "locally uploaded; @font-face below has the file inlined as a data
  URL" line. The fontFaceCss snippet inlines data URLs for custom
  entries so it's self-contained.

Bug fixes (player layerActiveAt):
- Mirrors interpolate.ts isLayerActiveAt now. The previous
  `if(total<=1) return true` short-circuit made layers with a single
  future keyframe visible at t=0; the lastHold→Infinity heuristic
  ignored end keyframes entirely. Both regressions affected the
  fullscreen preview and standalone exports. Replaced with a strict
  scan: min = earliest non-end kf, max = earliest 'end' kf or +Inf.
  Static layers (no normal kfs) stay always-visible.

Tests (5 new under "text font picker — model + render + export"):
- 6 new built-in ids in FONT_FAMILIES map.
- uploadCustomFont registers + persists + appears in AVAILABLE_FONTS;
  removeCustomFont reverses it. Stubs FontFace and document.fonts.add
  for the test environment via a stubFontFaceForTest() helper.
- uploadCustomFont rejects unsupported extensions.
- Exported runtime FONT_FAMILIES includes uploaded custom font ids.
- FontPicker popover shows the Upload font… entry.
- New "exported runtime layerActiveAt mirrors editor isLayerActiveAt"
  extracts the function from engineSource() and exercises five
  scenarios (single future kf, two normal kfs no end, end kf cuts
  off, static layer always-visible, end kf wins over separated tracks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 13:40:26 +02:00
1 parent 8c4f744afc
commit 27d6a47b72
12 files changed
+593 -81

No files matched your search

+46 -25
View File
@@ -1,5 +1,5 @@
import type { Asset, Project, Scene } from '@/types/scene'
import { AVAILABLE_FONTS, fontFaceCss, fontPrimary, SYSTEM_SANS } from './fonts'
import { AVAILABLE_FONTS, fontFaceCss, fontFamilyMapSnapshot, fontPrimary, SYSTEM_SANS } from './fonts'
/** Input accepted by every export helper below. A Project exports all
* its scenes (jumpToScene works in the bundle); a single Scene is
@@ -13,13 +13,17 @@ function isProject(p: ExportPayload): p is Project {
const RUNTIME_VERSION = '28'
const FONT_FAMILY_MAP_JSON = JSON.stringify(
Object.fromEntries(AVAILABLE_FONTS.map(f => [f.id, f.family])),
)
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
* and are dynamic) make it into the exported runtime. */
function engineSource(): string {
const fontMapJson = JSON.stringify(fontFamilyMapSnapshot())
return ENGINE_TEMPLATE.replace('__FONT_FAMILY_MAP__', fontMapJson)
}
const ENGINE_SOURCE = `(function(){
const ENGINE_TEMPLATE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
var FONT_FAMILIES = ${FONT_FAMILY_MAP_JSON};
var FONT_FAMILIES = __FONT_FAMILY_MAP__;
function fontFam(asset){
return (asset && asset.fontFamily && FONT_FAMILIES[asset.fontFamily]) || FONT_FAMILIES['system-sans'] || 'sans-serif';
}
@@ -726,25 +730,35 @@ const ENGINE_SOURCE = `(function(){
}
ctxm.restore();
}
// Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts.
// A layer is "alive" from its earliest NORMAL kf onward, ending
// either at the earliest 'end' kf or at +Infinity when none exists.
// Layers without any normal kf are static (always visible).
// End kfs only live on .keyframes (the main track).
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, lastHold=false;
function scan(arr){
var foundNormal=false, mn=Infinity;
function scanNormal(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; }
var k=arr[i];
if(k.kind==='end') continue;
foundNormal=true;
if(k.time<mn) mn=k.time;
}
}
scan(main); scan(pos); scan(rot);
if(lastHold) mx=Infinity;
scanNormal(main); scanNormal(pos); scanNormal(rot);
if(!foundNormal) return true;
var endTime=null;
for(var ei=0;ei<main.length;ei++){
var ek=main[ei];
if(ek.kind!=='end') continue;
if(endTime===null || ek.time<endTime) endTime=ek.time;
}
var mx=(endTime===null)?Infinity:endTime;
var EPS=1e-6;
return time>=mn-EPS && time<=mx+EPS;
return time>=mn-EPS && time<mx-EPS;
}
// Button-asset visibility: a still-frame (hold) kf shows ONLY on
// its own state frame, not on the state frames that follow. Mirrors
@@ -1265,7 +1279,7 @@ let cachedApi: any = null
function ensureEngine() {
if (cachedApi) return
// eslint-disable-next-line no-eval
;(0, eval)(ENGINE_SOURCE)
;(0, eval)(engineSource())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cachedApi = (window as any).AnimatrPlayer
}
@@ -1393,7 +1407,7 @@ export interface ExportOptions {
export function generateRuntimeScript(): string {
return (
'/* RUWIO runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE
engineSource()
)
}
@@ -1411,7 +1425,7 @@ export function generateSceneScript(payload: ExportPayload, options: ExportOptio
export function generateBundledScript(payload: ExportPayload, options: ExportOptions = {}): string {
return (
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
ENGINE_SOURCE +
engineSource() +
'\n' +
generateSceneScriptBody(payload, options)
)
@@ -1436,20 +1450,27 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
}
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = ENGINE_SOURCE
const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds
.map(id => AVAILABLE_FONTS.find(f => f.id === id))
.filter((f): f is typeof AVAILABLE_FONTS[number] => !!f && !!f.file)
.map(id => fonts.find(f => f.id === id))
.filter((f): f is typeof fonts[number] => !!f && !!f.file)
const builtinUsed = usedFonts.filter(f => !f.custom)
const customUsed = usedFonts.filter(f => f.custom)
const fontHint = usedFonts.length === 0 ? '' : `
<div class="usage">
<h2>Fonts used in this animation</h2>
<p style="margin-bottom: 8px;">This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the .woff2 files alongside this HTML and add the @font-face rules below to your CSS:</p>
<p style="margin-bottom: 8px;">This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the font files alongside this HTML and add the @font-face rules below to your CSS:</p>
<ul style="margin: 0 0 12px 18px; padding: 0; font-size: 12px; color: #6e6e73;">
${usedFonts.map(f => ` <li>${fontPrimary(f.id)} — <code>${f.file.replace(/^\//, '')}</code></li>`).join('\n')}
${builtinUsed.map(f => ` <li>${fontPrimary(f.id)} — <code>${f.file.replace(/^\//, '')}</code></li>`).join('\n')}${
customUsed.length > 0
? '\n' + customUsed.map(f => ` <li>${escapeHtml(f.label)} — <em>locally uploaded; the @font-face below has the file inlined as a data URL</em></li>`).join('\n')
: ''
}
</ul>
<pre>${escapeHtml(fontFaceCss(usedFontIds))}</pre>
</div>`