From 31aaba979b83e26590e680ca7475a9aa8160948a Mon Sep 17 00:00:00 2001 From: Nick Beaugeard Date: Fri, 28 Aug 2026 15:36:25 +1000 Subject: [PATCH 1/2] feat: add highlighted subtitle video export --- README.md | 16 +-- docs/ARCHITECTURE.md | 21 +++- docs/PRODUCT.md | 12 +- server/index.ts | 137 ++++++++++++++++++++- server/subtitleExport.ts | 173 ++++++++++++++++++++++++++ src/components/Studio.tsx | 216 ++++++++++++++++++++++++++++++--- src/hooks/useSpeechFollower.ts | 39 +++++- src/lib/studioPreferences.ts | 21 ++++ src/lib/videoExport.ts | 141 +++++++++++++++++++++ src/styles.css | 131 ++++++++++++++++++++ tests/studioControls.test.ts | 18 +++ tests/videoExport.test.ts | 67 ++++++++++ 12 files changed, 961 insertions(+), 31 deletions(-) create mode 100644 server/subtitleExport.ts create mode 100644 src/lib/videoExport.ts create mode 100644 tests/videoExport.test.ts diff --git a/README.md b/README.md index f717e37..4e55409 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ heard again, it resumes. The prompt overlay is never burned into the recording. - Pause prompt movement when speech leaves the script without pausing recording. - Choose current-word, current-line, or highlight-free prompt scrolling. - Place the prompt eye-line in the upper, middle, or lower part of the preview. -- Review and save every finished take as an MP4 with H.264 video and AAC audio. +- Review and export either a clean take or an H.264/AAC MP4 with a polished + single-line lower third that highlights each spoken word in a chosen font. - Install the app as a PWA or packaged Microsoft Store desktop app on Windows. ## Run locally @@ -77,7 +78,8 @@ to exercise the same loopback WebSocket used by Studio. The complete MVP is targeted at the packaged Windows desktop app and current Edge or Chrome on Windows. The app records native MP4 when available; otherwise the local API converts the browser's WebM take to MP4 with its bundled FFmpeg -binary. Speech-following streams PCM only to the PrompterPro API at `127.0.0.1`, +binary. Optional highlighted subtitles are rendered by that same local binary. +Speech-following streams PCM only to the PrompterPro API at `127.0.0.1`, where sherpa-onnx performs recognition. If the local model is not installed, recording still works and the prompt can be moved with the on-screen controls or arrow keys. @@ -97,8 +99,8 @@ Set `SHERPA_ONNX_MODEL_DIR` to use the same model from another location, or Scripts are stored locally in browser storage. Camera and microphone media stay on this computer. Microphone samples and transcripts used for prompt following -travel only over the loopback interface to the local sherpa-onnx engine. A WebM -take may pass through the same local API for MP4 conversion and is removed from -its temporary folder after export. Only the optional AI generation form is sent -to OpenAI; camera, microphone, recordings, transcripts, and the script library -are not. +travel only over the loopback interface to the local sherpa-onnx engine. A take +may pass through the same local API for MP4 conversion or subtitle rendering and +is removed from its temporary folder after export. Only the optional AI +generation form is sent to OpenAI; camera, microphone, recordings, transcripts, +and the script library are not. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b0227f7..24b6307 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,13 @@ flowchart LR PCM -->|"Loopback WebSocket"| STT["Local sherpa-onnx"] STT --> ALIGN["Bounded fuzzy alignment"] ALIGN --> OVERLAY["Prompt overlay"] + ALIGN --> WORDS["Timed spoken words"] + WORDS --> ASS["Local ASS lower third"] + FILE --> STYLE{"Subtitle export?"} + STYLE -->|"No"| DOWNLOAD["Clean download"] + STYLE -->|"Yes"| ASS + ASS --> RENDER["Local FFmpeg subtitle render"] + RENDER --> DOWNLOAD UI --> API["Local Node API"] API --> OAI["OpenAI Responses API"] OAI --> API @@ -50,10 +57,14 @@ original `MediaStream`, not a canvas composition, so the overlay is not recorded stable application data directory so random loopback ports do not split it across desktop sessions. - `src/components/Studio.tsx`: media device and recorder state machine. +- `src/lib/videoExport.ts`: spoken-word timing backfill, lower-third paging, + preview selection, and the local binary export envelope. - `src/components/TeleprompterOverlay.tsx`: prompt rendering, eye-line, and current-word scrolling. - `server/index.ts`: input validation, local MP4 conversion, and server-side OpenAI call. +- `server/subtitleExport.ts`: strict caption-envelope parsing and deterministic + ASS generation with active-word styling. - `scripts/build-server.mjs`: bundles the local API's JavaScript dependencies while leaving only FFmpeg and the native speech loader external. - `desktop/main.mjs`: hardened Electron window, media permissions and lifecycle @@ -75,6 +86,13 @@ available, it records a WebM/Opus take and posts it only to the loopback API. The API converts it to H.264/AAC MP4 with the bundled FFmpeg executable, returns the file for review/save, and removes its temporary working directory. +Speech-confirmed cursor advances are timestamped during recording. Review keeps +the clean recording as the source of truth and can preview those words as a +single lower-third line. A subtitle export sends the clean take, selected font, +and validated word timings only to the loopback API. The API builds a temporary +ASS track, re-encodes the video with libass so the active word is enlarged and +accented, returns an H.264/AAC MP4, and removes all temporary inputs. + ## Alignment approach The browser uses an `AudioWorklet` to copy mono PCM from the recording's @@ -151,6 +169,7 @@ before promising equivalent background behavior or codec support. - Recognition speed and quality depend on the Windows computer's CPU and microphone. - Long recordings consume memory until stopped and downloaded. -- MP4 fallback conversion briefly uses additional local disk, CPU, and memory. +- MP4 fallback conversion and optional subtitle rendering briefly use additional + local disk, CPU, and memory. - Scripts do not yet sync across devices or users. - There is no account, cloud library, or server-side recording store. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 3a26e11..29b8961 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -19,7 +19,9 @@ fixed scroll speed. 7. If speech does not match nearby script words, prompt movement pauses and the recording continues. 8. Resume from nearby script text and prompt movement catches up. -9. Stop, review, and save the finished MP4. +9. Stop and review the finished take. +10. Save a clean video, or choose a font and export a subtitled MP4 whose + lower-third line highlights the word currently being spoken. ## Information architecture @@ -43,7 +45,7 @@ recording and lets Studio devote most of the screen to eye-line and readability. | Recording / following | Green status and moving current phrase | Stop, move prompt manually | | Recording / off script | Amber status; prompt stays put | Keep speaking or return to nearby text | | Processing | Recording finalization | Wait briefly | -| Review | MP4 playback, save, new take | Save MP4 or record again | +| Review | Playback plus clean/subtitled style and font controls | Export video or record again | | Error | Specific recovery message | Retry devices or use manual prompt | ## Voice-following behavior @@ -66,8 +68,8 @@ recording and lets Studio devote most of the screen to eye-line and readability. - Large controls with visible keyboard focus. - `Space` starts/stops recording outside editable fields. - Arrow keys move the prompt in Studio. -- Prompt font size, vertical eye-line, mirror mode, and word/line highlighting - are adjustable. +- Prompt font size, vertical eye-line, mirror mode, word/line highlighting, and + exported subtitle font are adjustable. - Reduced-motion preferences disable smooth scrolling and decorative animation. - The presenter must explicitly grant camera and microphone permissions. @@ -86,6 +88,8 @@ recording and lets Studio devote most of the screen to eye-line and readability. - A user can complete the full journey without a developer tool. - A saved MP4 contains synchronized H.264 video and AAC microphone audio. +- Subtitle export shows one lower-third line and highlights the active spoken + word without adding the teleprompter overlay to the clean source. - Prompt movement responds to matching speech and stops for unmatched speech. - Recording duration continues to increase while prompt status is off-script. - Script CRUD persists after a refresh. diff --git a/server/index.ts b/server/index.ts index 1700025..daca197 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,4 +1,5 @@ import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { createRequire } from "node:module"; @@ -13,6 +14,10 @@ import { attachLocalSpeechServer, getSpeechModelStatus, } from "./localSpeech.js"; +import { + buildAssSubtitles, + parseCaptionExportBody, +} from "./subtitleExport.js"; const currentFile = fileURLToPath(import.meta.url); const serverDirectory = path.dirname(currentFile); @@ -27,7 +32,27 @@ const production = process.env.PROMPTER_PRODUCTION === "1"; const execFileAsync = promisify(execFile); const require = createRequire(import.meta.url); -const ffmpegPath = require("ffmpeg-static") as string | null; +const importedFfmpegPath = require("ffmpeg-static") as string | null; + +function resolveFfmpegPath(): string | null { + const candidates = [ + importedFfmpegPath, + importedFfmpegPath?.replace("app.asar", "app.asar.unpacked"), + path.join(rootDirectory, "node_modules", "ffmpeg-static", "ffmpeg.exe"), + ]; + return ( + candidates.find((candidate) => candidate && existsSync(candidate)) ?? null + ); +} + +function assVideoFilter(): string { + // The bundled Windows FFmpeg build uses fontconfig's system-font provider. + // Keeping the ASS path relative also avoids drive-letter escaping being + // misread by libass as another filter option. + return "ass=captions.ass"; +} + +const ffmpegPath = resolveFfmpegPath(); app.disable("x-powered-by"); @@ -116,6 +141,116 @@ app.post( }, ); +app.post( + "/api/recordings/subtitles", + express.raw({ + type: "application/x-prompter-export", + limit: "1gb", + }), + async (request, response) => { + if (!ffmpegPath) { + response.status(503).json({ + error: "Subtitle export is not available on this computer.", + }); + return; + } + if (!Buffer.isBuffer(request.body) || request.body.length === 0) { + response.status(400).json({ error: "No recorded video was supplied." }); + return; + } + + let parsedExport; + try { + parsedExport = parseCaptionExportBody(request.body); + } catch (error) { + response.status(400).json({ + error: + error instanceof Error + ? error.message + : "The subtitle export options are invalid.", + }); + return; + } + + const workingDirectory = await mkdtemp( + path.join(tmpdir(), "prompter-subtitle-export-"), + ); + const inputPath = path.join(workingDirectory, "take.recording"); + const subtitlePath = path.join(workingDirectory, "captions.ass"); + const outputPath = path.join(workingDirectory, "take-subtitled.mp4"); + + try { + await Promise.all([ + writeFile(inputPath, parsedExport.recording), + writeFile( + subtitlePath, + buildAssSubtitles( + parsedExport.request.words, + parsedExport.request.fontFamily, + ), + "utf8", + ), + ]); + await execFileAsync( + ffmpegPath, + [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + inputPath, + "-map", + "0:v:0", + "-map", + "0:a:0", + "-vf", + assVideoFilter(), + "-c:v", + "libx264", + "-preset", + "veryfast", + "-crf", + "20", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + outputPath, + ], + { + cwd: workingDirectory, + maxBuffer: 16 * 1024 * 1024, + windowsHide: true, + }, + ); + + response.type("video/mp4"); + response.setHeader("Cache-Control", "no-store"); + response.sendFile(outputPath, (sendError) => { + void rm(workingDirectory, { recursive: true, force: true }); + if (sendError && !response.headersSent) { + response.status(500).json({ + error: "The subtitled MP4 could not be returned.", + }); + } + }); + } catch (error) { + await rm(workingDirectory, { recursive: true, force: true }); + const message = + error instanceof Error ? error.message : "Unknown subtitle export error"; + console.error("Subtitle export failed:", message); + response.status(500).json({ + error: "Subtitle export failed. Your clean recording is still safe.", + }); + } + }, +); + app.use(express.json({ limit: "32kb" })); interface GenerateBody { diff --git a/server/subtitleExport.ts b/server/subtitleExport.ts new file mode 100644 index 0000000..f1f07aa --- /dev/null +++ b/server/subtitleExport.ts @@ -0,0 +1,173 @@ +import { + VIDEO_EXPORT_FONTS, + captionPages, + type CaptionExportRequest, + type TimedWord, + type VideoExportFont, +} from "../src/lib/videoExport.js"; + +const MAX_METADATA_BYTES = 512 * 1024; +const MAX_CAPTION_WORDS = 10_000; +const MAX_VIDEO_DURATION_MS = 4 * 60 * 60 * 1_000; + +export interface ParsedCaptionExport { + recording: Buffer; + request: CaptionExportRequest; +} + +function isExportFont(value: unknown): value is VideoExportFont { + return VIDEO_EXPORT_FONTS.some((font) => font.family === value); +} + +function parseWord(value: unknown): TimedWord | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as Partial; + const text = typeof candidate.text === "string" + ? candidate.text.trim().replaceAll(/\s+/g, " ").slice(0, 80) + : ""; + const startMs = Number(candidate.startMs); + const endMs = Number(candidate.endMs); + + if ( + !text || + !Number.isFinite(startMs) || + !Number.isFinite(endMs) || + startMs < 0 || + endMs <= startMs || + endMs > MAX_VIDEO_DURATION_MS + ) { + return null; + } + + return { + text, + startMs: Math.round(startMs), + endMs: Math.round(endMs), + }; +} + +export function parseCaptionExportBody(body: Buffer): ParsedCaptionExport { + if (body.length < 5) throw new Error("The caption export is empty."); + const metadataLength = body.readUInt32BE(0); + if ( + metadataLength === 0 || + metadataLength > MAX_METADATA_BYTES || + metadataLength + 4 >= body.length + ) { + throw new Error("The caption export metadata is invalid."); + } + + let metadata: unknown; + try { + metadata = JSON.parse(body.subarray(4, metadataLength + 4).toString("utf8")); + } catch { + throw new Error("The caption export metadata is not valid JSON."); + } + + if (typeof metadata !== "object" || metadata === null) { + throw new Error("The caption export options are invalid."); + } + const candidate = metadata as Partial; + if (!isExportFont(candidate.fontFamily)) { + throw new Error("Choose a supported subtitle font."); + } + if ( + !Array.isArray(candidate.words) || + candidate.words.length === 0 || + candidate.words.length > MAX_CAPTION_WORDS + ) { + throw new Error("No spoken-word timings were supplied."); + } + + const words = candidate.words.map(parseWord); + if (words.some((word) => word === null)) { + throw new Error("One or more spoken-word timings are invalid."); + } + const parsedWords = words as TimedWord[]; + for (let index = 1; index < parsedWords.length; index += 1) { + if (parsedWords[index].startMs < parsedWords[index - 1].endMs) { + throw new Error("Spoken-word timings must be chronological."); + } + } + + return { + recording: body.subarray(metadataLength + 4), + request: { fontFamily: candidate.fontFamily, words: parsedWords }, + }; +} + +function escapeAssText(text: string): string { + return text + .replaceAll("\\", "\\\\") + .replaceAll("{", "\\{") + .replaceAll("}", "\\}") + .replaceAll("\n", " ") + .replaceAll("\r", " "); +} + +function formatAssTime(milliseconds: number): string { + const centiseconds = Math.max(0, Math.round(milliseconds / 10)); + const hours = Math.floor(centiseconds / 360_000); + const minutes = Math.floor((centiseconds % 360_000) / 6_000); + const seconds = Math.floor((centiseconds % 6_000) / 100); + const remainder = centiseconds % 100; + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds + .toString() + .padStart(2, "0")}.${remainder.toString().padStart(2, "0")}`; +} + +function highlightedLine(words: TimedWord[], activeIndex: number): string { + return words + .map((word, index) => { + const text = escapeAssText(word.text); + if (index !== activeIndex) return text; + return `{\\c&H006AFFD4&\\b1\\fscx108\\fscy108}${text}{\\rLowerThird}`; + }) + .join(" "); +} + +export function buildAssSubtitles( + words: TimedWord[], + fontFamily: VideoExportFont, +): string { + const events: string[] = []; + for (const page of captionPages(words)) { + for (let index = 0; index < page.length; index += 1) { + const word = page[index]; + const nextWord = page[index + 1]; + const endMs = Math.max(word.startMs + 80, nextWord?.startMs ?? word.endMs); + events.push( + [ + "Dialogue: 0", + formatAssTime(word.startMs), + formatAssTime(endMs), + "LowerThird", + "", + "0", + "0", + "0", + "", + highlightedLine(page, index), + ].join(","), + ); + } + } + + return [ + "[Script Info]", + "ScriptType: v4.00+", + "PlayResX: 1920", + "PlayResY: 1080", + "ScaledBorderAndShadow: yes", + "WrapStyle: 2", + "", + "[V4+ Styles]", + "Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour,BackColour,Bold,Italic,Underline,StrikeOut,ScaleX,ScaleY,Spacing,Angle,BorderStyle,Outline,Shadow,Alignment,MarginL,MarginR,MarginV,Encoding", + `Style: LowerThird,${fontFamily},60,&H00F6F8F5,&H00F6F8F5,&H00101010,&H90060809,-1,0,0,0,100,100,0.8,0,3,11,0,2,76,76,92,1`, + "", + "[Events]", + "Format: Layer,Start,End,Style,Name,MarginL,MarginR,MarginV,Effect,Text", + ...events, + "", + ].join("\r\n"); +} diff --git a/src/components/Studio.tsx b/src/components/Studio.tsx index 418a5cd..562442d 100644 --- a/src/components/Studio.tsx +++ b/src/components/Studio.tsx @@ -15,6 +15,7 @@ import { Plus, RefreshCw, SlidersHorizontal, + Sparkles, Square, Video, } from "lucide-react"; @@ -42,6 +43,15 @@ import { loadStudioPreferences, updateStudioPreferences, } from "../lib/studioPreferences"; +import { + VIDEO_EXPORT_FONTS, + VIDEO_EXPORT_MIME_TYPE, + activeCaptionPage, + makeCaptionExportBody, + type TimedWord, + type VideoExportFont, + type VideoExportMode, +} from "../lib/videoExport"; import type { PrompterScript } from "../types"; import { TeleprompterOverlay } from "./TeleprompterOverlay"; @@ -98,6 +108,40 @@ async function makeMp4(recording: Blob): Promise { return new Blob([converted], { type: "video/mp4" }); } +async function makeSubtitledMp4( + recording: Blob, + words: TimedWord[], + fontFamily: VideoExportFont, +): Promise { + const response = await fetch("/api/recordings/subtitles", { + method: "POST", + headers: { "Content-Type": VIDEO_EXPORT_MIME_TYPE }, + body: makeCaptionExportBody(recording, { fontFamily, words }), + }); + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error || "The subtitled MP4 could not be exported."); + } + + const exported = await response.blob(); + if (exported.size === 0) { + throw new Error("The subtitle exporter returned an empty file."); + } + return new Blob([exported], { type: "video/mp4" }); +} + +function downloadBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1_000); +} + export function Studio({ script, onBack, @@ -116,6 +160,14 @@ export function Studio({ const [captionMode, setCaptionMode] = useState( initialStudioPreferences.captionMode, ); + const [exportMode, setExportMode] = useState( + initialStudioPreferences.exportMode, + ); + const [exportFont, setExportFont] = useState( + initialStudioPreferences.exportFont, + ); + const [exporting, setExporting] = useState(false); + const [reviewTimeMs, setReviewTimeMs] = useState(0); const [recordingUrl, setRecordingUrl] = useState(""); const [recordingType, setRecordingType] = useState(""); const [availableCameras, setAvailableCameras] = useState([]); @@ -138,6 +190,7 @@ export function Studio({ const recorderRef = useRef(null); const chunksRef = useRef([]); const recordingUrlRef = useRef(""); + const recordingBlobRef = useRef(null); const explicitDeviceSelectionRef = useRef(false); const componentActiveRef = useRef(true); const shouldRestoreDevicesRef = useRef( @@ -151,6 +204,7 @@ export function Studio({ ); const resetFollower = follower.reset; const movePrompt = follower.move; + const getTimedWords = follower.getTimedWords; useEffect(() => { if (!devicesOpen) return; @@ -320,8 +374,14 @@ export function Studio({ }, [enableDevices, initialStudioPreferences]); useEffect(() => { - updateStudioPreferences({ fontSize, promptPosition, captionMode }); - }, [captionMode, fontSize, promptPosition]); + updateStudioPreferences({ + fontSize, + promptPosition, + captionMode, + exportMode, + exportFont, + }); + }, [captionMode, exportFont, exportMode, fontSize, promptPosition]); const changeDevice = useCallback( async (kind: "camera" | "microphone", deviceId: string) => { @@ -417,7 +477,9 @@ export function Studio({ URL.revokeObjectURL(recordingUrlRef.current); } recordingUrlRef.current = ""; + recordingBlobRef.current = null; setRecordingUrl(""); + setReviewTimeMs(0); recorder.ondataavailable = (event) => { if (event.data.size > 0) chunksRef.current.push(event.data); @@ -436,6 +498,7 @@ export function Studio({ .then((mp4Recording) => { const url = URL.createObjectURL(mp4Recording); recordingUrlRef.current = url; + recordingBlobRef.current = mp4Recording; setRecordingType("video/mp4"); setRecordingUrl(url); setStudioState("review"); @@ -443,6 +506,7 @@ export function Studio({ .catch((conversionError) => { const url = URL.createObjectURL(rawRecording); recordingUrlRef.current = url; + recordingBlobRef.current = rawRecording; setRecordingType(type); setRecordingUrl(url); setError( @@ -488,15 +552,46 @@ export function Studio({ URL.revokeObjectURL(recordingUrlRef.current); } recordingUrlRef.current = ""; + recordingBlobRef.current = null; setRecordingUrl(""); setRecordingType(""); setError(""); setElapsed(0); + setReviewTimeMs(0); + setExporting(false); resetFollower(); setStudioState(streamRef.current ? "ready" : "setup"); window.setTimeout(attachPreview, 0); }, [attachPreview, resetFollower]); + const exportSubtitledTake = useCallback(async () => { + const recording = recordingBlobRef.current; + const words = getTimedWords(); + if (!recording || words.length === 0 || exporting) return; + + setError(""); + setExporting(true); + try { + const subtitled = await makeSubtitledMp4(recording, words, exportFont); + const timestamp = new Date() + .toISOString() + .slice(0, 19) + .replaceAll(":", "-"); + downloadBlob( + subtitled, + `${safeFileName(script.title)}-${timestamp}-subtitled.mp4`, + ); + } catch (caught) { + setError( + caught instanceof Error + ? `${caught.message} Your clean recording is still available.` + : "Subtitle export failed. Your clean recording is still available.", + ); + } finally { + setExporting(false); + } + }, [exportFont, exporting, getTimedWords, script.title]); + const handleBack = useCallback(() => { onBack(); }, [onBack]); @@ -621,7 +716,9 @@ export function Studio({ }, []); const statusLabel = - follower.status === "following" + studioState === "review" + ? `${follower.timedWords.length} timed words ready` + : follower.status === "following" ? "Following your voice" : follower.status === "off-script" ? "Off script · prompt paused" @@ -633,6 +730,14 @@ export function Studio({ const mp4Ready = recordingType.includes("mp4"); const downloadExtension = mp4Ready ? "mp4" : "webm"; + const cleanDownloadName = `${safeFileName(script.title)}-${new Date() + .toISOString() + .slice(0, 19) + .replaceAll(":", "-")}.${downloadExtension}`; + const captionPreview = + exportMode === "subtitles" + ? activeCaptionPage(follower.timedWords, reviewTimeMs) + : null; const promptPositionLabel = promptPosition[0].toUpperCase() + promptPosition.slice(1); const captionModeLabel = @@ -813,6 +918,12 @@ export function Studio({ src={recordingUrl} controls playsInline + onTimeUpdate={(event) => + setReviewTimeMs(event.currentTarget.currentTime * 1_000) + } + onSeeked={(event) => + setReviewTimeMs(event.currentTarget.currentTime * 1_000) + } /> ) : (