Railway-ready Remotion render worker for the Dark Factory stack. Provides:
- Remotion Studio for layout iteration
- A render service for long-form scenes
- A clip rendering pipeline that cuts shorts from a single source video
Use Remotion Studio to iterate on compositions (e.g. DarkFactoryShortClip):
npx remotion studioThis serves the editor UI (by default on port 3000) and uses the compositions defined in src/Root.tsx.
The render service exposes HTTP endpoints for server-side rendering. Run it locally with a filesystem output target:
REMOTION_LOCAL_OUTPUT_DIR=./renders/remotion npm startBy default the service listens on PORT (env, defaults to 3000). In development it’s often convenient to run it on a different port from Studio, for example:
PORT=4000 REMOTION_LOCAL_OUTPUT_DIR=./renders/remotion npm startRendered MP4s will appear under renders/remotion/.
The service can render multiple short clips from a single source video using a JSON manifest. See dta-local-clip-manifest.json for an example used during development.
Manifest shape:
Instead of video, you can also supply videoKey (an S3 key) when the worker is configured with S3 credentials.
The original (synchronous) endpoint blocks until all clips are rendered and uploaded. This is convenient for local CLI use, but not ideal behind proxies with short timeouts.
curl -X POST "http://localhost:4000/render-clips" \
-H 'content-type: application/json' \
--data-binary @dta-local-clip-manifest.jsonResponse shape:
{
"video": "...", // echo of manifest video/videoKey
"clips": [
{
"id": "coordination-not-sufficient",
"bucket": null, // or S3 bucket name
"key": "remotion/coordination-not-sufficient-...mp4",
"endpoint": "...", // S3 endpoint when configured
"location": "..." // absolute path (local) or S3 object key
}
]
}For environments like Railway where a single HTTP request cannot stay open for long-running work, use the async job API.
curl -X POST "http://localhost:4000/render-clips-async" \
-H 'content-type: application/json' \
--data-binary @dta-local-clip-manifest.jsonExample response:
{
"jobId": "c1f0...",
"status": "pending", // "pending" | "running" | "completed" | "failed"
"createdAt": "2026-04-01T18:40:00.000Z",
"inputSummary": {
"video": "/path/to/source.mp4",
"videoKey": null,
"clipCount": 10
}
}The server immediately returns 202 Accepted while the clips render in the background.
curl "http://localhost:4000/render-clips-async/<jobId>"Example completed job:
{
"id": "c1f0...",
"status": "completed",
"createdAt": "...",
"updatedAt": "...",
"inputSummary": { "video": "...", "videoKey": null, "clipCount": 10 },
"result": {
"video": "...",
"clips": [
{ "id": "coordination-not-sufficient", "bucket": null, "key": "...", "location": "..." }
]
}
}If the job fails, status will be "failed" and the record will include an error string.
Short clips can include captions in two ways:
-
Explicit per-clip captions in the manifest
Each clip can define a
captionsarray with times relative to the clip start:{ "id": "example", "start": 0, "end": 10, "title": "Digital Twin Agents", "captions": [ { "start": 0.2, "end": 1.4, "text": "First line" }, { "start": 1.4, "end": 3.0, "text": "Second line" } ] } -
Auto-derived from a transcript (Whisper style)
If the manifest has a
transcriptPathpointing at a JSON file with a top-levelsegmentsarray, the server will auto-generate caption cues for clips that don’t already definecaptions.Expected transcript shape (Whisper-like):
{ "segments": [ { "start": 537.20, "end": 544.00, "text": "And what I've seen is that..." }, { "start": 544.00, "end": 552.00, "text": "Coordination is necessary, but not sufficient..." } ] }For each clip, the server:
- Slices segments whose absolute
start/endoverlap the clip’s[start, end]window. - Converts them into clip-relative
start/endseconds. - Drops very short fragments (less than ~0.5s) to avoid “flashing” captions at the clip boundaries.
- Slices segments whose absolute
The ShortScene composition animates captions word-by-word and uses captions passed via props.
The main scene composition (REMOTION_COMPOSITION_ID, currently DarkFactoryScene) is rendered via the /render endpoint.
curl -X POST "http://localhost:4000/render" \
-H 'content-type: application/json' \
-d '{"composition":"DarkFactoryScene","props":{...}}'Request body:
composition?: string– optional; defaults toREMOTION_COMPOSITION_IDwhen omitted.outputKey?: string– optional suggested output key; falls back to an auto-generated one.props?: object– props for the scene. ForDarkFactoryScene, this usually includes:title?: stringaccent?: stringsegments?: { speaker: string; url: string; durationMs: number; color?: string }[]
Response:
{
"bucket": null, // or S3 bucket name
"key": "remotion/daily-brief-...mp4",
"endpoint": "...", // S3 endpoint when configured
"location": "...", // absolute path (local) or S3 object key
"url": "...", // public/CDN URL when REMOTION_STORAGE_PUBLIC_BASE_URL is set
"proxyUrl": "..." // service proxy URL for private/local outputs
}Use the async API to avoid long-lived HTTP connections when rendering full scenes.
- Start a job
curl -X POST "http://localhost:4000/render-async" \
-H 'content-type: application/json' \
-d '{"composition":"DarkFactoryScene","props":{...}}'Example response:
{
"jobId": "b7e1...",
"status": "pending", // "pending" | "running" | "completed" | "failed"
"createdAt": "2026-04-01T18:50:00.000Z",
"inputSummary": {
"composition": "DarkFactoryScene",
"hasSegments": true
}
}- Poll job status
curl "http://localhost:4000/render-async/<jobId>"On success:
{
"id": "b7e1...",
"status": "completed",
"createdAt": "...",
"updatedAt": "...",
"inputSummary": { "composition": "DarkFactoryScene", "hasSegments": true },
"result": {
"bucket": null,
"key": "remotion/daily-brief-...mp4",
"endpoint": "...",
"location": "...",
"url": "...",
"proxyUrl": "..."
}
}If something fails, status will be "failed" and an error string will be present.
- Prefer the async
/render-clips-asyncand/render-asyncAPIs instead of the synchronous/render-clipsand/renderwhen running on Railway, to avoid proxy timeouts on long renders. - Configure your S3 bucket and region via
PAPERCLIP_STORAGE_S3_*orREMOTION_STORAGE_S3_*env vars as needed, or useREMOTION_LOCAL_OUTPUT_DIRfor local filesystem output. - Set
REMOTION_STORAGE_PUBLIC_BASE_URLorPAPERCLIP_STORAGE_PUBLIC_BASE_URLwhen the bucket/CDN is public; render responses will includeurl. - Set
REMOTION_SERVICE_PUBLIC_URLto the deployed service origin; render responses will includeproxyUrlvalues like/render-outputs/<key>that stream private S3/local outputs through the service.
{ "video": "/absolute/or/relative/path/to/source.mp4" , "brandId": "raidguild", // optional, passed through to the ShortScene brand config "authorHandle": "@0xjustice", // optional, shown under the clip title "avatarUrl": "/assets/avatar.png", // optional, replaces the default hero image when present "transcriptPath": "/path/to/transcript.json", // optional, see Captions section "clips": [ { "id": "coordination-not-sufficient", // used as clipId and in output filename "start": 537.2, // seconds in source video "end": 561.5, // seconds in source video "title": "Digital Twin Agents" // title shown in the ShortScene composition } ] }