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: 2 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import characterRoutes from './routes/character.js';
import toolsRoutes from './routes/tools.js';
import imageGenRoutes from './routes/imageGen.js';
import videoGenRoutes from './routes/videoGen.js';
import continuousVideoEpisodeRoutes from './routes/continuousVideoEpisode.js';
import videoDownloadRoutes from './routes/videoDownload.js';
import videoTimelineRoutes from './routes/videoTimeline.js';
import mediaJobsRoutes from './routes/mediaJobs.js';
Expand Down Expand Up @@ -380,6 +381,7 @@ app.use('/api/character', characterRoutes);
app.use('/api/tools', toolsRoutes);
app.use('/api/image-gen', imageGenRoutes);
app.use('/api/video-gen', videoGenRoutes);
app.use('/api/continuous-video', continuousVideoEpisodeRoutes);
app.use('/api/devtools/video-download', videoDownloadRoutes);
app.use('/api/video-timeline', videoTimelineRoutes);
app.use('/api/media-jobs', mediaJobsRoutes);
Expand Down
25 changes: 21 additions & 4 deletions server/lib/apiRouteCatalog.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"/api/commands",
"/api/conflict-journal",
"/api/contacts",
"/api/continuous-video",
"/api/cos",
"/api/cos/gsd",
"/api/creative-commission",
Expand Down Expand Up @@ -3494,6 +3495,22 @@
"server/routes/contacts.js"
]
},
{
"method": "POST",
"path": "/api/continuous-video",
"mountPath": "/api/continuous-video",
"sources": [
"server/routes/continuousVideoEpisode.js"
]
},
{
"method": "GET",
"path": "/api/continuous-video/:jobId/events",
"mountPath": "/api/continuous-video",
"sources": [
"server/routes/continuousVideoEpisode.js"
]
},
{
"method": "GET",
"path": "/api/cos",
Expand Down Expand Up @@ -17552,9 +17569,9 @@
}
],
"stats": {
"mounts": 147,
"operations": 2174,
"declarations": 2182,
"sourceFiles": 230
"mounts": 148,
"operations": 2176,
"declarations": 2184,
"sourceFiles": 231
}
}
3 changes: 1 addition & 2 deletions server/lib/videoPromptLinter.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import { resolveBibleDescriptor } from './scriptVideoCompiler.js';
import { escapeRegExp } from './textUtils.js';

export const MAX_CLIP_PROMPT_LENGTH = 800;
const HARD_CUT_PREFIX = 'Hard cut to';
Expand All @@ -25,8 +26,6 @@ const BANNED_REFERENTS = ['same', 'still', 'again', 'continues', 'as before'];
const BANNED_NEGATIVES = ['no', 'without', 'never'];
const BANNED_OVERLAY_TERMS = ['text', 'caption', 'overlay'];

const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

// \b (not a hand-rolled [^a-z0-9] boundary) so "same_text" isn't flagged as containing
// the standalone word "same" — \w already includes '_', matching how prose reads a word.
const bannedTermPattern = (term) => ({ term, regex: new RegExp(`\\b${escapeRegExp(term).replace(/\s+/g, '\\s+')}\\b`, 'i') });
Expand Down
122 changes: 122 additions & 0 deletions server/routes/continuousVideoEpisode.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Continuous-video episode routes (#6227) — submit a script + bible for
* chained multi-clip generation and track its progress. Mirrors the
* SSE-progress shape `server/routes/videoGen.js` uses for single renders and
* chains, over its own outer-job registry (`continuousVideo.js`) since an
* episode is not a mediaJobQueue entry.
*/

import { Router } from 'express';
import { randomUUID } from 'crypto';
import { z } from 'zod';
import { asyncHandler, ServerError, failValidation } from '../lib/errorHandler.js';
import { getSettings } from '../services/settings.js';
import { lintClips } from '../lib/videoPromptLinter.js';
import {
generateContinuousVideoEpisode, composeEpisodeClips, attachEpisodeSseClient, CONTINUOUS_VIDEO_BACKENDS,
} from '../services/videoGen/continuousVideo.js';

const router = Router();

const lineSchema = z.object({
type: z.enum(['action', 'dialogue']),
speaker: z.string().min(1).max(200).optional(),
voice: z.string().max(200).optional(),
text: z.string().max(4000),
});

const sceneSchema = z.object({
sceneId: z.string().max(200).optional(),
location: z.string().max(200).optional(),
lines: z.array(lineSchema).min(1),
});

const bibleEntrySchema = z.object({ descriptor: z.string().min(1).max(2000) });
const bibleSchema = z.object({
styleDescriptor: z.string().max(2000).optional(),
cast: z.record(bibleEntrySchema).optional(),
locations: z.record(bibleEntrySchema).optional(),
});

// Backend render knobs a caller may steer. `settings`/`pythonPath` are always
// server-resolved below and never accepted here — Zod strips any unknown key
// (including an attempted apiKey/pythonPath/settings override) by default.
const renderOptionsSchema = z.object({
modelId: z.string().max(64).optional(),
width: z.number().min(64).max(2048).optional(),
height: z.number().min(64).max(2048).optional(),
negativePrompt: z.string().max(8000).optional(),
seed: z.number().optional(),
falModelId: z.string().min(1).max(200).optional(),
falDuration: z.number().min(1).max(60).optional(),
aspectRatio: z.enum(['16:9', '9:16', '1:1']).optional(),
reactorSeconds: z.number().min(1).max(60).optional(),
});

const compilerOptionsSchema = z.object({
maxWords: z.number().int().positive().max(200).optional(),
maxSpeakers: z.number().int().positive().max(10).optional(),
maxChainLength: z.number().int().positive().max(50).optional(),
fps: z.number().positive().max(60).optional(),
frameGrid: z.enum(['uniform', '17n+5']).optional(),
});

const submitBodySchema = z.object({
scenes: z.array(sceneSchema).min(1).max(200),
bible: bibleSchema,
framings: z.array(z.string().max(200).nullable()).max(2000).optional(),
backend: z.enum(CONTINUOUS_VIDEO_BACKENDS).optional(),
renderOptions: renderOptionsSchema.optional(),
compilerOptions: compilerOptionsSchema.optional(),
});

router.post('/', asyncHandler(async (req, res) => {
const parsed = submitBodySchema.safeParse(req.body || {});
if (!parsed.success) failValidation(parsed);
const {
scenes, bible, framings, backend, renderOptions, compilerOptions,
} = parsed.data;

// Lint BEFORE anything is submitted to a backend — a rule-violating clip
// prompt is rejected here, synchronously, rather than surfacing only later
// over the SSE progress stream.
const clips = composeEpisodeClips({
scenes, bible, framings, compilerOptions,
});
const lint = lintClips(clips, { bible });
if (!lint.pass) {
throw new ServerError('One or more clip prompts failed the continuous-video lint', {
status: 422, code: 'VIDEO_PROMPT_LINT_FAILED', context: { lint },
});
}

const settings = await getSettings();
const jobId = randomUUID();
// The orchestrator runs its own multi-clip chain in the background and
// reports progress over `GET /:jobId/events` (attachEpisodeSseClient) —
// matches the queued-then-SSE contract every other video-gen submit uses.
generateContinuousVideoEpisode({
backend,
jobId,
// Already compiled + linted above — pass the finished clips through
// rather than recompiling the script a second time (bible/scenes are
// only needed to build clips, which is done).
clips,
renderOptions: {
...renderOptions,
settings,
pythonPath: settings.imageGen?.local?.pythonPath || null,
},
}).catch((err) => {
console.log(`❌ Continuous video episode [${jobId.slice(0, 8)}] orchestration crashed: ${err.message}`);
});

res.json({ jobId, generationId: jobId, status: 'running' });
}));

router.get('/:jobId/events', (req, res) => {
const ok = attachEpisodeSseClient(req.params.jobId, res);
if (!ok) throw new ServerError('Job not found or expired', { status: 404 });
});

export default router;
85 changes: 85 additions & 0 deletions server/routes/continuousVideoEpisode.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
describe, it, expect, vi, beforeEach,
} from 'vitest';
import express from 'express';
import { request } from '../lib/testHelper.js';
import { errorMiddleware } from '../lib/errorHandler.js';

vi.mock('../services/settings.js', () => ({
getSettings: vi.fn(async () => ({ imageGen: { local: { pythonPath: '/usr/bin/python3' } } })),
}));
vi.mock('../services/videoGen/continuousVideo.js', () => ({
generateContinuousVideoEpisode: vi.fn(async () => ({ ok: true })),
composeEpisodeClips: vi.fn(),
attachEpisodeSseClient: vi.fn(() => false),
CONTINUOUS_VIDEO_BACKENDS: ['local', 'reactor', 'fal'],
}));
vi.mock('../lib/videoPromptLinter.js', () => ({ lintClips: vi.fn() }));

import * as continuousVideo from '../services/videoGen/continuousVideo.js';
import { lintClips } from '../lib/videoPromptLinter.js';
import continuousVideoEpisodeRoutes from './continuousVideoEpisode.js';

const scenes = [{ sceneId: 's1', location: 'loc1', lines: [{ type: 'action', text: 'A quiet street.' }] }];
const bible = { locations: { loc1: { descriptor: 'A quiet, rain-slicked street.' } } };

describe('continuousVideoEpisode routes', () => {
let app;
beforeEach(() => {
app = express();
app.use(express.json());
app.use('/api/continuous-video', continuousVideoEpisodeRoutes);
app.use(errorMiddleware);
vi.clearAllMocks();
continuousVideo.composeEpisodeClips.mockReturnValue([{ prompt: 'A quiet street.', cutType: 'fresh' }]);
lintClips.mockReturnValue({ pass: true, results: [] });
});

describe('POST /', () => {
it('rejects a request missing scenes/bible', async () => {
const r = await request(app).post('/api/continuous-video').send({});
expect(r.status).toBe(400);
expect(continuousVideo.generateContinuousVideoEpisode).not.toHaveBeenCalled();
});

it('rejects a lint failure before starting generation', async () => {
lintClips.mockReturnValue({ pass: false, results: [{ index: 0, pass: false, reasons: ['banned term'] }] });
const r = await request(app).post('/api/continuous-video').send({ scenes, bible });
expect(r.status).toBe(422);
expect(r.body.code).toBe('VIDEO_PROMPT_LINT_FAILED');
expect(continuousVideo.generateContinuousVideoEpisode).not.toHaveBeenCalled();
});

it('starts an episode and returns a running job descriptor', async () => {
const r = await request(app).post('/api/continuous-video').send({ scenes, bible, backend: 'local' });
expect(r.status).toBe(200);
expect(r.body.status).toBe('running');
expect(typeof r.body.jobId).toBe('string');
expect(continuousVideo.generateContinuousVideoEpisode).toHaveBeenCalledTimes(1);
const call = continuousVideo.generateContinuousVideoEpisode.mock.calls[0][0];
expect(call.jobId).toBe(r.body.jobId);
expect(call.backend).toBe('local');
// pythonPath/settings are server-resolved, never accepted from the client.
expect(call.renderOptions.pythonPath).toBe('/usr/bin/python3');
expect(call.renderOptions.settings).toBeTruthy();
});

it('strips a client-supplied pythonPath/settings override from renderOptions', async () => {
const r = await request(app).post('/api/continuous-video').send({
scenes, bible, renderOptions: { pythonPath: '/evil/python', settings: { hacked: true }, modelId: 'ltx2' },
});
expect(r.status).toBe(200);
const call = continuousVideo.generateContinuousVideoEpisode.mock.calls[0][0];
expect(call.renderOptions.pythonPath).toBe('/usr/bin/python3');
expect(call.renderOptions.settings).not.toEqual({ hacked: true });
expect(call.renderOptions.modelId).toBe('ltx2');
});
});

describe('GET /:jobId/events', () => {
it('404s when the job is not found', async () => {
const r = await request(app).get('/api/continuous-video/missing/events');
expect(r.status).toBe(404);
});
});
});
Loading