diff --git a/package.json b/package.json index 87b5124..efe233b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts index fe5b479..4b265f8 100644 --- a/src/components/canvas/players/html5-player.ts +++ b/src/components/canvas/players/html5-player.ts @@ -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; @@ -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 { + 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 { if (!this.iframe) return; const newHash = await this.hashAsset(); @@ -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(); } diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index e4955d4..1bd5960 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -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 { + // 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; } diff --git a/src/core/edit-document.ts b/src/core/edit-document.ts index 912d733..bce5db4 100644 --- a/src/core/edit-document.ts +++ b/src/core/edit-document.ts @@ -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(value: T): T { + return structuredClone(value); +} + // ─── EditDocument Class ─────────────────────────────────────────────────────── export class EditDocument { @@ -39,7 +48,7 @@ export class EditDocument { private clipBindings: Map> = new Map(); constructor(edit: Edit) { - this.data = structuredClone(edit); + this.data = ingest(edit); this.hydrateIds(); } @@ -168,7 +177,7 @@ export class EditDocument { updateClipById(clipId: string, updates: Partial): void { const found = this.getClipById(clipId); if (found) { - Object.assign(found.clip, updates); + Object.assign(found.clip, ingest(updates)); } } @@ -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; } @@ -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; } /** @@ -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): 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)[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; } /** @@ -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; } @@ -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 @@ -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 ────────────────────────────────────────────────────── @@ -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 ───────────────────────────────────────────────────── @@ -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 ───────────────────────────────────────────── diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 33765d8..5bad49e 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -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 }; @@ -608,8 +609,9 @@ export class Edit { return updated !== false; } - public addClip(trackIdx: number, clip: Clip): void | Promise { + public async addClip(trackIdx: number, clip: Clip): Promise { 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); @@ -939,6 +941,7 @@ export class Edit { public async addTrack(trackIdx: number, track: Track): Promise { 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)); @@ -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 { + 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 { + 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. @@ -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 { + 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 { return this.commandQueue.enqueue(async () => { @@ -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(); + } } /** diff --git a/src/core/url-validation.ts b/src/core/url-validation.ts new file mode 100644 index 0000000..87a525a --- /dev/null +++ b/src/core/url-validation.ts @@ -0,0 +1,53 @@ +import type { Clip, Track } from "@schemas"; + +/** + * Thrown by mutation methods (addClip, addTrack, addFont, setFonts) when a + * referenced asset/font URL fails preflight. + */ +export class InvalidAssetUrlError extends Error { + readonly code = "INVALID_ASSET_URL" as const; + readonly url: string; + readonly status: number | undefined; + readonly reason: string; + + constructor(url: string, status: number | undefined, reason: string) { + super(`Asset URL ${url} failed validation: ${reason}`); + this.name = "InvalidAssetUrlError"; + this.url = url; + this.status = status; + this.reason = reason; + } +} + +// ─── Extraction (schema-aware walkers) ─────────────────────────────────────── + +function pushIfHttp(urls: string[], seen: Set, value: unknown): void { + if (typeof value !== "string") return; + if (!/^https?:\/\//i.test(value)) return; + if (seen.has(value)) return; + seen.add(value); + urls.push(value); +} + +function walkClipUrls(clip: Partial | undefined, urls: string[], seen: Set): void { + if (!clip || typeof clip !== "object") return; + const { asset } = clip as { asset?: { src?: unknown } }; + if (asset) pushIfHttp(urls, seen, asset.src); +} + +export function extractClipUrls(clip: Partial): string[] { + const urls: string[] = []; + const seen = new Set(); + walkClipUrls(clip, urls, seen); + return urls; +} + +export function extractTrackUrls(track: Partial): string[] { + const urls: string[] = []; + const seen = new Set(); + const { clips } = track as { clips?: Partial[] }; + if (Array.isArray(clips)) { + for (const clip of clips) walkClipUrls(clip, urls, seen); + } + return urls; +} diff --git a/src/internal.ts b/src/internal.ts index 735a529..83415af 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -11,3 +11,6 @@ export { ShotstackEdit } from "@core/shotstack-edit"; // Re-export MergeField types for ShotstackEdit users export type { MergeField, MergeFieldService } from "@core/merge"; + +// URL preflight +export { InvalidAssetUrlError } from "@core/url-validation"; diff --git a/src/templates/emoji-test.json b/src/templates/emoji-test.json new file mode 100644 index 0000000..f42a248 --- /dev/null +++ b/src/templates/emoji-test.json @@ -0,0 +1,26 @@ +{ + "timeline": { + "background": "#0a0a0f", + "tracks": [ + { + "clips": [ + { + "asset": { + "type": "rich-text", + "text": "5 Life Hacks 🔥 Follow 💪", + "font": { "family": "Work Sans", "size": 60, "weight": 700, "color": "#ffffff", "opacity": 1 }, + "align": { "horizontal": "center", "vertical": "middle" }, + "animation": { "preset": "fadeIn", "duration": 0.6, "style": "word", "direction": "up" } + }, + "start": 0, + "length": 5, + "width": 560, + "height": 600, + "position": "center" + } + ] + } + ] + }, + "output": { "size": { "width": 608, "height": 1080 }, "format": "mp4" } +} diff --git a/tests/edit-document.test.ts b/tests/edit-document.test.ts index e0b51d9..3a14956 100644 --- a/tests/edit-document.test.ts +++ b/tests/edit-document.test.ts @@ -398,6 +398,92 @@ describe("EditDocument", () => { }); }); + // ─── Defensive Copy / Input Ownership Tests ─────────────────────────────── + // + // EditDocument must never retain or mutate a caller-provided object. Host apps + // (React/Redux/Immer) deep-freeze their state by default, so storing or mutating an + // ingested object in place throws ("Cannot add/delete property … object is not extensible"). + // Each test below fails against the pre-fix code and guards that specific regression. + + describe("defensive copy (input ownership)", () => { + // Recursively freeze, mimicking Immer's auto-freeze. + function deepFreeze(value: T): T { + if (value && typeof value === "object") { + Object.values(value as Record).forEach(v => deepFreeze(v)); + Object.freeze(value); + } + return value; + } + + it("addClip does not throw when handed a frozen clip", () => { + const doc = new EditDocument(createMinimalEdit()); + const frozen = deepFreeze(createImageClip("https://example.com/frozen.jpg", 0, 1)); + + expect(() => doc.addClip(0, frozen)).not.toThrow(); + expect(doc.getClipCountInTrack(0)).toBe(2); + }); + + it("addClip does not write the hydrated id onto the caller's clip", () => { + const doc = new EditDocument(createMinimalEdit()); + const input = createImageClip("https://example.com/x.jpg", 0, 1); + + doc.addClip(0, input); + + expect((input as { id?: string }).id).toBeUndefined(); + }); + + it("addClip stores a copy — later mutation of the caller's object does not leak in", () => { + const doc = new EditDocument(createMinimalEdit()); + const input = createImageClip("https://example.com/x.jpg", 0, 1); + + const added = doc.addClip(0, input); + expect(added).not.toBe(input); // returns the owned copy, not the caller's reference + + input.start = 999; + expect(doc.getClip(0, 1)?.start).toBe(0); + }); + + it("addTrack stores a copy — a clip in a frozen track stays mutable in the document", () => { + const doc = new EditDocument(createMinimalEdit()); + const frozenTrack = deepFreeze({ clips: [createImageClip("https://example.com/f.jpg", 0, 1)] }); + + doc.addTrack(0, frozenTrack); + + // If the frozen clip were stored by reference, updating it would throw. + expect(() => doc.updateClip(0, 0, { start: 5 })).not.toThrow(); + expect(doc.getClip(0, 0)?.start).toBe(5); + }); + + it("updateClip does not alias the caller's nested objects", () => { + const doc = new EditDocument(createEditWithTracks()); + const updates = deepFreeze({ offset: { x: 0.1, y: 0.2 } }) as Partial; + + expect(() => doc.updateClip(0, 0, updates)).not.toThrow(); + expect(doc.getClip(0, 0)?.offset).not.toBe(updates.offset); // copied, not shared + }); + + it("replaceClipProperties does not throw when the existing clip is frozen", () => { + const doc = new EditDocument(createEditWithTracks()); + const track = doc.getTrack(0); + if (track) deepFreeze(track.clips[0]); + + expect(() => + doc.replaceClipProperties(0, 0, { start: 1, length: 2, asset: { type: "image", src: "https://example.com/r.jpg" } }) + ).not.toThrow(); + expect(doc.getClip(0, 0)?.start).toBe(1); + }); + + it("setFonts stores a copy — adding a font after setting a frozen list does not throw", () => { + const doc = new EditDocument(createMinimalEdit()); + const frozen = deepFreeze([{ src: "https://example.com/a.ttf" }]); + + doc.setFonts(frozen); + + expect(() => doc.addFont("https://example.com/b.ttf")).not.toThrow(); + expect(doc.getFonts()).toHaveLength(2); + }); + }); + // ─── Clip Mutation Tests ────────────────────────────────────────────────── describe("clip mutations", () => {