Files
silo-server/web/src/lib/subtitleAppearance.ts
T

340 lines
13 KiB
TypeScript

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: "box",
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<string> = new Set(FONT_SIZE_OPTIONS.map((o) => o.value));
const VALID_FONT_FAMILIES: Set<string> = new Set(FONT_FAMILY_OPTIONS.map((o) => o.value));
const VALID_BG_STYLES: Set<string> = new Set(BACKGROUND_STYLE_OPTIONS.map((o) => o.value));
const VALID_POSITIONS: Set<string> = new Set(POSITION_OPTIONS.map((o) => o.value));
/**
* Coerces a stored subtitle appearance into a complete, valid one.
*
* Accepts both shapes the value has had: the canonical settings API stores it
* as a typed JSON object, while the legacy string-only endpoint stored the same
* object JSON-encoded into a string. Taking `unknown` means a caller never has
* to know which surface it read from, and every field still falls back to the
* default individually, so a partial or corrupt value degrades one field at a
* time rather than resetting the whole appearance.
*/
export function parseSubtitleAppearance(value: unknown): SubtitleAppearance {
if (value === null || value === undefined || value === "") {
return { ...DEFAULT_SUBTITLE_APPEARANCE };
}
try {
const p = (typeof value === "string" ? JSON.parse(value) : value) as Record<string, unknown>;
if (typeof p !== "object" || p === null || Array.isArray(p)) {
return { ...DEFAULT_SUBTITLE_APPEARANCE };
}
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 ──────────────────────────────────────────────────────
// Base cue font sizes in px at the 16:9 reference frame height below. The
// player scales these proportionally with the rendered video so subtitles
// keep the same relative size as the window grows or shrinks.
const FONT_SIZE_MAP: Record<SubtitleAppearance["fontSize"], number> = {
small: 20,
medium: 26,
large: 32,
xlarge: 40,
xxlarge: 48,
};
/** Reference frame height (px) at which FONT_SIZE_MAP values apply as-is. */
export const SUBTITLE_REFERENCE_HEIGHT = 720;
/** Floor so cues stay legible in very small windows. */
const MIN_SUBTITLE_FONT_PX = 12;
export function computeSubtitleFontSize(
fontSize: SubtitleAppearance["fontSize"],
fontScale = 1,
): string {
const px = Math.max(MIN_SUBTITLE_FONT_PX, Math.round(FONT_SIZE_MAP[fontSize] * fontScale));
return `${px}px`;
}
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, fontScale = 1): SubtitleStyles {
const containerStyle: CSSProperties = computePositionStyle(settings.position);
const cueStyle: CSSProperties = {};
// Font
cueStyle.fontSize = computeSubtitleFontSize(settings.fontSize, fontScale);
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) ────────────────────────────────────────────────
// Position offsets as a fraction of their anchor height. "Bottom" uses the
// player window; "Lower Third" and "Top" use the 16:9 video reference frame.
const POSITION_OFFSETS: Record<SubtitleAppearance["position"], number> = {
bottom: 0.07,
"lower-third": 0.12,
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: "12%" };
return { bottom: "7%" };
}
/**
* Height (px) of a 16:9 reference frame centered on the actually-rendered
* video area (object-fit: contain), or null before measurements are known.
* The frame matches the shorter dimension of the video so it never contracts
* inside it; for wider-than-16:9 content it extends into the letterbox.
*/
function resolveSubtitleReferenceHeight(
playerWidth: number,
playerHeight: number,
videoAspect: number,
): number | null {
if (!Number.isFinite(videoAspect) || videoAspect <= 0 || playerWidth <= 0 || playerHeight <= 0) {
return null;
}
// 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;
return videoAspect >= 16 / 9 ? videoWidth * (9 / 16) : videoHeight;
}
/**
* Font scale factor for the rendered video size: 1 at the 720px reference
* height, growing/shrinking proportionally with the window so subtitles keep
* the same size relative to the video. Falls back to 1 until measured.
*/
export function computeSubtitleFontScale(
playerWidth: number,
playerHeight: number,
videoAspect: number,
): number {
const refHeight = resolveSubtitleReferenceHeight(playerWidth, playerHeight, videoAspect);
return refHeight === null ? 1 : refHeight / SUBTITLE_REFERENCE_HEIGHT;
}
/**
* Aspect-aware positioning. "Bottom" is anchored to the player window so it
* can use the available letterbox space. "Lower Third" and "Top" are anchored
* to a 16:9 reference frame centered on the actually-rendered video area
* (object-fit: contain), keeping those positions attached to the video frame
* regardless of whether content is 16:9, 4:3, or 2.35:1.
*/
export function computeSubtitlePositionStyle(
position: SubtitleAppearance["position"],
playerWidth: number,
playerHeight: number,
videoAspect: number,
): CSSProperties {
if (position === "bottom") {
if (playerHeight <= 0) return computePositionStyle(position);
return { bottom: `${POSITION_OFFSETS.bottom * playerHeight}px` };
}
const refHeight = resolveSubtitleReferenceHeight(playerWidth, playerHeight, videoAspect);
if (refHeight === null) {
return computePositionStyle(position);
}
// 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` };
}