244 lines
6.0 KiB
TypeScript
244 lines
6.0 KiB
TypeScript
import type { DocumentInterface } from "@/srv/types/DocumentSchema";
|
|
import type { CaptionStyleConfigType } from "@/srv/types/CaptionStyleSchema";
|
|
import { serverConfig } from "@/srv/config";
|
|
import { statSync } from "node:fs";
|
|
|
|
const OUTPUT_DIR = "out";
|
|
const KILOBYTE = 1024;
|
|
const MEGABYTE = 1024 * KILOBYTE;
|
|
const MAX_CAPTURED_STREAM_OUTPUT_CHARS = 20_000;
|
|
const RENDER_CANCELLATION_POLL_MS = 1_000;
|
|
|
|
export const RENDER_CANCELLED_ERROR_MESSAGE = "Render cancelled";
|
|
|
|
export type RenderResult = {
|
|
output: string;
|
|
};
|
|
|
|
export type RenderProgress = {
|
|
renderedFrames: number;
|
|
totalFrames: number;
|
|
};
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes >= MEGABYTE) {
|
|
return `${(bytes / MEGABYTE).toFixed(1)} MB`;
|
|
}
|
|
|
|
if (bytes >= KILOBYTE) {
|
|
return `${(bytes / KILOBYTE).toFixed(1)} KB`;
|
|
}
|
|
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
function formatDuration(durationMs: number): string {
|
|
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
function appendCapturedOutput(current: string, nextLine: string): string {
|
|
const combined = current
|
|
? `${current}\n${nextLine}`
|
|
: nextLine;
|
|
|
|
if (combined.length <= MAX_CAPTURED_STREAM_OUTPUT_CHARS) {
|
|
return combined;
|
|
}
|
|
|
|
return combined.slice(-MAX_CAPTURED_STREAM_OUTPUT_CHARS);
|
|
}
|
|
|
|
function parseRenderProgressFromLine(
|
|
line: string,
|
|
onProgress?: (progress: RenderProgress) => void,
|
|
): void {
|
|
if (!onProgress) {
|
|
return;
|
|
}
|
|
|
|
const frameMatch = line.match(
|
|
/(?:Rendered?\s+frame|Rendered|Encoded)\s+(\d+)\s*\/\s*(\d+)/i,
|
|
);
|
|
|
|
if (!frameMatch) {
|
|
return;
|
|
}
|
|
|
|
onProgress({
|
|
renderedFrames: parseInt(frameMatch[1], 10),
|
|
totalFrames: parseInt(frameMatch[2], 10),
|
|
});
|
|
}
|
|
|
|
async function consumeProcessStream(
|
|
stream: ReadableStream<Uint8Array> | null | undefined,
|
|
label: "stdout" | "stderr",
|
|
onLine?: (line: string) => void,
|
|
): Promise<string> {
|
|
if (!stream) {
|
|
return "";
|
|
}
|
|
|
|
const reader = stream.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let captured = "";
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
const normalizedLine = line.trimEnd();
|
|
if (!normalizedLine) {
|
|
continue;
|
|
}
|
|
|
|
captured = appendCapturedOutput(captured, normalizedLine);
|
|
console.log(`[render:${label}] ${normalizedLine}`);
|
|
onLine?.(normalizedLine);
|
|
}
|
|
}
|
|
|
|
const tail = buffer.trim();
|
|
if (tail) {
|
|
captured = appendCapturedOutput(captured, tail);
|
|
console.log(`[render:${label}] ${tail}`);
|
|
onLine?.(tail);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
`[render:${label}] Stream reader closed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
|
|
return captured;
|
|
}
|
|
|
|
export async function renderCaptionedVideo(
|
|
transcription: DocumentInterface,
|
|
videoSrc: string,
|
|
filename: string,
|
|
styleConfig?: CaptionStyleConfigType,
|
|
onProgress?: (progress: RenderProgress) => void,
|
|
shouldCancel?: () => boolean | Promise<boolean>,
|
|
): Promise<RenderResult> {
|
|
const uniqueFilename = `${crypto.randomUUID()}-${filename}`;
|
|
const outputPath = `${OUTPUT_DIR}/${uniqueFilename}`;
|
|
const propsPath = `${OUTPUT_DIR}/${crypto.randomUUID()}.json`;
|
|
|
|
const props: Record<string, unknown> = {
|
|
videoSrc,
|
|
transcription,
|
|
fps: 30,
|
|
};
|
|
if (styleConfig) {
|
|
props.styleConfig = styleConfig;
|
|
}
|
|
|
|
await Bun.write(propsPath, JSON.stringify(props));
|
|
|
|
try {
|
|
const renderStartedAt = Date.now();
|
|
const args = [
|
|
"render",
|
|
"src/index.ts",
|
|
serverConfig.REMOTION_COMPOSITION_ID,
|
|
outputPath,
|
|
"--props",
|
|
propsPath,
|
|
];
|
|
|
|
const cmd = ["./node_modules/.bin/remotion", ...args];
|
|
console.log(`[render] Starting: ${cmd.join(" ")}`);
|
|
console.log(`[render] Source video: ${videoSrc}`);
|
|
console.log(`[render] Output: ${outputPath}, Props: ${propsPath}`);
|
|
|
|
const proc = Bun.spawn(cmd, {
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
});
|
|
let renderCancelled = false;
|
|
|
|
const stopRenderIfCancelled = async () => {
|
|
if (!shouldCancel || renderCancelled) {
|
|
return;
|
|
}
|
|
|
|
const cancellationRequested = await shouldCancel();
|
|
if (!cancellationRequested) {
|
|
return;
|
|
}
|
|
|
|
renderCancelled = true;
|
|
proc.kill();
|
|
};
|
|
|
|
const cancellationTimer = shouldCancel
|
|
? setInterval(() => {
|
|
void stopRenderIfCancelled();
|
|
}, RENDER_CANCELLATION_POLL_MS)
|
|
: null;
|
|
|
|
const handleProcessLine = (line: string) => {
|
|
parseRenderProgressFromLine(line, onProgress);
|
|
};
|
|
|
|
const stdoutPromise = consumeProcessStream(
|
|
proc.stdout,
|
|
"stdout",
|
|
handleProcessLine,
|
|
);
|
|
const stderrPromise = consumeProcessStream(
|
|
proc.stderr,
|
|
"stderr",
|
|
handleProcessLine,
|
|
);
|
|
|
|
const exitCode = await proc.exited;
|
|
if (cancellationTimer) {
|
|
clearInterval(cancellationTimer);
|
|
}
|
|
|
|
const [capturedStdout, capturedStderr] = await Promise.all([
|
|
stdoutPromise,
|
|
stderrPromise,
|
|
]);
|
|
|
|
if (renderCancelled) {
|
|
throw new Error(RENDER_CANCELLED_ERROR_MESSAGE);
|
|
}
|
|
|
|
if (exitCode !== 0) {
|
|
console.error(
|
|
`[render] Failed after ${formatDuration(Date.now() - renderStartedAt)} (exit ${exitCode})`,
|
|
);
|
|
if (capturedStdout) {
|
|
console.error(`[render] Last stdout:\n${capturedStdout}`);
|
|
}
|
|
if (capturedStderr) {
|
|
console.error(`[render] Last stderr:\n${capturedStderr}`);
|
|
}
|
|
throw new Error(
|
|
`Render process exited with code ${exitCode}: ${(capturedStderr || capturedStdout).slice(0, 2000)}`,
|
|
);
|
|
}
|
|
|
|
const outputSize = statSync(outputPath).size;
|
|
console.log(
|
|
`[render] Completed in ${formatDuration(Date.now() - renderStartedAt)} (${formatBytes(outputSize)})`,
|
|
);
|
|
|
|
return { output: outputPath };
|
|
} finally {
|
|
await Bun.file(propsPath).delete().catch(() => {});
|
|
}
|
|
}
|