1
0
Fork 0

feat(voiceover): collect take descriptions and surface reviewer feedback

Submitters can attach an optional 300-char note to each take, and their own takes list now shows the description plus the reviewer feedback that was previously admin-only.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-07 04:37:08 -04:00
parent ea386b9a33
commit 1b422ba1c2
5 changed files with 53 additions and 4 deletions

View file

@ -21,6 +21,8 @@ const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
const MAX_TAKES_PER_SCRIPT = 5;
const MAX_DESCRIPTION_CHARS = 300;
async function authenticate() {
const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig });
@ -99,6 +101,12 @@ export async function submitVoiceRecording(
};
}
const rawDescription = formData.get("description");
const description =
typeof rawDescription === "string" && rawDescription.trim().length > 0
? rawDescription.trim().slice(0, MAX_DESCRIPTION_CHARS)
: null;
const file = formData.get("file");
if (!(file instanceof File)) {
return { success: false, error: "No audio file provided." };
@ -119,6 +127,7 @@ export async function submitVoiceRecording(
script: scriptId,
user: user.id,
status: "pending",
description,
},
file: {
data: Buffer.from(await file.arrayBuffer()),

View file

@ -8,6 +8,7 @@ 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 { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
@ -16,7 +17,7 @@ const PREFERRED_MIME_TYPE = "audio/webm;codecs=opus";
export type VoiceCaptureSource = Blob | File;
interface VoiceCaptureProps {
readonly onSubmitTake: (source: VoiceCaptureSource) => Promise<boolean>;
readonly onSubmitTake: (source: VoiceCaptureSource, description: string) => Promise<boolean>;
}
function formatDuration(seconds: number): string {
@ -56,6 +57,7 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewUrl, setReviewUrl] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [description, setDescription] = useState("");
const stopLevelMeter = useCallback(() => {
if (meterAnimationRef.current !== null) {
@ -221,12 +223,13 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
}
setIsSubmitting(true);
const submitted = await onSubmitTake(source);
const submitted = await onSubmitTake(source, description.trim());
setIsSubmitting(false);
if (!submitted) return;
clearReview();
setSelectedFile(null);
setDescription("");
if (fileInputRef.current) fileInputRef.current.value = "";
}
@ -277,6 +280,19 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="voiceover-take-description">Describe your take (optional)</Label>
<Textarea
id="voiceover-take-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={300}
rows={2}
placeholder="Anything the reviewer should know about this take."
disabled={isSubmitting || isRecording}
/>
</div>
{support === "unsupported" && (
<div
role="alert"

View file

@ -19,13 +19,17 @@ export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScript
const router = useRouter();
const isOpen = script.status === "open";
async function handleSubmitTake(source: VoiceCaptureSource): Promise<boolean> {
async function handleSubmitTake(
source: VoiceCaptureSource,
description: string,
): Promise<boolean> {
const formData = new FormData();
if (source instanceof File) {
formData.append("file", source, source.name);
} else {
formData.append("file", source, "take.webm");
}
if (description) formData.append("description", description);
try {
const result = await submitVoiceRecording(script.id, formData);

View file

@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { CheckIcon, Clock3Icon, Trash2Icon, XIcon } from "lucide-react";
import { CheckIcon, Clock3Icon, MessageSquareQuoteIcon, Trash2Icon, XIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -81,6 +81,10 @@ export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps)
</time>
</div>
{submission.description && (
<p className="text-sm text-muted-foreground">{submission.description}</p>
)}
{submission.audioUrl ? (
<audio
controls
@ -93,6 +97,18 @@ export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps)
<p className="text-sm text-muted-foreground">Audio playback is unavailable.</p>
)}
{submission.reviewNote && (
<div className="flex gap-2 rounded-md border border-border bg-background/50 p-3">
<MessageSquareQuoteIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Reviewer feedback
</span>
<p className="text-sm">{submission.reviewNote}</p>
</div>
</div>
)}
{canWithdraw && (
<Button
type="button"

View file

@ -7,6 +7,8 @@ export interface VoiceoverSubmissionSummary {
status: string;
createdAt: string;
audioUrl: string;
description: string | null;
reviewNote: string | null;
}
export interface VoiceoverScriptView {
@ -38,6 +40,8 @@ function toSubmissionSummary(submission: VoiceSubmission): VoiceoverSubmissionSu
status: submission.status,
createdAt: submission.createdAt,
audioUrl: submissionAudioUrl(submission),
description: submission.description ?? null,
reviewNote: submission.reviewNote ?? null,
};
}