From 26bfb06deba6667aca9e95cfd1f16c3a4db1060f Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 15 May 2026 10:56:56 +1000 Subject: [PATCH] feat: add HTML5 player support and loading graphics --- package.json | 2 +- src/components/canvas/players/html5-cache.ts | 134 ++++ src/components/canvas/players/html5-player.ts | 634 ++++++++++++++++++ .../canvas/players/placeholder-graphic.ts | 54 ++ .../canvas/players/player-factory.ts | 3 + src/components/canvas/players/player.ts | 1 + src/components/canvas/shotstack-canvas.ts | 8 + src/core/events/edit-events.ts | 6 + src/main.ts | 2 +- src/templates/html5-bundle-demo.json | 67 ++ 10 files changed, 909 insertions(+), 2 deletions(-) create mode 100644 src/components/canvas/players/html5-cache.ts create mode 100644 src/components/canvas/players/html5-player.ts create mode 100644 src/templates/html5-bundle-demo.json diff --git a/package.json b/package.json index 0c53ca99..8ed38ea2 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { - "@shotstack/schemas": "1.10.9", + "@shotstack/schemas": "1.11.0", "@shotstack/shotstack-canvas": "^2.7.2", "howler": "^2.2.4", "mediabunny": "^1.11.2", diff --git a/src/components/canvas/players/html5-cache.ts b/src/components/canvas/players/html5-cache.ts new file mode 100644 index 00000000..9bb73c20 --- /dev/null +++ b/src/components/canvas/players/html5-cache.ts @@ -0,0 +1,134 @@ +const DB_NAME = "shotstack-html5-cache"; +const DB_VERSION = 1; +const STORE_NAME = "captures"; +const MAX_ENTRIES = 30; + +export interface Html5CacheEntry { + pngs: Blob[]; + fps: number; + frameCount: number; + width: number; + height: number; + createdAt: number; +} + +let dbPromise: Promise | null = null; +let evictionPromise: Promise | null = null; + +function openDb(): Promise { + if (dbPromise) return dbPromise; + dbPromise = new Promise(resolve => { + try { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => { + console.warn("[Html5CaptureCache] indexedDB.open failed:", req.error); + resolve(null); + }; + req.onblocked = () => { + console.warn("[Html5CaptureCache] indexedDB.open blocked"); + resolve(null); + }; + } catch (err) { + // SSR / non-browser / disabled storage — silently disable cache. + console.warn("[Html5CaptureCache] indexedDB unavailable:", err); + resolve(null); + } + }); + return dbPromise; +} + +function evictOldEntries(db: IDBDatabase): Promise { + if (evictionPromise) return evictionPromise; + evictionPromise = new Promise(resolve => { + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const keysReq = store.getAllKeys(); + const valuesReq = store.getAll(); + tx.oncomplete = () => resolve(); + tx.onerror = () => resolve(); + tx.onabort = () => resolve(); + Promise.all([ + new Promise(r => { + keysReq.onsuccess = () => r(keysReq.result); + keysReq.onerror = () => r([]); + }), + new Promise(r => { + valuesReq.onsuccess = () => r(valuesReq.result as Html5CacheEntry[]); + valuesReq.onerror = () => r([]); + }) + ]) + .then(([keys, values]) => { + if (keys.length <= MAX_ENTRIES) return; + const paired = keys.map((key, i) => ({ key, createdAt: values[i]?.createdAt ?? 0 })); + paired.sort((a, b) => a.createdAt - b.createdAt); + const toDelete = paired.slice(0, keys.length - MAX_ENTRIES); + const delTx = db.transaction(STORE_NAME, "readwrite"); + const delStore = delTx.objectStore(STORE_NAME); + for (const entry of toDelete) delStore.delete(entry.key); + }) + .catch(err => { + console.warn("[Html5CaptureCache] eviction failed:", err); + }); + } catch (err) { + console.warn("[Html5CaptureCache] eviction threw:", err); + resolve(); + } + }); + return evictionPromise; +} + +export async function html5CacheGet(key: string): Promise { + const db = await openDb(); + if (!db) return null; + evictOldEntries(db).catch(() => undefined); + return new Promise(resolve => { + try { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const req = store.get(key); + req.onsuccess = () => { + const value = req.result as Html5CacheEntry | undefined; + resolve(value ?? null); + }; + req.onerror = () => { + console.warn("[Html5CaptureCache] get failed:", req.error); + resolve(null); + }; + } catch (err) { + console.warn("[Html5CaptureCache] get threw:", err); + resolve(null); + } + }); +} + +export async function html5CachePut(key: string, value: Html5CacheEntry): Promise { + const db = await openDb(); + if (!db) return; + evictOldEntries(db).catch(() => undefined); + await new Promise(resolve => { + try { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const req = store.put(value, key); + req.onsuccess = () => resolve(); + req.onerror = () => { + console.warn("[Html5CaptureCache] put failed:", req.error); + resolve(); + }; + tx.onerror = () => resolve(); + } catch (err) { + console.warn("[Html5CaptureCache] put threw:", err); + resolve(); + } + }); +} + +export { computeHtml5CacheKey } from "@shotstack/shotstack-canvas"; diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts new file mode 100644 index 00000000..bea35fd0 --- /dev/null +++ b/src/components/canvas/players/html5-player.ts @@ -0,0 +1,634 @@ +import { Player, PlayerType } from "@canvas/players/player"; +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, type Html5Asset } from "@shotstack/shotstack-canvas"; +import * as pixi from "pixi.js"; + +import { computeHtml5CacheKey, html5CacheGet, html5CachePut } from "./html5-cache"; +import { createCaptureLoadingGraphic, createPlaceholderGraphic } from "./placeholder-graphic"; + +const IFRAME_OFFSCREEN_X = -10000; +const IFRAME_LOAD_TIMEOUT_MS = 10_000; +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; + +function yieldFrame(): Promise { + return new Promise(resolve => { + if (typeof requestAnimationFrame === "function") requestAnimationFrame(() => resolve()); + else setTimeout(() => resolve(), 0); + }); +} + +function forceLayout(el: HTMLElement): void { + el.getBoundingClientRect(); +} + +function waitForIframeLoad(iframe: HTMLIFrameElement, timeoutMs: number = IFRAME_LOAD_TIMEOUT_MS, awaitNextLoad: boolean = false): Promise { + return new Promise((resolve, reject) => { + if (!awaitNextLoad && iframe.contentDocument?.readyState === "complete") { + resolve(); + return; + } + let timer: number = 0; + const onLoad = (): void => { + window.clearTimeout(timer); + resolve(); + }; + timer = window.setTimeout(() => { + iframe.removeEventListener("load", onLoad); + reject(new Error(`iframe load timed out after ${timeoutMs}ms — user JS may have hung`)); + }, timeoutMs); + iframe.addEventListener("load", onLoad, { once: true }); + }); +} + +async function foreignObjectSvgToPng(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; + await img.decode(); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + 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"); + }); + if (!blob) throw new Error(`canvas.toBlob returned null — taint? (svg bytes=${svg.length})`); + return new Uint8Array(await blob.arrayBuffer()); +} + +/** + * Drives whether the live iframe or the captured frame[] is the visible source. + * + * editing — iframe live, no sprite. Default for new content. + * capturing — iframe parked, loading placeholder shown. Triggered by first Play. + * playback — captured sprite mounted, iframe parked. Pixi-side filters apply. + * stale — captured frame[] invalidated by content change; iframe re-shown. + */ +type Html5Mode = "editing" | "capturing" | "playback" | "stale"; + +export class Html5Player extends Player { + private static captureChain: Promise = Promise.resolve(); + + private iframe: HTMLIFrameElement | null = null; + private renderedWidth: number = 0; + private renderedHeight: number = 0; + private capturedFrames: Blob[] | null = null; + private decodedFrames: Map = new Map(); + private decodeInFlight: number | null = null; + private captureInFlight: Promise | null = null; + private captureFps: number = 30; + private mode: Html5Mode = "editing"; + private contentHash: string | null = null; + private capturedHash: string | null = null; + private lastFrameIdx: number = -1; + private playbackSprite: pixi.Sprite | null = null; + private hasTriggeredCapture: boolean = false; + private loadingGraphic: pixi.Container | null = null; + private loadingSetProgress: ((fraction: number) => void) | null = null; + private captureFramesDone: number = 0; + private captureFramesTotal: number = 0; + private disposed: boolean = false; + private seekErrorReported: boolean = false; + + constructor(edit: Edit, clipConfiguration: ResolvedClip) { + super(edit, clipConfiguration, PlayerType.Html5); + } + + private get asset(): Html5Asset { + return this.clipConfiguration.asset as Html5Asset; + } + + private get harnessWindow(): HarnessWindow | null { + return (this.iframe?.contentWindow ?? null) as HarnessWindow | null; + } + + private hashAsset(): Promise { + const { asset } = this; + return computeHtml5CacheKey({ + html: asset.html ?? "", + css: asset.css ?? "", + js: asset.js ?? "", + width: this.renderedWidth, + height: this.renderedHeight + }); + } + + private getStudioCanvas(): HTMLCanvasElement | null { + return this.edit.getCanvas()?.application.canvas ?? null; + } + + private getIframeMountElement(): HTMLElement { + return this.edit.getCanvas()?.getRootElement() ?? document.body; + } + + private parkIframe(): void { + if (this.iframe) this.iframe.style.left = `${IFRAME_OFFSCREEN_X}px`; + } + + private seekHarness(seconds: number): void { + const win = this.harnessWindow; + const seek = win?.[SEEK_KEY]; + if (typeof seek !== "function") return; + try { + seek.call(win, seconds * 1000); + } catch (err) { + if (!this.seekErrorReported) { + this.seekErrorReported = true; + console.warn("[Html5Player] __shotstackSeek threw (further errors suppressed):", err); + } + } + } + + /** + * Returns the animation duration in seconds, or null when the harness + * doesn't expose __shotstackDetectDurationMs. + */ + private detectAnimationDuration(): 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 / 1000; + } 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 { + this.edit.getInternalEvents().emit(EditEvent.ClipCaptureFailed, { + clipId: this.clipId, + assetType: "html5", + error: message, + fallback + }); + } catch (emitErr) { + console.warn("[Html5Player] failed to emit ClipCaptureFailed:", emitErr); + } + } + + private emitCaptureStarted(): void { + try { + this.edit.getInternalEvents().emit(EditEvent.ClipCaptureStarted, { + clipId: this.clipId, + assetType: "html5" + }); + } catch (emitErr) { + console.warn("[Html5Player] failed to emit ClipCaptureStarted:", emitErr); + } + } + + private emitCaptureCompleted(frameCount: number): void { + try { + this.edit.getInternalEvents().emit(EditEvent.ClipCaptureCompleted, { + clipId: this.clipId, + assetType: "html5", + frameCount + }); + } catch (emitErr) { + console.warn("[Html5Player] failed to emit ClipCaptureCompleted:", emitErr); + } + } + + public override async load(): Promise { + await super.load(); + try { + const validation = Html5AssetSchema.safeParse(this.asset); + if (!validation.success) { + this.createFallbackGraphic(); + return; + } + await this.mountIframe(validation.data); + this.configureKeyframes(); + } catch (error) { + console.error("Failed to render html5 asset:", error instanceof Error ? `${error.message}\n${error.stack}` : error); + this.createFallbackGraphic(); + } + } + + private async mountIframe(asset: Html5Asset): Promise { + const width = this.clipConfiguration.width || this.edit.size.width; + const height = this.clipConfiguration.height || this.edit.size.height; + this.renderedWidth = width; + this.renderedHeight = height; + this.captureFps = this.edit.getOutputFps() || 30; + this.contentHash = await this.hashAsset(); + + const iframe = document.createElement("iframe"); + iframe.setAttribute("aria-hidden", "true"); + iframe.style.cssText = + `position:absolute;left:${IFRAME_OFFSCREEN_X}px;top:0;width:${width}px;height:${height}px;` + + `border:0;margin:0;padding:0;pointer-events:none;transform-origin:top left;` + + `z-index:50;background:transparent`; + iframe.srcdoc = composeHtml5IframeSrcdoc(asset); + this.getIframeMountElement().appendChild(iframe); + await waitForIframeLoad(iframe); + + this.iframe = iframe; + this.transitionToEditing(); + } + + private async transitionToPlayback(): Promise { + if (!this.capturedFrames || this.capturedFrames.length === 0 || !this.iframe) return; + const firstTexture = await this.getDecodedFrame(0); + if (!firstTexture || this.disposed) return; + const sprite = new pixi.Sprite(firstTexture); + this.contentContainer.addChild(sprite); + this.playbackSprite = sprite; + this.lastFrameIdx = 0; + if (this.clipConfiguration.width && this.clipConfiguration.height) { + this.applyFixedDimensions(); + } + this.removeLoadingGraphic(); + this.parkIframe(); + this.mode = "playback"; + } + + private transitionToEditing(): void { + this.mode = "editing"; + this.removeLoadingGraphic(); + } + + private transitionToStale(): void { + if (this.mode === "stale" || this.mode === "editing") return; + if (this.playbackSprite) { + this.contentContainer.removeChild(this.playbackSprite); + this.playbackSprite.destroy(); + this.playbackSprite = null; + } + this.removeLoadingGraphic(); + this.disposeCapturedFrames(); + this.mode = "stale"; + } + + private transitionToCapturing(): void { + this.mode = "capturing"; + this.parkIframe(); + this.mountLoadingGraphic(); + this.emitCaptureStarted(); + } + + // ─── capture pipeline ────────────────────────────────────────────────────── + + private async captureFrames(): Promise { + if (this.capturedFrames) return this.capturedFrames; + if (this.captureInFlight) return this.captureInFlight; + const previous = Html5Player.captureChain; + this.captureInFlight = previous + .then(() => { + if (this.disposed) return null; + return this.runCapture(); + }) + .finally(() => { + this.captureInFlight = null; + }); + Html5Player.captureChain = this.captureInFlight.catch(() => undefined); + return this.captureInFlight; + } + + private async runCapture(): Promise { + if (!this.iframe || !this.iframe.contentDocument || !this.iframe.contentWindow) return null; + + if (!this.contentHash) return null; + const cacheKey = this.contentHash; + const stale = (): boolean => this.disposed || this.contentHash !== cacheKey; + + const cached = await html5CacheGet(cacheKey); + if (stale()) return null; + if (cached) { + this.captureFramesTotal = cached.frameCount; + this.captureFramesDone = cached.frameCount; + this.captureFps = cached.fps; + this.capturedFrames = cached.pngs; + this.capturedHash = cacheKey; + this.emitCaptureCompleted(cached.frameCount); + return cached.pngs; + } + + try { + await this.iframe.contentDocument.fonts.ready; + } catch { + /* older browsers — proceed */ + } + await yieldFrame(); + if (stale()) return null; + + const detectedSeconds = this.detectAnimationDuration(); + const clipLengthSeconds = this.getLength(); + const hasJs = !!this.asset.js?.trim(); + const isStatic = detectedSeconds === null && !hasJs; + const fps = this.captureFps; + const frameCount = isStatic ? 1 : Math.max(1, Math.ceil(Math.min(detectedSeconds ?? clipLengthSeconds, clipLengthSeconds) * fps)); + const W = this.renderedWidth; + const H = this.renderedHeight; + 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) { + 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 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); + + html5CachePut(cacheKey, { + pngs: blobs, + fps, + frameCount, + width: this.renderedWidth, + height: this.renderedHeight, + createdAt: Date.now() + }).catch(err => console.warn("[Html5Player] cache put failed:", err)); + + return blobs; + } + + private captureIframeAsForeignObjectSvg(width: number, height: number): string { + if (!this.iframe?.contentDocument) throw new Error("iframe not ready"); + const doc = this.iframe.contentDocument; + const styles = Array.from(doc.querySelectorAll("style")) + .map(el => ``) + .join(""); + const bodyClone = doc.body.cloneNode(true) as HTMLElement; + bodyClone.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + const existingStyle = bodyClone.getAttribute("style") ?? ""; + bodyClone.setAttribute("style", `width:${width}px;height:${height}px;margin:0;overflow:hidden;${existingStyle}`); + const bodyXml = new XMLSerializer().serializeToString(bodyClone); + return `${styles}${bodyXml}`; + } + + private async getDecodedFrame(idx: number): Promise { + if (!this.capturedFrames) return null; + const existing = this.decodedFrames.get(idx); + if (existing) { + this.decodedFrames.delete(idx); + this.decodedFrames.set(idx, existing); + return existing; + } + const blob = this.capturedFrames[idx]; + if (!blob) return null; + const bmp = await createImageBitmap(blob); + if (this.disposed) { + bmp.close(); + return null; + } + const texture = pixi.Texture.from(bmp); + if (this.disposed) { + texture.destroy(true); + return null; + } + this.decodedFrames.set(idx, texture); + this.evictDecodedOverflow(idx); + return texture; + } + + private evictDecodedOverflow(protectIdx: number): void { + while (this.decodedFrames.size > DECODED_FRAME_LIMIT) { + let candidate: number | undefined; + for (const k of this.decodedFrames.keys()) { + if (k !== protectIdx && k !== this.lastFrameIdx) { + candidate = k; + break; + } + } + if (candidate === undefined) break; + this.decodedFrames.get(candidate)?.destroy(true); + this.decodedFrames.delete(candidate); + } + } + + public override async reloadAsset(): Promise { + if (!this.iframe) return; + const newHash = await this.hashAsset(); + if (newHash === this.contentHash) return; + this.contentHash = newHash; + this.transitionToStale(); + this.captureInFlight = null; + this.hasTriggeredCapture = false; + this.seekErrorReported = false; + this.iframe.srcdoc = composeHtml5IframeSrcdoc(this.asset); + try { + await waitForIframeLoad(this.iframe, undefined, true); + } catch (err) { + console.warn("[Html5Player] reload iframe load failed:", err); + this.emitCaptureFailed(err, "static-placeholder"); + } + // Stay in stale mode (iframe live) until the user plays again. + } + + private disposeCapturedFrames(): void { + for (const texture of this.decodedFrames.values()) texture.destroy(true); + this.decodedFrames.clear(); + this.capturedFrames = null; + this.capturedHash = null; + this.lastFrameIdx = -1; + this.decodeInFlight = null; + } + + private createFallbackGraphic(): void { + const width = this.clipConfiguration.width || this.edit.size.width; + const height = this.clipConfiguration.height || this.edit.size.height; + const graphics = createPlaceholderGraphic(width, height); + this.renderedWidth = width; + this.renderedHeight = height; + this.contentContainer.addChild(graphics); + this.configureKeyframes(); + } + + public override update(deltaTime: number, elapsed: number): void { + super.update(deltaTime, elapsed); + this.triggerCaptureIfNeeded(); + if (this.mode === "playback") { + this.driveSpriteTextureSwap(); + } else if (this.mode === "capturing") { + if (this.loadingSetProgress && this.captureFramesTotal > 0) { + this.loadingSetProgress(this.captureFramesDone / this.captureFramesTotal); + } + } else if (this.iframe) { + this.syncIframePosition(); + if (this.isActive()) this.seekHarness(this.getPlaybackTime()); + } + } + + 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.transitionToCapturing(); + const hashAtTrigger = 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; + await this.transitionToPlayback(); + }) + .catch(err => { + console.warn("[Html5Player] capture failed:", err); + this.emitCaptureFailed(err, "live-iframe"); + this.transitionToEditing(); + }); + } + + private mountLoadingGraphic(): void { + if (this.loadingGraphic) return; + const width = this.renderedWidth || this.edit.size.width; + const height = this.renderedHeight || this.edit.size.height; + const { container, setProgress } = createCaptureLoadingGraphic(width, height); + this.loadingGraphic = container; + this.loadingSetProgress = setProgress; + this.contentContainer.addChild(container); + if (this.clipConfiguration.width && this.clipConfiguration.height) { + this.applyFixedDimensions(); + } + } + + private removeLoadingGraphic(): void { + if (!this.loadingGraphic) return; + this.contentContainer.removeChild(this.loadingGraphic); + this.loadingGraphic.destroy({ children: true }); + this.loadingGraphic = null; + this.loadingSetProgress = null; + } + + private driveSpriteTextureSwap(): void { + if (!this.playbackSprite || !this.capturedFrames) return; + const idx = Math.min(Math.max(0, Math.floor(this.getPlaybackTime() * this.captureFps)), this.capturedFrames.length - 1); + if (idx === this.lastFrameIdx) return; + + const cached = this.decodedFrames.get(idx); + if (cached) { + // Promote to MRU. + this.decodedFrames.delete(idx); + this.decodedFrames.set(idx, cached); + this.playbackSprite.texture = cached; + this.lastFrameIdx = idx; + return; + } + if (this.decodeInFlight === idx) return; + this.decodeInFlight = idx; + this.getDecodedFrame(idx) + .then(texture => { + if (this.decodeInFlight === idx) this.decodeInFlight = null; + if (!texture || !this.playbackSprite || this.disposed) return; + const current = Math.min(Math.max(0, Math.floor(this.getPlaybackTime() * this.captureFps)), (this.capturedFrames?.length ?? 1) - 1); + if (Math.abs(current - idx) > 3) return; + this.playbackSprite.texture = texture; + this.lastFrameIdx = idx; + }) + .catch(err => console.warn("[Html5Player] frame decode failed:", err)); + } + + // Computes the clip's rect within the Studio root + private syncIframePosition(): void { + if (!this.iframe) return; + if (!this.isActive()) { + this.parkIframe(); + return; + } + const studioCanvas = this.getStudioCanvas(); + if (!studioCanvas) { + this.parkIframe(); + return; + } + + const m = this.contentContainer.worldTransform; + const naturalW = this.renderedWidth; + const naturalH = this.renderedHeight; + const tlX = m.tx; + const tlY = m.ty; + const brX = m.a * naturalW + m.c * naturalH + m.tx; + const brY = m.b * naturalW + m.d * naturalH + m.ty; + const pixiX = Math.min(tlX, brX); + const pixiY = Math.min(tlY, brY); + const pixiW = Math.abs(brX - tlX); + const pixiH = Math.abs(brY - tlY); + + const rect = studioCanvas.getBoundingClientRect(); + const scaleX = rect.width / studioCanvas.width; + const scaleY = rect.height / studioCanvas.height; + const left = studioCanvas.offsetLeft + pixiX * scaleX; + const top = studioCanvas.offsetTop + pixiY * scaleY; + const w = Math.max(1, pixiW * scaleX); + const h = Math.max(1, pixiH * scaleY); + + const { style } = this.iframe; + style.left = `${left}px`; + style.top = `${top}px`; + style.width = `${naturalW}px`; + style.height = `${naturalH}px`; + style.transform = `scale(${w / naturalW}, ${h / naturalH})`; + style.opacity = `${this.getOpacity()}`; + } + + public override dispose(): void { + this.disposed = true; + super.dispose(); + this.iframe?.remove(); + this.iframe = null; + this.playbackSprite?.destroy(); + this.playbackSprite = null; + this.removeLoadingGraphic(); + this.disposeCapturedFrames(); + } + + public override getSize(): Size { + if (this.clipConfiguration.width && this.clipConfiguration.height) { + return { width: this.clipConfiguration.width, height: this.clipConfiguration.height }; + } + return this.getContentSize(); + } + + public override getContentSize(): Size { + return { + width: this.renderedWidth || this.edit.size.width, + height: this.renderedHeight || this.edit.size.height + }; + } + + protected override getFitScale(): number { + return 1; + } + + public override supportsEdgeResize(): boolean { + return true; + } + + protected override onDimensionsChanged(): void { + // iframe content is DOM-native at its natural resolution; clip-level + // scaling is applied by syncIframePosition per Pixi tick. Nothing to do. + } +} diff --git a/src/components/canvas/players/placeholder-graphic.ts b/src/components/canvas/players/placeholder-graphic.ts index fd43c3fb..ef76b807 100644 --- a/src/components/canvas/players/placeholder-graphic.ts +++ b/src/components/canvas/players/placeholder-graphic.ts @@ -18,3 +18,57 @@ export function createPlaceholderGraphic(width: number, height: number): pixi.Gr return graphics; } + +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); + + // 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 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; + 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; + const track = new pixi.Graphics(); + track.fillStyle = { color: "#1e293b", alpha: 1 }; + track.rect(trackX, trackY, trackWidth, trackHeight); + track.fill(); + container.addChild(track); + + const fill = new pixi.Graphics(); + container.addChild(fill); + const setProgress = (fraction: number) => { + const f = Math.max(0, Math.min(1, fraction)); + fill.clear(); + fill.fillStyle = { color: "#34d399", alpha: 1 }; + fill.rect(trackX, trackY, trackWidth * f, trackHeight); + fill.fill(); + }; + setProgress(0); + + return { container, setProgress }; +} diff --git a/src/components/canvas/players/player-factory.ts b/src/components/canvas/players/player-factory.ts index 2e656d88..2b3bfb11 100644 --- a/src/components/canvas/players/player-factory.ts +++ b/src/components/canvas/players/player-factory.ts @@ -4,6 +4,7 @@ import type { ResolvedClip } from "@schemas"; import { AudioPlayer } from "./audio-player"; import { CaptionPlayer } from "./caption-player"; import { HtmlPlayer } from "./html-player"; +import { Html5Player } from "./html5-player"; import { ImagePlayer } from "./image-player"; import { ImageToVideoPlayer } from "./image-to-video-player"; import { LumaPlayer } from "./luma-player"; @@ -35,6 +36,8 @@ export class PlayerFactory { return new ShapePlayer(edit, clipConfiguration); case "html": return new HtmlPlayer(edit, clipConfiguration); + case "html5": + return new Html5Player(edit, clipConfiguration); case "image": return new ImagePlayer(edit, clipConfiguration); case "video": diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 644a42b0..e4955d4f 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -41,6 +41,7 @@ export enum PlayerType { RichText = "rich-text", Luma = "luma", Html = "html", + Html5 = "html5", Shape = "shape", Caption = "caption", Svg = "svg", diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 9571f3e4..682b4336 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -307,6 +307,14 @@ export class Canvas { return this.currentZoom; } + /** + * The user supplied Studio root element. + * @internal + */ + public getRootElement(): HTMLDivElement | null { + return this.canvasRoot; + } + /** * Sync overlay container and toolbar positions after content transforms change. * Single point of update for all position-dependent UI elements. diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts index bb375103..fb6d2658 100644 --- a/src/core/events/edit-events.ts +++ b/src/core/events/edit-events.ts @@ -67,6 +67,9 @@ export const EditEvent = { ClipRestored: "clip:restored", ClipCopied: "clip:copied", ClipLoadFailed: "clip:loadFailed", + ClipCaptureStarted: "clip:captureStarted", + ClipCaptureCompleted: "clip:captureCompleted", + ClipCaptureFailed: "clip:captureFailed", ClipUnresolved: "clip:unresolved", // Selection @@ -154,6 +157,9 @@ export type EditEventMap = { [EditEvent.ClipRestored]: ClipLocation; [EditEvent.ClipCopied]: ClipLocation; [EditEvent.ClipLoadFailed]: ClipLocation & { error: string; assetType: string }; + [EditEvent.ClipCaptureStarted]: { clipId: string | null; assetType: string }; + [EditEvent.ClipCaptureCompleted]: { clipId: string | null; assetType: string; frameCount: number }; + [EditEvent.ClipCaptureFailed]: { clipId: string | null; assetType: string; error: string; fallback: string }; [EditEvent.ClipUnresolved]: ClipLocation & { assetType: string; clipId: string }; // Selection diff --git a/src/main.ts b/src/main.ts index 61483299..5827087f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { type Edit as EditSchema } from "@schemas"; import { Timeline } from "@timeline/index"; -import template from "./templates/test.json"; +import template from "./templates/html5-bundle-demo.json"; import { Edit, Canvas, Controls, UIController } from "./index"; diff --git a/src/templates/html5-bundle-demo.json b/src/templates/html5-bundle-demo.json new file mode 100644 index 00000000..9b456f21 --- /dev/null +++ b/src/templates/html5-bundle-demo.json @@ -0,0 +1,67 @@ +{ + "timeline": { + "background": "#000000", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "html5", + "html": "

Project Milestones

Q3 2026 \u00b7 5 of 5 on track
", + "css": "html,body{margin:0;padding:0;width:1920px;height:1080px;overflow:hidden;font-family:system-ui,sans-serif;color:#e2e8f0;background:#000}.stage{position:relative;width:1920px;height:1080px}.bg{position:absolute;inset:0;background:radial-gradient(ellipse at 30% 30%,#0b1530 0%,#06091c 60%,#03020b 100%)}#title{position:absolute;top:140px;left:0;right:0;text-align:center;margin:0;font-size:80px;font-weight:800;letter-spacing:-2px;color:#fff;opacity:0;transform:translateY(20px)}.subtitle{position:absolute;top:240px;left:0;right:0;text-align:center;font-size:26px;color:#94a3b8;opacity:0}.track{position:absolute;top:540px;left:160px;right:160px;height:200px;display:flex;justify-content:space-between;align-items:center}.line{position:absolute;left:60px;right:60px;top:50%;height:6px;border-radius:3px;background:rgba(255,255,255,0.08);transform:translateY(-50%);overflow:hidden}.line-fill{height:100%;width:0%;background:linear-gradient(90deg,#22d3ee,#a78bfa,#f472b6);border-radius:3px;box-shadow:0 0 20px rgba(167,139,250,0.6)}.node{position:relative;width:120px;height:120px;display:flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(255,255,255,0.04);border:2px solid rgba(255,255,255,0.1);font-size:36px;font-weight:800;color:#cbd5e1;opacity:0;transform:scale(0.6);z-index:1;transition:none}.node::before{content:attr(data-label);position:absolute;bottom:-44px;left:50%;transform:translateX(-50%);font-size:22px;font-weight:700;color:#cbd5e1;white-space:nowrap}.node::after{content:attr(data-date);position:absolute;top:-44px;left:50%;transform:translateX(-50%);font-size:18px;color:#94a3b8;white-space:nowrap;font-variant-numeric:tabular-nums}.node.lit{background:rgba(34,211,238,0.18);border-color:#22d3ee;color:#fff;box-shadow:0 0 32px rgba(34,211,238,0.55)}", + "js": "const nodes=document.querySelectorAll('.node');nodes.forEach((n,i)=>n.textContent=String(i+1));const tl=gsap.timeline({paused:true});tl.to('#title',{opacity:1,y:0,duration:0.7,ease:'power3.out'},0.1).to('#subtitle',{opacity:1,duration:0.5,ease:'power2.out'},0.5).to('.node',{opacity:1,scale:1,duration:0.5,ease:'back.out(1.6)',stagger:0.18},1.0).to('#line-fill',{width:'100%',duration:2.5,ease:'power2.inOut'},1.2);nodes.forEach((n,i)=>{tl.to(n,{onStart:()=>n.classList.add('lit'),duration:0.4,ease:'power2.out'},1.4+i*0.4)});" + }, + "start": 0, + "length": 6, + "width": 1920, + "height": 1080 + }, + { + "asset": { + "type": "html5", + "html": "

Sessions by Country

Last 30 days \u00b7 top 8 markets
", + "css": "html,body{margin:0;padding:0;width:1920px;height:1080px;overflow:hidden;font-family:system-ui,sans-serif;color:#e2e8f0;background:#000}.stage{position:relative;width:1920px;height:1080px}.bg{position:absolute;inset:0;background:radial-gradient(ellipse at 20% 0%,#0f172a 0%,#080614 60%,#03020b 100%)}#title{position:absolute;top:80px;left:160px;margin:0;font-size:64px;font-weight:800;letter-spacing:-2px;color:#fff;opacity:0;transform:translateX(-20px)}.subtitle{position:absolute;top:170px;left:160px;font-size:24px;color:#94a3b8;opacity:0}#chart{position:absolute;top:280px;left:160px;width:1600px;height:640px;opacity:0}.bar{fill:url(#barGrad);filter:drop-shadow(0 4px 14px rgba(34,211,238,0.3))}.bar-label{font-size:22px;font-weight:700;fill:#fff;font-variant-numeric:tabular-nums}.bar-name{font-size:18px;font-weight:500;fill:#94a3b8;letter-spacing:1px;text-transform:uppercase}.axis-line{stroke:rgba(255,255,255,0.08);stroke-width:1}", + "js": "const data=[{c:'US',v:48230},{c:'IN',v:32140},{c:'GB',v:21670},{c:'DE',v:18450},{c:'BR',v:15890},{c:'JP',v:14210},{c:'AU',v:11630},{c:'CA',v:9870}];const W=1600,H=640,M={t:20,r:120,b:60,l:120};const iw=W-M.l-M.r,ih=H-M.t-M.b;const svg=d3.select('#chart');const x=d3.scaleBand().domain(data.map(d=>d.c)).range([0,iw]).padding(0.28);const y=d3.scaleLinear().domain([0,d3.max(data,d=>d.v)*1.05]).range([ih,0]);const g=svg.append('g').attr('transform','translate('+M.l+','+M.t+')');g.append('line').attr('class','axis-line').attr('x1',0).attr('x2',iw).attr('y1',ih).attr('y2',ih);const bars=g.selectAll('rect.bar').data(data).enter().append('rect').attr('class','bar').attr('x',d=>x(d.c)).attr('y',ih).attr('width',x.bandwidth()).attr('height',0).attr('rx',8);const labels=g.selectAll('text.bar-label').data(data).enter().append('text').attr('class','bar-label').attr('x',d=>x(d.c)+x.bandwidth()/2).attr('y',ih).attr('text-anchor','middle').text(d=>d.v.toLocaleString()).style('opacity',0);const names=g.selectAll('text.bar-name').data(data).enter().append('text').attr('class','bar-name').attr('x',d=>x(d.c)+x.bandwidth()/2).attr('y',ih+34).attr('text-anchor','middle').text(d=>d.c).style('opacity',0);const tl=gsap.timeline({paused:true});tl.to('#title',{opacity:1,x:0,duration:0.7,ease:'power3.out'},0.1).to('#subtitle',{opacity:1,duration:0.5,ease:'power2.out'},0.5).to('#chart',{opacity:1,duration:0.4,ease:'power2.out'},0.7);bars.each(function(d,i){const targetH=ih-y(d.v);tl.to(this,{attr:{y:y(d.v),height:targetH},duration:0.7,ease:'power2.out'},1.0+i*0.08)});labels.each(function(d,i){tl.to(this,{attr:{y:y(d.v)-14},style:'opacity:1',duration:0.4,ease:'power2.out'},1.4+i*0.08)});names.each(function(d,i){tl.to(this,{style:'opacity:1',duration:0.3},1.5+i*0.08)});" + }, + "start": 6, + "length": 6, + "width": 1920, + "height": 1080 + }, + { + "asset": { + "type": "html5", + "html": "

Anime.js Showcase

staggered grid \u00b7 path morph \u00b7 color cycle
", + "css": "html,body{margin:0;padding:0;width:1920px;height:1080px;overflow:hidden;font-family:system-ui,sans-serif;color:#e2e8f0;background:#000}.stage{position:relative;width:1920px;height:1080px}.bg{position:absolute;inset:0;background:radial-gradient(ellipse at 60% 100%,#1a0f2e 0%,#0c0820 50%,#03020b 100%)}#title{position:absolute;top:96px;left:0;right:0;text-align:center;margin:0;font-size:72px;font-weight:800;letter-spacing:-2px;color:#fff;opacity:0}.subtitle{position:absolute;top:200px;left:0;right:0;text-align:center;font-size:24px;color:#94a3b8;opacity:0;letter-spacing:1px}.grid{position:absolute;top:320px;left:520px;display:grid;grid-template-columns:repeat(10,80px);grid-gap:12px}.cell{width:80px;height:80px;border-radius:14px;background:#22d3ee;opacity:0;box-shadow:0 0 24px rgba(34,211,238,0.4)}#morph{position:absolute;top:360px;left:160px;filter:drop-shadow(0 0 24px rgba(244,114,182,0.5))}", + "js": "const grid=document.getElementById('grid');for(let i=0;i<100;i++){const c=document.createElement('div');c.className='cell';c.dataset.i=i;grid.appendChild(c)}const cells=document.querySelectorAll('.cell');const titleAnim=anime({targets:'#title',opacity:[0,1],translateY:[20,0],duration:700,easing:'easeOutCubic',autoplay:false});const subAnim=anime({targets:'#subtitle',opacity:[0,1],delay:500,duration:500,easing:'easeOutQuad',autoplay:false});const gridAnim=anime({targets:cells,opacity:[0,1],scale:[0.4,1],delay:anime.stagger(20,{grid:[10,10],from:'center'}),duration:600,easing:'easeOutBack',autoplay:false});const colorAnim=anime({targets:cells,backgroundColor:['#22d3ee','#a78bfa','#f472b6','#fbbf24','#22d3ee'],delay:anime.stagger(20,{grid:[10,10],from:'center'}),duration:2400,easing:'easeInOutSine',autoplay:false});const morphAnim=anime({targets:'#shape',d:[{value:'M160,40 L280,160 L160,280 L40,160 Z'},{value:'M160,20 L300,140 L240,300 L80,300 L20,140 Z'},{value:'M40,40 L280,40 L280,280 L40,280 Z'},{value:'M160,40 C280,40 280,280 160,280 C40,280 40,40 160,40 Z'}],fill:['#22d3ee','#a78bfa','#f472b6','#fbbf24'],duration:3500,easing:'easeInOutQuad',autoplay:false});const all=[titleAnim,subAnim,gridAnim,colorAnim,morphAnim];" + }, + "start": 12, + "length": 6, + "width": 1920, + "height": 1080 + }, + { + "asset": { + "type": "html5", + "html": "

Lottie SVG Renderer

Bodymovin JSON \u00b7 seekable \u00b7 alpha-clean
Rotation + scale + colour, driven by lottie-web seek
", + "css": "html,body{margin:0;padding:0;width:1920px;height:1080px;overflow:hidden;font-family:system-ui,sans-serif;color:#e2e8f0;background:#000}.stage{position:relative;width:1920px;height:1080px}.bg{position:absolute;inset:0;background:radial-gradient(ellipse at 50% 50%,#102a3a 0%,#0a1622 50%,#03070d 100%)}#title{position:absolute;top:120px;left:0;right:0;text-align:center;margin:0;font-size:72px;font-weight:800;letter-spacing:-2px;color:#fff;opacity:0}.subtitle{position:absolute;top:228px;left:0;right:0;text-align:center;font-size:24px;color:#94a3b8;opacity:0;letter-spacing:1px}.lottie-shell{position:absolute;top:340px;left:50%;transform:translateX(-50%);width:480px;height:480px;border-radius:32px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);box-shadow:0 30px 80px rgba(0,0,0,0.45),inset 0 1px 0 rgba(255,255,255,0.06);opacity:0}#lottie-anim{width:100%;height:100%}.caption{position:absolute;top:870px;left:0;right:0;text-align:center;font-size:22px;color:#94a3b8;opacity:0}", + "js": "const lottieJson={\"v\":\"5.7.0\",\"fr\":60,\"ip\":0,\"op\":360,\"w\":480,\"h\":480,\"nm\":\"demo\",\"ddd\":0,\"assets\":[],\"layers\":[{\"ddd\":0,\"ind\":1,\"ty\":4,\"nm\":\"poly\",\"sr\":1,\"ks\":{\"o\":{\"a\":0,\"k\":100},\"r\":{\"a\":1,\"k\":[{\"i\":{\"x\":[0.5],\"y\":[1]},\"o\":{\"x\":[0.5],\"y\":[0]},\"t\":0,\"s\":[0]},{\"t\":360,\"s\":[720]}]},\"p\":{\"a\":0,\"k\":[240,240,0]},\"a\":{\"a\":0,\"k\":[0,0,0]},\"s\":{\"a\":1,\"k\":[{\"i\":{\"x\":[0.42,0.42,0.42],\"y\":[1,1,1]},\"o\":{\"x\":[0.58,0.58,0.58],\"y\":[0,0,0]},\"t\":0,\"s\":[40,40,100]},{\"t\":120,\"s\":[200,200,100]},{\"t\":240,\"s\":[140,140,100]},{\"t\":360,\"s\":[200,200,100]}]}},\"ao\":0,\"shapes\":[{\"ty\":\"gr\",\"it\":[{\"ty\":\"sr\",\"sy\":1,\"d\":1,\"pt\":{\"a\":0,\"k\":6},\"p\":{\"a\":0,\"k\":[0,0]},\"r\":{\"a\":1,\"k\":[{\"t\":0,\"s\":[0]},{\"t\":360,\"s\":[180]}]},\"ir\":{\"a\":0,\"k\":50},\"is\":{\"a\":0,\"k\":0},\"or\":{\"a\":0,\"k\":120},\"os\":{\"a\":0,\"k\":0}},{\"ty\":\"fl\",\"c\":{\"a\":1,\"k\":[{\"t\":0,\"s\":[0.13,0.83,0.93,1]},{\"t\":120,\"s\":[0.66,0.55,0.98,1]},{\"t\":240,\"s\":[0.96,0.45,0.71,1]},{\"t\":360,\"s\":[0.13,0.83,0.93,1]}]},\"o\":{\"a\":0,\"k\":100}},{\"ty\":\"tr\",\"p\":{\"a\":0,\"k\":[0,0]},\"a\":{\"a\":0,\"k\":[0,0]},\"s\":{\"a\":0,\"k\":[100,100]},\"r\":{\"a\":0,\"k\":0},\"o\":{\"a\":0,\"k\":100},\"sk\":{\"a\":0,\"k\":0},\"sa\":{\"a\":0,\"k\":0}}]}],\"ip\":0,\"op\":360,\"st\":0,\"bm\":0}]};const anim=lottie.loadAnimation({container:document.getElementById('lottie-anim'),renderer:'svg',loop:false,autoplay:false,animationData:lottieJson});const tl=gsap.timeline({paused:true});tl.to('#title',{opacity:1,duration:0.6,ease:'power2.out'},0.2).to('#subtitle',{opacity:1,duration:0.5,ease:'power2.out'},0.55).to('#shell',{opacity:1,duration:0.5,ease:'power2.out'},0.7).to('#caption',{opacity:1,duration:0.4,ease:'power2.out'},1.2);window.__shotstackSeek=function(ms){anim.goToAndStop(Math.max(0,ms-700),false)};" + }, + "start": 18, + "length": 7, + "width": 1920, + "height": 1080 + } + ] + } + ] + }, + "output": { + "format": "mp4", + "size": { + "width": 1920, + "height": 1080 + }, + "fps": 30 + } +} \ No newline at end of file