diff --git a/package.json b/package.json index 1c6dab8..60cd453 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ ], "license": "PolyForm Shield License 1.0.0", "engines": { - "node": ">=22 <23" + "node": ">=20 <=24" }, "repository": { "type": "git", @@ -90,7 +90,7 @@ }, "dependencies": { "@shotstack/schemas": "1.8.7", - "@shotstack/shotstack-canvas": "^2.0.13", + "@shotstack/shotstack-canvas": "^2.0.15", "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..081e8ce --- /dev/null +++ b/src/components/canvas/players/rich-caption-player.ts @@ -0,0 +1,564 @@ +import { Player, PlayerType } from "@canvas/players/player"; +import { Edit } from "@core/edit-session"; +import { parseFontFamily, resolveFontPath, getFontDisplayName } from "@core/fonts/font-config"; +import { extractFontNames, isGoogleFontUrl } from "@core/fonts/font-utils"; +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; + +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>(); + private lastRegisteredFontKey: string = ""; + private pendingLayoutId: number = 0; + + 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); + (richCaptionAsset as Record)['pauseThreshold'] = 5; + } else { + words = ((richCaptionAsset as RichCaptionAsset & { words?: WordTiming[] }).words ?? []).map((w: WordTiming) => ({ + 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.lastRegisteredFontKey = `${richCaptionAsset.font?.family ?? "Roboto"}|${richCaptionAsset.font?.weight ?? 400}`; + + 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; + + const fontKey = `${asset.font?.family ?? "Roboto"}|${asset.font?.weight ?? 400}`; + if (fontKey !== this.lastRegisteredFontKey) { + await this.registerFonts(asset); + this.lastRegisteredFontKey = fontKey; + } + + 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); + + if (!this.texture) { + this.texture = pixi.Texture.from(this.canvas); + } else { + this.texture.source.update(); + } + + if (!this.sprite) { + this.sprite = new pixi.Sprite(this.texture); + this.contentContainer.addChild(this.sprite); + } + + 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 ?? "Roboto"; + 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, bytes, { + 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 resolvedFamily = getFontDisplayName(asset.font?.family ?? "Roboto"); + + const payload: Record = { + type: asset.type, + words: words.map(w => ({ text: w.text, start: w.start, end: w.end, confidence: w.confidence })), + font: { family: resolvedFamily, ...asset.font }, + width, + height, + }; + + const optionalFields: Record = { + active: asset.active, + stroke: asset.stroke, + shadow: asset.shadow, + background: asset.background, + border: asset.border, + padding: asset.padding, + style: asset.style, + wordAnimation: asset.wordAnimation, + align: asset.align, + pauseThreshold: (asset as Record)['pauseThreshold'], + }; + + for (const [key, value] of Object.entries(optionalFields)) { + if (value !== undefined) { + payload[key] = value; + } + } + + if (customFonts.length > 0) { + payload['customFonts'] = customFonts; + } + + return payload; + } + + private buildLayoutConfig(asset: CanvasRichCaptionAsset, frameWidth: number, frameHeight: number): CaptionLayoutConfig { + const { font, style, align, padding: rawPadding } = asset; + const padding = typeof rawPadding === "number" ? rawPadding : (rawPadding?.left ?? 0); + + return { + frameWidth, + frameHeight, + availableWidth: frameWidth * 0.9, + maxLines: 2, + verticalAlign: align?.vertical ?? "bottom", + horizontalAlign: align?.horizontal ?? "center", + paddingLeft: padding, + 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: asset.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; + } + + const layoutId = ++this.pendingLayoutId; + this.layoutEngine.layoutCaption(this.words, layoutConfig).then(layout => { + if (layoutId !== this.pendingLayoutId) return; + 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 92d2235..0b3ed0f 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -2,6 +2,7 @@ import { Player, PlayerType } from "@canvas/players/player"; import { Edit } from "@core/edit-session"; import { InternalEvent } from "@core/events/edit-events"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; +import { extractFontNames, isGoogleFontUrl } from "@core/fonts/font-utils"; import { type Size, type Vector } from "@layouts/geometry"; import { RichTextAssetSchema, type RichTextAsset, type ResolvedClip } from "@schemas"; import { createTextEngine, type CanvasRichTextAsset } from "@shotstack/shotstack-canvas"; @@ -11,19 +12,7 @@ import * as pixi from "pixi.js"; // Derive TextEngine type from createTextEngine return type type TextEngine = Awaited>; -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 - }; -}; - -/** Check if a font URL is from Google Fonts CDN */ -const isGoogleFontUrl = (url: string): boolean => url.includes("fonts.gstatic.com"); export class RichTextPlayer extends Player { private static readonly PREVIEW_FPS = 60; @@ -126,7 +115,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 0f9b4f2..39c8262 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -1495,7 +1495,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/fonts/font-utils.ts b/src/core/fonts/font-utils.ts new file mode 100644 index 0000000..edddd51 --- /dev/null +++ b/src/core/fonts/font-utils.ts @@ -0,0 +1,12 @@ +export 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 + }; +}; + +export const isGoogleFontUrl = (url: string): boolean => url.includes("fonts.gstatic.com"); 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/composites/StylePanel.ts b/src/core/ui/composites/StylePanel.ts index 8c1cb03..f5e053f 100644 --- a/src/core/ui/composites/StylePanel.ts +++ b/src/core/ui/composites/StylePanel.ts @@ -14,6 +14,11 @@ export interface StyleState { opacity: number; radius: number; }; + stroke: { + width: number; + color: string; + opacity: number; + }; padding: { top: number; right: number; @@ -30,30 +35,34 @@ export interface StyleState { }; } -type StyleTab = "fill" | "border" | "padding" | "shadow"; +export type StyleTab = "fill" | "border" | "stroke" | "padding" | "shadow"; + +export interface StylePanelOptions { + hideTabs?: StyleTab[]; +} /** - * A consolidated style panel with tabbed UI for Fill, Border, Padding, and Shadow. + * A consolidated style panel with tabbed UI for Fill, Border, Stroke, Padding, and Shadow. * - * This composite replaces 4 separate toolbar buttons with a single "Style" dropdown + * This composite replaces separate toolbar buttons with a single "Style" dropdown * containing a tabbed panel. Follows video editor UX patterns for progressive disclosure. * * @example * ```typescript - * const stylePanel = new StylePanel(); + * const stylePanel = new StylePanel({ hideTabs: ["border"] }); * stylePanel.onFillChange(state => this.applyFill(state)); - * stylePanel.onBorderChange(state => this.applyBorder(state)); - * stylePanel.onPaddingChange(state => this.applyPadding(state)); - * stylePanel.onShadowChange(state => this.applyShadow(state)); + * stylePanel.onStrokeChange(state => this.applyStroke(state)); * stylePanel.mount(popupContainer); * ``` */ export class StylePanel extends UIComponent { private activeTab: StyleTab = "fill"; + private hiddenTabs: Set; private state: StyleState = { fill: { color: "#000000", opacity: 100 }, border: { width: 0, color: "#000000", opacity: 100, radius: 0 }, + stroke: { width: 0, color: "#000000", opacity: 100 }, padding: { top: 0, right: 0, bottom: 0, left: 0 }, // blur is fixed at 4 - canvas only checks blur > 0, doesn't implement actual blur effect shadow: { enabled: false, offsetX: 0, offsetY: 0, blur: 4, color: "#000000", opacity: 50 } @@ -62,6 +71,7 @@ export class StylePanel extends UIComponent { // Callbacks for each section private fillChangeCallback: ((state: StyleState["fill"]) => void) | null = null; private borderChangeCallback: ((state: StyleState["border"]) => void) | null = null; + private strokeChangeCallback: ((state: StyleState["stroke"]) => void) | null = null; private paddingChangeCallback: ((state: StyleState["padding"]) => void) | null = null; private shadowChangeCallback: ((state: StyleState["shadow"]) => void) | null = null; @@ -85,6 +95,13 @@ export class StylePanel extends UIComponent { private borderRadiusSlider: HTMLInputElement | null = null; private borderRadiusValue: HTMLSpanElement | null = null; + // Stroke elements + private strokeWidthSlider: HTMLInputElement | null = null; + private strokeWidthValue: HTMLSpanElement | null = null; + private strokeColorInput: HTMLInputElement | null = null; + private strokeOpacitySlider: HTMLInputElement | null = null; + private strokeOpacityValue: HTMLSpanElement | null = null; + // Padding elements private paddingTopSlider: HTMLInputElement | null = null; private paddingTopValue: HTMLSpanElement | null = null; @@ -107,20 +124,28 @@ export class StylePanel extends UIComponent { // Two-phase pattern: Track if drag is active private borderDragActive: boolean = false; + private strokeDragActive: boolean = false; private paddingDragActive: boolean = false; private shadowDragActive: boolean = false; + constructor(options: StylePanelOptions = {}) { + super(); + this.hiddenTabs = new Set(options.hideTabs ?? []); + } + render(): string { return `
+
${this.renderFillTab()} ${this.renderBorderTab()} + ${this.renderStrokeTab()} ${this.renderPaddingTab()} ${this.renderShadowTab()}
@@ -166,6 +191,30 @@ export class StylePanel extends UIComponent { `; } + private renderStrokeTab(): string { + return ` + + `; + } + private renderPaddingTab(): string { return `