diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index 32e674b2..c937ee53 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -3,22 +3,38 @@ import { type Size } from "@layouts/geometry"; import { type ResolvedClip, type ImageAsset } from "@schemas"; import * as pixi from "pixi.js"; +import { createPlaceholderGraphic } from "./placeholder-graphic"; import { Player, PlayerType } from "./player"; export class ImagePlayer extends Player { private texture: pixi.Texture | null; private sprite: pixi.Sprite | null; + private placeholder: pixi.Graphics | null; constructor(edit: Edit, clipConfiguration: ResolvedClip) { super(edit, clipConfiguration, PlayerType.Image); this.texture = null; this.sprite = null; + this.placeholder = null; } public override async load(): Promise { await super.load(); - await this.loadTexture(); + try { + await this.loadTexture(); + this.configureKeyframes(); + } catch { + this.createFallbackGraphic(); + } + } + + private createFallbackGraphic(): void { + const displaySize = this.getDisplaySize(); + this.clearPlaceholder(); + + this.placeholder = createPlaceholderGraphic(displaySize.width, displaySize.height); + this.contentContainer.addChild(this.placeholder); this.configureKeyframes(); } @@ -27,8 +43,9 @@ export class ImagePlayer extends Player { } public override dispose(): void { - super.dispose(); this.disposeTexture(); + this.clearPlaceholder(); + super.dispose(); } public override getSize(): Size { @@ -39,17 +56,31 @@ export class ImagePlayer extends Player { }; } - return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; + if (this.sprite) { + return { width: this.sprite.width, height: this.sprite.height }; + } + + return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 }; } public override getContentSize(): Size { - return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; + if (this.sprite) { + return { width: this.sprite.width, height: this.sprite.height }; + } + + return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 }; } /** Reload the image asset when asset.src changes (e.g., merge field update) */ public override async reloadAsset(): Promise { this.disposeTexture(); - await this.loadTexture(); + this.clearPlaceholder(); + + try { + await this.loadTexture(); + } catch { + this.createFallbackGraphic(); + } } private async loadTexture(): Promise { @@ -63,11 +94,12 @@ export class ImagePlayer extends Player { if (!(texture?.source instanceof pixi.ImageSource)) { if (texture) { texture.destroy(true); - // Asset unloading handled by ref counting in edit-session.unloadClipAssets() + await this.edit.assetLoader.rejectAsset(corsUrl); } throw new Error(`Invalid image source '${src}'.`); } + this.clearPlaceholder(); this.texture = this.createCroppedTexture(texture); this.sprite = new pixi.Sprite(this.texture); this.contentContainer.addChild(this.sprite); @@ -88,6 +120,14 @@ export class ImagePlayer extends Player { this.texture = null; } + private clearPlaceholder(): void { + if (this.placeholder) { + this.contentContainer.removeChild(this.placeholder); + this.placeholder.destroy(); + this.placeholder = null; + } + } + public override supportsEdgeResize(): boolean { return true; } diff --git a/src/components/canvas/players/image-to-video-player.ts b/src/components/canvas/players/image-to-video-player.ts index bc1214ac..f4a14026 100644 --- a/src/components/canvas/players/image-to-video-player.ts +++ b/src/components/canvas/players/image-to-video-player.ts @@ -84,6 +84,8 @@ export class ImageToVideoPlayer extends Player { } public override getSize(): Size { + const displaySize = this.getDisplaySize(); + if (this.clipConfiguration.width && this.clipConfiguration.height) { return { width: this.clipConfiguration.width, @@ -92,8 +94,8 @@ export class ImageToVideoPlayer extends Player { } return { - width: this.sprite?.width || this.edit.size.width, - height: this.sprite?.height || this.edit.size.height + width: this.sprite?.width || displaySize.width, + height: this.sprite?.height || displaySize.height }; } @@ -114,13 +116,6 @@ export class ImageToVideoPlayer extends Player { super.dispose(); } - private getDisplaySize(): Size { - return { - width: this.clipConfiguration.width ?? this.edit.size.width, - height: this.clipConfiguration.height ?? this.edit.size.height - }; - } - private async loadTexture(): Promise { const asset = this.clipConfiguration.asset as ImageToVideoAsset; const { src } = asset; @@ -132,6 +127,7 @@ export class ImageToVideoPlayer extends Player { if (!(texture?.source instanceof pixi.ImageSource)) { if (texture) { texture.destroy(true); + await this.edit.assetLoader.rejectAsset(corsUrl); } throw new Error(`Invalid image source '${src}'.`); } diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index b9df8c6e..02564373 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -31,10 +31,9 @@ export class LumaPlayer extends Player { const isValidLumaSource = texture?.source instanceof pixi.ImageSource || texture?.source instanceof pixi.VideoSource; if (!isValidLumaSource) { - // Clean up ref if texture loaded but has invalid source type - // (if texture was null, AssetLoader already decremented on failure) if (texture) { - this.edit.assetLoader.decrementRef(identifier); + texture.destroy(true); + await this.edit.assetLoader.rejectAsset(identifier); } throw new Error(`Invalid luma source '${lumaAsset.src}'.`); } diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index bc1c63d1..b67c49b7 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -395,6 +395,13 @@ export abstract class Player extends Entity { return this.getSize(); } + protected getDisplaySize(): Size { + return { + width: this.clipConfiguration.width ?? this.edit.size.width, + height: this.clipConfiguration.height ?? this.edit.size.height + }; + } + /** @internal */ public getContentContainer(): pixi.Container { return this.contentContainer; diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 102249fe..6c64f8a2 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -4,11 +4,13 @@ import { type Size } from "@layouts/geometry"; import { type ResolvedClip, type VideoAsset } from "@schemas"; import * as pixi from "pixi.js"; +import { createPlaceholderGraphic } from "./placeholder-graphic"; import { Player, PlayerType } from "./player"; export class VideoPlayer extends Player { private texture: pixi.Texture | null; private sprite: pixi.Sprite | null; + private placeholder: pixi.Graphics | null; private isPlaying: boolean; private volumeKeyframeBuilder: KeyframeBuilder; @@ -22,6 +24,7 @@ export class VideoPlayer extends Player { this.texture = null; this.sprite = null; + this.placeholder = null; this.isPlaying = false; const videoAsset = this.clipConfiguration.asset as VideoAsset; @@ -34,7 +37,21 @@ export class VideoPlayer extends Player { public override async load(): Promise { await super.load(); - await this.loadVideo(); + try { + await this.loadVideo(); + this.configureKeyframes(); + } catch (error) { + console.warn(`[VideoPlayer.load] FAILED clipId=${this.clipId}:`, error); + this.createFallbackGraphic(); + } + } + + private createFallbackGraphic(): void { + const { width, height } = this.getDisplaySize(); + this.clearPlaceholder(); + + this.placeholder = createPlaceholderGraphic(width, height); + this.contentContainer.addChild(this.placeholder); this.configureKeyframes(); } @@ -98,11 +115,9 @@ export class VideoPlayer extends Player { } public override dispose(): void { - try { - super.dispose(); - } finally { - this.disposeVideo(); - } + this.disposeVideo(); + this.clearPlaceholder(); + super.dispose(); } public override getSize(): Size { @@ -113,7 +128,11 @@ export class VideoPlayer extends Player { }; } - return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; + if (this.sprite) { + return { width: this.sprite.width, height: this.sprite.height }; + } + + return this.placeholder ? this.getDisplaySize() : { width: 0, height: 0 }; } public override supportsEdgeResize(): boolean { @@ -123,12 +142,20 @@ export class VideoPlayer extends Player { /** Reload the video asset when asset.src changes (e.g., merge field update) */ public override async reloadAsset(): Promise { this.skipVideoUpdate = true; - this.disposeVideo(); - await this.loadVideo(); this.isPlaying = false; this.syncTimer = 0; this.activeSyncTimer = 0; - this.skipVideoUpdate = false; + + try { + this.disposeVideo(); + this.clearPlaceholder(); + await this.loadVideo(); + } catch (error) { + console.warn(`[VideoPlayer.reloadAsset] FAILED clipId=${this.clipId}:`, error); + this.createFallbackGraphic(); + } finally { + this.skipVideoUpdate = false; + } } public override reconfigureAfterRestore(): void { @@ -157,6 +184,8 @@ export class VideoPlayer extends Player { throw new Error(`Invalid video source '${src}'.`); } + this.clearPlaceholder(); + // Fix alpha channel rendering for WebM VP9 videos (PixiJS 8 auto-detection is buggy) texture.source.alphaMode = "no-premultiply-alpha"; @@ -202,6 +231,16 @@ export class VideoPlayer extends Player { } } + private clearPlaceholder(): void { + if (!this.placeholder) { + return; + } + + this.contentContainer.removeChild(this.placeholder); + this.placeholder.destroy(); + this.placeholder = null; + } + public getVolume(): number { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } diff --git a/src/core/layout/fit-system.ts b/src/core/layout/fit-system.ts index 20f86bf8..eacf7270 100644 --- a/src/core/layout/fit-system.ts +++ b/src/core/layout/fit-system.ts @@ -42,6 +42,10 @@ export interface SpriteTransform { * - none: No scaling (returns 1) */ export function calculateFitScale(contentSize: Size, targetSize: Size, fit: FitMode): number { + if (contentSize.width === 0 || contentSize.height === 0) { + return 1; + } + const ratioX = targetSize.width / contentSize.width; const ratioY = targetSize.height / contentSize.height; diff --git a/src/core/loaders/asset-loader.ts b/src/core/loaders/asset-loader.ts index 7ef86523..e68bc5c9 100644 --- a/src/core/loaders/asset-loader.ts +++ b/src/core/loaders/asset-loader.ts @@ -42,6 +42,16 @@ export class AssetLoader { pixi.Assets.setPreferences({ crossOrigin: "anonymous" }); } + /** + * Release an asset that was loaded successfully but rejected by the caller + * (e.g. returned a non-image texture). Decrements the ref count and removes + * the stale entry from the PixiJS Assets cache to prevent GL corruption. + */ + public async rejectAsset(identifier: string): Promise { + console.warn(`[AssetLoader.rejectAsset] Rejected invalid asset "${identifier}".`); + await this.cleanupFailedLoad(identifier); + } + public async load(identifier: string, loadOptions: pixi.UnresolvedAsset): Promise { this.updateAssetLoadMetadata(identifier, "pending", 0); this.incrementRef(identifier); @@ -49,20 +59,25 @@ export class AssetLoader { try { const useSafari = await this.shouldUseSafariVideoLoader(loadOptions); - if (useSafari) { - return await this.loadVideoForSafari(identifier, loadOptions); + const resolvedAsset = useSafari + ? await this.loadVideoForSafari(identifier, loadOptions) + : await pixi.Assets.load(loadOptions, progress => { + this.updateAssetLoadMetadata(identifier, "loading", progress); + }); + + if (resolvedAsset == null) { + console.warn(`[AssetLoader.load] Empty asset returned for "${identifier}"`); + this.updateAssetLoadMetadata(identifier, "failed", 1); + await this.cleanupFailedLoad(identifier); + return null; } - const resolvedAsset = await pixi.Assets.load(loadOptions, progress => { - this.updateAssetLoadMetadata(identifier, "loading", progress); - }); - this.updateAssetLoadMetadata(identifier, "success", 1); return resolvedAsset; } catch (error) { - console.warn(`[AssetLoader] Failed to load asset "${identifier}":`, error); + console.warn(`[AssetLoader.load] Failed to load "${identifier}":`, error); this.updateAssetLoadMetadata(identifier, "failed", 1); - this.decrementRef(identifier); + await this.cleanupFailedLoad(identifier); return null; } } @@ -130,6 +145,15 @@ export class AssetLoader { return totalProgress / identifiers.length; } + private async cleanupFailedLoad(identifier: string): Promise { + this.decrementRef(identifier); + try { + await pixi.Assets.unload(identifier); + } catch { + // Ignore unload errors for already-failed assets + } + } + private extractUrl(opts: pixi.UnresolvedAsset): string | undefined { if (typeof opts === "string") return opts; const src = Array.isArray(opts.src) ? opts.src[0] : opts.src; diff --git a/src/core/ui/selection-handles.ts b/src/core/ui/selection-handles.ts index d2ec80de..b9b382d5 100644 --- a/src/core/ui/selection-handles.ts +++ b/src/core/ui/selection-handles.ts @@ -768,7 +768,15 @@ export class SelectionHandles implements CanvasOverlayRegistration { private getContentCenter(): Vector { if (!this.selectedPlayer) return { x: 0, y: 0 }; - const bounds = this.selectedPlayer.getContentContainer().getBounds(); + + const contentContainer = this.selectedPlayer.getContentContainer(); + + if (contentContainer.destroyed) { + const container = this.selectedPlayer.getContainer(); + return { x: container.x, y: container.y }; + } + + const bounds = contentContainer.getBounds(); return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 diff --git a/src/templates/lumas-asset-heavy.json b/src/templates/lumas-asset-heavy.json new file mode 100644 index 00000000..19960f76 --- /dev/null +++ b/src/templates/lumas-asset-heavy.json @@ -0,0 +1,1161 @@ +{ + "timeline": { + "fonts": [ + { + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/b2fe9b37-bf33-44ee-abc7-80132274ff84/source.ttf" + } + ], + "background": "#FFFFFF", + "tracks": [ + { + "clips": [ + { + "length": "end", + "asset": { + "type": "audio", + "src": "{{ AUDIO }}", + "volume": 1 + }, + "start": 0 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S3_Subtitle}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_3 }}", + "family": "Montserrat SemiBold", + "size": "50", + "lineHeight": 1 + }, + "width": 600, + "height": 200 + }, + "start": 13.19, + "length": "end", + "transition": { + "in": "slideUp" + }, + "offset": { + "x": 0, + "y": 0.073 + }, + "position": "center" + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S3_Body}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_3 }}", + "family": "Montserrat SemiBold", + "size": "30", + "lineHeight": 1 + }, + "width": 600, + "height": 200 + }, + "start": 13.07, + "length": "end", + "transition": { + "in": "slideUp" + }, + "position": "center" + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat SemiBold", + "size": 24, + "lineHeight": 1 + }, + "width": 800, + "height": 528, + "background": { + "color": "{{ TEXT_BACKGROUND_COLOR }}", + "opacity": 0.44, + "borderRadius": 91 + } + }, + "start": 13.04, + "length": "end", + "offset": { + "x": 0, + "y": 0.023 + }, + "position": "center", + "transition": { + "in": "slideUp" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat SemiBold", + "size": 24, + "lineHeight": 1 + }, + "width": 1080, + "height": 1920, + "background": { + "color": "{{ FONT_COLOR_3 }}", + "opacity": 0.39 + } + }, + "start": 13, + "length": "end", + "transition": { + "in": "fadeFast" + } + } + ] + }, + { + "clips": [ + { + "length": 2.66, + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/352f3f02-5665-4a9b-940d-5dc5c1fcee47/source.png" + }, + "start": 5.34, + "scale": 0.08, + "transition": { + "in": "slideUp", + "out": "fade" + } + }, + { + "length": 2.64, + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/4c41b1a9-4aa8-40ad-8f96-e4557a2dfb82/source.png" + }, + "start": 8.36, + "scale": 0.08, + "transition": { + "in": "slideUp", + "out": "fade" + } + }, + { + "length": "end", + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/260ff36d-33f8-406c-9ad3-ab123b91ef42/source.png" + }, + "start": 11, + "scale": 0.08, + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "length": 2.45, + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/4c1f1ca3-6acd-40ac-93a0-20cc590a8980/source.png" + }, + "start": 5.55, + "scale": 0.08, + "offset": { + "x": -0.327, + "y": -0.285 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + }, + "transform": { + "rotate": { + "angle": -13 + } + } + }, + { + "length": 2.78, + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/4ddf86ae-ea9c-497a-ade1-b4bc9acbf28a/source.png" + }, + "start": 8.22, + "scale": 0.08, + "offset": { + "x": -0.297, + "y": -0.248 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + }, + "transform": { + "rotate": { + "angle": -13 + } + } + }, + { + "length": "end", + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/8adf4035-e07a-48e6-9bff-2292b385d6c6/source.png" + }, + "start": 11.29, + "scale": 0.08, + "offset": { + "x": -0.257, + "y": -0.153 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + }, + "transform": { + "rotate": { + "angle": -13 + } + } + } + ] + }, + { + "clips": [ + { + "length": 2.8, + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/a1780263-d0a4-4e94-939e-77ba7defbcc3/source.png" + }, + "start": 5.2, + "scale": 0.07, + "offset": { + "x": 0.28, + "y": 0.177 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + }, + { + "length": 3, + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/4503cf03-ab2b-46cd-a32c-3b5a60b6a41e/source.png" + }, + "start": 8, + "scale": 0.07, + "offset": { + "x": 0.28, + "y": 0.177 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + }, + { + "length": "end", + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/04c8d948-dd60-493f-b02b-f928f0c14590/source.png" + }, + "start": 11.54, + "scale": 0.07, + "offset": { + "x": 0.28, + "y": 0.177 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "length": 3, + "asset": { + "type": "video", + "src": "{{ S2_VIDEO_1 }}", + "volume": 1 + }, + "start": 5, + "scale": 0.74, + "offset": { + "y": 0 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideLeft" + } + }, + { + "length": 3, + "asset": { + "type": "video", + "src": "{{ S3_VIDEO_2 }}", + "volume": 1 + }, + "start": 8, + "scale": 0.74, + "transition": { + "in": "fade", + "out": "fade" + } + }, + { + "length": 5.065957142857142, + "asset": { + "type": "video", + "src": "{{ S4_VIDEO_3 }}", + "volume": 1 + }, + "start": 11, + "scale": 0.74, + "transition": { + "in": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S2_Title}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_2 }}", + "family": "Montserrat SemiBold", + "size": "30", + "lineHeight": 1 + }, + "width": 400, + "height": 72 + }, + "start": 5, + "length": "end", + "offset": { + "x": 0, + "y": 0.403 + }, + "position": "center", + "transition": { + "in": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat SemiBold", + "size": 24, + "lineHeight": 1 + }, + "width": 950, + "height": 1704, + "background": { + "color": "{{ FONT_COLOR_3 }}", + "borderRadius": 100 + } + }, + "start": 5, + "length": "end", + "transition": { + "in": "slideLeft" + } + } + ] + }, + { + "clips": [ + { + "length": 5, + "asset": { + "type": "image", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/6b060247-b241-4c1d-aaaf-4e8750b8e02e/source.png" + }, + "start": 0, + "scale": 0.03, + "offset": { + "x": 0.4, + "y": -0.079 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S1_Folder_4}}", + "alignment": { + "horizontal": "left", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_3 }}", + "family": "DM Sans 9pt", + "size": 24, + "lineHeight": 1 + }, + "width": 200, + "height": 72 + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.359, + "y": -0.079 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S1_Folder_2}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_3 }}", + "family": "DM Sans 9pt", + "size": 24, + "lineHeight": 1 + }, + "width": 200, + "height": 72 + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.365, + "y": 0.052 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S1_Folder_3}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_3 }}", + "family": "DM Sans 9pt", + "size": 24, + "lineHeight": 1 + }, + "width": 200, + "height": 72 + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.128, + "y": -0.079 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S1_Folder_1}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_3 }}", + "family": "DM Sans 9pt", + "size": 24, + "lineHeight": 1 + }, + "width": 200, + "height": 72 + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.131, + "y": 0.052 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "First Date", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#ffffff", + "family": "DM Sans 9pt", + "size": 24, + "lineHeight": 1 + }, + "width": 200, + "height": 72 + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.131, + "y": 0.052 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S1_Event}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_2 }}", + "family": "DM Sans 9pt", + "size": "28", + "lineHeight": 1 + }, + "width": 400, + "height": 72 + }, + "start": 0, + "length": 5, + "offset": { + "x": -0.248, + "y": 0.361 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S1_Date}}", + "alignment": { + "horizontal": "left", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_2 }}", + "family": "DM Sans 9pt", + "size": "90", + "lineHeight": 1 + }, + "width": 400, + "height": 100 + }, + "start": 0, + "length": 5, + "offset": { + "x": -0.24, + "y": 0.407 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "{{S1_Day}}", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "{{ FONT_COLOR_1 }}", + "family": "DM Sans 9pt", + "size": 24, + "lineHeight": 1 + }, + "width": 200, + "height": 72 + }, + "start": 0, + "length": 5, + "offset": { + "x": -0.341, + "y": 0.442 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat ExtraBold", + "size": 46, + "lineHeight": 1 + }, + "width": 207, + "height": 207, + "background": { + "color": "{{ FOLDER_COLOR }}" + } + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.362, + "y": -0.014 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + }, + { + "asset": { + "type": "luma", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/62e3c2f1-d15b-4fe1-ae26-baa417011cfa/source.png" + }, + "start": 0, + "length": 5 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat ExtraBold", + "size": 46, + "lineHeight": 1 + }, + "width": 207, + "height": 207, + "background": { + "color": "{{ FOLDER_COLOR }}" + } + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.362, + "y": 0.12 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + }, + { + "asset": { + "type": "luma", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/d67eb9fc-5212-4ba3-92f9-b4d1aaf30293/source.png" + }, + "start": 0, + "length": 5 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat ExtraBold", + "size": 46, + "lineHeight": 1 + }, + "width": 207, + "height": 207, + "background": { + "color": "{{ FOLDER_COLOR }}" + } + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.128, + "y": -0.015 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + }, + { + "asset": { + "type": "luma", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/0901b167-9a7e-4031-a423-1f4fbb11ec2c/source.png" + }, + "start": 0, + "length": 5 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat ExtraBold", + "size": 46, + "lineHeight": 1 + }, + "width": 207, + "height": 207, + "background": { + "color": "{{ FOLDER_COLOR }}" + } + }, + "start": 0, + "length": 5, + "offset": { + "x": 0.128, + "y": 0.125 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + }, + { + "asset": { + "type": "luma", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/b373a06d-b9ed-4318-947a-13496ff2abcf/source.png" + }, + "start": 0, + "length": 5 + } + ] + }, + { + "clips": [ + { + "length": 5, + "asset": { + "type": "video", + "src": "{{ S2_VIDEO_2 }}", + "volume": 1 + }, + "start": 0, + "scale": 0.25, + "offset": { + "x": 0.244, + "y": 0.342 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + }, + { + "asset": { + "type": "luma", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/9ec88d85-4586-4f0b-a9c8-c252de7e7795/source.png" + }, + "start": 0, + "length": 5 + } + ] + }, + { + "clips": [ + { + "length": 5, + "asset": { + "type": "video", + "src": "{{ S1_Video_1 }}", + "volume": 1 + }, + "start": 0, + "scale": 0.4, + "offset": { + "x": -0.24, + "y": 0.102 + }, + "position": "center", + "transition": { + "out": "fade", + "in": "slideUp" + } + }, + { + "asset": { + "type": "luma", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/2b85a5ed-fce2-4f20-a9f6-257a00a64614/source.png" + }, + "start": 0, + "length": 5 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#000000", + "family": "Montserrat SemiBold", + "size": 24, + "lineHeight": 1 + }, + "width": 424, + "height": 265, + "background": { + "borderRadius": 55, + "color": "#ffffff" + } + }, + "start": 0, + "length": 5, + "offset": { + "x": -0.251, + "y": 0.4 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + } + ] + }, + { + "clips": [ + { + "length": 5, + "asset": { + "type": "video", + "src": "{{ S1_VIDEO_3 }}", + "volume": 0 + }, + "start": 0, + "scale": 0.35, + "offset": { + "x": -0.005, + "y": -0.3 + }, + "position": "center", + "transition": { + "in": "slideUp", + "out": "fade" + } + }, + { + "asset": { + "type": "luma", + "src": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/53a7077c-ab9a-46fb-bd70-853e67659e17/source.png" + }, + "start": 0, + "length": 5 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "text", + "text": "", + "alignment": { + "horizontal": "center", + "vertical": "center" + }, + "font": { + "color": "#dbd2d2", + "family": "Montserrat SemiBold", + "size": 24, + "lineHeight": 1 + }, + "width": 1080, + "height": 1920, + "background": { + "color": "#000000" + } + }, + "start": 0, + "length": 16, + "position": "center" + } + ] + } + ] + }, + "output": { + "format": "mp4", + "fps": 25, + "size": { + "width": 1080, + "height": 1920 + } + }, + "merge": [ + { + "find": "S1_Day", + "replace": "TUESTDAY" + }, + { + "find": "S1_Date", + "replace": "26 MAY" + }, + { + "find": "S1_Event", + "replace": "Remember this day" + }, + { + "find": "S1_Folder_1", + "replace": "First Date" + }, + { + "find": "S1_Folder_2", + "replace": "Love Throwback" + }, + { + "find": "S1_Folder_3", + "replace": "Beautiful Moment" + }, + { + "find": "S1_Folder_4", + "replace": "Favorite" + }, + { + "find": "S1_Video_1", + "replace": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/f610868b-ebc7-4081-851d-dc2759638ba5/source.mp4" + }, + { + "find": "S2_VIDEO_2", + "replace": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/42ebb9f2-7c2b-4c6b-b1cd-4e11d4366bbf/source.mp4" + }, + { + "find": "S1_VIDEO_3", + "replace": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/605afd06-0243-4403-b5e4-80538ef6da46/source.mp4" + }, + { + "find": "S2_Title", + "replace": "Romantic Holiday" + }, + { + "find": "S2_VIDEO_1", + "replace": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/1dd9fdd7-040b-4822-9ec1-1e9a594846bc/source.mp4" + }, + { + "find": "S3_VIDEO_2", + "replace": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/14ff00a9-a4cc-4f8d-9702-7cb0d464614b/source.mp4" + }, + { + "find": "S4_VIDEO_3", + "replace": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/20f5fe59-3e91-4c51-aaae-1de4b6173e48/source.mp4" + }, + { + "find": "FONT_COLOR_1", + "replace": "#ff0000" + }, + { + "find": "FONT_COLOR_2", + "replace": "#000000" + }, + { + "find": "CALENDER_COLOR", + "replace": "#efefef" + }, + { + "find": "FOLDER_COLOR", + "replace": "#eea50d" + }, + { + "find": "FONT_COLOR_3", + "replace": "#ffffff" + }, + { + "find": "TEXT_BACKGROUND_COLOR", + "replace": "#000000" + }, + { + "find": "S3_Body", + "replace": "From sunsets by the shore to laughter under the stars, our holiday gave us more than memories" + }, + { + "find": "AUDIO", + "replace": "https://templates.shotstack.io/holiday-love-story-template-romantic-memories-video/5f6210e5-73f0-4bcc-b8e4-292a94585f55/source.mp3" + }, + { + "find": "S3_Subtitle", + "replace": "Love, Promise, Gratitude." + } + ] +} diff --git a/tests/asset-loader.test.ts b/tests/asset-loader.test.ts index 2a1d61c1..21b3082b 100644 --- a/tests/asset-loader.test.ts +++ b/tests/asset-loader.test.ts @@ -8,6 +8,7 @@ */ import { AssetLoader } from "@loaders/asset-loader"; +import * as pixi from "pixi.js"; // Mock pixi.js VideoSource and Texture jest.mock("pixi.js", () => ({ @@ -28,6 +29,36 @@ jest.mock("pixi.js", () => ({ })); describe("AssetLoader", () => { + const pixiMock = pixi as unknown as { + Assets: { + load: jest.Mock; + unload: jest.Mock; + cache: { has: jest.Mock }; + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + pixiMock.Assets.unload.mockResolvedValue(undefined); + pixiMock.Assets.cache.has.mockReturnValue(false); + }); + + describe("load", () => { + it.each([null, undefined])("treats %p Pixi results as failed and cleans up state", async resolvedAsset => { + const loader = new AssetLoader(); + const url = "https://example.com/missing.png"; + const loadOptions = { src: url }; + + pixiMock.Assets.load.mockResolvedValueOnce(resolvedAsset); + + const result = await loader.load(url, loadOptions); + + expect(result).toBeNull(); + expect(loader.loadTracker.registry[url]).toEqual({ progress: 1, status: "failed" }); + expect(pixiMock.Assets.unload).toHaveBeenCalledWith(url); + }); + }); + describe("loadVideoUnique", () => { /** * Regression test for video playback glitch with overlapping clips. diff --git a/tests/fit-system.test.ts b/tests/fit-system.test.ts index fc4fd896..b736660f 100644 --- a/tests/fit-system.test.ts +++ b/tests/fit-system.test.ts @@ -98,14 +98,21 @@ describe("FitSystem", () => { const zeroWidth = { width: 0, height: 1080 }; const scale = calculateFitScale(zeroWidth, targetSize, "crop"); - expect(scale).toBe(Infinity); + expect(scale).toBe(1); }); it("handles zero content height gracefully", () => { const zeroHeight = { width: 1920, height: 0 }; const scale = calculateFitScale(zeroHeight, targetSize, "crop"); - expect(scale).toBe(Infinity); + expect(scale).toBe(1); + }); + + it("handles zero-size content gracefully", () => { + const zeroSize = { width: 0, height: 0 }; + const scale = calculateFitScale(zeroSize, targetSize, "contain"); + + expect(scale).toBe(1); }); it("defaults to crop when fit is undefined", () => { diff --git a/tests/media-player-fallback.test.ts b/tests/media-player-fallback.test.ts new file mode 100644 index 00000000..e7b0e0fa --- /dev/null +++ b/tests/media-player-fallback.test.ts @@ -0,0 +1,256 @@ +/** + * @jest-environment jsdom + */ +/* eslint-disable max-classes-per-file, @typescript-eslint/lines-between-class-members, no-underscore-dangle */ + +const mockCreatePlaceholderGraphic = jest.fn((width: number, height: number) => ({ + __placeholder: true, + width, + height, + destroy: jest.fn() +})); + +jest.mock("@canvas/players/placeholder-graphic", () => ({ + createPlaceholderGraphic: mockCreatePlaceholderGraphic +})); + +jest.mock("pixi.js", () => { + class MockPoint { + public x: number; + public y: number; + public set = jest.fn((x = 0, y = x) => { + this.x = x; + this.y = y; + }); + public copyFrom = jest.fn(({ x = 0, y = 0 }: { x?: number; y?: number }) => { + this.x = x; + this.y = y; + }); + + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + } + + class MockContainer { + public children: unknown[] = []; + public sortableChildren = false; + public eventMode: string | null = null; + public cursor: string | null = null; + public rotation = 0; + public angle = 0; + public alpha = 1; + public visible = true; + public zIndex = 0; + public mask: unknown = null; + public destroyed = false; + public position = new MockPoint(); + public scale = new MockPoint(1, 1); + public pivot = new MockPoint(); + public skew = { set: jest.fn() }; + public on = jest.fn(); + + public addChild = jest.fn((child: unknown) => { + this.children.push(child); + return child; + }); + + public removeChild = jest.fn((child: unknown) => { + this.children = this.children.filter(existing => existing !== child); + return child; + }); + + public destroy = jest.fn(() => { + this.destroyed = true; + }); + } + + class MockGraphics extends MockContainer { + public clear = jest.fn().mockReturnThis(); + public rect = jest.fn().mockReturnThis(); + public fill = jest.fn().mockReturnThis(); + public moveTo = jest.fn().mockReturnThis(); + public lineTo = jest.fn().mockReturnThis(); + public stroke = jest.fn().mockReturnThis(); + public roundRect = jest.fn().mockReturnThis(); + } + + class MockSprite extends MockContainer { + public texture: { width?: number; height?: number }; + public width: number; + public height: number; + public anchor = { set: jest.fn() }; + + constructor(texture: { width?: number; height?: number }) { + super(); + this.texture = texture; + this.width = texture?.width ?? 0; + this.height = texture?.height ?? 0; + } + } + + class MockTexture { + public source: unknown; + public width: number; + public height: number; + public destroyed = false; + + constructor({ source, frame, width = 0, height = 0 }: { source?: { width?: number; height?: number }; frame?: { width?: number; height?: number }; width?: number; height?: number } = {}) { + this.source = source; + this.width = width || frame?.width || source?.width || 0; + this.height = height || frame?.height || source?.height || 0; + } + + public destroy = jest.fn(() => { + this.destroyed = true; + }); + } + + class MockRectangle { + constructor( + public x: number, + public y: number, + public width: number, + public height: number + ) {} + } + + class MockImageSource {} + + class MockVideoSource { + public resource: unknown; + public alphaMode = "premultiply-alpha-on-upload"; + + constructor({ resource }: { resource: unknown }) { + this.resource = resource; + } + } + + return { + Container: MockContainer, + Graphics: MockGraphics, + Sprite: MockSprite, + Texture: MockTexture, + Rectangle: MockRectangle, + ImageSource: MockImageSource, + VideoSource: MockVideoSource + }; +}); + +// Import after mocks are set up. +// eslint-disable-next-line import/first +import { ImagePlayer } from "@canvas/players/image-player"; +// eslint-disable-next-line import/first +import { VideoPlayer } from "@canvas/players/video-player"; +// eslint-disable-next-line import/first +import type { ResolvedClip } from "@schemas"; +// eslint-disable-next-line import/first +import * as pixi from "pixi.js"; + +function createEdit() { + return { + size: { width: 1080, height: 1920 }, + playbackTime: 0, + isPlaying: false, + assetLoader: { + load: jest.fn(), + loadVideoUnique: jest.fn(), + rejectAsset: jest.fn() + } + }; +} + +function createImageClip(): ResolvedClip { + return { + asset: { + type: "image", + src: "https://example.com/missing.png" + }, + start: 0, + length: 5, + width: 400, + height: 300 + } as ResolvedClip; +} + +function createVideoClip(): ResolvedClip { + return { + asset: { + type: "video", + src: "https://example.com/video.mp4" + }, + start: 0, + length: 5 + } as ResolvedClip; +} + +function createVideoTexture(width: number, height: number) { + const resource = { + volume: 1, + currentTime: 0, + pause: jest.fn(), + play: jest.fn().mockResolvedValue(undefined), + load: jest.fn(), + src: "" + }; + + return new pixi.Texture({ + source: new pixi.VideoSource({ resource: resource as unknown as HTMLVideoElement }), + width, + height + } as ConstructorParameters[0]); +} + +describe("media player fallbacks", () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("uses display dimensions for a failed image placeholder", async () => { + const edit = createEdit(); + edit.assetLoader.load.mockResolvedValueOnce(null); + + const player = new ImagePlayer(edit as never, createImageClip()); + + await player.load(); + + expect(mockCreatePlaceholderGraphic).toHaveBeenCalledWith(400, 300); + expect(edit.assetLoader.rejectAsset).not.toHaveBeenCalled(); + expect(player.getSize()).toEqual({ width: 400, height: 300 }); + expect(player.getContentSize()).toEqual({ width: 400, height: 300 }); + expect(Number.isFinite(player.getScale())).toBe(true); + expect(player.getContentContainer().children).toHaveLength(1); + expect((player.getContentContainer().children[0] as { __placeholder?: boolean }).__placeholder).toBe(true); + }); + + it("replaces a failed video placeholder after a successful reload", async () => { + const edit = createEdit(); + edit.assetLoader.loadVideoUnique + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createVideoTexture(1280, 720)); + + const player = new VideoPlayer(edit as never, createVideoClip()); + + await player.load(); + + expect(mockCreatePlaceholderGraphic).toHaveBeenCalledWith(1080, 1920); + expect(player.getContentContainer().children).toHaveLength(1); + expect((player.getContentContainer().children[0] as { __placeholder?: boolean }).__placeholder).toBe(true); + expect(player.getSize()).toEqual({ width: 1080, height: 1920 }); + + await player.reloadAsset(); + + expect(player.getContentContainer().children).toHaveLength(1); + expect((player.getContentContainer().children[0] as { __placeholder?: boolean }).__placeholder).not.toBe(true); + expect(player.getSize()).toEqual({ width: 1280, height: 720 }); + expect(Number.isFinite(player.getScale())).toBe(true); + }); +});