diff --git a/src/components/canvas/players/caption-player.ts b/src/components/canvas/players/caption-player.ts index f8908d7e..bb4d6a06 100644 --- a/src/components/canvas/players/caption-player.ts +++ b/src/components/canvas/players/caption-player.ts @@ -2,6 +2,7 @@ import { Player, PlayerType } from "@canvas/players/player"; import { type Cue, findActiveCue } from "@core/captions"; import type { Edit } from "@core/edit-session"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; +import { sanitizeColor } from "@core/shared/color-utils"; import { isAliasReference } from "@core/timing/types"; import { type Size, type Vector } from "@layouts/geometry"; import { SubtitleLoadParser, type SubtitleAsset } from "@loaders/subtitle-load-parser"; @@ -48,7 +49,7 @@ export class CaptionPlayer extends Player { if (captionAsset.stroke?.width && captionAsset.stroke.width > 0 && captionAsset.stroke.color) { const strokeFilter = new pixiFilters.OutlineFilter({ thickness: captionAsset.stroke.width, - color: captionAsset.stroke.color + color: sanitizeColor(captionAsset.stroke.color) }); this.text.filters = [strokeFilter]; } @@ -146,7 +147,7 @@ export class CaptionPlayer extends Player { return new pixi.TextStyle({ fontFamily: baseFontFamily, fontSize, - fill: captionAsset.font?.color ?? "#ffffff", + fill: sanitizeColor(captionAsset.font?.color), fontWeight: fontWeight.toString() as pixi.TextStyleFontWeight, wordWrap: true, wordWrapWidth: width * 0.9, @@ -219,7 +220,7 @@ export class CaptionPlayer extends Player { this.background.clear(); this.background.fillStyle = { - color: bgConfig.color, + color: sanitizeColor(bgConfig.color), alpha: bgConfig.opacity ?? 0.8 }; diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 4c1ec3af..eb4bd199 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -2,6 +2,7 @@ import { Player, PlayerType } from "@canvas/players/player"; import { TextEditor } from "@canvas/text/text-editor"; import type { Edit } from "@core/edit-session"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; +import { sanitizeColor } from "@core/shared/color-utils"; import { type Size, type Vector } from "@layouts/geometry"; import { type ResolvedClip, type TextAsset } from "@schemas"; import * as pixiFilters from "pixi-filters"; @@ -43,7 +44,7 @@ export class TextPlayer extends Player { if (textAsset.stroke?.width && textAsset.stroke.width > 0 && textAsset.stroke.color) { const textStrokeFilter = new pixiFilters.OutlineFilter({ thickness: textAsset.stroke.width, - color: textAsset.stroke.color + color: sanitizeColor(textAsset.stroke.color) }); this.text.filters = [textStrokeFilter]; } @@ -85,7 +86,7 @@ export class TextPlayer extends Player { if (textAsset.stroke?.width && textAsset.stroke.width > 0 && textAsset.stroke.color) { const textStrokeFilter = new pixiFilters.OutlineFilter({ thickness: textAsset.stroke.width, - color: textAsset.stroke.color + color: sanitizeColor(textAsset.stroke.color) }); this.text.filters = [textStrokeFilter]; } else { @@ -157,7 +158,7 @@ export class TextPlayer extends Player { return new pixi.TextStyle({ fontFamily: baseFontFamily, fontSize: textAsset.font?.size ?? 32, - fill: textAsset.font?.color ?? "#ffffff", + fill: sanitizeColor(textAsset.font?.color), fontWeight: fontWeight.toString() as pixi.TextStyleFontWeight, wordWrap: true, wordWrapWidth: width, @@ -198,7 +199,7 @@ export class TextPlayer extends Player { const { width, height } = this.getSize(); this.background.clear(); this.background.fillStyle = { - color: textAsset.background.color, + color: sanitizeColor(textAsset.background.color), alpha: textAsset.background.opacity ?? 1 }; this.background.rect(0, 0, width, height); diff --git a/src/core/shared/color-utils.ts b/src/core/shared/color-utils.ts new file mode 100644 index 00000000..42914027 --- /dev/null +++ b/src/core/shared/color-utils.ts @@ -0,0 +1,30 @@ +/** + * Color helpers for Pixi text/style construction. + */ + +/** Matches Pixi Color HEX_PATTERN (3/4/6/8 hex digits, optional # or 0x). */ +const HEX_PATTERN = /^(#|0x)?(([a-f0-9]{3}){1,2}([a-f0-9]{2})?)$/i; +const FUNCTIONAL_COLOR = /^(rgb|rgba|hsl|hsla)\(/i; +const CSS_COLOR_NAME = /^[a-z]+$/i; + +/** + * Return a Pixi-safe color string, or `fallback` when the value is missing/invalid. + * Incomplete hex (e.g. `#00`, `#0`) and empty strings fall back instead of throwing in TextStyle. + * @internal + */ +export function sanitizeColor(color: string | undefined | null, fallback = "#ffffff"): string { + if (typeof color !== "string") { + return fallback; + } + + const value = color.trim(); + if (!value) { + return fallback; + } + + if (HEX_PATTERN.test(value) || FUNCTIONAL_COLOR.test(value) || CSS_COLOR_NAME.test(value)) { + return value; + } + + return fallback; +} diff --git a/tests/color-utils.test.ts b/tests/color-utils.test.ts new file mode 100644 index 00000000..5cea1d35 --- /dev/null +++ b/tests/color-utils.test.ts @@ -0,0 +1,27 @@ +import { sanitizeColor } from "@core/shared/color-utils"; + +describe("sanitizeColor", () => { + it("falls back for incomplete hex colors that crash Pixi TextStyle", () => { + expect(sanitizeColor("#00")).toBe("#ffffff"); + expect(sanitizeColor("#0")).toBe("#ffffff"); + expect(sanitizeColor("")).toBe("#ffffff"); + expect(sanitizeColor(" ")).toBe("#ffffff"); + }); + + it("falls back for nullish and non-string values", () => { + expect(sanitizeColor(undefined)).toBe("#ffffff"); + expect(sanitizeColor(null)).toBe("#ffffff"); + }); + + it("preserves valid hex, named, and functional colors", () => { + expect(sanitizeColor("#ffffff")).toBe("#ffffff"); + expect(sanitizeColor("#fff")).toBe("#fff"); + expect(sanitizeColor("#ff00ff00")).toBe("#ff00ff00"); + expect(sanitizeColor("red")).toBe("red"); + expect(sanitizeColor("rgb(255, 0, 0)")).toBe("rgb(255, 0, 0)"); + }); + + it("uses a custom fallback when provided", () => { + expect(sanitizeColor("#00", "#000000")).toBe("#000000"); + }); +});