Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 20 additions & 4 deletions docs/features/fableloom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 45 additions & 13 deletions scripts/setup-reactor.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,52 @@
#!/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, 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, 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() {
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], 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")'], 15_000);
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; });
25 changes: 18 additions & 7 deletions server/services/videoGen/reactor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand All @@ -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();

Expand Down Expand Up @@ -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}`);
});
Expand All @@ -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);
Expand All @@ -272,6 +270,18 @@ 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);
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);
const result = await captureClip(entry, {
Expand Down Expand Up @@ -302,6 +312,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);
Expand Down
38 changes: 34 additions & 4 deletions server/services/videoGen/reactor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
Expand All @@ -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' }) })));
Expand Down Expand Up @@ -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();
});

Expand Down
36 changes: 36 additions & 0 deletions server/services/videoGen/reactorRuntime.js
Original file line number Diff line number Diff line change
@@ -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: 1024 * 1024 }).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;
}
Loading