Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,15 @@ 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.
Speech-confirmed cursor advances are timestamped during recording with a
1.5-second correction for the local model's observed recognition delay. 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, optionally fades the final second to black, returns an
H.264/AAC MP4, and removes all temporary inputs. The final caption remains
visible for 1.5 seconds after its last timed word.

## Alignment approach

Expand Down
8 changes: 5 additions & 3 deletions docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ fixed scroll speed.
8. Resume from nearby script text and prompt movement catches up.
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.
lower-third line highlights the word currently being spoken. Either export
can optionally fade to black over its final second.

## Information architecture

Expand Down Expand Up @@ -88,8 +89,9 @@ 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.
- Subtitle export compensates for local-model recognition delay, keeps the
final lower-third on screen briefly, 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.
Expand Down
49 changes: 23 additions & 26 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "./localSpeech.js";
import {
buildAssSubtitles,
buildVideoFilter,
parseCaptionExportBody,
} from "./subtitleExport.js";

Expand Down Expand Up @@ -45,13 +46,6 @@ function resolveFfmpegPath(): string | 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");
Expand Down Expand Up @@ -142,15 +136,15 @@ app.post(
);

app.post(
"/api/recordings/subtitles",
"/api/recordings/render",
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.",
error: "Video rendering is not available on this computer.",
});
return;
}
Expand All @@ -177,20 +171,24 @@ app.post(
);
const inputPath = path.join(workingDirectory, "take.recording");
const subtitlePath = path.join(workingDirectory, "captions.ass");
const outputPath = path.join(workingDirectory, "take-subtitled.mp4");
const outputPath = path.join(workingDirectory, "take-rendered.mp4");

try {
await Promise.all([
writeFile(inputPath, parsedExport.recording),
writeFile(
subtitlePath,
buildAssSubtitles(
parsedExport.request.words,
parsedExport.request.fontFamily,
const writes = [writeFile(inputPath, parsedExport.recording)];
if (parsedExport.request.mode === "subtitles") {
writes.push(
writeFile(
subtitlePath,
buildAssSubtitles(
parsedExport.request.words,
parsedExport.request.fontFamily,
),
"utf8",
),
"utf8",
),
]);
);
}
await Promise.all(writes);
const videoFilter = buildVideoFilter(parsedExport.request);
await execFileAsync(
ffmpegPath,
[
Expand All @@ -204,8 +202,7 @@ app.post(
"0:v:0",
"-map",
"0:a:0",
"-vf",
assVideoFilter(),
...(videoFilter ? ["-vf", videoFilter] : []),
"-c:v",
"libx264",
"-preset",
Expand Down Expand Up @@ -235,17 +232,17 @@ app.post(
void rm(workingDirectory, { recursive: true, force: true });
if (sendError && !response.headersSent) {
response.status(500).json({
error: "The subtitled MP4 could not be returned.",
error: "The rendered 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);
error instanceof Error ? error.message : "Unknown video render error";
console.error("Video rendering failed:", message);
response.status(500).json({
error: "Subtitle export failed. Your clean recording is still safe.",
error: "Video rendering failed. Your clean recording is still safe.",
});
}
},
Expand Down
63 changes: 56 additions & 7 deletions server/subtitleExport.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {
VIDEO_EXPORT_FONTS,
FINAL_CAPTION_HOLD_MS,
captionPages,
type CaptionExportRequest,
type TimedWord,
type VideoExportFont,
type VideoRenderRequest,
} from "../src/lib/videoExport.js";

const MAX_METADATA_BYTES = 512 * 1024;
Expand All @@ -12,9 +13,11 @@ const MAX_VIDEO_DURATION_MS = 4 * 60 * 60 * 1_000;

export interface ParsedCaptionExport {
recording: Buffer;
request: CaptionExportRequest;
request: VideoRenderRequest;
}

const FADE_TO_BLACK_DURATION_MS = 1_000;

function isExportFont(value: unknown): value is VideoExportFont {
return VIDEO_EXPORT_FONTS.some((font) => font.family === value);
}
Expand Down Expand Up @@ -67,17 +70,33 @@ export function parseCaptionExportBody(body: Buffer): ParsedCaptionExport {
if (typeof metadata !== "object" || metadata === null) {
throw new Error("The caption export options are invalid.");
}
const candidate = metadata as Partial<CaptionExportRequest>;
const candidate = metadata as Partial<VideoRenderRequest>;
if (candidate.mode !== "clean" && candidate.mode !== "subtitles") {
throw new Error("Choose a supported video export style.");
}
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("The spoken-word timings are invalid.");
}
if (candidate.mode === "subtitles" && candidate.words.length === 0) {
throw new Error("No spoken-word timings were supplied.");
}
if (typeof candidate.fadeToBlack !== "boolean") {
throw new Error("The fade option is invalid.");
}
const videoDurationMs = Number(candidate.videoDurationMs);
if (
!Number.isFinite(videoDurationMs) ||
videoDurationMs <= 0 ||
videoDurationMs > MAX_VIDEO_DURATION_MS
) {
throw new Error("The video duration is invalid.");
}

const words = candidate.words.map(parseWord);
if (words.some((word) => word === null)) {
Expand All @@ -92,10 +111,32 @@ export function parseCaptionExportBody(body: Buffer): ParsedCaptionExport {

return {
recording: body.subarray(metadataLength + 4),
request: { fontFamily: candidate.fontFamily, words: parsedWords },
request: {
mode: candidate.mode,
fontFamily: candidate.fontFamily,
words: parsedWords,
fadeToBlack: candidate.fadeToBlack,
videoDurationMs: Math.round(videoDurationMs),
},
};
}

export function buildVideoFilter(request: VideoRenderRequest): string | null {
const filters = request.mode === "subtitles" ? ["ass=captions.ass"] : [];
if (request.fadeToBlack) {
const fadeStartMs = Math.max(
0,
request.videoDurationMs - FADE_TO_BLACK_DURATION_MS,
);
filters.push(
`fade=t=out:st=${(fadeStartMs / 1_000).toFixed(3)}:d=${(
FADE_TO_BLACK_DURATION_MS / 1_000
).toFixed(3)}:color=black`,
);
}
return filters.length > 0 ? filters.join(",") : null;
}

function escapeAssText(text: string): string {
return text
.replaceAll("\\", "\\\\")
Expand Down Expand Up @@ -131,11 +172,19 @@ export function buildAssSubtitles(
fontFamily: VideoExportFont,
): string {
const events: string[] = [];
for (const page of captionPages(words)) {
const pages = captionPages(words);
for (let pageIndex = 0; pageIndex < pages.length; pageIndex += 1) {
const page = pages[pageIndex];
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);
const nextPageStartMs = pages[pageIndex + 1]?.[0]?.startMs;
const endMs = Math.max(
word.startMs + 80,
nextWord?.startMs ??
nextPageStartMs ??
word.endMs + FINAL_CAPTION_HOLD_MS,
);
events.push(
[
"Dialogue: 0",
Expand Down
Loading