initial commit

This commit is contained in:
Daniil
2026-05-14 02:23:02 +03:00
commit b8b8247ff3
34 changed files with 3297 additions and 0 deletions
+243
View File
@@ -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(() => {});
}
}