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:
parent
ea386b9a33
commit
1b422ba1c2
5 changed files with 53 additions and 4 deletions
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue