1
0
Fork 0

feat(voiceover): add submission pages, recorder UI, and server actions

Add /voiceover script list and take detail pages, teleprompter reader, voice capture/recorder components, submission server actions with a 9 MB audio cap, and a Contribute > Voiceover entry in the user menu.

Ultraworked with [Sisyphus](https://github.com/code-yeongju/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-06 17:22:52 -04:00
parent f83301a923
commit 44b78ba7d9
11 changed files with 1935 additions and 0 deletions

View file

@ -0,0 +1,62 @@
import type { Metadata } from "next";
import config from "@payload-config";
import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeftIcon, MicIcon } from "lucide-react";
import { VoiceoverRecorder } from "@/components/frontend/voiceover/VoiceoverRecorder";
import { getScriptForUser } from "@/lib/voiceover";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const metadata: Metadata = {
title: "Record voiceover — Polaris Task Force",
description: "Read a Polaris Task Force voiceover script and submit a recording.",
};
export default async function VoiceoverScriptPage({
params,
}: {
readonly params: Promise<{ id: string }>;
}) {
const { id } = await params;
const scriptId = Number(id);
if (!Number.isInteger(scriptId) || scriptId < 1) notFound();
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!isPayloadUser(user)) {
redirect(`/login?returnTo=${encodeURIComponent(`/voiceover/${id}`)}`);
}
const script = await getScriptForUser(payload, user, scriptId);
if (!script) notFound();
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<Link
href="/voiceover"
className="flex w-fit items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeftIcon className="size-3" />
Voiceover scripts
</Link>
<div className="flex items-start gap-3">
<MicIcon className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-lg font-semibold">{script.title}</h1>
<p className="text-sm text-muted-foreground">
Read the script at your pace, then submit one clear take for review.
</p>
</div>
</div>
<VoiceoverRecorder script={script} />
</div>
);
}

View file

@ -0,0 +1,303 @@
"use server";
import config from "@payload-config";
import { getPayload } from "payload";
import { emitGameEvent } from "@/utils/event-log/emit";
import { EventTypes } from "@/utils/event-log/eventTypes";
import { broadcastToUser } from "@/lib/realtime/bus";
import { notifyUser } from "@/lib/notifications";
import hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import type { VoiceScript, VoiceSubmission } from "@/payload-types";
interface ActionResult<T = undefined> {
success: boolean;
error?: string;
data?: T;
}
/** Server actions body limit is 10mb — enforce a 9 MB cap server-side with a clear error. */
const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { headers } = await import("next/headers");
const hdrs = await headers();
const { user } = await payload.auth({
headers: hdrs,
canSetHeaders: false,
});
if (!isPayloadUser(user)) {
throw new Error("Unauthorized");
}
return { payload, user };
}
async function fetchScript(
payload: Awaited<ReturnType<typeof getPayload>>,
id: number,
): Promise<VoiceScript | null> {
try {
return await payload.findByID({
collection: "voice-scripts",
id,
depth: 0,
overrideAccess: true,
});
} catch {
return null;
}
}
async function fetchSubmission(
payload: Awaited<ReturnType<typeof getPayload>>,
id: number,
): Promise<VoiceSubmission | null> {
try {
return await payload.findByID({
collection: "voice-submissions",
id,
depth: 0,
overrideAccess: true,
});
} catch {
return null;
}
}
export async function submitVoiceRecording(
scriptId: number,
formData: FormData,
): Promise<ActionResult<{ id: number }>> {
try {
const { payload, user } = await authenticate();
const script = await fetchScript(payload, scriptId);
if (!script) {
return { success: false, error: "Voiceover script not found." };
}
if (script.status !== "open") {
return { success: false, error: "This script is closed to new submissions." };
}
const file = formData.get("file");
if (!(file instanceof File)) {
return { success: false, error: "No audio file provided." };
}
if (!file.type.startsWith("audio/")) {
return { success: false, error: "Only audio files can be submitted." };
}
if (file.size > MAX_AUDIO_BYTES) {
return { success: false, error: "Audio recordings must be 9 MB or smaller." };
}
const submission = await payload.create({
collection: "voice-submissions",
data: {
script: scriptId,
user: user.id,
status: "pending",
},
file: {
data: Buffer.from(await file.arrayBuffer()),
mimetype: file.type,
name: file.name,
size: file.size,
},
overrideAccess: true,
depth: 0,
});
await emitGameEvent(payload, {
type: EventTypes.VoiceoverSubmit,
message: `${user.displayName ?? user.username} submitted a recording for "${script.title}"`,
actor: user.id,
targetCollection: "voice-submissions",
targetId: submission.id,
data: { scriptId, scriptTitle: script.title },
});
return { success: true, data: { id: submission.id } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function withdrawSubmission(
submissionId: number,
): Promise<ActionResult<{ ok: true }>> {
try {
const { payload, user } = await authenticate();
const submission = await fetchSubmission(payload, submissionId);
if (!submission) {
return { success: false, error: "Submission not found." };
}
if (submission.user !== user.id) {
return { success: false, error: "You can only withdraw your own submissions." };
}
if (submission.status !== "pending") {
return { success: false, error: "Only pending submissions can be withdrawn." };
}
await payload.delete({
collection: "voice-submissions",
id: submissionId,
overrideAccess: true,
});
return { success: true, data: { ok: true } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}
export async function reviewVoiceSubmission(
submissionId: number,
decision: "accepted" | "rejected",
reviewNote?: string,
): Promise<ActionResult<{ id: number; status: string }>> {
try {
const { payload, user } = await authenticate();
if (!hasRoles(["admin", "developer"], user)) {
return {
success: false,
error: "Only admins can review voiceover submissions.",
};
}
const submission = await fetchSubmission(payload, submissionId);
if (!submission) {
return { success: false, error: "Submission not found." };
}
if (submission.status !== "pending") {
return { success: false, error: "This submission has already been reviewed." };
}
const script = await fetchScript(
payload,
typeof submission.script === "number" ? submission.script : submission.script.id,
);
if (!script) {
return { success: false, error: "The script for this submission no longer exists." };
}
const submitterId = typeof submission.user === "number" ? submission.user : submission.user.id;
// Resolve the profile before mutating anything so a missing profile fails
// the review cleanly instead of leaving the submission accepted without XP.
let profileId: number | undefined;
let progression: {
xp?: unknown;
voiceover?: { xpEarned?: number };
} = {};
if (decision === "accepted") {
const profileRes = await payload.find({
collection: "profiles",
where: { user: { equals: submitterId } },
limit: 1,
depth: 0,
overrideAccess: true,
});
const profile = profileRes.docs[0];
if (!profile) {
return { success: false, error: "No profile found for the submitter." };
}
profileId = profile.id;
progression = (profile.progression ?? {}) as {
xp?: unknown;
voiceover?: { xpEarned?: number };
};
}
await payload.update({
collection: "voice-submissions",
id: submissionId,
data: {
status: decision,
reviewNote: reviewNote ?? null,
},
overrideAccess: true,
depth: 0,
});
if (decision === "accepted" && profileId !== undefined) {
const voiceover = progression.voiceover ?? {};
const xpEarned = (voiceover.xpEarned ?? 0) + script.xpAmount;
// Strip the virtual `xp` field before writing (computed, not stored).
const { xp: _xp, ...progressionRest } = progression;
await payload.update({
collection: "profiles",
id: profileId,
data: {
progression: {
...progressionRest,
voiceover: {
...voiceover,
xpEarned,
},
},
},
overrideAccess: true,
depth: 0,
});
broadcastToUser("profile-update", submitterId, {
reason: "xp",
xpGained: script.xpAmount,
profileId,
});
await notifyUser(payload, {
userId: submitterId,
type: "voiceover:accepted",
title: "Voiceover accepted",
message: `Your recording for "${script.title}" was accepted (+${script.xpAmount} XP).`,
link: "/voiceover",
});
} else {
await notifyUser(payload, {
userId: submitterId,
type: "voiceover:rejected",
title: "Voiceover rejected",
message: `Your recording for "${script.title}" was not accepted this time.`,
link: "/voiceover",
});
}
await emitGameEvent(payload, {
type: decision === "accepted" ? EventTypes.VoiceoverAccepted : EventTypes.VoiceoverRejected,
message: `${user.displayName ?? user.username} ${decision} a voiceover submission for "${script.title}"`,
actor: user.id,
targetCollection: "voice-submissions",
targetId: submissionId,
data: {
scriptId: script.id,
scriptTitle: script.title,
submitterId,
decision,
reviewNote: reviewNote ?? null,
xpAwarded: decision === "accepted" ? script.xpAmount : 0,
},
});
return { success: true, data: { id: submissionId, status: decision } };
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (message === "Unauthorized") {
return { success: false, error: "You must be logged in." };
}
return { success: false, error: message };
}
}

View file

@ -0,0 +1,174 @@
import type { Metadata } from "next";
import Link from "next/link";
import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation";
import { getPayload } from "payload";
import config from "@payload-config";
import { ArrowRightIcon, CheckCircle2Icon, Clock3Icon, MicIcon, SparklesIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listScriptsForUser, type VoiceoverScriptView } from "@/lib/voiceover";
import { cn } from "@/lib/utils";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
export const metadata: Metadata = {
title: "Voiceover — Polaris Task Force",
description: "Record voiceover takes for active Polaris Task Force scripts.",
};
const submissionStatusClasses: Record<string, string> = {
pending: "border-amber-500/40 text-amber-400",
accepted: "border-emerald-500/40 text-emerald-400",
rejected: "border-red-500/40 text-red-400",
};
function ScriptAvailability({ status }: { readonly status: string }) {
const isOpen = status === "open";
return (
<Badge
variant="outline"
className={cn(
"shrink-0 gap-1",
isOpen ? "border-emerald-500/40 text-emerald-400" : "text-muted-foreground",
)}
>
{isOpen ? <CheckCircle2Icon className="size-3" /> : <Clock3Icon className="size-3" />}
{isOpen ? "Open" : "Closed"}
</Badge>
);
}
function SubmissionStatuses({ script }: { readonly script: VoiceoverScriptView }) {
if (script.mySubmissions.length === 0) {
return <p className="text-xs text-muted-foreground">No takes submitted yet.</p>;
}
return (
<div className="flex flex-wrap items-center gap-2" aria-label="Your take statuses">
{script.mySubmissions.map((submission, index) => (
<Badge
key={submission.id}
variant="outline"
className={cn(
"font-normal capitalize",
submissionStatusClasses[submission.status] ?? "text-muted-foreground",
)}
>
Take {index + 1}: {submission.status}
</Badge>
))}
</div>
);
}
function ScriptCard({ script }: { readonly script: VoiceoverScriptView }) {
const isOpen = script.status === "open";
return (
<Card className={cn("flex h-full flex-col", !isOpen && "opacity-75")}>
<CardHeader className="gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-2">
<CardTitle className="min-w-0">
{isOpen ? (
<Link
href={`/voiceover/${script.id}`}
className="rounded-sm outline-none transition-colors hover:text-primary focus-visible:ring-2 focus-visible:ring-ring"
>
{script.title}
</Link>
) : (
script.title
)}
</CardTitle>
<CardDescription>Voiceover script #{script.id}</CardDescription>
</div>
<ScriptAvailability status={script.status} />
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
<SparklesIcon className="size-3" />+{script.xpAmount} XP
</Badge>
<span className="text-xs text-muted-foreground">
{script.otherSubmissionCount} other submission
{script.otherSubmissionCount === 1 ? "" : "s"}
</span>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Your submissions
</span>
<SubmissionStatuses script={script} />
</div>
{isOpen ? (
<Button asChild className="mt-auto w-full sm:w-fit">
<Link href={`/voiceover/${script.id}`}>
<MicIcon data-icon="inline-start" />
Record a take
<ArrowRightIcon data-icon="inline-end" />
</Link>
</Button>
) : (
<p className="mt-auto text-sm text-muted-foreground">
This script is closed to new takes.
</p>
)}
</CardContent>
</Card>
);
}
export default async function VoiceoverPage() {
const headers = await nextHeaders();
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
const { user } = await payload.auth({ headers, canSetHeaders: false });
if (!isPayloadUser(user)) {
redirect(`/login?returnTo=${encodeURIComponent("/voiceover")}`);
}
const scripts = await listScriptsForUser(payload, user);
return (
<div className="flex min-w-0 flex-col gap-6 p-5">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<MicIcon className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Voiceover submissions</h1>
</div>
<p className="text-sm text-muted-foreground">
Choose an active script, read it in the teleprompter, and submit your best take.
</p>
</div>
{scripts.length > 0 ? (
<div className="grid min-w-0 gap-4 xl:grid-cols-2">
{scripts.map((script) => (
<ScriptCard key={script.id} script={script} />
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center gap-3 p-8 text-center">
<MicIcon className="size-8 text-muted-foreground" />
<div className="flex flex-col gap-1">
<h2 className="font-semibold">No scripts available</h2>
<p className="text-sm text-muted-foreground">
New voiceover opportunities will appear here when they are published.
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -9,6 +9,7 @@ import {
FileTextIcon, FileTextIcon,
Gamepad2, Gamepad2,
LogOut, LogOut,
Mic,
Radio, Radio,
Users2, Users2,
} from "lucide-react"; } from "lucide-react";
@ -166,10 +167,26 @@ export function NavUser({
Qualification Course Qualification Course
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/qualification">
<Crosshair />
Qualification Course
</Link>
</DropdownMenuItem>
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Contribute</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link href="/voiceover">
<Mic />
Voiceover
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href="/account"> <Link href="/account">

View file

@ -0,0 +1,373 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AudioLinesIcon,
GaugeIcon,
PauseIcon,
PlayIcon,
RotateCcwIcon,
SlidersHorizontalIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { parseEmotionTags } from "@/lib/voiceover/emotionTags";
import {
DEFAULT_WORDS_PER_MINUTE,
MAX_WORDS_PER_MINUTE,
MIN_WORDS_PER_MINUTE,
buildPacingTimeline,
} from "@/lib/voiceover/pacing";
const MIN_SCROLL_SPEED = 5;
const MAX_SCROLL_SPEED = 100;
const DEFAULT_SCROLL_SPEED = 20;
const TAG_COLORS = [
{ text: "text-sky-200/75", underline: "decoration-sky-300/50", label: "text-sky-300/80" },
{ text: "text-amber-200/75", underline: "decoration-amber-300/50", label: "text-amber-300/80" },
{
text: "text-emerald-200/75",
underline: "decoration-emerald-300/50",
label: "text-emerald-300/80",
},
{ text: "text-rose-200/75", underline: "decoration-rose-300/50", label: "text-rose-300/80" },
{
text: "text-violet-200/75",
underline: "decoration-violet-300/50",
label: "text-violet-300/80",
},
{ text: "text-teal-200/75", underline: "decoration-teal-300/50", label: "text-teal-300/80" },
{
text: "text-fuchsia-200/75",
underline: "decoration-fuchsia-300/50",
label: "text-fuchsia-300/80",
},
{
text: "text-orange-200/75",
underline: "decoration-orange-300/50",
label: "text-orange-300/80",
},
] as const;
function tagColor(tags: readonly string[]) {
const key = tags.join(", ");
let hash = 0;
for (let index = 0; index < key.length; index += 1) {
hash = (hash * 31 + key.charCodeAt(index)) >>> 0;
}
return TAG_COLORS[hash % TAG_COLORS.length];
}
export function TeleprompterReader({ script }: { readonly script: string }) {
const readerRef = useRef<HTMLDivElement | null>(null);
const elapsedRef = useRef(0);
const [isScrolling, setIsScrolling] = useState(false);
const [scrollSpeed, setScrollSpeed] = useState(DEFAULT_SCROLL_SPEED);
const [pacingEnabled, setPacingEnabled] = useState(false);
const [wordsPerMinute, setWordsPerMinute] = useState(DEFAULT_WORDS_PER_MINUTE);
const [elapsedMs, setElapsedMs] = useState(0);
const segments = useMemo(() => parseEmotionTags(script), [script]);
const timeline = useMemo(
() => buildPacingTimeline(segments, wordsPerMinute),
[segments, wordsPerMinute],
);
const toggleScrolling = useCallback(() => {
setIsScrolling((scrolling) => !scrolling);
}, []);
const scrollMultiplier = useCallback(
(elapsed: number) => {
let multiplier = 1;
for (const directive of timeline.scrollDirectives) {
if (elapsed < directive.atMs) break;
multiplier = directive.multiplier;
}
return multiplier;
},
[timeline.scrollDirectives],
);
useEffect(() => {
if (!isScrolling) return;
let animationFrame = 0;
let previousTime = performance.now();
const scroll = (now: number) => {
const reader = readerRef.current;
if (!reader) {
setIsScrolling(false);
return;
}
const delta = now - previousTime;
previousTime = now;
if (pacingEnabled) {
elapsedRef.current += delta;
setElapsedMs(elapsedRef.current);
}
const effectiveSpeed =
scrollSpeed * (pacingEnabled ? scrollMultiplier(elapsedRef.current) : 1);
const maxScrollTop = reader.scrollHeight - reader.clientHeight;
const scrollFinished = maxScrollTop <= 0 || reader.scrollTop >= maxScrollTop;
const pacingFinished = !pacingEnabled || elapsedRef.current >= timeline.totalMs;
if (scrollFinished && pacingFinished) {
setIsScrolling(false);
return;
}
if (!scrollFinished) {
reader.scrollTop = Math.min(
maxScrollTop,
reader.scrollTop + (effectiveSpeed * delta) / 1000,
);
}
animationFrame = requestAnimationFrame(scroll);
};
animationFrame = requestAnimationFrame(scroll);
return () => cancelAnimationFrame(animationFrame);
}, [isScrolling, scrollSpeed, pacingEnabled, timeline.totalMs, scrollMultiplier]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space" || event.repeat) return;
const target = event.target;
if (
target instanceof HTMLElement &&
(target.isContentEditable ||
["BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(target.tagName))
) {
return;
}
event.preventDefault();
toggleScrolling();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleScrolling]);
function resetReader() {
const reader = readerRef.current;
if (reader) reader.scrollTop = 0;
elapsedRef.current = 0;
setElapsedMs(0);
setIsScrolling(false);
}
function handlePacingToggle(enabled: boolean) {
elapsedRef.current = 0;
setElapsedMs(0);
setPacingEnabled(enabled);
}
function handleWordsPerMinuteChange(value: string) {
const next = Number(value);
if (!Number.isFinite(next)) return;
setWordsPerMinute(Math.min(MAX_WORDS_PER_MINUTE, Math.max(MIN_WORDS_PER_MINUTE, next)));
}
function renderSpokenText(
segment: (typeof segments)[number],
pacing: (typeof timeline.segmentPacing)[number],
wordClass?: string,
) {
if (!pacingEnabled || !pacing || pacing.words.length === 0) {
return segment.text;
}
return (
<>
{pacing.leadingWhitespace}
{pacing.words.map((word, wordIndex) => (
<span
key={`word-${wordIndex}`}
className={cn(
"transition-opacity duration-500",
wordClass,
elapsedMs >= word.endMs && "opacity-35",
)}
>
{word.text}
{word.whitespaceAfter}
</span>
))}
</>
);
}
function handleSpeedChange(value: string) {
const nextSpeed = Number(value);
if (!Number.isFinite(nextSpeed)) return;
setScrollSpeed(Math.min(MAX_SCROLL_SPEED, Math.max(MIN_SCROLL_SPEED, nextSpeed)));
}
return (
<Card>
<CardHeader className="gap-3 border-b border-border">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<CardTitle>Teleprompter</CardTitle>
<CardDescription>Press spacebar to start or pause the reader.</CardDescription>
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Switch
id="voiceover-pacing"
checked={pacingEnabled}
onCheckedChange={handlePacingToggle}
aria-label="Toggle pacing highlighter"
/>
<Label
htmlFor="voiceover-pacing"
className="cursor-pointer text-xs text-muted-foreground"
>
Follow along
</Label>
</div>
{pacingEnabled && (
<div className="flex items-center gap-1.5">
<Input
id="voiceover-wpm"
type="number"
min={MIN_WORDS_PER_MINUTE}
max={MAX_WORDS_PER_MINUTE}
step={10}
value={wordsPerMinute}
onChange={(event) => handleWordsPerMinuteChange(event.target.value)}
className="h-7 w-20 text-xs"
aria-label="Speaking pace in words per minute"
/>
<span className="text-xs text-muted-foreground">wpm</span>
</div>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="voiceover-scroll-speed" className="text-xs text-muted-foreground">
Speed (px/s)
</Label>
<Input
id="voiceover-scroll-speed"
type="number"
min={MIN_SCROLL_SPEED}
max={MAX_SCROLL_SPEED}
step={5}
value={scrollSpeed}
onChange={(event) => handleSpeedChange(event.target.value)}
className="w-24"
/>
</div>
<Button type="button" onClick={toggleScrolling} aria-pressed={isScrolling}>
{isScrolling ? (
<PauseIcon data-icon="inline-start" />
) : (
<PlayIcon data-icon="inline-start" />
)}
{isScrolling ? "Pause" : "Start"}
</Button>
<Button type="button" variant="outline" size="icon" onClick={resetReader}>
<RotateCcwIcon />
<span className="sr-only">Reset teleprompter</span>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3 p-4 sm:p-6">
<div
ref={readerRef}
tabIndex={0}
aria-label="Voiceover script reader"
className="max-h-[34rem] min-h-[24rem] overflow-y-auto scroll-auto rounded-md border border-border bg-background/70 px-4 py-8 outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-8 sm:py-12"
>
<p className="whitespace-pre-wrap text-xl leading-relaxed tracking-wide text-foreground sm:text-2xl">
{segments.map((segment, index) => {
if (segment.directive) {
return (
<span
key={`directive-${index}`}
className="my-3 flex items-center gap-1.5 text-[10px] uppercase tracking-widest text-muted-foreground/70"
>
<SlidersHorizontalIcon className="size-3 shrink-0" aria-hidden />
{segment.directive}
</span>
);
}
if (segment.sfx) {
return (
<span
key={`sfx-${index}`}
className="my-6 flex items-center gap-1.5 text-xs italic text-muted-foreground"
>
<AudioLinesIcon className="size-3.5 shrink-0" aria-hidden />
{segment.sfx}
</span>
);
}
if (segment.tags.length === 0) {
return (
<span key={`text-${index}`}>
{renderSpokenText(segment, timeline.segmentPacing[index])}
</span>
);
}
const color = tagColor(segment.tags);
const segmentPacing = timeline.segmentPacing[index];
const pacingActive = pacingEnabled && segmentPacing !== undefined;
return (
<span key={`tagged-${index}`} className="relative inline">
<span
className={cn(
"absolute -top-3.5 left-0 whitespace-nowrap text-[10px] italic leading-none",
color.label,
)}
aria-label={`Stage direction: ${segment.tags.join(", ")}`}
>
{segment.tags.join(", ")}
</span>
<span
className={cn(
"underline-offset-4",
color.text,
!pacingActive && "underline",
color.underline,
)}
>
{renderSpokenText(
segment,
segmentPacing,
pacingActive ? cn("underline", color.underline) : undefined,
)}
</span>
</span>
);
})}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<GaugeIcon className="size-3.5" />
<span>Constant scroll speed · {scrollSpeed} px/s</span>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,425 @@
"use client";
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from "react";
import { MicIcon, SquareIcon, UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
const PREFERRED_MIME_TYPE = "audio/webm;codecs=opus";
export type VoiceCaptureSource = Blob | File;
interface VoiceCaptureProps {
readonly onSubmitTake: (source: VoiceCaptureSource) => Promise<boolean>;
}
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes.toString().padStart(2, "0")}:${remainder.toString().padStart(2, "0")}`;
}
function recordingErrorMessage(error: unknown): string {
if (
error instanceof DOMException &&
["NotAllowedError", "PermissionDeniedError", "SecurityError"].includes(error.name)
) {
return "Microphone access was denied. Allow microphone access in your browser settings, then try again.";
}
return "The microphone could not be opened. Check that it is connected and available to this browser.";
}
export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const recordingStartedAtRef = useRef<number | null>(null);
const audioContextRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const meterAnimationRef = useRef<number | null>(null);
const mountedRef = useRef(true);
const [support, setSupport] = useState<"checking" | "supported" | "unsupported">("checking");
const [isRecording, setIsRecording] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [level, setLevel] = useState(0);
const [recordingError, setRecordingError] = useState<string | null>(null);
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewUrl, setReviewUrl] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const stopLevelMeter = useCallback(() => {
if (meterAnimationRef.current !== null) {
cancelAnimationFrame(meterAnimationRef.current);
meterAnimationRef.current = null;
}
analyserRef.current = null;
const context = audioContextRef.current;
audioContextRef.current = null;
if (context) void context.close();
setLevel(0);
}, []);
const stopStream = useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, []);
const clearReview = useCallback(() => {
setReviewBlob(null);
setReviewUrl(null);
}, []);
useEffect(() => {
setSupport(
typeof MediaRecorder !== "undefined" &&
typeof navigator !== "undefined" &&
!!navigator.mediaDevices?.getUserMedia
? "supported"
: "unsupported",
);
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (mediaRecorderRef.current?.state === "recording") mediaRecorderRef.current.stop();
stopLevelMeter();
stopStream();
};
}, [stopLevelMeter, stopStream]);
useEffect(() => {
if (!reviewUrl) return;
return () => URL.revokeObjectURL(reviewUrl);
}, [reviewUrl]);
useEffect(() => {
if (!isRecording) return;
const timer = window.setInterval(() => {
const startedAt = recordingStartedAtRef.current;
if (startedAt !== null) setRecordingSeconds(Math.floor((Date.now() - startedAt) / 1000));
}, 250);
return () => window.clearInterval(timer);
}, [isRecording]);
function startLevelMeter(stream: MediaStream) {
if (typeof AudioContext === "undefined") return;
try {
const context = new AudioContext();
const analyser = context.createAnalyser();
analyser.fftSize = 256;
context.createMediaStreamSource(stream).connect(analyser);
const data = new Uint8Array(analyser.fftSize);
audioContextRef.current = context;
analyserRef.current = analyser;
const update = () => {
if (!mountedRef.current || !analyserRef.current) return;
analyser.getByteTimeDomainData(data);
let sum = 0;
for (const value of data) {
const normalized = (value - 128) / 128;
sum += normalized * normalized;
}
setLevel(Math.min(1, Math.sqrt(sum / data.length) * 4));
meterAnimationRef.current = requestAnimationFrame(update);
};
void context.resume().catch(() => undefined);
meterAnimationRef.current = requestAnimationFrame(update);
} catch {
setLevel(0);
}
}
async function handleStart() {
if (support !== "supported" || isRecording || isSubmitting) return;
setRecordingError(null);
clearReview();
setSelectedFile(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (!mountedRef.current) {
stream.getTracks().forEach((track) => track.stop());
return;
}
const mimeType = MediaRecorder.isTypeSupported(PREFERRED_MIME_TYPE)
? PREFERRED_MIME_TYPE
: "";
const recorder = mimeType
? new MediaRecorder(stream, { mimeType })
: new MediaRecorder(stream);
chunksRef.current = [];
streamRef.current = stream;
mediaRecorderRef.current = recorder;
recordingStartedAtRef.current = Date.now();
setRecordingSeconds(0);
setIsStopping(false);
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) chunksRef.current.push(event.data);
});
recorder.addEventListener("error", () => {
if (mountedRef.current)
setRecordingError("The recording could not be completed. Try again.");
});
recorder.addEventListener("stop", () => {
const blob = new Blob(chunksRef.current, {
type: recorder.mimeType || mimeType || "audio/webm",
});
stopLevelMeter();
stopStream();
mediaRecorderRef.current = null;
recordingStartedAtRef.current = null;
if (!mountedRef.current) return;
setReviewBlob(blob);
setReviewUrl(URL.createObjectURL(blob));
setIsRecording(false);
setIsStopping(false);
setLevel(0);
});
recorder.start();
setIsRecording(true);
startLevelMeter(stream);
} catch (error) {
setIsRecording(false);
setIsStopping(false);
setRecordingError(recordingErrorMessage(error));
stopLevelMeter();
stopStream();
}
}
function handleStop() {
if (mediaRecorderRef.current?.state !== "recording") return;
setIsStopping(true);
mediaRecorderRef.current.stop();
}
async function submitSource(source: VoiceCaptureSource) {
if (source.size > MAX_AUDIO_BYTES) {
toast.error("Audio files must be 9 MB or smaller.");
return;
}
setIsSubmitting(true);
const submitted = await onSubmitTake(source);
setIsSubmitting(false);
if (!submitted) return;
clearReview();
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
if (!file) {
setSelectedFile(null);
return;
}
if (file.size > MAX_AUDIO_BYTES) {
toast.error("Audio files must be 9 MB or smaller.");
event.target.value = "";
setSelectedFile(null);
return;
}
if (file.type && !file.type.startsWith("audio/")) {
toast.error("Choose an audio file to submit.");
event.target.value = "";
setSelectedFile(null);
return;
}
setRecordingError(null);
setSelectedFile(file);
}
async function handleReviewSubmit() {
if (reviewBlob) await submitSource(reviewBlob);
}
async function handleFileSubmit() {
if (selectedFile) await submitSource(selectedFile);
}
function handleRerecord() {
clearReview();
setRecordingError(null);
}
const meterValue = Math.max(0.08, level);
return (
<Card>
<CardHeader className="gap-1">
<CardTitle>Record your take</CardTitle>
<CardDescription>
Use your microphone or upload an existing audio file. Audio submissions are limited to 9
MB.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
{support === "unsupported" && (
<div
role="alert"
className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm"
>
This browser does not support microphone recording. Upload an audio file instead.
</div>
)}
{support === "checking" && (
<p className="text-sm text-muted-foreground">Checking microphone support…</p>
)}
{support === "supported" && !reviewBlob && (
<div className="flex flex-col gap-3 rounded-md border border-border bg-background/50 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span
className={cn(
"size-2 rounded-full bg-muted-foreground",
isRecording &&
"bg-destructive motion-safe:animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
<span className="text-sm font-medium">
{isRecording ? "Recording" : "Microphone ready"}
</span>
</div>
<span
className="font-mono text-sm tabular-nums text-muted-foreground"
aria-live="polite"
>
{formatDuration(recordingSeconds)}
</span>
</div>
<div
className="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar"
aria-label="Microphone level"
aria-valuemin={0}
aria-valuemax={1}
aria-valuenow={isRecording ? level : 0}
>
<div
className="h-full origin-left rounded-full bg-primary transition-transform motion-reduce:transition-none"
style={{ transform: `scaleX(${isRecording ? meterValue : 0})` }}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isRecording ? (
<Button type="button" onClick={() => void handleStart()} disabled={isSubmitting}>
<MicIcon data-icon="inline-start" />
Start recording
</Button>
) : (
<Button
type="button"
variant="destructive"
onClick={handleStop}
disabled={isStopping}
>
<SquareIcon data-icon="inline-start" />
{isStopping ? "Finishing…" : "Stop recording"}
</Button>
)}
</div>
</div>
)}
{recordingError && (
<p role="alert" className="text-sm text-destructive">
{recordingError}
</p>
)}
{reviewBlob && reviewUrl && (
<div className="flex flex-col gap-3 rounded-md border border-border bg-background/50 p-4">
<div className="flex flex-col gap-1">
<h3 className="text-sm font-medium">Review your take</h3>
<p className="text-xs text-muted-foreground">
Listen back before sending it for review.
</p>
</div>
<audio
controls
preload="metadata"
src={reviewUrl}
className="w-full"
aria-label="Recorded take"
/>
<div className="flex flex-wrap gap-2">
<Button
type="button"
onClick={() => void handleReviewSubmit()}
disabled={isSubmitting}
>
<UploadIcon data-icon="inline-start" />
{isSubmitting ? "Submitting…" : "Submit take"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleRerecord}
disabled={isSubmitting}
>
Re-record
</Button>
</div>
</div>
)}
<div className="flex flex-col gap-3 rounded-md border border-border bg-background/50 p-4">
<div className="flex flex-col gap-1">
<Label htmlFor="voiceover-audio-file">Upload a file instead</Label>
<p className="text-xs text-muted-foreground">Audio files only, up to 9 MB.</p>
</div>
<Input
ref={fileInputRef}
id="voiceover-audio-file"
type="file"
accept="audio/*"
onChange={handleFileChange}
disabled={isSubmitting || isRecording}
/>
{selectedFile && (
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="min-w-0 truncate text-sm text-muted-foreground">
{selectedFile.name}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void handleFileSubmit()}
disabled={isSubmitting}
>
{isSubmitting ? "Submitting…" : "Submit file"}
</Button>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,112 @@
"use client";
import { useRouter } from "next/navigation";
import { CheckCircle2Icon, LockKeyholeIcon, MicIcon, SparklesIcon } from "lucide-react";
import { toast } from "sonner";
import { submitVoiceRecording, withdrawSubmission } from "@/app/(frontend)/voiceover/actions";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import type { VoiceoverScriptView } from "@/lib/voiceover";
import { TeleprompterReader } from "@/components/frontend/voiceover/TeleprompterReader";
import {
VoiceCapture,
type VoiceCaptureSource,
} from "@/components/frontend/voiceover/VoiceCapture";
import { VoiceoverTakes } from "@/components/frontend/voiceover/VoiceoverTakes";
export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScriptView }) {
const router = useRouter();
const isOpen = script.status === "open";
async function handleSubmitTake(source: VoiceCaptureSource): Promise<boolean> {
const formData = new FormData();
if (source instanceof File) {
formData.append("file", source, source.name);
} else {
formData.append("file", source, "take.webm");
}
try {
const result = await submitVoiceRecording(script.id, formData);
if (!result.success) {
toast.error(result.error ?? "Could not submit your take.");
return false;
}
toast.success("Take submitted", {
description: "Your recording is now pending review.",
});
router.refresh();
return true;
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not submit your take.");
return false;
}
}
async function handleWithdraw(submissionId: number): Promise<boolean> {
try {
const result = await withdrawSubmission(submissionId);
if (!result.success) {
toast.error(result.error ?? "Could not withdraw this take.");
return false;
}
toast.success("Take withdrawn");
router.refresh();
return true;
} catch (error) {
toast.error(error instanceof Error ? error.message : "Could not withdraw this take.");
return false;
}
}
return (
<div className="flex min-w-0 flex-col gap-6">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="gap-1 border-amber-500/40 text-amber-300">
<SparklesIcon className="size-3" />+{script.xpAmount} XP
</Badge>
<Badge
variant="outline"
className={
isOpen ? "gap-1 border-emerald-500/40 text-emerald-400" : "gap-1 text-muted-foreground"
}
>
{isOpen ? (
<CheckCircle2Icon className="size-3" />
) : (
<LockKeyholeIcon className="size-3" />
)}
{isOpen ? "Open for submissions" : "Closed to submissions"}
</Badge>
<span className="text-sm text-muted-foreground">
{script.otherSubmissionCount} other submission
{script.otherSubmissionCount === 1 ? "" : "s"}
</span>
</div>
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="flex min-w-0 flex-col gap-6">
<TeleprompterReader script={script.script} />
<VoiceoverTakes submissions={script.mySubmissions} onWithdraw={handleWithdraw} />
</div>
<div className="lg:sticky lg:top-24">
{isOpen ? (
<VoiceCapture onSubmitTake={handleSubmitTake} />
) : (
<Card>
<CardContent className="flex items-center gap-3 p-4 text-sm text-muted-foreground">
<MicIcon className="size-4 shrink-0" />
This script is closed. Existing takes remain available below for playback.
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
"use client";
import { useState } from "react";
import { CheckIcon, Clock3Icon, Trash2Icon, XIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { VoiceoverSubmissionSummary } from "@/lib/voiceover";
import { cn } from "@/lib/utils";
interface VoiceoverTakesProps {
readonly submissions: readonly VoiceoverSubmissionSummary[];
readonly onWithdraw: (submissionId: number) => Promise<boolean>;
}
const statusMeta: Record<string, { readonly className: string; readonly icon: typeof Clock3Icon }> =
{
pending: {
className: "border-amber-500/40 text-amber-400",
icon: Clock3Icon,
},
accepted: {
className: "border-emerald-500/40 text-emerald-400",
icon: CheckIcon,
},
rejected: { className: "border-red-500/40 text-red-400", icon: XIcon },
};
function formatDate(value: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps) {
const [withdrawingId, setWithdrawingId] = useState<number | null>(null);
async function handleWithdraw(id: number) {
setWithdrawingId(id);
await onWithdraw(id);
setWithdrawingId(null);
}
return (
<Card>
<CardHeader className="gap-1">
<CardTitle>My takes</CardTitle>
<CardDescription>Your submitted recordings and their review status.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{submissions.length === 0 ? (
<p className="text-sm text-muted-foreground">
You have not submitted a take for this script.
</p>
) : (
submissions.map((submission, index) => {
const meta = statusMeta[submission.status] ?? {
className: "text-muted-foreground",
icon: Clock3Icon,
};
const StatusIcon = meta.icon;
const canWithdraw = submission.status === "pending";
return (
<div
key={submission.id}
className="flex flex-col gap-3 rounded-md border border-border p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Take {index + 1}</span>
<Badge variant="outline" className={cn("gap-1 capitalize", meta.className)}>
<StatusIcon className="size-3" />
{submission.status}
</Badge>
</div>
<time dateTime={submission.createdAt} className="text-xs text-muted-foreground">
{formatDate(submission.createdAt)}
</time>
</div>
{submission.audioUrl ? (
<audio
controls
preload="metadata"
src={submission.audioUrl}
className="w-full"
aria-label={`Playback for take ${index + 1}`}
/>
) : (
<p className="text-sm text-muted-foreground">Audio playback is unavailable.</p>
)}
{canWithdraw && (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void handleWithdraw(submission.id)}
disabled={withdrawingId !== null}
>
<Trash2Icon data-icon="inline-start" />
{withdrawingId === submission.id ? "Withdrawing…" : "Withdraw"}
</Button>
)}
</div>
);
})
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,105 @@
export interface VoiceoverSegment {
readonly text: string;
readonly tags: readonly string[];
readonly sfx?: string;
readonly directive?: string;
}
const TAG_PATTERN = /\[([a-z][a-z\s,-]*)\]|\{([a-z][a-z\s,-]*)\}|<([a-z]+)>/gi;
function normalizeScript(script: string): string {
return script
.replace(/\r\n?/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/(?<!\n)\n(?!\n)/g, " ")
.replace(/[^\S\n]{2,}/g, " ");
}
function normalizeTag(raw: string): string {
return raw
.trim()
.replace(/[\s,]+$/, "")
.replace(/\s+/g, " ")
.toLowerCase();
}
function trimAroundSfx(segments: VoiceoverSegment[]): VoiceoverSegment[] {
const isStandalone = (segment: VoiceoverSegment | undefined) =>
segment !== undefined && (segment.sfx !== undefined || segment.directive !== undefined);
return segments.map((segment, index) => {
let text = segment.text;
if (isStandalone(segments[index - 1])) text = text.replace(/^\s+/, "");
if (isStandalone(segments[index + 1])) text = text.replace(/\s+$/, "");
return text === segment.text ? segment : { ...segment, text };
});
}
export function parseEmotionTags(script: string): VoiceoverSegment[] {
const segments: VoiceoverSegment[] = [];
let cursor = 0;
let pendingTags: string[] = [];
let carriedWhitespace = "";
const source = normalizeScript(script);
const flush = (rawText: string, isFinal: boolean) => {
let text = carriedWhitespace + rawText;
carriedWhitespace = "";
// Whitespace-only runs between consecutive tags carry forward so a group
// like "[calm] [formal]\nWE RECEIVED…" attaches every tag to the line.
if (pendingTags.length > 0 && text.trim().length === 0 && !isFinal) {
carriedWhitespace = text;
return;
}
if (text.length === 0) return;
if (pendingTags.length > 0 && text.trim().length > 0) {
// A tag labels the text that follows it; strip the whitespace the source
// used to separate the tag line from its paragraph — the label already
// renders on its own line.
segments.push({ text: text.replace(/^\s+/, ""), tags: pendingTags });
pendingTags = [];
return;
}
segments.push({ text, tags: [] });
};
for (const match of source.matchAll(TAG_PATTERN)) {
const matchIndex = match.index ?? cursor;
if (matchIndex > cursor) {
flush(source.slice(cursor, matchIndex), false);
}
const emotive = normalizeTag(match[1] ?? "");
if (emotive) {
pendingTags.push(emotive);
}
const sfx = normalizeTag(match[2] ?? "");
if (sfx) {
segments.push({ text: "", tags: [], sfx });
}
const directive = normalizeTag(match[3] ?? "");
if (directive) {
segments.push({ text: "", tags: [], directive });
}
cursor = matchIndex + match[0].length;
}
flush(source.slice(cursor), true);
const hasSfx = segments.some((segment) => segment.sfx);
const trimmed = hasSfx ? trimAroundSfx(segments) : segments;
return trimmed.filter(
(segment) =>
segment.sfx !== undefined ||
segment.directive !== undefined ||
segment.tags.length > 0 ||
segment.text.length > 0,
);
}

128
src/lib/voiceover/index.ts Normal file
View file

@ -0,0 +1,128 @@
import type { User, VoiceScript, VoiceSubmission } from "@/payload-types";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
export interface VoiceoverSubmissionSummary {
id: number;
status: string;
createdAt: string;
audioUrl: string;
}
export interface VoiceoverScriptView {
id: number;
title: string;
script: string;
xpAmount: number;
status: string;
createdAt: string;
/** This user's own submissions on the script, newest first. */
mySubmissions: VoiceoverSubmissionSummary[];
/**
* How many submissions OTHER users have made on this script. A number only —
* never identities (privacy requirement).
*/
otherSubmissionCount: number;
}
/** Upload docs expose `url`; fall back to the media file route like wiki image uploads do. */
function submissionAudioUrl(submission: VoiceSubmission): string {
return submission.url ?? (submission.filename ? `/api/media/file/${submission.filename}` : "");
}
function toSubmissionSummary(submission: VoiceSubmission): VoiceoverSubmissionSummary {
return {
id: submission.id,
status: submission.status,
createdAt: submission.createdAt,
audioUrl: submissionAudioUrl(submission),
};
}
function buildScriptView(
script: VoiceScript,
submissions: VoiceSubmission[],
userId: number,
): VoiceoverScriptView {
const mySubmissions: VoiceoverSubmissionSummary[] = [];
let otherSubmissionCount = 0;
for (const submission of submissions) {
if (submission.user === userId) {
mySubmissions.push(toSubmissionSummary(submission));
} else {
// Other users' submissions are counted, never surfaced — identities stay server-side.
otherSubmissionCount += 1;
}
}
return {
id: script.id,
title: script.title,
script: script.script,
xpAmount: script.xpAmount,
status: script.status,
createdAt: script.createdAt,
mySubmissions,
otherSubmissionCount,
};
}
/**
* All voiceover scripts (newest first) annotated with the user's own submissions
* and an anonymous count of other users' submissions.
*
* Round-trip pattern: one query for scripts + one for all submissions, grouped
* in memory — avoids per-script count queries (N+1).
*/
export async function listScriptsForUser(
payload: PayloadType,
user: User,
): Promise<VoiceoverScriptView[]> {
const [scriptsRes, submissionsRes] = await Promise.all([
payload.find({
collection: "voice-scripts",
sort: "-createdAt",
limit: 0,
depth: 0,
overrideAccess: true,
}),
payload.find({
collection: "voice-submissions",
sort: "-createdAt",
limit: 0,
depth: 0,
overrideAccess: true,
}),
]);
return scriptsRes.docs.map((script) => buildScriptView(script, submissionsRes.docs, user.id));
}
/** A single voiceover script in the same shape as `listScriptsForUser`, or null. */
export async function getScriptForUser(
payload: PayloadType,
user: User,
scriptId: number,
): Promise<VoiceoverScriptView | null> {
const script = await payload
.findByID({
collection: "voice-scripts",
id: scriptId,
depth: 0,
overrideAccess: true,
})
.catch(() => null);
if (!script) return null;
const submissionsRes = await payload.find({
collection: "voice-submissions",
where: { script: { equals: scriptId } },
sort: "-createdAt",
limit: 0,
depth: 0,
overrideAccess: true,
});
return buildScriptView(script, submissionsRes.docs, user.id);
}

120
src/lib/voiceover/pacing.ts Normal file
View file

@ -0,0 +1,120 @@
import type { VoiceoverSegment } from "./emotionTags";
export const DEFAULT_WORDS_PER_MINUTE = 140;
export const MIN_WORDS_PER_MINUTE = 60;
export const MAX_WORDS_PER_MINUTE = 300;
export interface PacingWord {
readonly text: string;
readonly whitespaceAfter: string;
readonly startMs: number;
readonly endMs: number;
}
export interface SegmentPacing {
readonly leadingWhitespace: string;
readonly words: ReadonlyArray<PacingWord>;
}
export interface ScrollDirective {
readonly atMs: number;
readonly multiplier: number;
}
export interface PacingTimeline {
readonly segmentPacing: ReadonlyArray<SegmentPacing | undefined>;
readonly scrollDirectives: ReadonlyArray<ScrollDirective>;
readonly totalMs: number;
}
const WORD_RATE_MULTIPLIERS: Record<string, number> = { slow: 1.5, fast: 0.7, normal: 1 };
const SCROLL_RATE_MULTIPLIERS: Record<string, number> = { slow: 0.6, fast: 1.5, normal: 1 };
const DIRECTIVE_PAUSE_MS = 1000;
const WORD_SPLIT_PATTERN = /(\s+)/;
const SENTENCE_PAUSE_PATTERN = /[.!?…]["')\]]?$/;
const CLAUSE_PAUSE_PATTERN = /[,;:—–-]["')\]]?$/;
function wordCost(word: string, baseMs: number): number {
const spokenLength = word.replace(/[^\p{L}\p{N}]/gu, "").length || 1;
const lengthFactor =
spokenLength <= 3 ? 0.7 : spokenLength <= 6 ? 1 : 1 + (spokenLength - 6) * 0.1;
return baseMs * lengthFactor;
}
function pauseAfter(word: string, whitespaceAfter: string, baseMs: number): number {
if (SENTENCE_PAUSE_PATTERN.test(word)) return baseMs * 0.9;
if (CLAUSE_PAUSE_PATTERN.test(word)) return baseMs * 0.4;
if (whitespaceAfter.includes("\n\n")) return baseMs * 0.6;
return 0;
}
export function buildPacingTimeline(
segments: ReadonlyArray<VoiceoverSegment>,
wordsPerMinute: number,
): PacingTimeline {
const clampedWpm = Math.min(MAX_WORDS_PER_MINUTE, Math.max(MIN_WORDS_PER_MINUTE, wordsPerMinute));
const baseMs = 60000 / clampedWpm;
const segmentPacing: Array<SegmentPacing | undefined> = [];
const scrollDirectives: ScrollDirective[] = [];
let cursorMs = 0;
let wordRate = 1;
for (const segment of segments) {
if (segment.directive) {
if (segment.directive === "pause") {
cursorMs += DIRECTIVE_PAUSE_MS;
} else if (segment.directive in WORD_RATE_MULTIPLIERS) {
wordRate = WORD_RATE_MULTIPLIERS[segment.directive] ?? 1;
scrollDirectives.push({
atMs: cursorMs,
multiplier: SCROLL_RATE_MULTIPLIERS[segment.directive] ?? 1,
});
}
segmentPacing.push(undefined);
continue;
}
if (segment.sfx || segment.text.length === 0) {
segmentPacing.push(undefined);
continue;
}
const pieces = segment.text.split(WORD_SPLIT_PATTERN);
const words: Array<{
text: string;
whitespaceAfter: string;
startMs: number;
endMs: number;
}> = [];
let leadingWhitespace = "";
for (const piece of pieces) {
if (piece === undefined || piece.length === 0) continue;
if (/^\s+$/.test(piece)) {
const lastWord = words[words.length - 1];
if (lastWord) {
lastWord.whitespaceAfter += piece;
} else {
leadingWhitespace += piece;
}
continue;
}
words.push({ text: piece, whitespaceAfter: "", startMs: 0, endMs: 0 });
}
for (const word of words) {
word.startMs = cursorMs;
const spokenMs =
(wordCost(word.text, baseMs) + pauseAfter(word.text, word.whitespaceAfter, baseMs)) *
wordRate;
word.endMs = cursorMs + spokenMs;
cursorMs = word.endMs;
}
segmentPacing.push({ leadingWhitespace, words });
}
return { segmentPacing, scrollDirectives, totalMs: cursorMs };
}