diff --git a/package.json b/package.json index cdf9f8a..1fb824c 100644 --- a/package.json +++ b/package.json @@ -87,8 +87,8 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { - "@shotstack/schemas": "1.7.0", - "@shotstack/shotstack-canvas": "^1.9.6", + "@shotstack/schemas": "1.8.3", + "@shotstack/shotstack-canvas": "^2.0.7", "howler": "^2.2.4", "mediabunny": "^1.11.2", "opentype.js": "^1.3.4", diff --git a/src/components/canvas/players/player-factory.ts b/src/components/canvas/players/player-factory.ts index 12ed797..2e656d8 100644 --- a/src/components/canvas/players/player-factory.ts +++ b/src/components/canvas/players/player-factory.ts @@ -8,6 +8,7 @@ import { ImagePlayer } from "./image-player"; import { ImageToVideoPlayer } from "./image-to-video-player"; import { LumaPlayer } from "./luma-player"; import type { Player } from "./player"; +import { RichCaptionPlayer } from "./rich-caption-player"; import { RichTextPlayer } from "./rich-text-player"; import { ShapePlayer } from "./shape-player"; import { SvgPlayer } from "./svg-player"; @@ -44,6 +45,8 @@ export class PlayerFactory { return new LumaPlayer(edit, clipConfiguration); case "caption": return new CaptionPlayer(edit, clipConfiguration); + case "rich-caption": + return new RichCaptionPlayer(edit, clipConfiguration); case "svg": return new SvgPlayer(edit, clipConfiguration); case "text-to-image": diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index da31cac..7520125 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -46,7 +46,8 @@ export enum PlayerType { Svg = "svg", TextToImage = "text-to-image", ImageToVideo = "image-to-video", - TextToSpeech = "text-to-speech" + TextToSpeech = "text-to-speech", + RichCaption = "rich-caption" } /** diff --git a/src/components/canvas/players/rich-caption-player.ts b/src/components/canvas/players/rich-caption-player.ts new file mode 100644 index 0000000..571e58a --- /dev/null +++ b/src/components/canvas/players/rich-caption-player.ts @@ -0,0 +1,546 @@ +import { Player, PlayerType } from "@canvas/players/player"; +import { Edit } from "@core/edit-session"; +import { parseFontFamily, resolveFontPath, getFontDisplayName } from "@core/fonts/font-config"; +import { type Size, type Vector } from "@layouts/geometry"; +import { RichCaptionAssetSchema, type RichCaptionAsset, type ResolvedClip } from "@schemas"; +import { + FontRegistry, + CaptionLayoutEngine, + generateRichCaptionFrame, + createDefaultGeneratorConfig, + createWebPainter, + parseSubtitleToWords, + CanvasRichCaptionAssetSchema, + type CanvasRichCaptionAsset, + type CaptionLayout, + type CaptionLayoutConfig, + type RichCaptionGeneratorConfig, + type WordTiming +} from "@shotstack/shotstack-canvas"; +import * as pixi from "pixi.js"; + +const SOFT_WORD_LIMIT = 1500; +const HARD_WORD_LIMIT = 5000; +const SUBTITLE_FETCH_TIMEOUT_MS = 10_000; + +const extractFontNames = (url: string): { full: string; base: string } => { + const filename = url.split("/").pop() || ""; + const withoutExtension = filename.replace(/\.(ttf|otf|woff|woff2)$/i, ""); + const baseFamily = withoutExtension.replace(/-(Bold|Light|Regular|Italic|Medium|SemiBold|Black|Thin|ExtraLight|ExtraBold|Heavy)$/i, ""); + + return { + full: withoutExtension, + base: baseFamily + }; +}; + +const isGoogleFontUrl = (url: string): boolean => url.includes("fonts.gstatic.com"); + +export class RichCaptionPlayer extends Player { + private fontRegistry: FontRegistry | null = null; + private layoutEngine: CaptionLayoutEngine | null = null; + private captionLayout: CaptionLayout | null = null; + private validatedAsset: CanvasRichCaptionAsset | null = null; + private generatorConfig: RichCaptionGeneratorConfig | null = null; + + private canvas: HTMLCanvasElement | null = null; + private painter: ReturnType | null = null; + private texture: pixi.Texture | null = null; + private sprite: pixi.Sprite | null = null; + + private words: WordTiming[] = []; + private loadComplete: boolean = false; + + private readonly fontRegistrationCache = new Map>(); + + constructor(edit: Edit, clipConfiguration: ResolvedClip) { + const { fit, ...configWithoutFit } = clipConfiguration; + super(edit, configWithoutFit, PlayerType.RichCaption); + } + + public override async load(): Promise { + await super.load(); + + const richCaptionAsset = this.clipConfiguration.asset as RichCaptionAsset; + + try { + const validationResult = RichCaptionAssetSchema.safeParse(richCaptionAsset); + if (!validationResult.success) { + this.createFallbackGraphic("Invalid caption asset"); + return; + } + + let words: WordTiming[]; + if (richCaptionAsset.src) { + words = await this.fetchAndParseSubtitle(richCaptionAsset.src); + } else { + words = (richCaptionAsset.words ?? []).map(w => ({ + text: w.text, + start: w.start, + end: w.end, + confidence: w.confidence + })); + } + + if (words.length === 0) { + this.createFallbackGraphic("No caption words found"); + return; + } + + if (words.length > HARD_WORD_LIMIT) { + this.createFallbackGraphic(`Word count (${words.length}) exceeds limit of ${HARD_WORD_LIMIT}`); + return; + } + if (words.length > SOFT_WORD_LIMIT) { + console.warn(`RichCaptionPlayer: ${words.length} words exceeds soft limit of ${SOFT_WORD_LIMIT}. Performance may degrade.`); + } + + const canvasPayload = this.buildCanvasPayload(richCaptionAsset, words); + const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); + if (!canvasValidation.success) { + console.error("Canvas caption validation failed:", canvasValidation.error?.issues ?? canvasValidation.error); + this.createFallbackGraphic("Caption validation failed"); + return; + } + this.validatedAsset = canvasValidation.data; + this.words = words; + + this.fontRegistry = await FontRegistry.getSharedInstance(); + await this.registerFonts(richCaptionAsset); + + this.layoutEngine = new CaptionLayoutEngine(this.fontRegistry); + + const { width, height } = this.getSize(); + const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); + + const canvasTextMeasurer = this.createCanvasTextMeasurer(); + if (canvasTextMeasurer) { + layoutConfig.measureTextWidth = canvasTextMeasurer; + } + + this.captionLayout = await this.layoutEngine.layoutCaption(words, layoutConfig); + + this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); + + this.canvas = document.createElement("canvas"); + this.canvas.width = width; + this.canvas.height = height; + this.painter = createWebPainter(this.canvas); + + this.renderFrameSync(0); + this.configureKeyframes(); + this.loadComplete = true; + } catch (error) { + console.error("RichCaptionPlayer load failed:", error); + this.cleanupResources(); + this.createFallbackGraphic("Failed to load caption"); + } + } + + public override update(deltaTime: number, elapsed: number): void { + super.update(deltaTime, elapsed); + + if (!this.isActive() || !this.loadComplete) { + return; + } + + const currentTimeMs = this.getPlaybackTime() * 1000; + this.renderFrameSync(currentTimeMs); + } + + public override reconfigureAfterRestore(): void { + super.reconfigureAfterRestore(); + this.reconfigure(); + } + + private async reconfigure(): Promise { + if (!this.loadComplete || !this.layoutEngine || !this.canvas || !this.painter) { + return; + } + + try { + const asset = this.clipConfiguration.asset as RichCaptionAsset; + + await this.registerFonts(asset); + + const canvasPayload = this.buildCanvasPayload(asset, this.words); + const canvasValidation = CanvasRichCaptionAssetSchema.safeParse(canvasPayload); + if (!canvasValidation.success) { + console.error("Caption reconfigure validation failed:", canvasValidation.error?.issues); + return; + } + this.validatedAsset = canvasValidation.data; + + const { width, height } = this.getSize(); + const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); + const canvasTextMeasurer = this.createCanvasTextMeasurer(); + if (canvasTextMeasurer) { + layoutConfig.measureTextWidth = canvasTextMeasurer; + } + this.captionLayout = await this.layoutEngine.layoutCaption(this.words, layoutConfig); + + this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); + + this.renderFrameSync(this.getPlaybackTime() * 1000); + } catch (error) { + console.error("RichCaptionPlayer reconfigure failed:", error); + } + } + + private renderFrameSync(timeMs: number): void { + if (!this.layoutEngine || !this.captionLayout || !this.canvas || !this.painter || !this.validatedAsset || !this.generatorConfig) { + return; + } + + try { + const { ops } = generateRichCaptionFrame(this.validatedAsset, this.captionLayout, timeMs, this.layoutEngine, this.generatorConfig); + + if (ops.length === 0 && this.sprite) { + this.sprite.visible = false; + return; + } + + const ctx = this.canvas.getContext("2d"); + if (ctx) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + this.painter.render(ops); + + const tex = pixi.Texture.from(this.canvas); + + if (!this.sprite) { + this.sprite = new pixi.Sprite(tex); + this.contentContainer.addChild(this.sprite); + } else { + this.sprite.texture = tex; + } + + if (this.texture) { + this.texture.destroy(); + } + this.texture = tex; + + this.sprite.visible = true; + } catch (err) { + console.error("Failed to render rich caption frame:", err); + } + } + + private async fetchAndParseSubtitle(src: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), SUBTITLE_FETCH_TIMEOUT_MS); + try { + const response = await fetch(src, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Subtitle fetch failed: ${response.status}`); + } + const content = await response.text(); + return parseSubtitleToWords(content); + } finally { + clearTimeout(timeoutId); + } + } + + private async registerFonts(asset: RichCaptionAsset): Promise { + if (!this.fontRegistry) return; + + const family = asset.font?.family; + if (family) { + const assetWeight = asset.font?.weight ? parseInt(String(asset.font.weight), 10) || 400 : 400; + const resolved = this.resolveFontWithWeight(family, assetWeight); + if (resolved) { + await this.registerFontFromUrl(resolved.url, resolved.baseFontFamily, resolved.fontWeight); + } + } + + const customFonts = this.buildCustomFontsFromTimeline(asset); + for (const customFont of customFonts) { + await this.registerFontFromUrl(customFont.src, customFont.family, parseInt(customFont.weight, 10) || 400); + } + } + + private async registerFontFromUrl(url: string, family: string, weight: number): Promise { + if (!this.fontRegistry) return false; + const cacheKey = `${url}|${family}|${weight}`; + const cached = this.fontRegistrationCache.get(cacheKey); + if (cached) return cached; + + const registrationPromise = (async (): Promise => { + try { + const response = await fetch(url); + if (!response.ok) return false; + const bytes = await response.arrayBuffer(); + await this.fontRegistry!.registerFromBytes(bytes, { family, weight: weight.toString() }); + + try { + const fontFace = new FontFace(family, `url(${url})`, { + weight: weight.toString() + }); + await fontFace.load(); + document.fonts.add(fontFace); + } catch { + // Browser FontFace registration is best-effort + } + + return true; + } catch { + return false; + } + })(); + + this.fontRegistrationCache.set(cacheKey, registrationPromise); + return registrationPromise; + } + + private resolveFontWithWeight(family: string, requestedWeight: number): { url: string; baseFontFamily: string; fontWeight: number } | null { + const resolvedFamily = getFontDisplayName(family); + const { baseFontFamily, fontWeight: parsedWeight } = parseFontFamily(resolvedFamily); + const effectiveWeight = parsedWeight !== 400 ? parsedWeight : requestedWeight; + + const metadataUrl = this.edit.getFontUrlByFamilyAndWeight(baseFontFamily, effectiveWeight); + if (metadataUrl) { + return { url: metadataUrl, baseFontFamily, fontWeight: effectiveWeight }; + } + + const editData = this.edit.getEdit(); + const timelineFonts = editData?.timeline?.fonts || []; + const matchingFont = timelineFonts.find(font => { + const { full, base } = extractFontNames(font.src); + const requested = family.toLowerCase(); + return full.toLowerCase() === requested || base.toLowerCase() === requested; + }); + + if (matchingFont) { + return { url: matchingFont.src, baseFontFamily, fontWeight: effectiveWeight }; + } + + const weightedFamilyName = this.buildWeightedFamilyName(baseFontFamily, effectiveWeight); + if (weightedFamilyName) { + const weightedPath = resolveFontPath(weightedFamilyName); + if (weightedPath) { + return { url: weightedPath, baseFontFamily, fontWeight: effectiveWeight }; + } + } + + const builtInPath = resolveFontPath(family); + if (builtInPath) { + return { url: builtInPath, baseFontFamily, fontWeight: effectiveWeight }; + } + + return null; + } + + private buildWeightedFamilyName(baseFontFamily: string, weight: number): string | null { + const WEIGHT_TO_MODIFIER: Record = { + 100: "Thin", + 200: "ExtraLight", + 300: "Light", + 400: "Regular", + 500: "Medium", + 600: "SemiBold", + 700: "Bold", + 800: "ExtraBold", + 900: "Black" + }; + + const modifier = WEIGHT_TO_MODIFIER[weight]; + if (!modifier || modifier === "Regular") return null; + + return `${baseFontFamily} ${modifier}`; + } + + private buildCustomFontsFromTimeline(asset: RichCaptionAsset): Array<{ src: string; family: string; weight: string }> { + const rawFamily = asset.font?.family; + if (!rawFamily) return []; + + const requestedFamily = getFontDisplayName(rawFamily); + const { baseFontFamily, fontWeight } = parseFontFamily(requestedFamily); + + const timelineFonts = this.edit.getTimelineFonts(); + const matchingFont = timelineFonts.find(font => { + const { full, base } = extractFontNames(font.src); + const requested = requestedFamily.toLowerCase(); + return full.toLowerCase() === requested || base.toLowerCase() === requested; + }); + + if (matchingFont) { + return [{ src: matchingFont.src, family: baseFontFamily || requestedFamily, weight: fontWeight.toString() }]; + } + + const fontMetadata = this.edit.getFontMetadata(); + const lowerRequested = (baseFontFamily || requestedFamily).toLowerCase(); + const nonGoogleFonts = timelineFonts.filter(font => !isGoogleFontUrl(font.src)); + + const metadataMatch = nonGoogleFonts.find(font => { + const meta = fontMetadata.get(font.src); + return meta?.baseFamilyName.toLowerCase() === lowerRequested; + }); + + if (metadataMatch) { + return [{ src: metadataMatch.src, family: baseFontFamily || requestedFamily, weight: fontWeight.toString() }]; + } + + return []; + } + + private buildCanvasPayload(asset: RichCaptionAsset, words: WordTiming[]): Record { + const { width, height } = this.getSize(); + const customFonts = this.buildCustomFontsFromTimeline(asset); + const { src, ...assetWithoutSrc } = asset; + const resolvedFamily = getFontDisplayName(asset.font?.family ?? "Roboto"); + + return { + ...assetWithoutSrc, + words: words.map(w => ({ text: w.text, start: w.start, end: w.end, confidence: w.confidence })), + width, + height, + ...(asset.font && { font: { ...asset.font, family: resolvedFamily } }), + ...(customFonts.length > 0 && { customFonts }) + }; + } + + private buildLayoutConfig(asset: CanvasRichCaptionAsset, frameWidth: number, frameHeight: number): CaptionLayoutConfig { + const font = asset.font; + const style = asset.style; + + return { + frameWidth, + frameHeight, + maxWidth: asset.maxWidth ?? 0.9, + maxLines: asset.maxLines ?? 2, + position: asset.position ?? "bottom", + fontSize: font?.size ?? 24, + fontFamily: font?.family ?? "Roboto", + fontWeight: String(font?.weight ?? "400"), + letterSpacing: style?.letterSpacing ?? 0, + wordSpacing: typeof style?.wordSpacing === "number" ? style.wordSpacing : 0, + lineHeight: style?.lineHeight ?? 1.2, + textTransform: (style?.textTransform as CaptionLayoutConfig["textTransform"]) ?? "none", + pauseThreshold: 500 + }; + } + + private createCanvasTextMeasurer(): ((text: string, font: string) => number) | undefined { + try { + const measureCanvas = document.createElement("canvas"); + const ctx = measureCanvas.getContext("2d"); + if (!ctx) return undefined; + + return (text: string, font: string): number => { + ctx.font = font; + return ctx.measureText(text).width; + }; + } catch { + return undefined; + } + } + + private createFallbackGraphic(message: string): void { + const { width, height } = this.getSize(); + + const style = new pixi.TextStyle({ + fontFamily: "Arial", + fontSize: 24, + fill: "#ffffff", + align: "center", + wordWrap: true, + wordWrapWidth: width + }); + + const fallbackText = new pixi.Text(message, style); + fallbackText.anchor.set(0.5, 0.5); + fallbackText.x = width / 2; + fallbackText.y = height / 2; + + this.contentContainer.addChild(fallbackText); + } + + private cleanupResources(): void { + if (this.fontRegistry) { + try { + this.fontRegistry.release(); + } catch (e) { + console.warn("Error releasing font registry:", e); + } + this.fontRegistry = null; + } + + this.layoutEngine = null; + this.captionLayout = null; + this.validatedAsset = null; + this.generatorConfig = null; + this.canvas = null; + this.painter = null; + } + + public override dispose(): void { + super.dispose(); + this.loadComplete = false; + + if (this.texture) { + this.texture.destroy(); + } + this.texture = null; + + if (this.sprite) { + this.sprite.destroy(); + this.sprite = null; + } + + this.cleanupResources(); + } + + public override getSize(): Size { + const editData = this.edit.getEdit(); + return { + width: this.clipConfiguration.width || editData?.output?.size?.width || this.edit.size.width, + height: this.clipConfiguration.height || editData?.output?.size?.height || this.edit.size.height + }; + } + + public override getContentSize(): Size { + return { + width: this.clipConfiguration.width || this.canvas?.width || this.edit.size.width, + height: this.clipConfiguration.height || this.canvas?.height || this.edit.size.height + }; + } + + protected override getFitScale(): number { + return 1; + } + + protected override getContainerScale(): Vector { + const scale = this.getScale(); + return { x: scale, y: scale }; + } + + protected override onDimensionsChanged(): void { + if (!this.layoutEngine || !this.validatedAsset || !this.canvas || !this.painter) return; + + const { width, height } = this.getSize(); + + this.canvas.width = width; + this.canvas.height = height; + + if (this.texture) { + this.texture.destroy(); + this.texture = null; + } + + this.generatorConfig = createDefaultGeneratorConfig(width, height, 1); + + const layoutConfig = this.buildLayoutConfig(this.validatedAsset, width, height); + const canvasTextMeasurer = this.createCanvasTextMeasurer(); + if (canvasTextMeasurer) { + layoutConfig.measureTextWidth = canvasTextMeasurer; + } + + this.layoutEngine.layoutCaption(this.words, layoutConfig).then(layout => { + this.captionLayout = layout; + this.renderFrameSync(this.getPlaybackTime() * 1000); + }); + } + + public override supportsEdgeResize(): boolean { + return true; + } +} diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 24b39f5..7044373 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -126,7 +126,7 @@ export class RichTextPlayer extends Player { ...richTextAsset, width, height, - font: richTextAsset.font ? { ...richTextAsset.font, family: resolvedFamily || "Roboto", weight: fontWeight } : undefined, + font: richTextAsset.font ? { ...richTextAsset.font, family: resolvedFamily || "Open Sans", weight: fontWeight } : undefined, stroke: richTextAsset.font?.stroke, ...(customFonts && { customFonts }) }; diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 763c368..b47042f 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -1491,7 +1491,7 @@ export class Edit { const usedFilenames = new Set(); for (const clip of this.clips) { const { asset } = clip.clipConfiguration; - if (asset && asset.type === "rich-text" && asset.font?.family) { + if (asset && (asset.type === "rich-text" || asset.type === "rich-caption") && asset.font?.family) { usedFilenames.add(asset.font.family); } } diff --git a/src/core/export/video-frame-processor.ts b/src/core/export/video-frame-processor.ts index 9d9ab8f..1ad8aef 100644 --- a/src/core/export/video-frame-processor.ts +++ b/src/core/export/video-frame-processor.ts @@ -23,7 +23,8 @@ export function isVideoPlayer(player: unknown): player is VideoPlayerExtended { const hasVideoTexture = texture?.source?.resource instanceof HTMLVideoElement; const isRichText = p.constructor?.name === "RichTextPlayer"; - if (isRichText) return false; + const isRichCaption = p.constructor?.name === "RichCaptionPlayer"; + if (isRichText || isRichCaption) return false; return hasVideoConstructor || hasVideoTexture; } @@ -43,6 +44,21 @@ export function isRichTextPlayer(player: unknown): player is RichTextPlayerExten return hasRichTextConstructor || hasRichTextAsset; } +export interface RichCaptionPlayerExtended { + clipConfiguration?: { asset?: { type?: string } }; + constructor?: { name?: string }; +} + +export function isRichCaptionPlayer(player: unknown): player is RichCaptionPlayerExtended { + if (!player || typeof player !== "object") return false; + const p = player as Record; + const hasRichCaptionConstructor = p.constructor?.name === "RichCaptionPlayer"; + const config = p["clipConfiguration"] as Record | undefined; + const asset = config?.["asset"] as Record | undefined; + const hasRichCaptionAsset = asset?.["type"] === "rich-caption"; + return hasRichCaptionConstructor || hasRichCaptionAsset; +} + export class VideoFrameProcessor { private frameCache = new SimpleLRUCache(10); private textureCache = new SimpleLRUCache(5); diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index 0cd1361..ac339e8 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -25,6 +25,7 @@ import { shapeAssetSchema, lumaAssetSchema, svgAssetSchema, + richCaptionAssetSchema, textToImageAssetSchema, imageToVideoAssetSchema, textToSpeechAssetSchema, @@ -64,6 +65,7 @@ export type ShapeAsset = components["schemas"]["ShapeAsset"]; export type LumaAsset = components["schemas"]["LumaAsset"]; export type TitleAsset = components["schemas"]["TitleAsset"]; export type SvgAsset = components["schemas"]["SvgAsset"]; +export type RichCaptionAsset = components["schemas"]["RichCaptionAsset"]; export type TextToImageAsset = components["schemas"]["TextToImageAsset"]; export type ImageToVideoAsset = components["schemas"]["ImageToVideoAsset"]; export type TextToSpeechAsset = components["schemas"]["TextToSpeechAsset"]; @@ -155,6 +157,7 @@ export { shapeAssetSchema as ShapeAssetSchema, lumaAssetSchema as LumaAssetSchema, svgAssetSchema as SvgAssetSchema, + richCaptionAssetSchema as RichCaptionAssetSchema, textToImageAssetSchema as TextToImageAssetSchema, imageToVideoAssetSchema as ImageToVideoAssetSchema, textToSpeechAssetSchema as TextToSpeechAssetSchema, diff --git a/src/core/ui/rich-caption-toolbar.ts b/src/core/ui/rich-caption-toolbar.ts new file mode 100644 index 0000000..a38f143 --- /dev/null +++ b/src/core/ui/rich-caption-toolbar.ts @@ -0,0 +1,488 @@ +import type { Edit } from "@core/edit-session"; +import type { RichCaptionAsset, ResolvedClip } from "@schemas"; + +import { RichTextToolbar } from "./rich-text-toolbar"; + +/** + * Toolbar for rich-caption clips. Extends RichTextToolbar to reuse shared + * property controls (font, style, spacing, fill, shadow) while hiding + * text-edit / animation / transition / effect controls and adding + * caption-specific panels: Layout, Word Animation, and Active Word. + */ +export class RichCaptionToolbar extends RichTextToolbar { + // Caption popup panels + private layoutPopup: HTMLDivElement | null = null; + private wordAnimPopup: HTMLDivElement | null = null; + private activeWordPopup: HTMLDivElement | null = null; + + // Layout slider refs + private maxWidthSlider: HTMLInputElement | null = null; + private maxWidthValue: HTMLSpanElement | null = null; + + // Word Animation slider refs + private wordAnimSpeedSlider: HTMLInputElement | null = null; + private wordAnimSpeedValue: HTMLSpanElement | null = null; + private wordAnimDirectionSection: HTMLDivElement | null = null; + + // Active Word control refs + private activeColorInput: HTMLInputElement | null = null; + private activeOpacitySlider: HTMLInputElement | null = null; + private activeOpacityValue: HTMLSpanElement | null = null; + private activeBgColorInput: HTMLInputElement | null = null; + private activeScaleSlider: HTMLInputElement | null = null; + private activeScaleValue: HTMLSpanElement | null = null; + + // Current slider values during drag (for final commit) + private currentMaxWidth = 0.9; + private currentWordAnimSpeed = 1; + private currentActiveOpacity = 1; + private currentActiveScale = 1; + + constructor(edit: Edit) { + super(edit); + } + + // ─── Lifecycle ───────────────────────────────────────────────────── + + override mount(parent: HTMLElement): void { + super.mount(parent); + if (!this.container) return; + + // Hide rich-text controls irrelevant to captions + for (const action of ["text-edit-toggle", "animation-toggle", "transition-toggle", "effect-toggle", "underline", "linethrough"]) { + const btn = this.container.querySelector(`[data-action="${action}"]`) as HTMLElement | null; + if (!btn) continue; + const dropdown = btn.closest(".ss-toolbar-dropdown") as HTMLElement | null; + (dropdown ?? btn).style.display = "none"; + } + + this.injectCaptionControls(); + } + + override dispose(): void { + super.dispose(); + this.layoutPopup = null; + this.wordAnimPopup = null; + this.activeWordPopup = null; + this.maxWidthSlider = null; + this.maxWidthValue = null; + this.wordAnimSpeedSlider = null; + this.wordAnimSpeedValue = null; + this.wordAnimDirectionSection = null; + this.activeColorInput = null; + this.activeOpacitySlider = null; + this.activeOpacityValue = null; + this.activeBgColorInput = null; + this.activeScaleSlider = null; + this.activeScaleValue = null; + } + + // ─── Overrides ───────────────────────────────────────────────────── + + protected override handleClick(e: MouseEvent): void { + const button = (e.target as HTMLElement).closest("button"); + if (!button) return; + + const { action } = button.dataset; + if (!action) return; + + switch (action) { + case "caption-layout-toggle": + this.togglePopup(this.layoutPopup); + return; + case "caption-word-anim-toggle": + this.togglePopup(this.wordAnimPopup); + return; + case "caption-active-word-toggle": + this.togglePopup(this.activeWordPopup); + return; + } + + super.handleClick(e); + } + + protected override getPopupList(): (HTMLElement | null)[] { + return [...super.getPopupList(), this.layoutPopup, this.wordAnimPopup, this.activeWordPopup]; + } + + protected override syncState(): void { + super.syncState(); + + const asset = this.getCaptionAsset(); + if (!asset) return; + + // ─── Layout ──────────────────────────────────────── + const position = asset.position ?? "bottom"; + this.container?.querySelectorAll("[data-caption-position]").forEach(btn => { + this.setButtonActive(btn, btn.dataset["captionPosition"] === position); + }); + + const maxWidth = asset.maxWidth ?? 0.9; + if (this.maxWidthSlider) this.maxWidthSlider.value = String(maxWidth); + if (this.maxWidthValue) this.maxWidthValue.textContent = `${Math.round(maxWidth * 100)}%`; + + const maxLines = asset.maxLines ?? 2; + this.container?.querySelectorAll("[data-caption-max-lines]").forEach(btn => { + this.setButtonActive(btn, btn.dataset["captionMaxLines"] === String(maxLines)); + }); + + // ─── Word Animation ──────────────────────────────── + const wordAnim = asset.wordAnimation; + const animStyle = wordAnim?.style ?? "karaoke"; + this.container?.querySelectorAll("[data-caption-word-style]").forEach(btn => { + this.setButtonActive(btn, btn.dataset["captionWordStyle"] === animStyle); + }); + + const speed = wordAnim?.speed ?? 1; + if (this.wordAnimSpeedSlider) this.wordAnimSpeedSlider.value = String(speed); + if (this.wordAnimSpeedValue) this.wordAnimSpeedValue.textContent = `${speed.toFixed(1)}x`; + + if (this.wordAnimDirectionSection) { + this.wordAnimDirectionSection.style.display = animStyle === "slide" ? "" : "none"; + } + + const direction = wordAnim?.direction ?? "up"; + this.container?.querySelectorAll("[data-caption-word-direction]").forEach(btn => { + this.setButtonActive(btn, btn.dataset["captionWordDirection"] === direction); + }); + + // ─── Active Word ─────────────────────────────────── + if (this.activeColorInput) this.activeColorInput.value = asset.active?.font?.color ?? "#ffff00"; + + const opacity = asset.active?.font?.opacity ?? 1; + if (this.activeOpacitySlider) this.activeOpacitySlider.value = String(opacity); + if (this.activeOpacityValue) this.activeOpacityValue.textContent = `${Math.round(opacity * 100)}%`; + + if (this.activeBgColorInput) this.activeBgColorInput.value = asset.active?.font?.background ?? "#000000"; + + const scale = asset.active?.scale ?? 1; + if (this.activeScaleSlider) this.activeScaleSlider.value = String(scale); + if (this.activeScaleValue) this.activeScaleValue.textContent = `${scale.toFixed(1)}x`; + } + + // ─── Caption Asset Helper ────────────────────────────────────────── + + private getCaptionAsset(): RichCaptionAsset | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + return clip ? (clip.asset as RichCaptionAsset) : null; + } + + // ─── DOM Injection ───────────────────────────────────────────────── + + private injectCaptionControls(): void { + if (!this.container) return; + + const fragment = document.createDocumentFragment(); + + // ── Layout Group ─────────────────────────────────── + const layoutDropdown = document.createElement("div"); + layoutDropdown.className = "ss-toolbar-dropdown"; + layoutDropdown.innerHTML = ` + +
+
+
Position
+
+ + + +
+
+
+
Max Width
+
+ + 90% +
+
+
+
Max Lines
+
+ + + + +
+
+
+ `; + this.layoutPopup = layoutDropdown.querySelector("[data-caption-layout-popup]"); + this.maxWidthSlider = layoutDropdown.querySelector("[data-caption-max-width]"); + this.maxWidthValue = layoutDropdown.querySelector("[data-caption-max-width-value]"); + fragment.appendChild(layoutDropdown); + + // ── Word Animation Group ─────────────────────────── + const wordAnimDropdown = document.createElement("div"); + wordAnimDropdown.className = "ss-toolbar-dropdown"; + wordAnimDropdown.innerHTML = ` + +
+
+
Style
+
+ + + + + + + + +
+
+
+
Speed
+
+ + 1.0x +
+
+ +
+ `; + this.wordAnimPopup = wordAnimDropdown.querySelector("[data-caption-word-anim-popup]"); + this.wordAnimSpeedSlider = wordAnimDropdown.querySelector("[data-caption-word-speed]"); + this.wordAnimSpeedValue = wordAnimDropdown.querySelector("[data-caption-word-speed-value]"); + this.wordAnimDirectionSection = wordAnimDropdown.querySelector("[data-caption-word-direction-section]"); + fragment.appendChild(wordAnimDropdown); + + // ── Active Word Group ────────────────────────────── + const activeWordDropdown = document.createElement("div"); + activeWordDropdown.className = "ss-toolbar-dropdown"; + activeWordDropdown.innerHTML = ` + +
+
+
Color
+
+ +
+
+
+
Opacity
+
+ + 100% +
+
+
+
Background
+
+ +
+
+
+
Scale
+
+ + 1.0x +
+
+
+ `; + this.activeWordPopup = activeWordDropdown.querySelector("[data-caption-active-popup]"); + this.activeColorInput = activeWordDropdown.querySelector("[data-caption-active-color]"); + this.activeOpacitySlider = activeWordDropdown.querySelector("[data-caption-active-opacity]"); + this.activeOpacityValue = activeWordDropdown.querySelector("[data-caption-active-opacity-value]"); + this.activeBgColorInput = activeWordDropdown.querySelector("[data-caption-active-bg]"); + this.activeScaleSlider = activeWordDropdown.querySelector("[data-caption-active-scale]"); + this.activeScaleValue = activeWordDropdown.querySelector("[data-caption-active-scale-value]"); + fragment.appendChild(activeWordDropdown); + + this.container.appendChild(fragment); + + this.wireLayoutControls(layoutDropdown); + this.wireWordAnimControls(wordAnimDropdown); + this.wireActiveWordControls(); + } + + // ─── Layout Wiring ───────────────────────────────────────────────── + + private wireLayoutControls(root: HTMLElement): void { + // Position buttons (discrete command) + root.querySelectorAll("[data-caption-position]").forEach(btn => { + btn.addEventListener("click", () => { + const pos = btn.dataset["captionPosition"]; + if (pos) this.updateClipProperty({ position: pos }); + }); + }); + + // Max Width slider (two-phase drag) + this.maxWidthSlider?.addEventListener("pointerdown", () => { + const state = this.captureClipState(); + if (state) this.dragManager.start("caption-max-width", state.clipId, state.initialState); + }); + this.maxWidthSlider?.addEventListener("input", e => { + const value = parseFloat((e.target as HTMLInputElement).value); + this.currentMaxWidth = value; + if (this.maxWidthValue) this.maxWidthValue.textContent = `${Math.round(value * 100)}%`; + this.liveCaptionUpdate(asset => ({ ...asset, maxWidth: value })); + }); + this.maxWidthSlider?.addEventListener("change", () => { + this.commitCaptionDrag("caption-max-width", a => { + a["maxWidth"] = this.currentMaxWidth; + }); + }); + + // Max Lines buttons (discrete command) + root.querySelectorAll("[data-caption-max-lines]").forEach(btn => { + btn.addEventListener("click", () => { + const lines = parseInt(btn.dataset["captionMaxLines"]!, 10); + if (lines) this.updateClipProperty({ maxLines: lines }); + }); + }); + } + + // ─── Word Animation Wiring ───────────────────────────────────────── + + private wireWordAnimControls(root: HTMLElement): void { + // Style buttons (discrete command) + root.querySelectorAll("[data-caption-word-style]").forEach(btn => { + btn.addEventListener("click", () => { + const style = btn.dataset["captionWordStyle"]; + if (!style) return; + const asset = this.getCaptionAsset(); + this.updateClipProperty({ wordAnimation: { ...(asset?.wordAnimation ?? {}), style } }); + }); + }); + + // Speed slider (two-phase drag) + this.wordAnimSpeedSlider?.addEventListener("pointerdown", () => { + const state = this.captureClipState(); + if (state) this.dragManager.start("caption-word-speed", state.clipId, state.initialState); + }); + this.wordAnimSpeedSlider?.addEventListener("input", e => { + const value = parseFloat((e.target as HTMLInputElement).value); + this.currentWordAnimSpeed = value; + if (this.wordAnimSpeedValue) this.wordAnimSpeedValue.textContent = `${value.toFixed(1)}x`; + this.liveCaptionUpdate(asset => ({ + ...asset, + wordAnimation: { ...(asset.wordAnimation ?? {}), speed: value } + })); + }); + this.wordAnimSpeedSlider?.addEventListener("change", () => { + this.commitCaptionDrag("caption-word-speed", a => { + const wa = { ...((a["wordAnimation"] as Record) ?? {}) }; + wa["speed"] = this.currentWordAnimSpeed; + a["wordAnimation"] = wa; + }); + }); + + // Direction buttons (discrete command) + root.querySelectorAll("[data-caption-word-direction]").forEach(btn => { + btn.addEventListener("click", () => { + const direction = btn.dataset["captionWordDirection"]; + if (!direction) return; + const asset = this.getCaptionAsset(); + this.updateClipProperty({ wordAnimation: { ...(asset?.wordAnimation ?? {}), direction } }); + }); + }); + } + + // ─── Active Word Wiring ──────────────────────────────────────────── + + private wireActiveWordControls(): void { + // Color (discrete command on change) + this.activeColorInput?.addEventListener("change", () => { + const color = this.activeColorInput!.value; + const asset = this.getCaptionAsset(); + this.updateClipProperty({ + active: { ...(asset?.active ?? {}), font: { ...(asset?.active?.font ?? {}), color } } + }); + }); + + // Opacity slider (two-phase drag) + this.activeOpacitySlider?.addEventListener("pointerdown", () => { + const state = this.captureClipState(); + if (state) this.dragManager.start("caption-active-opacity", state.clipId, state.initialState); + }); + this.activeOpacitySlider?.addEventListener("input", e => { + const value = parseFloat((e.target as HTMLInputElement).value); + this.currentActiveOpacity = value; + if (this.activeOpacityValue) this.activeOpacityValue.textContent = `${Math.round(value * 100)}%`; + this.liveCaptionUpdate(asset => ({ + ...asset, + active: { ...(asset.active ?? {}), font: { ...(asset.active?.font ?? {}), opacity: value } } + })); + }); + this.activeOpacitySlider?.addEventListener("change", () => { + this.commitCaptionDrag("caption-active-opacity", a => { + const active = { ...((a["active"] as Record) ?? {}) }; + const font = { ...((active["font"] as Record) ?? {}) }; + font["opacity"] = this.currentActiveOpacity; + active["font"] = font; + a["active"] = active; + }); + }); + + // Background color (discrete command on change) + this.activeBgColorInput?.addEventListener("change", () => { + const bg = this.activeBgColorInput!.value; + const asset = this.getCaptionAsset(); + this.updateClipProperty({ + active: { ...(asset?.active ?? {}), font: { ...(asset?.active?.font ?? {}), background: bg } } + }); + }); + + // Scale slider (two-phase drag) + this.activeScaleSlider?.addEventListener("pointerdown", () => { + const state = this.captureClipState(); + if (state) this.dragManager.start("caption-active-scale", state.clipId, state.initialState); + }); + this.activeScaleSlider?.addEventListener("input", e => { + const value = parseFloat((e.target as HTMLInputElement).value); + this.currentActiveScale = value; + if (this.activeScaleValue) this.activeScaleValue.textContent = `${value.toFixed(1)}x`; + this.liveCaptionUpdate(asset => ({ + ...asset, + active: { ...(asset.active ?? {}), scale: value } + })); + }); + this.activeScaleSlider?.addEventListener("change", () => { + this.commitCaptionDrag("caption-active-scale", a => { + const active = { ...((a["active"] as Record) ?? {}) }; + active["scale"] = this.currentActiveScale; + a["active"] = active; + }); + }); + } + + // ─── Two-Phase Drag Helpers ──────────────────────────────────────── + + /** + * Live-update the caption asset during a slider drag (no undo command). + */ + private liveCaptionUpdate(mutate: (asset: RichCaptionAsset) => Record): void { + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + const asset = this.getCaptionAsset(); + if (!asset) return; + + this.edit.updateClipInDocument(clipId, { asset: mutate(asset) as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + } + + /** + * End a drag session and commit a single undo command. + */ + private commitCaptionDrag(controlId: string, applyFinal: (asset: Record) => void): void { + const session = this.dragManager.end(controlId); + if (!session) return; + + const finalClip = structuredClone(session.initialState); + if (finalClip.asset) { + applyFinal(finalClip.asset as Record); + } + this.edit.commitClipUpdate(session.clipId, session.initialState, finalClip); + } +} diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index a0d770b..dd1b855 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -68,7 +68,7 @@ export class RichTextToolbar extends BaseToolbar { /** * Per-control drag state manager. */ - private dragManager = new DragStateManager(); + protected dragManager = new DragStateManager(); private lastSyncedClipId: string | null = null; @@ -421,7 +421,7 @@ export class RichTextToolbar extends BaseToolbar { // Construct final clip state const finalClip = structuredClone(session.initialState); - if (finalClip.asset && finalClip.asset.type === "rich-text" && finalClip.asset.style) { + if (finalClip.asset && (finalClip.asset.type === "rich-text" || finalClip.asset.type === "rich-caption") && finalClip.asset.style) { finalClip.asset.style.letterSpacing = finalState.letterSpacing; finalClip.asset.style.lineHeight = finalState.lineHeight; } @@ -636,16 +636,18 @@ export class RichTextToolbar extends BaseToolbar { // Construct final clip state with actual user-selected values const finalClip = structuredClone(session.initialState); - if (finalClip.asset && finalClip.asset.type === "rich-text") { + if (finalClip.asset && (finalClip.asset.type === "rich-text" || finalClip.asset.type === "rich-caption")) { // Border: Convert opacity from percentage (0-100) to decimal (0-1) - finalClip.asset.border = { + // Cast needed: rich-caption shares border/padding/shadow at runtime via $ref + const asset = finalClip.asset as Record; + asset["border"] = { width: finalState.border.width, color: finalState.border.color, opacity: finalState.border.opacity / 100, radius: finalState.border.radius }; - finalClip.asset.padding = finalState.padding; - finalClip.asset.shadow = finalState.shadow.enabled + asset["padding"] = finalState.padding; + asset["shadow"] = finalState.shadow.enabled ? { offsetX: finalState.shadow.offsetX, offsetY: finalState.shadow.offsetY, @@ -722,7 +724,7 @@ export class RichTextToolbar extends BaseToolbar { // Build final state const finalClip = structuredClone(session.initialState); - if (finalClip.asset && finalClip.asset.type === "rich-text") { + if (finalClip.asset && (finalClip.asset.type === "rich-text" || finalClip.asset.type === "rich-caption")) { const enabled = this.backgroundColorPicker?.isEnabled() ?? false; const color = this.backgroundColorPicker?.getColor() ?? "#FFFFFF"; const opacity = this.backgroundColorPicker?.getOpacity() ?? 1; @@ -840,7 +842,7 @@ export class RichTextToolbar extends BaseToolbar { "asset.style.lineHeight": "1.2" }; - private handleClick(e: MouseEvent): void { + protected handleClick(e: MouseEvent): void { const target = e.target as HTMLElement; const button = target.closest("button"); if (!button) return; @@ -915,7 +917,7 @@ export class RichTextToolbar extends BaseToolbar { } } - private getCurrentAsset(): RichTextAsset | null { + protected getCurrentAsset(): RichTextAsset | null { const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); if (!clip) return null; return clip.asset as RichTextAsset; @@ -1349,7 +1351,7 @@ export class RichTextToolbar extends BaseToolbar { * * @returns Object with clipId and cloned initial state, or null if no clip selected */ - private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { + protected captureClipState(): { clipId: string; initialState: ResolvedClip } | null { const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; @@ -1590,7 +1592,7 @@ export class RichTextToolbar extends BaseToolbar { }); } - private updateClipProperty(assetUpdates: Record): void { + protected updateClipProperty(assetUpdates: Record): void { const updates: Partial = { asset: assetUpdates as ResolvedClip["asset"] }; this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); this.syncState(); @@ -1648,7 +1650,7 @@ export class RichTextToolbar extends BaseToolbar { } if (this.fontPreview) { - const fontFamily = asset.font?.family ?? "Roboto"; + const fontFamily = asset.font?.family ?? "Open Sans"; this.fontPreview.textContent = this.getDisplayName(fontFamily); this.fontPreview.style.fontFamily = `'${fontFamily}', sans-serif`; } diff --git a/src/core/ui/ui-controller.ts b/src/core/ui/ui-controller.ts index bc34014..8a112a0 100644 --- a/src/core/ui/ui-controller.ts +++ b/src/core/ui/ui-controller.ts @@ -9,6 +9,7 @@ import { AssetToolbar } from "./asset-toolbar"; import { CanvasToolbar } from "./canvas-toolbar"; import { ClipToolbar } from "./clip-toolbar"; import { MediaToolbar } from "./media-toolbar"; +import { RichCaptionToolbar } from "./rich-caption-toolbar"; import { RichTextToolbar } from "./rich-text-toolbar"; import { SelectionHandles } from "./selection-handles"; import { SvgToolbar } from "./svg-toolbar"; @@ -239,6 +240,7 @@ export class UIController { // Asset-specific toolbars this.registerToolbar("text", new TextToolbar(this.edit)); this.registerToolbar("rich-text", new RichTextToolbar(this.edit, { mergeFields: this.mergeFieldsEnabled })); + this.registerToolbar("rich-caption", new RichCaptionToolbar(this.edit)); this.registerToolbar("svg", new SvgToolbar(this.edit)); this.registerToolbar(["video", "image"], new MediaToolbar(this.edit, { mergeFields: this.mergeFieldsEnabled })); this.registerToolbar("audio", new MediaToolbar(this.edit, { mergeFields: this.mergeFieldsEnabled })); diff --git a/src/main.ts b/src/main.ts index 6148329..3db2a96 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { type Edit as EditSchema } from "@schemas"; import { Timeline } from "@timeline/index"; -import template from "./templates/test.json"; +import template from "./templates/caption.json"; import { Edit, Canvas, Controls, UIController } from "./index"; diff --git a/src/templates/caption-alias.json b/src/templates/caption-alias.json index 9902c40..1e6012c 100644 --- a/src/templates/caption-alias.json +++ b/src/templates/caption-alias.json @@ -5,7 +5,7 @@ "clips": [ { "asset": { - "type": "caption", + "type": "rich-caption", "src": "alias://VIDEO" }, "start": 0, diff --git a/src/templates/caption.json b/src/templates/caption.json index c0c8060..01ac82e 100644 --- a/src/templates/caption.json +++ b/src/templates/caption.json @@ -5,7 +5,7 @@ "clips": [ { "asset": { - "type": "caption", + "type": "rich-caption", "src": "https://shotstack-assets.s3.amazonaws.com/captions/transcript.srt" }, "start": 0, diff --git a/tests/rich-caption-player.test.ts b/tests/rich-caption-player.test.ts new file mode 100644 index 0000000..f4bbc0b --- /dev/null +++ b/tests/rich-caption-player.test.ts @@ -0,0 +1,801 @@ +/** + * @jest-environment jsdom + */ + +import type { Edit } from "@core/edit-session"; +import type { RichCaptionAsset, ResolvedClip } from "@schemas"; + +let mockRegisterFromBytes: jest.Mock; +let mockLayoutCaption: jest.Mock; +let mockGetVisibleWordsAtTime: jest.Mock; +let mockGetActiveWordAtTime: jest.Mock; +let mockGenerateRichCaptionFrame: jest.Mock; +let mockCreateWebPainter: jest.Mock; +let mockPainterRender: jest.Mock; +let mockParseSubtitleToWords: jest.Mock; +let mockGetSharedInstance: jest.Mock; +let mockRelease: jest.Mock; +const mockFetch = jest.fn(); + +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + return { + children, + sortableChildren: true, + cursor: "default", + eventMode: "none", + visible: true, + label: null as string | null, + parent: null as unknown, + scale: { set: jest.fn() }, + pivot: { set: jest.fn() }, + position: { set: jest.fn() }, + zIndex: 0, + angle: 0, + alpha: 1, + skew: { set: jest.fn() }, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx >= 0) children.splice(idx, 1); + return child; + }), + destroy: jest.fn(), + on: jest.fn(), + setMask: jest.fn(), + getBounds: jest.fn(() => ({ x: 0, y: 0, width: 1920, height: 1080 })) + }; + }; + + return { + Container: jest.fn().mockImplementation(createMockContainer), + Texture: { + from: jest.fn().mockImplementation(() => ({ + destroy: jest.fn(), + update: jest.fn() + })), + WHITE: {} + }, + Sprite: jest.fn().mockImplementation((texture: unknown) => ({ + texture, + anchor: { set: jest.fn() }, + position: { set: jest.fn() }, + destroy: jest.fn() + })), + Graphics: jest.fn().mockImplementation(() => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + destroy: jest.fn() + })), + Text: jest.fn().mockImplementation(() => ({ + anchor: { set: jest.fn(), x: 0 }, + position: { set: jest.fn() }, + filters: [], + text: "", + x: 0, + y: 0, + width: 0, + height: 0, + destroy: jest.fn() + })), + TextStyle: jest.fn().mockImplementation(() => ({})), + Rectangle: jest.fn().mockImplementation((x: number, y: number, w: number, h: number) => ({ x, y, width: w, height: h })), + Assets: { + load: jest.fn(), + unload: jest.fn(), + cache: { has: jest.fn().mockReturnValue(false) } + } + }; +}); + +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +jest.mock("@core/fonts/font-config", () => ({ + parseFontFamily: jest.fn().mockImplementation((family: string) => ({ + baseFontFamily: family, + fontWeight: 400 + })), + resolveFontPath: jest.fn().mockReturnValue(null), + getFontDisplayName: jest.fn().mockImplementation((family: string) => family) +})); + +jest.mock("@schemas", () => ({ + RichCaptionAssetSchema: { + safeParse: jest.fn().mockImplementation((asset: unknown) => ({ + success: true, + data: asset + })) + } +})); + +jest.mock("@shotstack/shotstack-canvas", () => { + mockPainterRender = jest.fn().mockResolvedValue(undefined); + mockRelease = jest.fn(); + mockRegisterFromBytes = jest.fn().mockResolvedValue(undefined); + mockLayoutCaption = jest.fn().mockResolvedValue({ + store: { + length: 3, + words: ["Hello", "World", "Test"], + startTimes: [0, 500, 1000], + endTimes: [400, 900, 1400], + xPositions: [100, 300, 500], + yPositions: [540, 540, 540], + widths: [120, 130, 100] + }, + groups: [{ + wordIndices: [0, 1, 2], + startTime: 0, + endTime: 1400, + lines: [{ wordIndices: [0, 1, 2], x: 100, y: 540, width: 400, height: 48 }] + }], + shapedWords: [ + { text: "Hello", width: 120, glyphs: [], isRTL: false }, + { text: "World", width: 130, glyphs: [], isRTL: false }, + { text: "Test", width: 100, glyphs: [], isRTL: false } + ] + }); + mockGetVisibleWordsAtTime = jest.fn().mockReturnValue([ + { wordIndex: 0, text: "Hello", x: 100, y: 540, width: 120, startTime: 0, endTime: 400, isRTL: false }, + { wordIndex: 1, text: "World", x: 300, y: 540, width: 130, startTime: 500, endTime: 900, isRTL: false } + ]); + mockGetActiveWordAtTime = jest.fn().mockReturnValue( + { wordIndex: 0, text: "Hello", x: 100, y: 540, width: 120, startTime: 0, endTime: 400, isRTL: false } + ); + mockGenerateRichCaptionFrame = jest.fn().mockReturnValue({ + ops: [{ op: "DrawCaptionWord", text: "Hello" }], + visibleWordCount: 1, + activeWordIndex: 0 + }); + mockCreateWebPainter = jest.fn().mockReturnValue({ render: mockPainterRender }); + mockParseSubtitleToWords = jest.fn().mockReturnValue([ + { text: "Hello", start: 0, end: 400 }, + { text: "World", start: 500, end: 900 } + ]); + mockGetSharedInstance = jest.fn().mockResolvedValue({ + registerFromBytes: mockRegisterFromBytes, + release: mockRelease, + getFace: jest.fn().mockResolvedValue(undefined) + }); + + return { + FontRegistry: { + getSharedInstance: mockGetSharedInstance + }, + CaptionLayoutEngine: jest.fn().mockImplementation(() => ({ + layoutCaption: mockLayoutCaption, + getVisibleWordsAtTime: mockGetVisibleWordsAtTime, + getActiveWordAtTime: mockGetActiveWordAtTime, + clearCache: jest.fn() + })), + generateRichCaptionFrame: (...args: unknown[]) => mockGenerateRichCaptionFrame(...args), + createDefaultGeneratorConfig: jest.fn().mockReturnValue({ + frameWidth: 1920, + frameHeight: 1080, + pixelRatio: 1 + }), + createWebPainter: (...args: unknown[]) => mockCreateWebPainter(...args), + parseSubtitleToWords: (...args: unknown[]) => mockParseSubtitleToWords(...args), + CanvasRichCaptionAssetSchema: { + safeParse: jest.fn().mockImplementation((asset: unknown) => ({ + success: true, + data: asset + })) + } + }; +}); + +// eslint-disable-next-line import/first +import { RichCaptionPlayer } from "@canvas/players/rich-caption-player"; + +function createMockEdit(overrides: Partial> = {}): Edit { + const events = { emit: jest.fn(), on: jest.fn(), off: jest.fn() }; + return { + size: { width: 1920, height: 1080 }, + playbackTime: 0, + isPlaying: false, + events, + getInternalEvents: jest.fn(() => events), + getTimelineFonts: jest.fn().mockReturnValue([]), + getFontMetadata: jest.fn().mockReturnValue(new Map()), + getFontUrlByFamilyAndWeight: jest.fn().mockReturnValue(null), + getEdit: jest.fn().mockReturnValue({ + output: { size: { width: 1920, height: 1080 } }, + timeline: { fonts: [] } + }), + ...overrides + } as unknown as Edit; +} + +function createClip(asset: RichCaptionAsset, overrides: Partial = {}): ResolvedClip { + return { + id: "clip-1", + start: 0, + length: 6, + width: 1920, + height: 1080, + asset, + ...overrides + } as unknown as ResolvedClip; +} + +function createAsset(overrides: Partial = {}): RichCaptionAsset { + return { + type: "rich-caption", + words: [ + { text: "Hello", start: 0, end: 400 }, + { text: "World", start: 500, end: 900 }, + { text: "Test", start: 1000, end: 1400 } + ], + font: { family: "Roboto", size: 48, color: "#ffffff" }, + position: "bottom", + maxWidth: 0.9, + maxLines: 2, + ...overrides + } as unknown as RichCaptionAsset; +} + +describe("RichCaptionPlayer", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetch.mockReset(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: jest.fn().mockResolvedValue("1\n00:00:00,000 --> 00:00:00,400\nHello\n\n2\n00:00:00,500 --> 00:00:00,900\nWorld"), + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(64)) + }); + global.fetch = mockFetch as unknown as typeof fetch; + + // Mock FontFace + (global as Record).FontFace = jest.fn().mockImplementation(() => ({ + load: jest.fn().mockResolvedValue(undefined) + })); + (document as Record).fonts = { + add: jest.fn() + }; + }); + + describe("Construction & Validation", () => { + it("strips fit property from clip config", () => { + const edit = createMockEdit(); + const clip = createClip(createAsset(), { fit: "cover" } as Partial); + const player = new RichCaptionPlayer(edit, clip); + // @ts-expect-error accessing private property + expect(player.clipConfiguration.fit).toBeUndefined(); + }); + + it("loads successfully with valid inline words", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + expect(mockLayoutCaption).toHaveBeenCalledTimes(1); + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(1); + }); + + it("falls back to placeholder on invalid asset", async () => { + const { RichCaptionAssetSchema } = jest.requireMock("@schemas") as { + RichCaptionAssetSchema: { safeParse: jest.Mock } + }; + RichCaptionAssetSchema.safeParse.mockReturnValueOnce({ success: false, error: new Error("invalid") }); + + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + expect(mockLayoutCaption).not.toHaveBeenCalled(); + }); + + it("rejects assets with word count exceeding hard limit", async () => { + const manyWords = Array.from({ length: 5001 }, (_, i) => ({ + text: `word${i}`, + start: i * 100, + end: i * 100 + 80 + })); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset({ words: manyWords } as Partial))); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + expect(mockLayoutCaption).not.toHaveBeenCalled(); + }); + + it("warns but proceeds for word count exceeding soft limit", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + const manyWords = Array.from({ length: 1501 }, (_, i) => ({ + text: `word${i}`, + start: i * 100, + end: i * 100 + 80 + })); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset({ words: manyWords } as Partial))); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("soft limit")); + warnSpy.mockRestore(); + }); + }); + + describe("Font Resolution", () => { + it("resolves font via metadata URL", async () => { + const edit = createMockEdit({ + getFontUrlByFamilyAndWeight: jest.fn().mockReturnValue("https://cdn.test/roboto.ttf") + }); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + expect(mockFetch).toHaveBeenCalledWith("https://cdn.test/roboto.ttf"); + }); + + it("resolves font from timeline fonts by filename", async () => { + const edit = createMockEdit({ + getTimelineFonts: jest.fn().mockReturnValue([{ src: "https://cdn.test/Roboto-Regular.ttf" }]), + getEdit: jest.fn().mockReturnValue({ + output: { size: { width: 1920, height: 1080 } }, + timeline: { fonts: [{ src: "https://cdn.test/Roboto-Regular.ttf" }] } + }) + }); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("Roboto")); + }); + + it("registers font with FontRegistry via registerFromBytes", async () => { + const edit = createMockEdit({ + getFontUrlByFamilyAndWeight: jest.fn().mockReturnValue("https://cdn.test/roboto.ttf") + }); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + expect(mockRegisterFromBytes).toHaveBeenCalledTimes(1); + expect(mockRegisterFromBytes).toHaveBeenCalledWith( + expect.any(ArrayBuffer), + expect.objectContaining({ family: "Roboto" }) + ); + }); + + it("handles font registration failure gracefully", async () => { + mockRegisterFromBytes.mockRejectedValueOnce(new Error("registration failed")); + const edit = createMockEdit({ + getFontUrlByFamilyAndWeight: jest.fn().mockReturnValue("https://cdn.test/roboto.ttf") + }); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + + // Should not throw - fallback path + await player.load(); + }); + }); + + describe("Subtitle Loading", () => { + it("fetches and parses subtitle from src URL", async () => { + const asset = createAsset({ src: "https://cdn.test/captions.srt", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + expect(mockFetch).toHaveBeenCalledWith("https://cdn.test/captions.srt", expect.objectContaining({ signal: expect.any(AbortSignal) })); + expect(mockParseSubtitleToWords).toHaveBeenCalled(); + }); + + it("handles fetch error gracefully with fallback", async () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(); + mockFetch.mockRejectedValueOnce(new Error("network error")); + + const asset = createAsset({ src: "https://cdn.test/captions.srt", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + errorSpy.mockRestore(); + }); + + it("handles 404 response gracefully", async () => { + const errorSpy = jest.spyOn(console, "error").mockImplementation(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + text: jest.fn().mockResolvedValue("Not Found") + }); + + const asset = createAsset({ src: "https://cdn.test/missing.srt", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + errorSpy.mockRestore(); + }); + }); + + describe("Rendering", () => { + it("renders first frame during load", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(1); + expect(mockPainterRender).toHaveBeenCalledTimes(1); + }); + + it("renders on every update call", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + mockGenerateRichCaptionFrame.mockClear(); + mockPainterRender.mockClear(); + + (edit as Record).playbackTime = 0.2; + player.update(0.016, 0.2); + + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(1); + }); + + it("renders on consecutive updates with different times", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + mockGenerateRichCaptionFrame.mockClear(); + + (edit as Record).playbackTime = 0.2; + player.update(0.016, 0.2); + + (edit as Record).playbackTime = 0.6; + player.update(0.016, 0.6); + + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(2); + }); + + it("renders karaoke animation on every update", async () => { + const asset = createAsset({ + wordAnimation: { style: "karaoke", speed: 1, direction: "up" } + } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + mockGenerateRichCaptionFrame.mockClear(); + + (edit as Record).playbackTime = 0.1; + player.update(0.016, 0.1); + player.update(0.016, 0.116); + + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(2); + }); + + it("renders synchronously without race conditions", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + mockGenerateRichCaptionFrame.mockClear(); + mockPainterRender.mockClear(); + + (edit as Record).playbackTime = 0.5; + player.update(0.016, 0.5); + + (edit as Record).playbackTime = 1.0; + player.update(0.016, 1.0); + + (edit as Record).playbackTime = 1.5; + player.update(0.016, 1.5); + + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(3); + expect(mockPainterRender).toHaveBeenCalledTimes(3); + }); + + it("renders correctly after seek", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + mockGenerateRichCaptionFrame.mockClear(); + + (edit as Record).playbackTime = 1.0; + player.update(0.016, 1.0); + + expect(mockGenerateRichCaptionFrame).toHaveBeenCalledTimes(1); + }); + }); + + describe("Lifecycle", () => { + it("releases FontRegistry reference on dispose", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + player.dispose(); + + expect(mockRelease).toHaveBeenCalledTimes(1); + }); + + it("destroys texture, sprite, and canvas on dispose", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + player.dispose(); + + // @ts-expect-error accessing private property + expect(player.texture).toBeNull(); + // @ts-expect-error accessing private property + expect(player.sprite).toBeNull(); + // @ts-expect-error accessing private property + expect(player.canvas).toBeNull(); + }); + + it("sets loadComplete to false on dispose", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + + player.dispose(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + }); + + it("returns clip or edit dimensions from getSize()", () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset(), { width: 1280, height: 720 })); + const size = player.getSize(); + expect(size).toEqual({ width: 1280, height: 720 }); + }); + + it("falls back to edit size when clip has no dimensions", () => { + const edit = createMockEdit(); + const clip = createClip(createAsset()); + delete (clip as Record).width; + delete (clip as Record).height; + + const player = new RichCaptionPlayer(edit, clip); + const size = player.getSize(); + expect(size).toEqual({ width: 1920, height: 1080 }); + }); + + it("supports edge resize", () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + expect(player.supportsEdgeResize()).toBe(true); + }); + + it("getContainerScale returns user-defined scale only", () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + + // @ts-expect-error accessing protected method + const scale = player.getContainerScale(); + expect(scale.x).toBe(scale.y); + }); + }); + + describe("Edge Cases", () => { + it("handles empty words array without error", async () => { + const asset = createAsset({ words: [] } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + + // Should handle empty words gracefully + await player.load(); + }); + + it("handles single word correctly", async () => { + const asset = createAsset({ + words: [{ text: "Solo", start: 0, end: 1000 }] + } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + expect(mockLayoutCaption).toHaveBeenCalledTimes(1); + }); + + it("handles word count at exact soft boundary (1500)", async () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + const words = Array.from({ length: 1500 }, (_, i) => ({ + text: `word${i}`, + start: i * 100, + end: i * 100 + 80 + })); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset({ words } as Partial))); + await player.load(); + + // Exactly 1500 should NOT warn (only > 1500) + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("soft limit")); + warnSpy.mockRestore(); + }); + + it("handles word count at exact hard boundary (5000)", async () => { + const words = Array.from({ length: 5000 }, (_, i) => ({ + text: `word${i}`, + start: i * 100, + end: i * 100 + 80 + })); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset({ words } as Partial))); + await player.load(); + + // Exactly 5000 should NOT fail (only > 5000) + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(true); + }); + + it("handles canvas validation failure", async () => { + const { CanvasRichCaptionAssetSchema } = jest.requireMock("@shotstack/shotstack-canvas") as { + CanvasRichCaptionAssetSchema: { safeParse: jest.Mock } + }; + CanvasRichCaptionAssetSchema.safeParse.mockReturnValueOnce({ + success: false, + error: new Error("canvas validation failed") + }); + + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + }); + + it("does not render when not active", async () => { + const edit = createMockEdit(); + (edit as Record).playbackTime = -1; + + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + mockGenerateRichCaptionFrame.mockClear(); + + player.update(0.016, -1); + + expect(mockGenerateRichCaptionFrame).not.toHaveBeenCalled(); + }); + + it("does not render before load completes", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + + // Don't call load - update should be a no-op + (edit as Record).playbackTime = 0.5; + player.update(0.016, 0.5); + + expect(mockGenerateRichCaptionFrame).not.toHaveBeenCalled(); + }); + + it("shows fallback for empty words array", async () => { + const asset = createAsset({ words: [] } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + expect(mockLayoutCaption).not.toHaveBeenCalled(); + }); + + it("shows fallback when SRT returns empty words", async () => { + mockParseSubtitleToWords.mockReturnValueOnce([]); + const asset = createAsset({ src: "https://cdn.test/empty.srt", words: undefined } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // @ts-expect-error accessing private property + expect(player.loadComplete).toBe(false); + expect(mockLayoutCaption).not.toHaveBeenCalled(); + }); + }); + + describe("Texture Reuse", () => { + it("creates new texture each frame for pixi v8 compatibility", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + const pixi = jest.requireMock("pixi.js") as { Texture: { from: jest.Mock } }; + const fromCallCount = pixi.Texture.from.mock.calls.length; + + (edit as Record).playbackTime = 0.2; + player.update(0.016, 0.2); + + (edit as Record).playbackTime = 0.4; + player.update(0.016, 0.4); + + + expect(pixi.Texture.from.mock.calls.length).toBeGreaterThan(fromCallCount); + }); + + it("hides sprite when ops are empty", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + mockGenerateRichCaptionFrame.mockReturnValueOnce({ + ops: [], + visibleWordCount: 0, + activeWordIndex: -1 + }); + + (edit as Record).playbackTime = 5.0; + player.update(0.016, 5.0); + + // @ts-expect-error accessing private property + if (player.sprite) { + // @ts-expect-error accessing private property + expect(player.sprite.visible).toBe(false); + } + }); + }); + + describe("Dimensions Changed", () => { + it("supports edge resize", () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + expect(player.supportsEdgeResize()).toBe(true); + }); + + it("re-layouts on dimensions changed", async () => { + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(createAsset())); + await player.load(); + + const layoutCallsBefore = mockLayoutCaption.mock.calls.length; + + // Trigger dimension change + // @ts-expect-error accessing protected method + player.onDimensionsChanged(); + + // Wait for async layout + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(mockLayoutCaption.mock.calls.length).toBe(layoutCallsBefore + 1); + }); + }); + + describe("Google Font Resolution", () => { + it("resolves Google Font hash via getFontDisplayName", async () => { + const { parseFontFamily: mockParseFontFamily } = jest.requireMock("@core/fonts/font-config") as { + parseFontFamily: jest.Mock + }; + + const asset = createAsset({ + font: { family: "mem8YaGs126MiZpBA-U1UpcaXcl0Aw", size: 48, color: "#ffffff" } + } as Partial); + const edit = createMockEdit(); + const player = new RichCaptionPlayer(edit, createClip(asset)); + await player.load(); + + // parseFontFamily should be called (it's used in font resolution) + expect(mockParseFontFamily).toHaveBeenCalled(); + }); + }); +}); diff --git a/vite.config.internal.ts b/vite.config.internal.ts index 3373043..1475fd3 100644 --- a/vite.config.internal.ts +++ b/vite.config.internal.ts @@ -75,6 +75,7 @@ export default defineConfig({ rollupOptions: { external: id => { if (id === "pixi.js" || id.startsWith("pixi.js/")) return true; + if (id.startsWith("@napi-rs/")) return true; return ["harfbuzzjs", "opentype.js", "howler"].includes(id); }, output: { diff --git a/vite.config.ts b/vite.config.ts index 537e69f..b66968e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -56,6 +56,7 @@ export default defineConfig({ rollupOptions: { external: id => { if (id === "pixi.js" || id.startsWith("pixi.js/")) return true; + if (id.startsWith("@napi-rs/")) return true; return ["harfbuzzjs", "opentype.js", "howler"].includes(id); }, output: {