diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index 4b265f83..2dbbbf90 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -3,13 +3,7 @@ import type { Edit } from "@core/edit-session"; import { EditEvent } from "@core/events/edit-events"; import { type Size } from "@layouts/geometry"; import type { ResolvedClip } from "@schemas"; -import { - Html5AssetSchema, - composeHtml5IframeSrcdoc, - computeHtml5FrameCount, - detectHtml5DurationWithRetry, - type Html5Asset -} from "@shotstack/shotstack-canvas"; +import { Html5AssetSchema, composeHtml5IframeSrcdoc, computeHtml5FrameCount, type Html5Asset } from "@shotstack/shotstack-canvas"; import * as pixi from "pixi.js"; import { computeHtml5CacheKey, html5CacheGet, html5CachePut } from "./html5-cache"; @@ -21,10 +15,9 @@ const DECODED_FRAME_LIMIT = 30; type HarnessWindow = Window & { ["__shotstackSeek"]?: (ms: number) => void; - ["__shotstackDetectDurationMs"]?: () => number; }; const SEEK_KEY = "__shotstackSeek" as const; -const DETECT_KEY = "__shotstackDetectDurationMs" as const; +const CAPTURE_CONCURRENCY = 4; function yieldFrame(): Promise { return new Promise(resolve => { @@ -56,7 +49,7 @@ function waitForIframeLoad(iframe: HTMLIFrameElement, timeoutMs: number = IFRAME }); } -async function foreignObjectSvgToPng(svg: string, width: number, height: number): Promise { +async function foreignObjectSvgToWebp(svg: string, width: number, height: number): Promise { const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; const img = new Image(); img.src = url; @@ -68,7 +61,7 @@ async function foreignObjectSvgToPng(svg: string, width: number, height: number) if (!ctx) throw new Error("2D context unavailable for foreignObject rasterise"); ctx.drawImage(img, 0, 0, width, height); const blob = await new Promise(resolve => { - canvas.toBlob(resolve, "image/png"); + canvas.toBlob(resolve, "image/webp", 0.85); }); if (!blob) throw new Error(`canvas.toBlob returned null — taint? (svg bytes=${svg.length})`); return new Uint8Array(await blob.arrayBuffer()); @@ -158,21 +151,6 @@ export class Html5Player extends Player { } } - /** - * Returns the harness-reported duration in ms, or null if unavailable. - */ - private probeDurationMs(): number | null { - const detect = this.harnessWindow?.[DETECT_KEY]; - if (typeof detect !== "function") return null; - try { - const ms = detect(); - if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms; - } catch (err) { - console.warn("[Html5Player] __shotstackDetectDurationMs threw:", err); - } - return null; - } - private emitCaptureFailed(error: unknown, fallback: string): void { const message = error instanceof Error ? error.message : String(error); try { @@ -220,7 +198,7 @@ export class Html5Player extends Player { } await this.mountIframe(validation.data); this.configureKeyframes(); - this.prewarmCapture(); + this.beginCapture(); } catch (error) { console.error("Failed to render html5 asset:", error instanceof Error ? `${error.message}\n${error.stack}` : error); this.createFallbackGraphic(); @@ -292,8 +270,12 @@ export class Html5Player extends Player { // ─── capture pipeline ────────────────────────────────────────────────────── private async captureFrames(): Promise { - if (this.capturedFrames) return this.capturedFrames; - if (this.captureInFlight) return this.captureInFlight; + if (this.capturedFrames && this.capturedHash === this.contentHash) { + return this.capturedFrames; + } + if (this.captureInFlight) { + return this.captureInFlight; + } const previous = Html5Player.captureChain; this.captureInFlight = previous .then(() => { @@ -334,11 +316,7 @@ export class Html5Player extends Player { await yieldFrame(); if (stale()) return null; - const detectedDurationMs = await detectHtml5DurationWithRetry(() => this.probeDurationMs(), stale); - if (stale()) return null; - const { frameCount } = computeHtml5FrameCount({ - detectedDurationMs, clipLengthSeconds: this.getLength(), jsContent: this.asset.js, cssContent: this.asset.css, @@ -350,25 +328,24 @@ export class Html5Player extends Player { this.captureFramesTotal = frameCount; this.captureFramesDone = 0; - const rasterisePromises: Promise[] = []; - const onFrameDone = (png: Uint8Array): Uint8Array => { - this.captureFramesDone += 1; - return png; - }; - - for (let i = 0; i < frameCount; i += 1) { + const blobs: Blob[] = []; + for (let i = 0; i < frameCount; i += CAPTURE_CONCURRENCY) { await yieldFrame(); if (stale()) return null; - this.seekHarness(i / fps); - forceLayout(this.iframe.contentDocument.body); - const svg = this.captureIframeAsForeignObjectSvg(W, H); - rasterisePromises.push(foreignObjectSvgToPng(svg, W, H).then(onFrameDone)); + const batch: Promise[] = []; + for (let j = i; j < Math.min(i + CAPTURE_CONCURRENCY, frameCount); j += 1) { + this.seekHarness(j / fps); + forceLayout(this.iframe.contentDocument.body); + batch.push(foreignObjectSvgToWebp(this.captureIframeAsForeignObjectSvg(W, H), W, H)); + } + const frames = await Promise.all(batch); + if (stale()) return null; + for (const frame of frames) { + this.captureFramesDone += 1; + blobs.push(new Blob([frame as BlobPart], { type: "image/webp" })); + } } - const pngs = await Promise.all(rasterisePromises); - if (stale()) return null; - const blobs: Blob[] = pngs.map(png => new Blob([png as BlobPart], { type: "image/png" })); - this.capturedFrames = blobs; this.capturedHash = cacheKey; this.emitCaptureCompleted(frameCount); @@ -484,13 +461,14 @@ export class Html5Player extends Player { if (newHash === this.contentHash) return; this.contentHash = newHash; this.transitionToStale(); + this.disposeCapturedFrames(); this.captureInFlight = null; this.hasTriggeredCapture = false; this.seekErrorReported = false; this.iframe.srcdoc = composeHtml5IframeSrcdoc(this.asset); try { await waitForIframeLoad(this.iframe, undefined, true); - this.prewarmCapture(); + this.beginCapture(); } catch (err) { console.warn("[Html5Player] reload iframe load failed:", err); this.emitCaptureFailed(err, "static-placeholder"); @@ -531,54 +509,47 @@ export class Html5Player extends Player { } } - /** - * Run capture in the background without changing UI mode. - */ - private prewarmCapture(): void { + private beginCapture(): void { if (this.disposed || !this.iframe) return; - if (this.capturedFrames || this.captureInFlight) return; - this.captureFrames().catch(err => { - console.warn("[Html5Player] prewarm capture failed:", err); - }); - } - - private triggerCaptureIfNeeded(): void { - if (this.hasTriggeredCapture) return; - if (!this.iframe || !this.edit.isPlaying) return; - if (this.mode !== "editing" && this.mode !== "stale") return; + if (this.mode === "capturing" || this.mode === "playback") return; + if (this.captureInFlight) return; if (this.capturedFrames && this.capturedHash === this.contentHash) { - const hashAtTrigger = this.contentHash; - this.hasTriggeredCapture = true; - this.transitionToPlayback() - .catch(err => { - console.warn("[Html5Player] transitionToPlayback failed:", err); - this.transitionToEditing(); - }) - .finally(() => { - if (this.contentHash !== hashAtTrigger) this.hasTriggeredCapture = false; - }); + this.transitionToPlayback().catch(err => { + console.warn("[Html5Player] transitionToPlayback failed:", err); + this.transitionToEditing(); + }); return; } - this.hasTriggeredCapture = true; this.transitionToCapturing(); - const hashAtTrigger = this.contentHash; - + const hashAtStart = this.contentHash; this.captureFrames() .then(async frames => { - if (!frames || frames.length === 0) return; - if (this.capturedHash !== hashAtTrigger || this.contentHash !== hashAtTrigger) return; if (this.disposed) return; + const fresh = !!frames && frames.length > 0 && this.capturedHash === hashAtStart && this.contentHash === hashAtStart; + if (!fresh) { + // Stale/empty result — fall back to the live iframe instead of stranding the loader. + if (this.mode === "capturing") this.transitionToEditing(); + return; + } await this.transitionToPlayback(); }) .catch(err => { console.warn("[Html5Player] capture failed:", err); this.emitCaptureFailed(err, "live-iframe"); - this.transitionToEditing(); + if (this.mode === "capturing") this.transitionToEditing(); }); } + private triggerCaptureIfNeeded(): void { + if (this.hasTriggeredCapture) return; + if (!this.iframe || !this.edit.isPlaying) return; + if (this.mode !== "editing" && this.mode !== "stale") return; + this.hasTriggeredCapture = true; + this.beginCapture(); + } + private mountLoadingGraphic(): void { if (this.loadingGraphic) return; const width = this.renderedWidth || this.edit.size.width; diff --git a/src/components/canvas/players/placeholder-graphic.ts b/src/components/canvas/players/placeholder-graphic.ts index ef76b807..fc09351a 100644 --- a/src/components/canvas/players/placeholder-graphic.ts +++ b/src/components/canvas/players/placeholder-graphic.ts @@ -22,40 +22,38 @@ export function createPlaceholderGraphic(width: number, height: number): pixi.Gr export function createCaptureLoadingGraphic(width: number, height: number): { container: pixi.Container; setProgress: (fraction: number) => void } { const container = new pixi.Container(); - // Background - const bg = new pixi.Graphics(); - bg.fillStyle = { color: "#0f172a", alpha: 0.92 }; - bg.rect(0, 0, width, height); - bg.fill(); - container.addChild(bg); + const sideMargin = Math.max(12, Math.round(width * 0.06)); + const barWidth = width - sideMargin * 2; + const barHeight = Math.max(4, Math.round(height / 220)); + const radius = barHeight / 2; + const barX = sideMargin; + const barY = height - Math.max(18, Math.round(height / 36)) - barHeight; - // Border - const border = new pixi.Graphics(); - border.strokeStyle = { color: "#334155", width: 2 }; - border.rect(1, 1, width - 2, height - 2); - border.stroke(); - container.addChild(border); - - const labelStyle = new pixi.TextStyle({ - fontFamily: "system-ui, sans-serif", - fontSize: Math.min(48, Math.max(18, height / 16)), - fill: 0xe2e8f0, - fontWeight: "600" + const fontSize = Math.min(30, Math.max(13, Math.round(height / 42))); + const label = new pixi.Text({ + text: "Loading clip…", + style: new pixi.TextStyle({ fontFamily: "system-ui, sans-serif", fontSize, fill: 0xe2e8f0, fontWeight: "600" }) }); - const label = new pixi.Text({ text: "Loading clip...", style: labelStyle }); - label.anchor.set(0.5, 0.5); - label.x = width / 2; - label.y = height / 2 - 10; + + // Small rounded backing behind the label so it stays legible over any clip content. + const padX = Math.round(fontSize * 0.6); + const padY = Math.round(fontSize * 0.3); + const labelW = label.width + padX * 2; + const labelH = label.height + padY * 2; + const labelY = barY - labelH - Math.round(barHeight * 2) - 4; + const labelBg = new pixi.Graphics(); + labelBg.fillStyle = { color: "#0f172a", alpha: 0.7 }; + labelBg.roundRect(barX, labelY, labelW, labelH, Math.round(labelH / 2)); + labelBg.fill(); + container.addChild(labelBg); + label.x = barX + padX; + label.y = labelY + padY; container.addChild(label); - // Progress bar - const trackWidth = Math.min(360, width * 0.5); - const trackHeight = 6; - const trackX = (width - trackWidth) / 2; - const trackY = height / 2 + 60; + // Progress track, pinned to the bottom edge. const track = new pixi.Graphics(); - track.fillStyle = { color: "#1e293b", alpha: 1 }; - track.rect(trackX, trackY, trackWidth, trackHeight); + track.fillStyle = { color: "#0f172a", alpha: 0.6 }; + track.roundRect(barX, barY, barWidth, barHeight, radius); track.fill(); container.addChild(track); @@ -64,8 +62,9 @@ export function createCaptureLoadingGraphic(width: number, height: number): { co const setProgress = (fraction: number) => { const f = Math.max(0, Math.min(1, fraction)); fill.clear(); + if (f <= 0) return; fill.fillStyle = { color: "#34d399", alpha: 1 }; - fill.rect(trackX, trackY, trackWidth * f, trackHeight); + fill.roundRect(barX, barY, Math.max(barHeight, barWidth * f), barHeight, radius); fill.fill(); }; setProgress(0); diff --git a/src/core/edit-document.ts b/src/core/edit-document.ts index bce5db4c..cfaa15a3 100644 --- a/src/core/edit-document.ts +++ b/src/core/edit-document.ts @@ -10,6 +10,7 @@ import type { Size } from "@layouts/geometry"; +import { resolveGoogleFontUrl } from "./fonts/font-config"; import type { Clip, Track, Edit, Soundtrack } from "./schemas"; import { setNestedValue } from "./shared/utils"; @@ -621,6 +622,9 @@ export class EditDocument { const result = structuredClone(this.data); const includeIds = options?.includeIds ?? false; + const fonts = result.timeline.fonts ?? []; + const registeredSrcs = new Set(fonts.map(f => f.src)); + // Restore placeholders from document bindings before optionally stripping IDs for (const track of result.timeline.tracks) { for (const clip of track.clips) { @@ -637,9 +641,20 @@ export class EditDocument { // Strip internal ID — render API ignores it; default keeps payloads clean. delete (clip as InternalClip).id; } + + const family = (clip.asset as { font?: { family?: string } } | undefined)?.font?.family; + const fontSrc = family ? resolveGoogleFontUrl(family) : undefined; + if (fontSrc && !registeredSrcs.has(fontSrc)) { + fonts.push({ src: fontSrc }); + registeredSrcs.add(fontSrc); + } } } + if (fonts.length > 0) { + result.timeline.fonts = fonts; + } + if (result.merge?.length === 0) { delete result.merge; } diff --git a/src/core/fonts/font-config.ts b/src/core/fonts/font-config.ts index be5a2076..0301dd27 100644 --- a/src/core/fonts/font-config.ts +++ b/src/core/fonts/font-config.ts @@ -114,6 +114,10 @@ export function isGoogleFont(fontFamily: string): boolean { return GOOGLE_FONTS_BY_FILENAME.has(fontFamily) || GOOGLE_FONTS_BY_NAME.has(fontFamily); } +export function resolveGoogleFontUrl(fontFamily: string): string | undefined { + return (GOOGLE_FONTS_BY_FILENAME.get(fontFamily) ?? GOOGLE_FONTS_BY_NAME.get(fontFamily))?.url; +} + /** * Get the display name for a font (resolves Google Font filename hashes to readable names) */ diff --git a/tests/font-autoregister.test.ts b/tests/font-autoregister.test.ts new file mode 100644 index 00000000..d2251242 --- /dev/null +++ b/tests/font-autoregister.test.ts @@ -0,0 +1,40 @@ +/** + * EditDocument.toJSON auto-registers catalogued Google Fonts referenced by a clip + * into timeline.fonts. The editor preview resolves a font from its family hash on its + * own, but the render API only loads what's listed in timeline.fonts — so an edit built + * via addClip/updateClip (e.g. by an LLM that never called addFont) must still export a + * complete fonts array, or it renders with a fallback / fails. + */ +import { EditDocument } from "@core/edit-document"; +import type { Edit } from "@schemas"; + +const OPEN_SANS_FAMILY = "mem8YaGs126MiZpBA-U1UpcaXcl0Aw"; +const OPEN_SANS_URL = "https://fonts.gstatic.com/s/opensans/v44/mem8YaGs126MiZpBA-U1UpcaXcl0Aw.ttf"; + +function richTextEdit(family: string, fonts: Array<{ src: string }> = []): Edit { + return { + timeline: { + background: "#000000", + fonts, + tracks: [{ clips: [{ asset: { type: "rich-text", text: "Hi", font: { family, size: 24 } }, start: 0, length: 5 }] }] + }, + output: { format: "mp4", size: { width: 1080, height: 1920 } } + } as unknown as Edit; +} + +describe("EditDocument.toJSON — Google Font auto-registration", () => { + it("registers a catalogued font referenced by a clip but missing from timeline.fonts", () => { + const out = EditDocument.fromJSON(richTextEdit(OPEN_SANS_FAMILY)).toJSON(); + expect(out.timeline.fonts).toEqual([{ src: OPEN_SANS_URL }]); + }); + + it("does not duplicate a font already present in timeline.fonts", () => { + const out = EditDocument.fromJSON(richTextEdit(OPEN_SANS_FAMILY, [{ src: OPEN_SANS_URL }])).toJSON(); + expect(out.timeline.fonts).toEqual([{ src: OPEN_SANS_URL }]); + }); + + it("leaves non-catalogued (custom) font families untouched", () => { + const out = EditDocument.fromJSON(richTextEdit("Totally Custom Font")).toJSON(); + expect(out.timeline.fonts ?? []).toEqual([]); + }); +});