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:
parent
f83301a923
commit
44b78ba7d9
11 changed files with 1935 additions and 0 deletions
62
src/app/(frontend)/voiceover/[id]/page.tsx
Normal file
62
src/app/(frontend)/voiceover/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
303
src/app/(frontend)/voiceover/actions.ts
Normal file
303
src/app/(frontend)/voiceover/actions.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
174
src/app/(frontend)/voiceover/page.tsx
Normal file
174
src/app/(frontend)/voiceover/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
FileTextIcon,
|
||||
Gamepad2,
|
||||
LogOut,
|
||||
Mic,
|
||||
Radio,
|
||||
Users2,
|
||||
} from "lucide-react";
|
||||
|
|
@ -166,10 +167,26 @@ export function NavUser({
|
|||
Qualification Course
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/qualification">
|
||||
<Crosshair />
|
||||
Qualification Course
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Contribute</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/voiceover">
|
||||
<Mic />
|
||||
Voiceover
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/account">
|
||||
|
|
|
|||
373
src/components/frontend/voiceover/TeleprompterReader.tsx
Normal file
373
src/components/frontend/voiceover/TeleprompterReader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
425
src/components/frontend/voiceover/VoiceCapture.tsx
Normal file
425
src/components/frontend/voiceover/VoiceCapture.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/frontend/voiceover/VoiceoverRecorder.tsx
Normal file
112
src/components/frontend/voiceover/VoiceoverRecorder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/frontend/voiceover/VoiceoverTakes.tsx
Normal file
116
src/components/frontend/voiceover/VoiceoverTakes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/lib/voiceover/emotionTags.ts
Normal file
105
src/lib/voiceover/emotionTags.ts
Normal 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
128
src/lib/voiceover/index.ts
Normal 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
120
src/lib/voiceover/pacing.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue