diff --git a/package.json b/package.json index 605e58a..87b5124 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ }, "dependencies": { "@shotstack/schemas": "1.11.0", - "@shotstack/shotstack-canvas": "^2.7.2", + "@shotstack/shotstack-canvas": "^2.7.3", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index bea35fd..fe5b479 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -3,7 +3,13 @@ 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 { + Html5AssetSchema, + composeHtml5IframeSrcdoc, + computeHtml5FrameCount, + detectHtml5DurationWithRetry, + type Html5Asset +} from "@shotstack/shotstack-canvas"; import * as pixi from "pixi.js"; import { computeHtml5CacheKey, html5CacheGet, html5CachePut } from "./html5-cache"; @@ -152,15 +158,14 @@ export class Html5Player extends Player { } /** - * Returns the animation duration in seconds, or null when the harness - * doesn't expose __shotstackDetectDurationMs. + * Returns the harness-reported duration in ms, or null if unavailable. */ - private detectAnimationDuration(): number | null { + 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 / 1000; + if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms; } catch (err) { console.warn("[Html5Player] __shotstackDetectDurationMs threw:", err); } @@ -214,6 +219,7 @@ export class Html5Player extends Player { } await this.mountIframe(validation.data); this.configureKeyframes(); + this.prewarmCapture(); } catch (error) { console.error("Failed to render html5 asset:", error instanceof Error ? `${error.message}\n${error.stack}` : error); this.createFallbackGraphic(); @@ -327,12 +333,17 @@ export class Html5Player extends Player { 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 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, + fps: this.captureFps + }); 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; @@ -379,12 +390,13 @@ export class Html5Player extends Player { const styles = Array.from(doc.querySelectorAll("style")) .map(el => ``) .join(""); + const animationOverride = ``; 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}`; + return `${styles}${animationOverride}${bodyXml}`; } private async getDecodedFrame(idx: number): Promise { @@ -439,11 +451,11 @@ export class Html5Player extends Player { this.iframe.srcdoc = composeHtml5IframeSrcdoc(this.asset); try { await waitForIframeLoad(this.iframe, undefined, true); + this.prewarmCapture(); } 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 { @@ -480,11 +492,36 @@ export class Html5Player extends Player { } } + /** + * Run capture in the background without changing UI mode. + */ + private prewarmCapture(): 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.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; + }); + return; + } + this.hasTriggeredCapture = true; this.transitionToCapturing(); const hashAtTrigger = this.contentHash; diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 682b433..1632b61 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -315,6 +315,25 @@ export class Canvas { return this.canvasRoot; } + /** + * Capture the current canvas content as a base64-encoded data URL. + */ + public async captureFrame( + options: { + format?: "png" | "jpeg" | "webp"; + quality?: number; + } = {} + ): Promise { + this.application.renderer.render(this.application.stage); + const requested = options.format ?? "png"; + const pixiFormat: "png" | "jpg" | "webp" = requested === "jpeg" ? "jpg" : requested; + return this.application.renderer.extract.base64({ + target: this.application.stage, + format: pixiFormat, + quality: options.quality ?? 0.85 + }); + } + /** * 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/edit-document.ts b/src/core/edit-document.ts index 733622f..912d733 100644 --- a/src/core/edit-document.ts +++ b/src/core/edit-document.ts @@ -616,10 +616,14 @@ export class EditDocument { /** * Export the document as raw Edit JSON (preserves "auto", "end", merge fields, aliases) */ - toJSON(): Edit { + /** + * Serialise the document to plain Edit JSON. + */ + toJSON(options?: { includeIds?: boolean }): Edit { const result = structuredClone(this.data); + const includeIds = options?.includeIds ?? false; - // Restore placeholders from document bindings before stripping IDs + // Restore placeholders from document bindings before optionally stripping IDs for (const track of result.timeline.tracks) { for (const clip of track.clips) { const clipId = (clip as InternalClip).id; @@ -631,8 +635,10 @@ export class EditDocument { } } } - // Strip internal ID (not part of Shotstack API) - delete (clip as InternalClip).id; + if (!includeIds) { + // Strip internal ID — render API ignores it; default keeps payloads clean. + delete (clip as InternalClip).id; + } } } diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 3e37ba6..33765d8 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -10,6 +10,7 @@ import { AddTrackCommand } from "@core/commands/add-track-command"; import { AddTracksCommand } from "@core/commands/add-tracks-command"; import { DeleteClipCommand } from "@core/commands/delete-clip-command"; import { DeleteTrackCommand } from "@core/commands/delete-track-command"; +import { MoveClipCommand } from "@core/commands/move-clip-command"; import { SetOutputAspectRatioCommand } from "@core/commands/set-output-aspect-ratio-command"; import { SetOutputDestinationsCommand } from "@core/commands/set-output-destinations-command"; import { SetOutputFormatCommand } from "@core/commands/set-output-format-command"; @@ -381,13 +382,81 @@ export class Edit { player.layer = this.tracks.length + 1; await this.addPlayer(this.tracks.length, player); } - public getEdit(): EditConfig { - const doc = this.document.toJSON(); + /** + * Serialise the current edit to plain JSON. + */ + public getEdit(options?: { includeIds?: boolean }): EditConfig { + const doc = this.document.toJSON(options); const mergeFields = this.mergeFieldService.toSerializedArray(); if (mergeFields.length > 0) doc.merge = mergeFields; return doc; } + // ─── ID-Based Clip Mutations (public passthroughs) ───────────────────────── + + /** + * Look up a clip by its stable ID. + * @returns The clip or null if no clip with that ID exists. + */ + public getClipById(clipId: string): Clip | null { + return this.document.getClipById(clipId)?.clip ?? null; + } + + /** + * Look up the (trackIndex, clipIndex) position of a clip by its stable ID. + */ + public getClipPositionById(clipId: string): { trackIndex: number; clipIndex: number } | null { + const found = this.document.getClipById(clipId); + if (!found) return null; + return { trackIndex: found.trackIndex, clipIndex: found.clipIndex }; + } + + /** + * Patch fields on a clip identified by stable ID. + */ + public updateClipById(clipId: string, updates: Partial): Promise { + const found = this.document.getClipById(clipId); + if (!found) { + console.warn(`updateClipById: no clip with id ${clipId}`); + return Promise.resolve(); + } + return this.updateClip(found.trackIndex, found.clipIndex, updates); + } + + /** + * Remove a clip identified by stable ID. + */ + public deleteClipById(clipId: string): Promise { + const found = this.document.getClipById(clipId); + if (!found) { + console.warn(`deleteClipById: no clip with id ${clipId}`); + return Promise.resolve(); + } + return this.deleteClip(found.trackIndex, found.clipIndex); + } + + /** + * Move a clip identified by stable ID to a different track and/or start time. + */ + public moveClipById(clipId: string, toTrackIndex: number, newStart?: Seconds): Promise { + const found = this.document.getClipById(clipId); + if (!found) { + console.warn(`moveClipById: no clip with id ${clipId}`); + return Promise.resolve(); + } + const currentStart = found.clip.start; + const start = newStart ?? (typeof currentStart === "number" ? (currentStart as Seconds) : null); + if (start === null) { + return Promise.reject( + new Error( + `moveClipById: clip ${clipId} has a smart-clip start ("${currentStart}"). Pass newStart explicitly or resolve start to a number first.` + ) + ); + } + const command = new MoveClipCommand(found.trackIndex, found.clipIndex, toTrackIndex, start); + return Promise.resolve(this.executeCommand(command)); + } + /** * Validates an edit configuration. * @internal @@ -1988,6 +2057,51 @@ export class Edit { return this.executeCommand(command); } + /** + * Capture the current canvas as a base64 data URL. + */ + public async captureFrame( + options: { + time?: number; + format?: "png" | "jpeg" | "webp"; + quality?: number; + } = {} + ): Promise { + if (!this.canvas) { + throw new Error("captureFrame: no Canvas is attached — Edit must be mounted to a viewport."); + } + if (options.time !== undefined) { + this.seek(options.time); + // One rAF lets players + reconciler reflect the new playhead time. + await new Promise(resolve => { + requestAnimationFrame(() => resolve()); + }); + } + return this.canvas.captureFrame({ format: options.format, quality: options.quality }); + } + + /** + * Atomically apply multiple output settings. + */ + public async setOutput(options: { + size?: { width: number; height: number }; + resolution?: string; + format?: string; + fps?: number; + aspectRatio?: string; + }): Promise { + if (options.size && options.resolution !== undefined) { + throw new Error( + "setOutput: `size` and `resolution` are mutually exclusive — pick one. Use `size` for custom dimensions or `resolution` for a preset (preview, mobile, sd, hd, 1080, 4k)." + ); + } + if (options.size) await this.setOutputSize(options.size.width, options.size.height); + if (options.resolution !== undefined) await this.setOutputResolution(options.resolution); + if (options.format !== undefined) await this.setOutputFormat(options.format); + if (options.fps !== undefined) await this.setOutputFps(options.fps); + if (options.aspectRatio !== undefined) await this.setOutputAspectRatio(options.aspectRatio); + } + public getOutputAspectRatio(): string | undefined { return this.outputSettings.getAspectRatio(); } diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts index 17bf1fc..88accb0 100644 --- a/src/core/player-reconciler.ts +++ b/src/core/player-reconciler.ts @@ -332,18 +332,25 @@ export class PlayerReconciler { } /** - * Update player's asset and trigger reload if src changed. + * Update player's asset and trigger reload if the asset content changed. */ private updateAsset(player: Player, newAsset: unknown): void { - const oldSrc = (player.clipConfiguration.asset as { src?: string })?.src; - const newSrc = (newAsset as { src?: string })?.src; + const oldAsset = player.clipConfiguration.asset; + const assetType = (newAsset as { type?: string })?.type; - // Update the asset // eslint-disable-next-line no-param-reassign -- Intentional player state update player.clipConfiguration.asset = newAsset as ResolvedClip["asset"]; - // If src changed, trigger async reload - if (oldSrc !== newSrc && player.reloadAsset) { + let needsReload: boolean; + if (assetType === "html5") { + needsReload = JSON.stringify(oldAsset) !== JSON.stringify(newAsset); + } else { + const oldSrc = (oldAsset as { src?: string })?.src; + const newSrc = (newAsset as { src?: string })?.src; + needsReload = oldSrc !== newSrc; + } + + if (needsReload && player.reloadAsset) { player .reloadAsset() .then(() => {