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 ``;
+ return ``;
}
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(() => {