From 25af80310c025fb13c050717e66b416ee14e246d Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 01:21:16 +0000 Subject: [PATCH 1/2] fix: provision Reactor runtime automatically on first render --- docs/STORAGE.md | 2 +- docs/features/fableloom.md | 24 ++++++-- scripts/setup-reactor.js | 59 +++++++++++++++---- server/services/videoGen/reactor.js | 24 +++++--- server/services/videoGen/reactor.test.js | 38 ++++++++++-- server/services/videoGen/reactorRuntime.js | 36 +++++++++++ .../services/videoGen/reactorRuntime.test.js | 54 +++++++++++++++++ 7 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 server/services/videoGen/reactorRuntime.js create mode 100644 server/services/videoGen/reactorRuntime.test.js diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 0b5c39a13e..53bb2a0151 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -225,4 +225,4 @@ Apply this checklist to **every new feature that persists data**, and require it `data/model-comparison.json` is `file-primary`: a bounded, externally researched reference snapshot, directly inspectable/importable as a portable JSON document, with no app-record foreign keys, cross-record queries, search index or accumulated history. It follows the local-assessment reference pattern, rather than representing app-native relational records. It is intentionally machine-local and never federated because configuration and quota interpretation can be install-specific. Schema version 1 is seeded for new installs and migration 351 preserves existing catalogs. Rsync backups include it; no backup exclusion, sync cursor or tombstone is added. Source dates and exact benchmark/configuration identities remain attached to metrics. The server rejects future/malformed versions and merges imports through a serialized last-good-preserving write. See [Models Comparison](MODEL-COMPARISON.md). -Optional SDK environments under `data/venvs/` are machine-local, regenerable runtime files, not application records. Reactor installs its pinned SDK only through `npm run setup:reactor`; no seed, migration, database table, or peer synchronization is needed. Data Manager identifies these environments but does not purge them while render processes may use them. +Optional SDK environments under `data/venvs/` are machine-local, regenerable runtime files, not application records. Reactor provisions its pinned SDK, private Python and checksum-verified uv manager on the first authorized render (or optionally through `npm run setup:reactor`); no seed, migration, database table, or peer synchronization is needed. Data Manager identifies these environments but does not purge them while render processes may use them. diff --git a/docs/features/fableloom.md b/docs/features/fableloom.md index 1053eebf56..21ef2b2ee1 100644 --- a/docs/features/fableloom.md +++ b/docs/features/fableloom.md @@ -366,10 +366,26 @@ Draft visual bindings can select a character image from the gallery. This fixed ### Reactor video previews Reactor FastH3 uses a live SDK session: upload the storyboard image, enqueue one -shot, play it once, capture its video and native audio, then disconnect. Install -the pinned SDK with `npm run setup:reactor` (Python 3.11+ and ffmpeg required). -`REACTOR_PYTHON_PATH` can select an existing SDK environment. Installation is -explicit; starting PortOS never installs or opens a Reactor session. +shot, play it once, capture its video and native audio, then disconnect. Configure +an API key in Settings > Video Gen and render. On the first authorized render, +PortOS automatically downloads a checksum-verified runtime manager, private +Python 3.12, and the pinned Reactor SDK. Preparation appears in job status and +finishes before a paid session opens. Failed or incomplete installations are +retried on the next render. ffmpeg remains a standard PortOS prerequisite. +Starting PortOS never installs this runtime or opens a Reactor session. + +The Python SDK supplies the native WebRTC receiver needed by background render +jobs. Reactor's JavaScript SDK uses browser WebRTC APIs; it is not a Node HTTP +render-to-MP4 client. Moving to it would require a managed browser or a new native +WebRTC layer. Reactor's recording API also requires a live session and model +recording support, so it is not a drop-in replacement for FastH3 capture. See +[SDK connection](https://docs.reactor.inc/sdk-reference/reactor-class) and +[recordings](https://docs.reactor.inc/concepts/recordings). + +Existing `REACTOR_PYTHON_PATH` overrides remain supported and are verified without +modifying that environment. Remove an incompatible override to use automatic +setup. `npm run setup:reactor` remains an optional preinstallation command; +neither it nor an environment variable is required for normal renders. The outline and shot editor show incoming reference images beside the current shot, including every incoming branch at a convergence. Review room geometry, diff --git a/scripts/setup-reactor.js b/scripts/setup-reactor.js index 5c315f81ab..141289cc0b 100644 --- a/scripts/setup-reactor.js +++ b/scripts/setup-reactor.js @@ -1,20 +1,53 @@ #!/usr/bin/env node -// Explicit install only: the cloud adapter never installs dependencies at boot. +// Invoked automatically on the first authorized render, never at server boot. import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; + const root = fileURLToPath(new URL('..', import.meta.url)); -const venv = join(root, 'data', 'venvs', 'reactor'); -const bootstrap = process.env.PYTHON || (process.platform === 'win32' ? 'python' : 'python3'); -const python = join(venv, process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'); -for (const [executable, args] of [ - [bootstrap, ['-m', 'venv', venv]], - [python, ['-m', 'pip', 'install', '-r', join(root, 'scripts', 'requirements-reactor.txt')]], -]) { - const result = spawnSync(executable, args, { stdio: 'inherit', shell: false }); - if (result.error || result.status !== 0) { - console.error('❌ Reactor SDK setup failed. Install Python 3.11+ and retry.'); - process.exit(1); +const data = process.env.PORTOS_REACTOR_DATA || join(root, 'data'); +const venv = join(data, 'venvs', 'reactor'); +const windows = process.platform === 'win32'; +const python = join(venv, windows ? 'Scripts/python.exe' : 'bin/python'); +const version = '0.12.10'; +// Official uv release digests; verify the archive before executing its binary. +const targets = { + 'darwin-arm64': ['aarch64-apple-darwin', '51c6170e8e3a01cef9f33b94f582b7b81ac65046f55d40afb35f9cff5a68c179'], + 'darwin-x64': ['x86_64-apple-darwin', '5296d5aa2b9143360405eea866f8ef4d5dc8986b164eb0dc35e8f876a9304d30'], + 'linux-arm64': ['aarch64-unknown-linux-gnu', '9ff6b9d4665edcdd3a88dcc73cd1eb641754deb927f14e8c62ebfde6bf4f5f5e'], + 'linux-x64': ['x86_64-unknown-linux-gnu', '173d95a0c32d18c896c46ba6fafbf3cf9c14ab74b033f81b76c883ef492a976b'], + 'win32-x64': ['x86_64-pc-windows-msvc', 'f65744f94072152b1f86ba2aace4d01f1124d9a8ecb235805039e3718c36cac2'], +}; +const env = { ...process.env, UV_PYTHON_INSTALL_DIR: join(data, 'venvs', 'reactor-python'), UV_NO_PROGRESS: '1' }; +function run(executable, args) { + const result = spawnSync(executable, args, { env, stdio: 'ignore', shell: false, windowsHide: true, timeout: 240_000, killSignal: 'SIGKILL' }); + if (result.error || result.status !== 0) throw new Error('Reactor runtime preparation failed; check network access and available disk space, then retry the render'); +} +async function setup() { + const target = targets[`${process.platform}-${process.arch}`]; + if (!target) throw new Error('Reactor runtime is not supported on this operating system/architecture'); + const [triple, digest] = target; + const directory = join(data, 'venvs', `reactor-uv-${version}`); + const archive = join(directory, windows ? 'uv.zip' : 'uv.tar.gz'); + await mkdir(directory, { recursive: true }); + const cached = await readFile(archive).catch(() => null); + let bytes = cached; + if (!bytes || createHash('sha256').update(bytes).digest('hex') !== digest) { + const response = await fetch(`https://github.com/astral-sh/uv/releases/download/${version}/uv-${triple}.${windows ? 'zip' : 'tar.gz'}`, { signal: AbortSignal.timeout(120_000) }); + if (!response.ok) throw new Error('Could not download the Reactor runtime manager; retry the render when network access is restored'); + bytes = Buffer.from(await response.arrayBuffer()); + if (createHash('sha256').update(bytes).digest('hex') !== digest) throw new Error('Reactor runtime download failed integrity verification'); + await writeFile(archive, bytes); } + run('tar', ['-xf', archive, '-C', directory]); + const uv = join(directory, ...(windows ? ['uv.exe'] : [`uv-${triple}`, 'uv'])); + // An interrupted installation is repaired in place; no system Python or pip changes. + run(uv, ['venv', '--python', '3.12', '--managed-python', '--allow-existing', venv]); + run(uv, ['pip', 'install', '--reinstall-package', 'reactor-sdk', '--python', python, '--only-binary', ':all:', '-r', join(root, 'scripts', 'requirements-reactor.txt')]); + run(python, ['-c', 'from reactor_sdk import Reactor; Reactor("reactor/fast-h3")']); + await rm(archive, { force: true }); + console.log('✅ Reactor runtime ready'); } -console.log('✅ Reactor SDK installed. Video generation also requires ffmpeg on PATH.'); +setup().catch((error) => { console.error(`❌ ${error.message}`); process.exitCode = 1; }); diff --git a/server/services/videoGen/reactor.js b/server/services/videoGen/reactor.js index 6aaf8d865d..6450083424 100644 --- a/server/services/videoGen/reactor.js +++ b/server/services/videoGen/reactor.js @@ -18,6 +18,7 @@ import { } from '../../lib/reactorVideoClip.js'; import { extractEvaluationFrames } from '../../lib/ffmpeg.js'; import { describeFrameStats, isDegenerateFrame } from '../../lib/imageFrameStats.js'; +import { ensureReactorRuntime } from './reactorRuntime.js'; import { prepareReactorStartingFrame } from '../../lib/reactorStartingFrame.js'; export const REACTOR_API_BASE = 'https://api.reactor.inc'; @@ -186,7 +187,7 @@ function captureClip(entry, input, pythonPath, job, jobId) { child.stderr.on('data', countOutput); child.stdin.on('error', () => stop('Could not send request to Reactor renderer')); child.on('error', () => { - failure ||= new Error('Could not start Reactor renderer; run the Reactor runtime setup'); + failure ||= new Error('Could not start Reactor renderer; retry the render to verify its runtime'); }); child.on('close', (code) => { clearTimeout(timeout); @@ -206,9 +207,6 @@ export async function generateVideo({ sourceImagePath = null, jobId: providedJobId = null, }) { const request = validateReactorRequest({ prompt, continueFromClipId, sourceImagePath, seconds, seed, aspect }); - const pythonPath = process.env.REACTOR_PYTHON_PATH || join(PATHS.data, 'venvs', 'reactor', ...(process.platform === 'win32' ? ['Scripts', 'python.exe'] : ['bin', 'python'])); - const runtime = await stat(pythonPath).catch(() => null); - if (!runtime?.isFile()) { throw new ServerError('Reactor runtime is missing. Run npm run setup:reactor, or set REACTOR_PYTHON_PATH', { status: 400, code: 'REACTOR_RUNTIME_MISSING' }); } await ensureDir(PATHS.videos); const renderStartedAtMs = Date.now(); @@ -245,10 +243,10 @@ export async function generateVideo({ console.log(`🎬 Generating video [${jobId.slice(0, 8)}] reactor (${REACTOR_MODEL_ID}): ${prompt.slice(0, 60)}…`); videoGenEvents.emit('started', { generationId: jobId, totalSteps: 1, ...meta }); activeJobs.set(jobId, { ...meta, generationId: jobId, totalSteps: 1, step: 0, progress: 0 }); - broadcastSse(job, { type: 'status', message: 'Minting reactor.inc session…' }); + broadcastSse(job, { type: 'status', message: 'Preparing Reactor runtime…' }); runReactorVideo(job, jobId, { - apiKey, ...request, pythonPath, sourceImagePath, outputPath, filename, meta, + apiKey, ...request, sourceImagePath, outputPath, filename, meta, }).catch((err) => { console.log(`❌ reactor video run failed [${jobId.slice(0, 8)}]: ${err?.message}`); }); @@ -261,7 +259,7 @@ export async function generateVideo({ } async function runReactorVideo(job, jobId, { - apiKey, prompt, seconds, seed, aspect, continueFromClipId, pythonPath, sourceImagePath, outputPath, filename, meta, + apiKey, prompt, seconds, seed, aspect, continueFromClipId, sourceImagePath, outputPath, filename, meta, }) { const entry = { aborted: false, stop: null }; activeRequests.set(jobId, entry); @@ -272,6 +270,17 @@ async function runReactorVideo(job, jobId, { // Resolved before the token is minted, so a starting frame PortOS could not // fit costs nothing on the reactor side. frame = await prepareReactorStartingFrame(sourceImagePath, aspect, outputPath); + if (entry.aborted) return finalizeCanceled(job, jobId); + // The shared install may finish for another job after this one is canceled. + // Cancellation stops this job immediately, without opening a paid session. + const pythonPath = await new Promise((resolve, reject) => { + entry.stop = () => reject(new Error('Canceled')); + ensureReactorRuntime().then(resolve, reject); + if (entry.aborted) entry.stop(); + }); + entry.stop = null; + if (entry.aborted) return finalizeCanceled(job, jobId); + broadcastSse(job, { type: 'status', message: 'Minting reactor.inc session…' }); const { jwt } = await mintReactorToken(apiKey); if (entry.aborted) return finalizeCanceled(job, jobId); const result = await captureClip(entry, { @@ -302,6 +311,7 @@ async function runReactorVideo(job, jobId, { : ''; finalizeError(job, jobId, entry.aborted ? 'Canceled' : `Reactor video generation failed: ${err?.message || 'unknown error'}${continuationHint}`, { force: true }); } finally { + await rm(`${outputPath}.capture`, { recursive: true, force: true }).catch(() => {}); if (frame.fittedPath) await rm(frame.fittedPath, { force: true }).catch(() => {}); if (entry.aborted) await rm(outputPath, { force: true }).catch(() => {}); activeRequests.delete(jobId); diff --git a/server/services/videoGen/reactor.test.js b/server/services/videoGen/reactor.test.js index 40e65e7629..6a01ee7695 100644 --- a/server/services/videoGen/reactor.test.js +++ b/server/services/videoGen/reactor.test.js @@ -5,8 +5,9 @@ import { join } from 'path'; import { tmpdir } from 'os'; const root = join(tmpdir(), `reactor-test-${process.pid}`); -const mocks = vi.hoisted(() => ({ spawn: vi.fn(), finalize: vi.fn(), settings: vi.fn(), samples: vi.fn() })); +const mocks = vi.hoisted(() => ({ spawn: vi.fn(), finalize: vi.fn(), settings: vi.fn(), samples: vi.fn(), runtime: vi.fn() })); vi.mock('../../lib/childProcess.js', async (importOriginal) => ({ ...await importOriginal(), spawn: mocks.spawn })); +vi.mock('./reactorRuntime.js', () => ({ ensureReactorRuntime: mocks.runtime })); vi.mock('../../lib/ffmpeg.js', () => ({ extractEvaluationFrames: mocks.samples })); vi.mock('./generateVideoHelpers.js', () => ({ finalizeGeneratedVideo: mocks.finalize })); vi.mock('../settings.js', () => ({ getSettings: mocks.settings })); @@ -25,6 +26,7 @@ beforeEach(async () => { await writeFile(join(root, 'python'), 'placeholder'); vi.stubEnv('REACTOR_PYTHON_PATH', join(root, 'python')); vi.stubEnv('REACTOR_API_KEY', ''); + mocks.runtime.mockResolvedValue(join(root, 'python')); mocks.settings.mockResolvedValue(settings); mocks.samples.mockResolvedValue([]); vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ jwt: 'example-jwt' }) }))); @@ -170,18 +172,46 @@ describe('Reactor SDK adapter', () => { expect(mocks.finalize).not.toHaveBeenCalled(); }); - it('fails missing runtime before minting a token', async () => { - vi.stubEnv('REACTOR_PYTHON_PATH', join(root, 'missing-python')); - await expect(reactor.generateVideo({ prompt: 'A gate' })).rejects.toMatchObject({ code: 'REACTOR_RUNTIME_MISSING' }); + it('reports automatic setup failure without minting a token', async () => { + mocks.runtime.mockRejectedValue(new Error('Automatic Reactor runtime preparation failed')); + const failed = once(videoGenEvents, 'failed'); + await reactor.generateVideo({ prompt: 'A gate' }); + const [event] = await failed; + expect(event.error).toContain('Automatic Reactor runtime preparation failed'); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('cancels during setup immediately and never opens a session after setup finishes', async () => { + let finish; + mocks.runtime.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const job = await reactor.generateVideo({ prompt: 'A gate' }); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + const failed = once(videoGenEvents, 'failed'); + expect(reactor.cancel(job.jobId)).toBe(true); + await failed; + finish(join(root, 'python')); + await new Promise(setImmediate); expect(fetch).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(reactor.getActiveJob()).toBeNull(); + }); + + it('does not prepare a runtime without a configured key', async () => { + mocks.settings.mockResolvedValue({}); + await expect(reactor.generateVideo({ prompt: 'A gate' })).rejects.toMatchObject({ code: 'REACTOR_NOT_CONFIGURED' }); + expect(mocks.runtime).not.toHaveBeenCalled(); }); it('does not finalize a completion marker without its output file', async () => { await started(); + const scratch = `${input.outputPath}.capture`; + await mkdir(scratch); + await writeFile(join(scratch, 'video.bgra'), 'partial capture'); const failed = once(videoGenEvents, 'failed'); child.stdout.emit('data', Buffer.from('{"type":"complete","clipId":"clip-example","seconds":6}\n')); child.emit('close', 0); await failed; + await vi.waitFor(async () => { await expect(stat(scratch)).rejects.toBeTruthy(); }); expect(mocks.finalize).not.toHaveBeenCalled(); }); diff --git a/server/services/videoGen/reactorRuntime.js b/server/services/videoGen/reactorRuntime.js new file mode 100644 index 0000000000..0075fe0d91 --- /dev/null +++ b/server/services/videoGen/reactorRuntime.js @@ -0,0 +1,36 @@ +/** Machine-local Reactor runtime, provisioned only by an authorized render. */ +import { promisify } from 'node:util'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { execFile } from '../../lib/childProcess.js'; +import { PATHS } from '../../lib/fileUtils.js'; + +const execute = promisify(execFile); +let preparation; +const probe = (python, expected) => execute(python, ['-c', 'import sys; from importlib.metadata import version; assert version("reactor-sdk") == sys.argv[1]; from reactor_sdk import Reactor; Reactor("reactor/fast-h3")', expected], { timeout: 15_000, maxBuffer: 4096 }).then(() => true, () => false); + +export async function ensureReactorRuntime() { + const requirements = await readFile(join(PATHS.root, 'scripts', 'requirements-reactor.txt'), 'utf8'); + const expected = requirements.match(/^reactor-sdk==([0-9.]+)\r?$/m)?.[1]; + if (!expected) throw new Error('Reactor SDK version pin is missing'); + const override = process.env.REACTOR_PYTHON_PATH; + const python = override || join(PATHS.data, 'venvs', 'reactor', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python'); + if (override) { + // Preserve custom environments; never install into an operator-owned path. + if (await probe(python, expected)) return python; + throw new Error('The custom Reactor Python runtime is unavailable or incompatible; remove the custom override to use automatic setup'); + } + // Share verification as well as installation: a late failed probe must not + // start a second repair while the first caller is already rendering. + preparation ||= prepareRuntime(python, expected).finally(() => { preparation = null; }); + return preparation; +} + +async function prepareRuntime(python, expected) { + if (await probe(python, expected)) return python; + await execute(process.execPath, [join(PATHS.root, 'scripts', 'setup-reactor.js')], { + env: { ...process.env, PORTOS_REACTOR_DATA: PATHS.data }, timeout: 1_200_000, maxBuffer: 8192, + }).catch(() => { throw new Error('Automatic Reactor runtime preparation failed; check network access and disk space, then retry the render'); }); + if (!await probe(python, expected)) throw new Error('Reactor runtime verification failed; retry the render to repair the installation'); + return python; +} diff --git a/server/services/videoGen/reactorRuntime.test.js b/server/services/videoGen/reactorRuntime.test.js new file mode 100644 index 0000000000..429cccad30 --- /dev/null +++ b/server/services/videoGen/reactorRuntime.test.js @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { join } from 'node:path'; +const mocks = vi.hoisted(() => ({ execFile: vi.fn() })); +vi.mock('node:fs/promises', () => ({ readFile: vi.fn(async () => 'reactor-sdk==1.0.1\r\n') })); +vi.mock('../../lib/childProcess.js', () => ({ execFile: mocks.execFile })); +vi.mock('../../lib/fileUtils.js', () => ({ PATHS: { root: '/example/app', data: '/example/data' } })); +let ensureReactorRuntime; +beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv('REACTOR_PYTHON_PATH', ''); + ({ ensureReactorRuntime } = await import('./reactorRuntime.js')); +}); +afterEach(() => vi.unstubAllEnvs()); +const reply = (error = null) => (file, args, options, callback) => callback(error, '', ''); + +describe('automatic Reactor runtime preparation', () => { + it('reuses a verified SDK without downloading or starting a model session', async () => { + mocks.execFile.mockImplementation(reply()); + expect(await ensureReactorRuntime()).toBe(join('/example/data', 'venvs', 'reactor', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python')); + expect(mocks.execFile).toHaveBeenCalledOnce(); + expect(mocks.execFile.mock.calls[0][1][2]).toBe('1.0.1'); + }); + + it('shares one repair for concurrent jobs and verifies the repaired runtime', async () => { + let installed = false; + let finish; + mocks.execFile.mockImplementation((file, args, options, callback) => { + if (file === process.execPath) finish = () => { installed = true; callback(null, '', ''); }; + else callback(installed ? null : new Error('missing SDK'), '', ''); + }); + const first = ensureReactorRuntime(); + const second = ensureReactorRuntime(); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + expect(mocks.execFile.mock.calls.filter(([file]) => file === process.execPath)).toHaveLength(1); + finish(); + expect(await first).toBe(await second); + expect(mocks.execFile).toHaveBeenCalledTimes(3); + }); + + it('retries a failed install without exposing installer diagnostics', async () => { + mocks.execFile.mockImplementation(reply(new Error('private installer details'))); + await expect(ensureReactorRuntime()).rejects.toThrow('Automatic Reactor runtime preparation failed'); + mocks.execFile.mockImplementationOnce(reply(new Error('missing'))).mockImplementation(reply()); + await expect(ensureReactorRuntime()).resolves.toBeTruthy(); + }); + + it('refuses an incompatible custom environment without modifying it', async () => { + vi.stubEnv('REACTOR_PYTHON_PATH', '/example/custom/python'); + mocks.execFile.mockImplementation(reply(new Error('incompatible'))); + await expect(ensureReactorRuntime()).rejects.toThrow('remove the custom override'); + expect(mocks.execFile).toHaveBeenCalledOnce(); + }); +}); From 8f23fe1b0b372a995db483b6cb4ac1f0d93b5372 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 6 Sep 2026 01:24:41 +0000 Subject: [PATCH 2/2] fix: make Reactor runtime repair tolerant of slow downloads --- scripts/setup-reactor.js | 11 +++++------ server/services/videoGen/reactor.js | 1 + server/services/videoGen/reactorRuntime.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/setup-reactor.js b/scripts/setup-reactor.js index 141289cc0b..982295fc47 100644 --- a/scripts/setup-reactor.js +++ b/scripts/setup-reactor.js @@ -2,7 +2,7 @@ // Invoked automatically on the first authorized render, never at server boot. import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; @@ -21,8 +21,8 @@ const targets = { 'win32-x64': ['x86_64-pc-windows-msvc', 'f65744f94072152b1f86ba2aace4d01f1124d9a8ecb235805039e3718c36cac2'], }; const env = { ...process.env, UV_PYTHON_INSTALL_DIR: join(data, 'venvs', 'reactor-python'), UV_NO_PROGRESS: '1' }; -function run(executable, args) { - const result = spawnSync(executable, args, { env, stdio: 'ignore', shell: false, windowsHide: true, timeout: 240_000, killSignal: 'SIGKILL' }); +function run(executable, args, timeout = 450_000) { + const result = spawnSync(executable, args, { env, stdio: 'ignore', shell: false, windowsHide: true, timeout, killSignal: 'SIGKILL' }); if (result.error || result.status !== 0) throw new Error('Reactor runtime preparation failed; check network access and available disk space, then retry the render'); } async function setup() { @@ -41,13 +41,12 @@ async function setup() { if (createHash('sha256').update(bytes).digest('hex') !== digest) throw new Error('Reactor runtime download failed integrity verification'); await writeFile(archive, bytes); } - run('tar', ['-xf', archive, '-C', directory]); + run('tar', ['-xf', archive, '-C', directory], 30_000); const uv = join(directory, ...(windows ? ['uv.exe'] : [`uv-${triple}`, 'uv'])); // An interrupted installation is repaired in place; no system Python or pip changes. run(uv, ['venv', '--python', '3.12', '--managed-python', '--allow-existing', venv]); run(uv, ['pip', 'install', '--reinstall-package', 'reactor-sdk', '--python', python, '--only-binary', ':all:', '-r', join(root, 'scripts', 'requirements-reactor.txt')]); - run(python, ['-c', 'from reactor_sdk import Reactor; Reactor("reactor/fast-h3")']); - await rm(archive, { force: true }); + run(python, ['-c', 'from reactor_sdk import Reactor; Reactor("reactor/fast-h3")'], 15_000); console.log('✅ Reactor runtime ready'); } setup().catch((error) => { console.error(`❌ ${error.message}`); process.exitCode = 1; }); diff --git a/server/services/videoGen/reactor.js b/server/services/videoGen/reactor.js index 6450083424..31fbd82e58 100644 --- a/server/services/videoGen/reactor.js +++ b/server/services/videoGen/reactor.js @@ -280,6 +280,7 @@ async function runReactorVideo(job, jobId, { }); entry.stop = null; if (entry.aborted) return finalizeCanceled(job, jobId); + videoGenEvents.emit('activity', { generationId: jobId }); broadcastSse(job, { type: 'status', message: 'Minting reactor.inc session…' }); const { jwt } = await mintReactorToken(apiKey); if (entry.aborted) return finalizeCanceled(job, jobId); diff --git a/server/services/videoGen/reactorRuntime.js b/server/services/videoGen/reactorRuntime.js index 0075fe0d91..df36a10d99 100644 --- a/server/services/videoGen/reactorRuntime.js +++ b/server/services/videoGen/reactorRuntime.js @@ -7,7 +7,7 @@ import { PATHS } from '../../lib/fileUtils.js'; const execute = promisify(execFile); let preparation; -const probe = (python, expected) => execute(python, ['-c', 'import sys; from importlib.metadata import version; assert version("reactor-sdk") == sys.argv[1]; from reactor_sdk import Reactor; Reactor("reactor/fast-h3")', expected], { timeout: 15_000, maxBuffer: 4096 }).then(() => true, () => false); +const probe = (python, expected) => execute(python, ['-c', 'import sys; from importlib.metadata import version; assert version("reactor-sdk") == sys.argv[1]; from reactor_sdk import Reactor; Reactor("reactor/fast-h3")', expected], { timeout: 15_000, maxBuffer: 1024 * 1024 }).then(() => true, () => false); export async function ensureReactorRuntime() { const requirements = await readFile(join(PATHS.root, 'scripts', 'requirements-reactor.txt'), 'utf8');