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.3",
"@shotstack/shotstack-canvas": "^2.8.0",
"howler": "^2.2.4",
"mediabunny": "^1.11.2",
"opentype.js": "^1.3.4",
Expand Down
41 changes: 41 additions & 0 deletions src/components/canvas/players/html5-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export class Html5Player extends Player {
private capturedHash: string | null = null;
private lastFrameIdx: number = -1;
private playbackSprite: pixi.Sprite | null = null;
private staticSprite: pixi.Sprite | null = null;
private hasTriggeredCapture: boolean = false;
private loadingGraphic: pixi.Container | null = null;
private loadingSetProgress: ((fraction: number) => void) | null = null;
Expand Down Expand Up @@ -439,6 +440,44 @@ export class Html5Player extends Player {
}
}

/**
* Project the current playhead's frame into the Pixi scene for an off-playback capture.
* @internal
*/
public override async prepareStaticRender(): Promise<void> {
try {
if (this.disposed || !this.iframe) return;
const frames = await this.captureFrames();
if (this.disposed || !frames || frames.length === 0) return;
const idx = Math.min(Math.max(0, Math.floor(this.getPlaybackTime() * this.captureFps)), frames.length - 1);
const texture = await this.getDecodedFrame(idx);
if (this.disposed || !texture) return;
if (this.playbackSprite) {
this.playbackSprite.texture = texture;
return;
}
if (this.staticSprite) {
this.staticSprite.texture = texture;
} else {
this.staticSprite = new pixi.Sprite(texture);
this.contentContainer.addChild(this.staticSprite);
if (this.clipConfiguration.width && this.clipConfiguration.height) this.applyFixedDimensions();
}
} catch (err) {
// A capture failure must leave only this clip blank in the snapshot — never break the whole capture.
console.warn("[Html5Player] prepareStaticRender failed:", err);
}
}

/** @internal */
public override endStaticRender(): void {
if (!this.staticSprite) return;
this.contentContainer.removeChild(this.staticSprite);
// Texture is owned by the decoded-frame cache — destroy the sprite but not the texture.
this.staticSprite.destroy();
this.staticSprite = null;
}

public override async reloadAsset(): Promise<void> {
if (!this.iframe) return;
const newHash = await this.hashAsset();
Expand Down Expand Up @@ -638,6 +677,8 @@ export class Html5Player extends Player {
this.iframe = null;
this.playbackSprite?.destroy();
this.playbackSprite = null;
this.staticSprite?.destroy();
this.staticSprite = null;
this.removeLoadingGraphic();
this.disposeCapturedFrames();
}
Expand Down
13 changes: 13 additions & 0 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,19 @@ export abstract class Player extends Entity {
return this.edit.playbackTime >= this.getStart() && this.edit.playbackTime < this.getEnd();
}

/**
* Project this player's current frame into the Pixi scene for a one-off static capture.
* @internal
*/
public async prepareStaticRender(): Promise<void> {
// Default: content is already a Pixi-scene projection — nothing to do.
}

/** @internal Undo any transient scene mutation made by prepareStaticRender(). Default: no-op. */
public endStaticRender(): void {
// Default: no-op.
}

public shouldDiscardFrame(): boolean {
return this.getPlaybackTime() < Player.DiscardedFrameCount;
}
Expand Down
62 changes: 30 additions & 32 deletions src/core/edit-document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ export interface ClipLookupResult {
clipIndex: number;
}

// ─── Defensive copy ─────────────────────────────────────────────────────────────

/**
* Copy a caller-provided value before the document stores or mutates it.
*/
function ingest<T>(value: T): T {
return structuredClone(value);
}

// ─── EditDocument Class ───────────────────────────────────────────────────────

export class EditDocument {
Expand All @@ -39,7 +48,7 @@ export class EditDocument {
private clipBindings: Map<string, Map<string, MergeFieldBinding>> = new Map();

constructor(edit: Edit) {
this.data = structuredClone(edit);
this.data = ingest(edit);
this.hydrateIds();
}

Expand Down Expand Up @@ -168,7 +177,7 @@ export class EditDocument {
updateClipById(clipId: string, updates: Partial<Clip>): void {
const found = this.getClipById(clipId);
if (found) {
Object.assign(found.clip, updates);
Object.assign(found.clip, ingest(updates));
}
}

Expand Down Expand Up @@ -257,7 +266,7 @@ export class EditDocument {
* @returns The added track
*/
addTrack(index: number, track?: Track): Track {
const newTrack: Track = track ?? { clips: [] };
const newTrack: Track = track ? ingest(track) : { clips: [] };
this.data.timeline.tracks.splice(index, 0, newTrack);
return newTrack;
}
Expand Down Expand Up @@ -290,15 +299,14 @@ export class EditDocument {
throw new Error(`Track ${trackIndex} does not exist`);
}

// Hydrate with stable ID if not present
const internalClip = clip as InternalClip;
if (!internalClip.id) {
internalClip.id = crypto.randomUUID();
const owned = ingest(clip) as InternalClip;
if (!owned.id) {
owned.id = crypto.randomUUID();
}

const insertIndex = clipIndex ?? track.clips.length;
track.clips.splice(insertIndex, 0, clip);
return clip;
track.clips.splice(insertIndex, 0, owned);
return owned;
}

/**
Expand All @@ -322,35 +330,25 @@ export class EditDocument {
if (!clip) {
throw new Error(`Clip at track ${trackIndex}, index ${clipIndex} does not exist`);
}
Object.assign(clip, updates);
Object.assign(clip, ingest(updates));
}

/**
* Replace all properties on a clip while preserving its internal ID.
* Unlike updateClip (which merges via Object.assign), this deletes properties
* that exist on the current clip but not in the new state — ensuring undo
* correctly removes properties that were added during a drag.
*/
replaceClipProperties(trackIndex: number, clipIndex: number, newProperties: Partial<Clip>): void {
const clip = this.getClip(trackIndex, clipIndex) as (Clip & { id?: string }) | null;
if (!clip) {
const track = this.data.timeline.tracks[trackIndex];
const existing = track?.clips[clipIndex] as (Clip & { id?: string }) | undefined;
if (!track || !existing) {
throw new Error(`Clip at track ${trackIndex}, index ${clipIndex} does not exist`);
}

const { id } = clip;

// Delete all own properties, then assign new ones
for (const key of Object.keys(clip)) {
if (key !== "id") {
delete (clip as Record<string, unknown>)[key];
}
}
Object.assign(clip, newProperties);

// Restore internal ID (in case newProperties contained id or didn't)
const { id } = existing;
const next = ingest(newProperties) as Clip & { id?: string };
if (id) {
clip.id = id;
next.id = id;
}
track.clips[clipIndex] = next;
}

/**
Expand All @@ -362,7 +360,7 @@ export class EditDocument {
return null;
}
const oldClip = track.clips[clipIndex];
track.clips[clipIndex] = newClip;
track.clips[clipIndex] = ingest(newClip);
return oldClip;
}

Expand All @@ -389,7 +387,7 @@ export class EditDocument {

// Apply updates (e.g., new start time)
if (updates) {
Object.assign(clip, updates);
Object.assign(clip, ingest(updates));
}

// Find insertion point based on start time
Expand Down Expand Up @@ -423,7 +421,7 @@ export class EditDocument {
* Set soundtrack
*/
setSoundtrack(soundtrack: Soundtrack | undefined): void {
this.data.timeline.soundtrack = soundtrack;
this.data.timeline.soundtrack = soundtrack ? ingest(soundtrack) : undefined;
}

// ─── Font Mutations ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -460,7 +458,7 @@ export class EditDocument {
* Set all timeline fonts (replaces existing)
*/
setFonts(fonts: Array<{ src: string }>): void {
this.data.timeline.fonts = fonts;
this.data.timeline.fonts = ingest(fonts);
}

// ─── Output Mutations ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -527,7 +525,7 @@ export class EditDocument {
* Set merge field definitions
*/
setMergeFields(mergeFields: Edit["merge"]): void {
this.data.merge = mergeFields;
this.data.merge = ingest(mergeFields);
}

// ─── Clip Binding Management ─────────────────────────────────────────────
Expand Down
64 changes: 62 additions & 2 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import type { EditCommand, CommandContext, CommandResult } from "./commands/type
import { EditDocument } from "./edit-document";
import { PlayerReconciler } from "./player-reconciler";
import { resolve as resolveDocument, resolveClip as resolveClipById, type SingleClipContext } from "./resolver";
import { InvalidAssetUrlError, extractClipUrls, extractTrackUrls } from "./url-validation";

/** Internal type for clips with hydrated IDs during edit updates */
type ClipWithId = Clip & { id?: string };
Expand Down Expand Up @@ -608,8 +609,9 @@ export class Edit {
return updated !== false;
}

public addClip(trackIdx: number, clip: Clip): void | Promise<void> {
public async addClip(trackIdx: number, clip: Clip): Promise<void> {
ClipSchema.parse(clip);
await this.preflightAssetUrls(extractClipUrls(clip));
// Cast to ResolvedClip - the Player and timing resolver handle "auto"/"end" at runtime
const command = new AddClipCommand(trackIdx, clip as unknown as ResolvedClip);
return this.executeCommand(command);
Expand Down Expand Up @@ -939,6 +941,7 @@ export class Edit {

public async addTrack(trackIdx: number, track: Track): Promise<void> {
TrackSchema.parse(track);
await this.preflightAssetUrls(extractTrackUrls(track));

// Single atomic command — track + all its clips in one undo step.
await this.executeCommand(new AddTrackCommand(trackIdx, track));
Expand All @@ -947,6 +950,28 @@ export class Edit {
await this.autoLinkCaptionSources(trackIdx, track.clips);
}

/**
* Add a font to the timeline.
*/
public async addFont(src: string): Promise<void> {
await this.preflightAssetUrls([src]);
await this.commandQueue.enqueue(() => {
this.document.addFont(src);
this.emitEditChanged("addFont");
});
}

/**
* Replace timeline fonts wholesale.
*/
public async setFonts(fonts: Array<{ src: string }>): Promise<void> {
await this.preflightAssetUrls(fonts.map(f => f.src));
await this.commandQueue.enqueue(() => {
this.document.setFonts(fonts);
this.emitEditChanged("setFonts");
});
}

/**
* Auto-link rich-caption clips to the first eligible source clip.
* If an alias reference in the caption's src can't be resolved, link it automatically.
Expand Down Expand Up @@ -1157,6 +1182,29 @@ export class Edit {
return this.executeCommand(command);
}

/**
* HEAD-check every external http(s) URL in a payload
*/
private async preflightAssetUrls(urls: string[]): Promise<void> {
if (typeof document === "undefined" || urls.length === 0) return;

const results = await Promise.all(
urls.map(async url => {
try {
const resp = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(4_000), redirect: "follow" });
return { url, status: resp.status };
} catch {
return { url, status: 0 };
}
})
);
for (const { url, status } of results) {
if (status >= 400 && status < 500) {
throw new InvalidAssetUrlError(url, status, `HTTP ${status}`);
}
}
}

/** @internal */
protected executeCommand(command: EditCommand): Promise<void> {
return this.commandQueue.enqueue(async () => {
Expand Down Expand Up @@ -2077,7 +2125,19 @@ export class Edit {
requestAnimationFrame(() => resolve());
});
}
return this.canvas.captureFrame({ format: options.format, quality: options.quality });
// Project html5 frames into the scene.
const activePlayers: Player[] = [];
for (const track of this.tracks) {
for (const player of track) {
if (player.isActive()) activePlayers.push(player);
}
}
try {
await Promise.all(activePlayers.map(player => player.prepareStaticRender()));
return await this.canvas.captureFrame({ format: options.format, quality: options.quality });
} finally {
for (const player of activePlayers) player.endStaticRender();
}
}

/**
Expand Down
Loading
Loading