feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync

This commit is contained in:
Renato
2026-07-02 16:12:23 +02:00
parent 50bdaedebb
commit c7c3c4b74a
154 changed files with 36256 additions and 3 deletions
+43
View File
@@ -0,0 +1,43 @@
import path from "node:path";
import { bundle } from "@remotion/bundler";
let bundleLocation: string | null = null;
/**
* Bundles the Remotion project on startup and caches the result.
* Uses REMOTION_BUNDLE_PATH env var to locate the remotion source
* (default: "../remotion" relative to this service's root).
*/
export async function initBundle(): Promise<void> {
const remotionRoot = process.env.REMOTION_BUNDLE_PATH
? path.resolve(process.env.REMOTION_BUNDLE_PATH)
: path.resolve(import.meta.dirname, "../../remotion");
const entryPoint = path.join(remotionRoot, "src", "index.ts");
console.log(`[bundle] Bundling Remotion project from: ${entryPoint}`);
bundleLocation = await bundle({
entryPoint,
onProgress: (progress: number) => {
if (progress % 10 === 0) {
console.log(`[bundle] Progress: ${progress}%`);
}
},
});
console.log(`[bundle] Bundle created at: ${bundleLocation}`);
}
/**
* Returns the cached bundle location.
* Throws if initBundle() has not been called yet.
*/
export function getBundleLocation(): string {
if (!bundleLocation) {
throw new Error(
"Bundle not initialized. Call initBundle() before getBundleLocation()."
);
}
return bundleLocation;
}
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { selectComposition, renderMedia } from "@remotion/renderer";
import { getBundleLocation } from "./bundle.js";
import { renderJobs } from "./server.js";
export interface RenderParams {
renderId: string;
jobId: string;
clipIndex: number;
props: {
videoUrl: string;
durationInFrames: number;
fps: number;
width: number;
height: number;
subtitles: unknown;
hook: unknown;
effects: unknown;
};
}
/**
* Executes a Remotion render in the background.
* Updates the in-memory render job map with progress and final status.
*/
export async function executeRender(params: RenderParams): Promise<void> {
const { renderId, jobId, clipIndex, props } = params;
const job = renderJobs.get(renderId);
if (!job) {
console.error(`[render-worker] Job ${renderId} not found in map`);
return;
}
try {
job.status = "rendering";
job.progress = 0;
console.log(
`[render-worker] Starting render ${renderId} (job=${jobId}, clip=${clipIndex})`
);
const bundleLocation = getBundleLocation();
// Select the composition with the provided input props
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "ShortVideo",
inputProps: props,
});
// Determine output directory and file path
const outputDir = process.env.OUTPUT_DIR
? path.resolve(process.env.OUTPUT_DIR)
: path.resolve(import.meta.dirname, "../../output");
const jobOutputDir = path.join(outputDir, jobId);
fs.mkdirSync(jobOutputDir, { recursive: true });
const timestamp = Date.now();
const outputFileName = `remotion_${clipIndex}_${timestamp}.mp4`;
const outputLocation = path.join(jobOutputDir, outputFileName);
console.log(`[render-worker] Output: ${outputLocation}`);
// Render the video
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "h264",
crf: 22,
outputLocation,
onProgress: ({ progress }) => {
const percent = Math.round(progress * 100);
job.progress = percent;
if (percent % 10 === 0) {
console.log(`[render-worker] ${renderId} progress: ${percent}%`);
}
},
});
// Success
job.status = "done";
job.progress = 100;
job.outputUrl = outputLocation;
console.log(`[render-worker] Render ${renderId} completed: ${outputLocation}`);
} catch (err) {
job.status = "error";
job.error = err instanceof Error ? err.message : String(err);
console.error(`[render-worker] Render ${renderId} failed:`, err);
}
}
+166
View File
@@ -0,0 +1,166 @@
import express from "express";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { initBundle } from "./bundle.js";
import { executeRender } from "./render-worker.js";
// --- Render status types ---
export type RenderStatus = "queued" | "rendering" | "done" | "error";
export interface RenderJob {
renderId: string;
jobId: string;
clipIndex: number;
status: RenderStatus;
progress: number;
outputUrl?: string;
error?: string;
}
// In-memory render job map
export const renderJobs = new Map<string, RenderJob>();
// --- Request validation schema ---
const renderRequestSchema = z.object({
jobId: z.string().min(1),
clipIndex: z.number().int().min(0),
props: z.object({
videoUrl: z.string(),
durationInFrames: z.number().int().positive(),
fps: z.number().positive(),
width: z.number().int().positive(),
height: z.number().int().positive(),
subtitles: z.any().nullable().optional(),
hook: z.any().nullable().optional(),
effects: z.any().nullable().optional(),
}),
});
// --- Express app ---
const app = express();
app.use(express.json({ limit: "10mb" }));
const PORT = parseInt(process.env.PORT || "3100", 10);
const OUTPUT_DIR = process.env.OUTPUT_DIR || "/output";
// Serve video files from the shared output volume so Remotion can access them via HTTP
app.use("/output", express.static(OUTPUT_DIR));
// Health check
app.get("/health", (_req, res) => {
res.json({ ok: true });
});
// Submit a render job
app.post("/render", (req, res) => {
const parsed = renderRequestSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: "Invalid request body",
details: parsed.error.issues,
});
return;
}
const { jobId, clipIndex, props } = parsed.data;
const renderId = uuidv4();
const job: RenderJob = {
renderId,
jobId,
clipIndex,
status: "queued",
progress: 0,
};
renderJobs.set(renderId, job);
console.log(
`[render] Queued render ${renderId} for job=${jobId} clip=${clipIndex}`
);
// Resolve video URL: convert frontend/backend URLs to renderer's own static server
// The renderer serves /output/* from the shared Docker volume
let resolvedVideoUrl = props.videoUrl;
const videoPathMatch = props.videoUrl.match(/\/videos\/([^/]+)\/(.+)$/);
if (videoPathMatch) {
resolvedVideoUrl = `http://localhost:${PORT}/output/${videoPathMatch[1]}/${videoPathMatch[2]}`;
console.log(`[render] Resolved video URL: ${props.videoUrl} -> ${resolvedVideoUrl}`);
}
// Fire and forget - render runs in background
executeRender({
renderId,
jobId,
clipIndex,
props: {
videoUrl: resolvedVideoUrl,
durationInFrames: props.durationInFrames,
fps: props.fps,
width: props.width,
height: props.height,
subtitles: props.subtitles ?? null,
hook: props.hook ?? null,
effects: props.effects ?? null,
},
}).catch((err) => {
console.error(`[render] Unhandled error for ${renderId}:`, err);
const existingJob = renderJobs.get(renderId);
if (existingJob) {
existingJob.status = "error";
existingJob.error =
err instanceof Error ? err.message : "Unknown error";
}
});
res.status(202).json({ renderId, status: "queued" });
});
// Get render status
app.get("/render/:renderId", (req, res) => {
const { renderId } = req.params;
const job = renderJobs.get(renderId);
if (!job) {
res.status(404).json({ error: "Render not found" });
return;
}
const response: Record<string, unknown> = {
renderId: job.renderId,
status: job.status,
};
if (job.progress !== undefined) {
response.progress = job.progress;
}
if (job.outputUrl) {
response.outputUrl = job.outputUrl;
}
if (job.error) {
response.error = job.error;
}
res.json(response);
});
// --- Start server ---
async function main() {
console.log("[render-service] Initializing Remotion bundle...");
await initBundle();
console.log("[render-service] Bundle ready.");
app.listen(PORT, () => {
console.log(`[render-service] Listening on port ${PORT}`);
});
}
main().catch((err) => {
console.error("[render-service] Fatal error during startup:", err);
process.exit(1);
});