import type { CSSProperties } from "react"; // ─── Types ────────────────────────────────────────────────────────────────── export interface SubtitleAppearance { fontSize: "small" | "medium" | "large" | "xlarge" | "xxlarge"; fontFamily: "sans-serif" | "serif" | "monospace"; fontColor: string; backgroundColor: string; backgroundStyle: "box" | "shadow" | "outline" | "none"; backgroundOpacity: number; textOutline: boolean; textOutlineColor: string; position: "bottom" | "lower-third" | "top"; } // ─── Defaults ─────────────────────────────────────────────────────────────── export const DEFAULT_SUBTITLE_APPEARANCE: SubtitleAppearance = { fontSize: "large", fontFamily: "sans-serif", fontColor: "#ffffff", backgroundColor: "#000000", backgroundStyle: "shadow", backgroundOpacity: 75, textOutline: false, textOutlineColor: "#000000", position: "bottom", }; // ─── Option Arrays ────────────────────────────────────────────────────────── export const FONT_SIZE_OPTIONS = [ { value: "small" as const, label: "Small" }, { value: "medium" as const, label: "Medium" }, { value: "large" as const, label: "Large" }, { value: "xlarge" as const, label: "X-Large" }, { value: "xxlarge" as const, label: "XX-Large" }, ]; export const FONT_FAMILY_OPTIONS = [ { value: "sans-serif" as const, label: "Sans-serif" }, { value: "serif" as const, label: "Serif" }, { value: "monospace" as const, label: "Monospace" }, ]; export const BACKGROUND_STYLE_OPTIONS = [ { value: "box" as const, label: "Box" }, { value: "shadow" as const, label: "Drop Shadow" }, { value: "outline" as const, label: "Outline" }, { value: "none" as const, label: "None" }, ]; export const POSITION_OPTIONS = [ { value: "bottom" as const, label: "Bottom" }, { value: "lower-third" as const, label: "Lower Third" }, { value: "top" as const, label: "Top" }, ]; // ─── Color Palettes ───────────────────────────────────────────────────────── interface ColorSwatch { hex: string; label: string; } export const FONT_COLOR_PALETTE: ColorSwatch[] = [ { hex: "#ffffff", label: "White" }, { hex: "#facc15", label: "Yellow" }, { hex: "#22c55e", label: "Green" }, { hex: "#06b6d4", label: "Cyan" }, { hex: "#d946ef", label: "Magenta" }, { hex: "#ef4444", label: "Red" }, { hex: "#3b82f6", label: "Blue" }, { hex: "#000000", label: "Black" }, ]; export const BG_COLOR_PALETTE: ColorSwatch[] = [ { hex: "#000000", label: "Black" }, { hex: "#374151", label: "Dark Gray" }, { hex: "#1e3a5f", label: "Navy" }, { hex: "#7f1d1d", label: "Dark Red" }, { hex: "#14532d", label: "Dark Green" }, ]; // ─── Parser ───────────────────────────────────────────────────────────────── const VALID_FONT_SIZES: Set = new Set(FONT_SIZE_OPTIONS.map((o) => o.value)); const VALID_FONT_FAMILIES: Set = new Set(FONT_FAMILY_OPTIONS.map((o) => o.value)); const VALID_BG_STYLES: Set = new Set(BACKGROUND_STYLE_OPTIONS.map((o) => o.value)); const VALID_POSITIONS: Set = new Set(POSITION_OPTIONS.map((o) => o.value)); export function parseSubtitleAppearance(json: string | null): SubtitleAppearance { if (!json) return { ...DEFAULT_SUBTITLE_APPEARANCE }; try { const p = JSON.parse(json) as Record; return { fontSize: VALID_FONT_SIZES.has(p.fontSize as string) ? (p.fontSize as SubtitleAppearance["fontSize"]) : DEFAULT_SUBTITLE_APPEARANCE.fontSize, fontFamily: VALID_FONT_FAMILIES.has(p.fontFamily as string) ? (p.fontFamily as SubtitleAppearance["fontFamily"]) : DEFAULT_SUBTITLE_APPEARANCE.fontFamily, fontColor: typeof p.fontColor === "string" && /^#[0-9a-fA-F]{6}$/.test(p.fontColor) ? p.fontColor : DEFAULT_SUBTITLE_APPEARANCE.fontColor, backgroundColor: typeof p.backgroundColor === "string" && /^#[0-9a-fA-F]{6}$/.test(p.backgroundColor) ? p.backgroundColor : DEFAULT_SUBTITLE_APPEARANCE.backgroundColor, backgroundStyle: VALID_BG_STYLES.has(p.backgroundStyle as string) ? (p.backgroundStyle as SubtitleAppearance["backgroundStyle"]) : DEFAULT_SUBTITLE_APPEARANCE.backgroundStyle, backgroundOpacity: typeof p.backgroundOpacity === "number" && p.backgroundOpacity >= 0 && p.backgroundOpacity <= 100 ? p.backgroundOpacity : DEFAULT_SUBTITLE_APPEARANCE.backgroundOpacity, textOutline: typeof p.textOutline === "boolean" ? p.textOutline : DEFAULT_SUBTITLE_APPEARANCE.textOutline, textOutlineColor: typeof p.textOutlineColor === "string" && /^#[0-9a-fA-F]{6}$/.test(p.textOutlineColor) ? p.textOutlineColor : DEFAULT_SUBTITLE_APPEARANCE.textOutlineColor, position: VALID_POSITIONS.has(p.position as string) ? (p.position as SubtitleAppearance["position"]) : DEFAULT_SUBTITLE_APPEARANCE.position, }; } catch { return { ...DEFAULT_SUBTITLE_APPEARANCE }; } } // ─── Style Computation ────────────────────────────────────────────────────── const FONT_SIZE_MAP: Record = { small: "1.25rem", medium: "1.6rem", large: "2rem", xlarge: "2.5rem", xxlarge: "3rem", }; function hexToRgb(hex: string): { r: number; g: number; b: number } { const clean = hex.replace("#", ""); return { r: parseInt(clean.substring(0, 2), 16), g: parseInt(clean.substring(2, 4), 16), b: parseInt(clean.substring(4, 6), 16), }; } function buildTextShadow(settings: SubtitleAppearance): string | undefined { const shadows: string[] = []; const outlineColor = settings.textOutlineColor; if (settings.backgroundStyle === "shadow") { shadows.push("2px 2px 4px rgba(0,0,0,0.9)"); } if (settings.backgroundStyle === "outline") { // 1px cardinal + 2px diagonal for a visible, rounded outline const offsets = [ [-1, 0], [1, 0], [0, -1], [0, 1], [-2, -2], [-2, 2], [2, -2], [2, 2], ]; for (const [x, y] of offsets) { shadows.push(`${x}px ${y}px 0 ${outlineColor}`); } } if (settings.textOutline) { shadows.push(`0 0 3px ${outlineColor}`, `0 0 3px ${outlineColor}`); } return shadows.length > 0 ? shadows.join(", ") : undefined; } export interface SubtitleStyles { containerStyle: CSSProperties; cueStyle: CSSProperties; } export function computeSubtitleStyles(settings: SubtitleAppearance): SubtitleStyles { const containerStyle: CSSProperties = computePositionStyle(settings.position); const cueStyle: CSSProperties = {}; // Font cueStyle.fontSize = FONT_SIZE_MAP[settings.fontSize]; cueStyle.fontFamily = settings.fontFamily; cueStyle.color = settings.fontColor; // Background if (settings.backgroundStyle === "box") { const { r, g, b } = hexToRgb(settings.backgroundColor); cueStyle.backgroundColor = `rgba(${r}, ${g}, ${b}, ${settings.backgroundOpacity / 100})`; } // Text shadow (handles shadow, outline, and textOutline — concatenated) const textShadow = buildTextShadow(settings); if (textShadow) { cueStyle.textShadow = textShadow; } return { containerStyle, cueStyle }; } // ─── Position (aspect-aware) ──────────────────────────────────────────────── // Offsets as a fraction of the 16:9 reference frame height. const POSITION_OFFSETS: Record = { bottom: 0.07, "lower-third": 0.18, top: 0.07, }; /** * Percentage-of-container fallback used before the video's intrinsic aspect * ratio is known (or in the preview pane where there's no real video). */ function computePositionStyle(position: SubtitleAppearance["position"]): CSSProperties { if (position === "top") return { top: "8%", bottom: "auto" }; if (position === "lower-third") return { bottom: "18%" }; return { bottom: "7%" }; } /** * Aspect-aware positioning. Anchors subtitles relative to a 16:9 reference * frame centered on the actually-rendered video area (object-fit: contain). * This keeps "Lower Third" and "Bottom" visually consistent regardless of * whether content is 16:9, 4:3, or 2.35:1 — wider content's subs may land * in the letterbox, which is the intended behavior. */ export function computeSubtitlePositionStyle( position: SubtitleAppearance["position"], playerWidth: number, playerHeight: number, videoAspect: number, ): CSSProperties { if (!Number.isFinite(videoAspect) || videoAspect <= 0 || playerWidth <= 0 || playerHeight <= 0) { return computePositionStyle(position); } // Rendered video dimensions inside the player (object-fit: contain). const playerAspect = playerWidth / playerHeight; const videoHeight = playerAspect > videoAspect ? playerHeight : playerWidth / videoAspect; const videoWidth = playerAspect > videoAspect ? playerHeight * videoAspect : playerWidth; // 16:9 reference frame: match the shorter dimension of the video so the // frame never contracts inside it. For wider-than-16:9 content, this // extends the reference into the letterbox above/below the video. const refHeight = videoAspect >= 16 / 9 ? videoWidth * (9 / 16) : videoHeight; // Reference frame is centered on the video, which is itself centered in // the player container — so the reference is centered in the player too. const refBottomFromPlayerBottom = (playerHeight - refHeight) / 2; const offset = POSITION_OFFSETS[position] * refHeight; if (position === "top") { return { top: `${refBottomFromPlayerBottom + offset}px`, bottom: "auto" }; } return { bottom: `${refBottomFromPlayerBottom + offset}px` }; }