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_TAKES_PER_SCRIPT = 5;
|
||||||
|
|
||||||
|
const MAX_DESCRIPTION_CHARS = 300;
|
||||||
|
|
||||||
async function authenticate() {
|
async function authenticate() {
|
||||||
const payloadConfig = await config;
|
const payloadConfig = await config;
|
||||||
const payload = await getPayload({ config: payloadConfig });
|
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");
|
const file = formData.get("file");
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
return { success: false, error: "No audio file provided." };
|
return { success: false, error: "No audio file provided." };
|
||||||
|
|
@ -119,6 +127,7 @@ export async function submitVoiceRecording(
|
||||||
script: scriptId,
|
script: scriptId,
|
||||||
user: user.id,
|
user: user.id,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
|
description,
|
||||||
},
|
},
|
||||||
file: {
|
file: {
|
||||||
data: Buffer.from(await file.arrayBuffer()),
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
|
const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
|
||||||
|
|
@ -16,7 +17,7 @@ const PREFERRED_MIME_TYPE = "audio/webm;codecs=opus";
|
||||||
export type VoiceCaptureSource = Blob | File;
|
export type VoiceCaptureSource = Blob | File;
|
||||||
|
|
||||||
interface VoiceCaptureProps {
|
interface VoiceCaptureProps {
|
||||||
readonly onSubmitTake: (source: VoiceCaptureSource) => Promise<boolean>;
|
readonly onSubmitTake: (source: VoiceCaptureSource, description: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(seconds: number): string {
|
function formatDuration(seconds: number): string {
|
||||||
|
|
@ -56,6 +57,7 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
|
||||||
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
|
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
|
||||||
const [reviewUrl, setReviewUrl] = useState<string | null>(null);
|
const [reviewUrl, setReviewUrl] = useState<string | null>(null);
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
const stopLevelMeter = useCallback(() => {
|
const stopLevelMeter = useCallback(() => {
|
||||||
if (meterAnimationRef.current !== null) {
|
if (meterAnimationRef.current !== null) {
|
||||||
|
|
@ -221,12 +223,13 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const submitted = await onSubmitTake(source);
|
const submitted = await onSubmitTake(source, description.trim());
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
if (!submitted) return;
|
if (!submitted) return;
|
||||||
|
|
||||||
clearReview();
|
clearReview();
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
setDescription("");
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,6 +280,19 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-5">
|
<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" && (
|
{support === "unsupported" && (
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,17 @@ export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScript
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isOpen = script.status === "open";
|
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();
|
const formData = new FormData();
|
||||||
if (source instanceof File) {
|
if (source instanceof File) {
|
||||||
formData.append("file", source, source.name);
|
formData.append("file", source, source.name);
|
||||||
} else {
|
} else {
|
||||||
formData.append("file", source, "take.webm");
|
formData.append("file", source, "take.webm");
|
||||||
}
|
}
|
||||||
|
if (description) formData.append("description", description);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await submitVoiceRecording(script.id, formData);
|
const result = await submitVoiceRecording(script.id, formData);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -81,6 +81,10 @@ export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps)
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{submission.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{submission.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{submission.audioUrl ? (
|
{submission.audioUrl ? (
|
||||||
<audio
|
<audio
|
||||||
controls
|
controls
|
||||||
|
|
@ -93,6 +97,18 @@ export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps)
|
||||||
<p className="text-sm text-muted-foreground">Audio playback is unavailable.</p>
|
<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 && (
|
{canWithdraw && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ export interface VoiceoverSubmissionSummary {
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
|
description: string | null;
|
||||||
|
reviewNote: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceoverScriptView {
|
export interface VoiceoverScriptView {
|
||||||
|
|
@ -38,6 +40,8 @@ function toSubmissionSummary(submission: VoiceSubmission): VoiceoverSubmissionSu
|
||||||
status: submission.status,
|
status: submission.status,
|
||||||
createdAt: submission.createdAt,
|
createdAt: submission.createdAt,
|
||||||
audioUrl: submissionAudioUrl(submission),
|
audioUrl: submissionAudioUrl(submission),
|
||||||
|
description: submission.description ?? null,
|
||||||
|
reviewNote: submission.reviewNote ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue