@memberjunction/video-studio
@memberjunction/video-studio is the server-side engine and action package for MemberJunction Video Studio, housing VideoRenderEngine drivers, pre-production asset actions, and data snapshot actions. The brand-kit and archetype caches (BrandKitEngine, VideoStudioEngine) live in @memberjunction/core-entities so browser and server share them. It runs inside MJAPI with standard contextUser authorization and delegates all headless Chromium and FFmpeg composition rendering over the wire contract to @memberjunction/video-studio-render-worker rather than executing untrusted composition bundles in-process. See plans/video-studio.md for the full specification.
Rendering (VideoRenderEngine)
Section titled “Rendering (VideoRenderEngine)”VideoRenderEngine (VideoRenderEngine.Instance.Driver) is the process-wide singleton port (BaseSingleton + IShutdownable) that resolves and manages the active VideoRenderDriverBase implementation via MJGlobal.Instance.ClassFactory.
mj.config.cjs Configuration
Section titled “mj.config.cjs Configuration”Add the optional videoStudio.render and videoStudio.outputs blocks to mj.config.cjs (omitted fields fall back to defaults; invalid values log a warning and fall back to defaults without throwing):
module.exports = { // ...existing MJ configuration... videoStudio: { render: { // 'LocalWorker' (default) | 'Remote' (VS-RND-3) | 'Docker' (VS-RND-4) driver: 'LocalWorker', // Maximum output payload size in bytes for MP4 downloads (/videos/<VideoID>) and image-carrying JSON responses (/stills, /contact-sheet, /keyframes, which also stop at buffer.constants.MAX_STRING_LENGTH even when maxOutputBytes is higher); non-image JSON responses (/health, /check), NDJSON lines, and error bodies are capped at 1 MiB (512 MiB default) maxOutputBytes: 536870912, timeouts: { startupMs: 60000, // 60 s default renderMs: 2100000, // 35 min default requestMs: 600000, // 10 min default }, local: { // Optional explicit path to @memberjunction/video-studio-render-worker dist/main.js // workerEntry: '/path/to/packages/AI/VideoStudio/RenderWorker/dist/main.js', // Optional V8 --max-old-space-size in MB for the spawned child process // maxOldSpaceMb: 4096, }, remote: { // Single remote render worker HTTP/HTTPS URL (or env VIDEO_STUDIO_RENDER_URL) // url: 'https://render-worker.internal.example.com', // Or a pool of worker URLs (`urls` wins when both `url` and `urls` are set); // RemoteRenderDriver pins each job round-robin to one URL for both POST /render and GET /videos/<VideoID>, // and Health() probes every worker in the pool without advancing the job rotation. // urls: ['https://render-worker-1.internal.example.com', 'https://render-worker-2.internal.example.com'], // Bearer token sent in Authorization: Bearer <token> (or env VIDEO_STUDIO_RENDER_TOKEN) // token: process.env.VIDEO_STUDIO_RENDER_TOKEN, }, docker: { // Docker image tag built from packages/AI/VideoStudio/RenderWorker/Dockerfile image: 'mj-video-render-worker:local', // Optional host workspace root directory bind-mounted into per-job containers // (when omitted, DockerRenderDriver creates and reuses a per-process 0o700 mkdtemp directory under os.tmpdir()) // workspaceRoot: '/var/tmp/mj-video-render', cpus: 2, // --cpus 2 memoryMb: 4096, // --memory 4096m pidsLimit: 512, // --pids-limit 512 shmSizeMb: 1024, // --shm-size 1024m }, }, outputs: { // Max MP4 bytes allowed to fall back to InlineBase64 when no FileStorageAccount resolves or MJStorage fails // (default: 2097152 / 2 MB; set to 0 to disable InlineBase64 fallback). // VideoStudioAgent strips Output.Videos[*].InlineBase64 ('[inline-mp4]') from _CURRENT_PAYLOAD during the loop // and restores the real base64 bytes when the run exits (Delivered, G4 Plan, or Failed). // Intra-run intermediate files (narration clips, captions, poster, contact sheets) fall back to a bounded // in-process ephemeral store ('inline:<uuid>') when no FileStorageAccount is configured. inlineMaxBytes: 2097152, }, },};Render Drivers
Section titled “Render Drivers”Driver (videoStudio.render.driver) | Description | Network Isolation (Driver.IsNetworkIsolated) |
|---|---|---|
LocalWorker (default) | Managed local child process (LocalWorkerRenderDriver). Spawns @memberjunction/video-studio-render-worker (mj-video-render-worker) on 127.0.0.1:0 with a sanitized environment allowlist (BuildWorkerEnv — never passes MJAPI database or vendor secrets) and a fresh 32-byte random bearer token per spawn. Cannot run as root (uid === 0): the worker refuses root unless VIDEO_STUDIO_RENDER_ALLOW_ROOT=1 is set, and BuildWorkerEnv never forwards that variable. Requires ffmpeg on PATH and a Chromium headless shell (pnpm --filter @memberjunction/video-studio-render-worker run browser:install or CHROME_PATH); @memberjunction/video-studio-render-worker is declared in optionalDependencies of @memberjunction/video-studio so npm install @memberjunction/video-studio installs it by default for LocalWorker, while container builds targeting Docker or Remote can skip it with --omit=optional. | false — Development / single-node trusted compositions only. Logs a one-time startup warning (LOCAL_WORKER_NETWORK_WARNING) when selected. |
Remote | Stateless HTTP/HTTPS client (RemoteRenderDriver) targeting one or more external render worker endpoints via RenderWorkerHttpClient. Configured via videoStudio.render.remote.url / .urls / .token (urls takes precedence when both url and urls are set; falls back to VIDEO_STUDIO_RENDER_URL / VIDEO_STUDIO_RENDER_TOKEN when neither is set; mj.config.cjs wins over env). Health() probes every worker in the pool without advancing the per-job rotation and reports Healthy: true only when all workers are healthy. Logs a one-time warning per distinct worker URL per driver instance if a plain http:// URL targets a non-loopback host because the bundle’s frozen data snapshot travels in the request body. | false — RemoteRenderDriver.IsNetworkIsolated is always false (VideoRenderDriverBase.IsNetworkIsolated). |
Docker | Hardened one-container-per-job driver (DockerRenderDriver, Linux/macOS host only) provisioned via @memberjunction/ai-agent-harness (DockerSandboxProvider). Runs node /app/dist/main.js run <op> over a bind-mounted workspace with --network none, --read-only, --tmpfs /tmp:rw,size=256m, --cap-drop ALL, --security-opt no-new-privileges, --cpus, --memory, --pids-limit, and --shm-size, passing only TMPDIR and HOME in the process environment. Fails closed on Windows (process.platform === 'win32') and directs operators to Remote. | true — Required boundary for untrusted compositions (Decision 4 / D15). |
Running the Docker Driver (DockerRenderDriver)
Section titled “Running the Docker Driver (DockerRenderDriver)”Build the local render worker image from the repository root before using driver: 'Docker':
docker build -f packages/AI/VideoStudio/RenderWorker/Dockerfile -t mj-video-render-worker:local .- Why
Dockeris the required boundary for untrusted compositions: Model-written HTML/CSS/JS compositions generated from untrusted prompts cannot rely on the static lint gate alone: static pattern checks cannot stop obfuscated or dynamically constructed runtime JavaScript (window['fet' + 'ch'], WebRTC, dynamic DOM injection) from making outbound requests in headless Chromium, nor do they stop HyperFrames’ own Node-side Google Fonts and GSAP CDN fetches during compilation andcheckruns. Running each operation inside a--network nonecontainer removes all non-loopback network interfaces at the kernel namespace level so neither Node nor Chromium can exfiltrate the bundle’sdata.jsonsnapshot or reach external hosts. - One container per operation: Every
Render,CaptureStills,BuildContactSheet,Check,ExtractKeyframes, andHealthcall provisions a fresh container viaISandboxProvider.Provision, stagesrequest.jsonandout//tmp//home/intohandle.HostWorkspacePath(chmod 0o700on Linux; whenvideoStudio.render.docker.workspaceRootis omitted,DockerRenderDrivercreates and reuses a per-process0o700mkdtemproot underos.tmpdir()), runsnode /app/dist/main.js run <op> --request /workspace/request.json --out /workspace/out, movesout/into a newly created0o700<HostWorkspacePath>.out/outdirectory outside the bind mount, verifies regular-fileO_NOFOLLOWhandles and output byte caps (1 MiBfor non-image JSON outputs anderror.json,maxOutputBytesfor image-carrying JSON results (stills,contact-sheet,keyframes), andmaxOutputBytesplusVideoSizeBytesequality forvideo.mp4) before reading, and infinallycallsISandboxProvider.Finalizefirst and removes<HostWorkspacePath>.outafterFinalizewhen the container is no longer running. - Container hardening flags:
DockerRenderDriverpassesNetworkPolicy: 'none'(--network none),ReadOnlyRoot: true(--read-only --tmpfs /tmp:rw,size=256m),DropCapabilities: true(--cap-drop ALL --security-opt no-new-privileges),Cpus(--cpus 2),MemoryMb(--memory 4096m),PidsLimit(--pids-limit 512), andShmSizeMb(--shm-size 1024m). OnlyTMPDIR=/workspace/tmpandHOME=/workspace/homeare passed intoExecutor.Run— parent process environment variables are never forwarded. - Host platform &
--userbehavior: On Linux (process.platform === 'linux'),DockerRenderDriverpassesUser: "${process.getuid()}:${process.getgid()}"(--user <uid:gid>) so files written by the container into the bind-mounted host workspace are owned by the host user and removable byFinalize. On macOS (process.platform === 'darwin', Docker Desktop VM),Useris omitted (undefined) so the container runs as the image’s default non-rootnodeuser (1000:1000), and Docker Desktop’s VirtioFS/gRPC-FUSE mount translates host ownership automatically. On Windows (process.platform === 'win32'),DockerRenderDriverfails closed withVideoRenderError('WorkerUnavailable')before starting any container because Windows lacksO_NOFOLLOWsymlink-safe host file reads; Windows hosts must use theRemoterender driver instead.
Running a Remote Worker Pool (Remote Driver)
Section titled “Running a Remote Worker Pool (Remote Driver)”Build the local image (packages/AI/VideoStudio/RenderWorker/Dockerfile, running as USER node UID 1000 and listening on 0.0.0.0:8080) and run it with bearer token authentication and container hardening flags:
# Build from the repository root (mj-video-render-worker:local is built locally, not published to a public registry)docker build -f packages/AI/VideoStudio/RenderWorker/Dockerfile -t mj-video-render-worker:local .
# Run with bearer token authentication and container hardeningdocker run --rm -p 127.0.0.1:18080:8080 \ -e VIDEO_STUDIO_RENDER_TOKEN="replace-with-strong-shared-secret" \ --read-only \ --tmpfs /tmp:rw,noexec,nosuid,size=2g \ --cap-drop=ALL \ --security-opt=no-new-privileges \ mj-video-render-worker:local- Bearer token is the only credential: The worker refuses to bind to a non-loopback address without
VIDEO_STUDIO_RENDER_TOKENand checksAuthorization: Bearer <token>on every route (includingGET /health). The worker never holds database, storage, or AI vendor credentials; that bearer token is the only credential the worker ever holds. - TLS termination: Composition bundles include the frozen
data.jsonsnapshot. Terminate TLS at your ingress or reverse proxy and pointvideoStudio.render.remote.url/videoStudio.render.remote.urls(orVIDEO_STUDIO_RENDER_URL) at thehttps://endpoint(s). Plainhttp://to a non-loopback address is permitted for cluster-internal networks that terminate TLS at the mesh/ingress layer, and logs one warning per distinct worker URL per driver instance. - Ingress request body limit: An ingress or reverse-proxy body limit applies before the worker’s 256 MB cap (
VIDEO_STUDIO_RENDER_MAX_BODY_MB=256). For example, nginx ingress defaultsclient_max_body_sizeto1m, so most composition bundles and keyframe payloads will fail with413 BodyTooLargeuntil the ingress body limit is raised to matchVIDEO_STUDIO_RENDER_MAX_BODY_MB. - Response size caps (
maxOutputBytesvs1 MiB):RenderWorkerHttpClient(used by bothRemoteRenderDriverandLocalWorkerRenderDriver) caps/health,/check, NDJSON stream lines on/render, and all non-2xx error response bodies at1 MiB(1,048,576bytes), while capping successful2xximage-carrying JSON response bodies (/stills,/contact-sheet,/keyframes) atMath.min(maxOutputBytes, buffer.constants.MAX_STRING_LENGTH)(so JSON bodies always stop at V8’s string limitbuffer.constants.MAX_STRING_LENGTH,536,870,888bytes on 64-bit Node, even whenmaxOutputBytesis higher) and streamed MP4 downloads (/videos/<VideoID>, additionally verified against any claimedVideoSizeBytes) atvideoStudio.render.maxOutputBytes(512 MiB/536,870,912bytes default). Responses exceeding their cap abort the socket stream immediately and reject withBodyTooLarge. - Scaling & replica affinity: Each worker runs one render job at a time by default (
VIDEO_STUDIO_RENDER_CONCURRENCY=1) and keeps rendered MP4 bytes in memory inside that replica’s ephemeral store until the immediateGET /videos/<VideoID>fetch completes. BothPOST /renderandGET /videos/<VideoID>for a job must reach the same worker replica (RenderWorkerHttpClientstores no cookies, so cookie stickiness and per-request load balancing lose the video withNotFound). The supported way to scale a pool is to list each worker replica URL invideoStudio.render.remote.urls(urlstakes precedence if bothurlandurlsare configured):RemoteRenderDriverselects one worker URL per job (round-robin) and pins bothPOST /renderandGET /videos/<VideoID>to that same worker with no load balancer required, whileHealth()probes every worker inurlswithout advancing the job rotation and returnsHealthy: trueonly when all workers are healthy. If you place a load balancer in front of multiple workers under a singlevideoStudio.render.remote.url, configure client-IP affinity (e.g., Kubernetes ServicesessionAffinity: ClientIPor nginxip_hash) so all requests from an MJAPI host reach the same replica.