initial commit
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user