Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 49 additions & 12 deletions src/components/canvas/players/html5-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -379,12 +390,13 @@ export class Html5Player extends Player {
const styles = Array.from(doc.querySelectorAll("style"))
.map(el => `<style>${el.textContent ?? ""}</style>`)
.join("");
const animationOverride = `<style>*,*::before,*::after{animation:none!important;transition:none!important}</style>`;
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 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${bodyXml}</foreignObject></svg>`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject width="${width}" height="${height}">${styles}${animationOverride}${bodyXml}</foreignObject></svg>`;
}

private async getDecodedFrame(idx: number): Promise<pixi.Texture | null> {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/components/canvas/shotstack-canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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.
Expand Down
14 changes: 10 additions & 4 deletions src/core/edit-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
}

Expand Down
118 changes: 116 additions & 2 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Clip>): Promise<void> {
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<void> {
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<void> {
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
Expand Down Expand Up @@ -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<string> {
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<void>(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<void> {
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();
}
Expand Down
19 changes: 13 additions & 6 deletions src/core/player-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading