274 lines
10 KiB
TypeScript
274 lines
10 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: "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<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));
|
|
|
|
export function parseSubtitleAppearance(json: string | null): SubtitleAppearance {
|
|
if (!json) return { ...DEFAULT_SUBTITLE_APPEARANCE };
|
|
try {
|
|
const p = JSON.parse(json) as Record<string, unknown>;
|
|
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<SubtitleAppearance["fontSize"], string> = {
|
|
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<SubtitleAppearance["position"], number> = {
|
|
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` };
|
|
}
|