From 23d98468ea8589da1bf7cccf6d3a2f0b596f240e Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Fri, 10 Apr 2026 13:12:57 +0600 Subject: [PATCH 1/9] feat(pipeline): add direction file and score replay support Two new pipeline inputs: --direction for creative briefs that influence DirectorScore generation, and --score for replaying from a saved score.json (skips research, director, and critic stages). Direction is free-form markdown injected into the creative director prompt. Score replay populates all closure bindings (score, config, costBreakdown) and runs cost estimation with accurate replay pricing. --- src/agents/creative-director.ts | 16 ++++++-- src/cli/args.ts | 9 +++++ src/cli/cost-estimator.ts | 17 +++++--- src/index.ts | 58 ++++++++++++++++++++++++++ src/pipeline/orchestrator.ts | 72 ++++++++++++++++++++++++++++++++- src/pipeline/utils.ts | 2 + 6 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src/agents/creative-director.ts b/src/agents/creative-director.ts index 1caa87f..387a3e0 100644 --- a/src/agents/creative-director.ts +++ b/src/agents/creative-director.ts @@ -60,7 +60,7 @@ export async function generateDirectorScore( llm: LLMProvider, topic: string, researchContext: ResearchResult, - options?: { archetype?: string; pacing?: string; videoEnabled?: boolean }, + options?: { archetype?: string; pacing?: string; videoEnabled?: boolean; direction?: string }, ): Promise { const systemPrompt = loadDirectorSystemPrompt(); @@ -80,6 +80,10 @@ export async function generateDirectorScore( // Resolve pacing tier: explicit --pacing override > archetype default > lookup table const pacingInstruction = buildPacingInstruction(options?.archetype, options?.pacing); + const directionSection = options?.direction?.trim() + ? `\n## Creative Direction (from the producer)\n\n${options.direction}\n\nHonor these creative constraints while exercising your judgment on anything not specified.\n` + : ""; + const userMessage = `Topic: ${topic} Research context: @@ -94,7 +98,7 @@ ${archetypeInstruction} ${pacingInstruction} Use ${visualTypes}.${videoGuidance} -CRITICAL RULE: Never use the same visual_type more than 2 times in a row. With more scenes, plan your visual_type sequence BEFORE writing scenes to ensure variety. +${directionSection}CRITICAL RULE: Never use the same visual_type more than 2 times in a row. With more scenes, plan your visual_type sequence BEFORE writing scenes to ensure variety. Every scene MUST have a script_line (the voiceover text). The first scene should be a strong hook. If over budget, cut a scene rather than cramming.`; @@ -197,7 +201,7 @@ export async function reviseDirectorScore( researchContext: ResearchResult, originalScore: DirectorScore, critique: CritiqueResult, - options?: { archetype?: string; pacing?: string; videoEnabled?: boolean }, + options?: { archetype?: string; pacing?: string; videoEnabled?: boolean; direction?: string }, ): Promise { const systemPrompt = loadDirectorSystemPrompt(); @@ -212,6 +216,10 @@ export async function reviseDirectorScore( ? "all 5 visual types (ai_image, ai_video, stock_image, stock_video, text_card)" : "all 4 visual types (ai_image, stock_image, stock_video, text_card)"; + const directionSection = options?.direction?.trim() + ? `\n## Creative Direction (from the producer)\n\n${options.direction}\n\nHonor these creative constraints while exercising your judgment on anything not specified.\n` + : ""; + const userMessage = `Topic: ${topic} Research context: @@ -224,7 +232,7 @@ Mood: ${researchContext.mood} ${pacingInstruction} Use ${visualTypes}. - +${directionSection} ## Current Plan (score: ${critique.score}/10) ${JSON.stringify(originalScore, null, 2)} diff --git a/src/cli/args.ts b/src/cli/args.ts index 78229c9..9b2bc79 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -38,6 +38,8 @@ export interface CLIOptions { stockConfidence: number; stockMaxAttempts: number; verificationModel?: string; + direction?: string; + score?: string; usage: boolean; } @@ -167,6 +169,11 @@ export function parseArgs(): CLIOptions { "Video model override (e.g. veo-3.1-lite-preview, fal-ai/kling-video/v2.1/standard/image-to-video)", ) .option("--video", "Enable AI video generation (use --no-video to disable)", true) + .option( + "--direction ", + "Creative brief file (markdown). Describe visual style, script notes, music mood, scene ideas. The AI reads it like a human editor would. See examples/direction-brief.md", + ) + .option("--score ", "Replay from a saved score.json, skipping research and director stages") .option("--usage", "Show cost usage report from past runs in the output directory", false) .parse(); @@ -244,6 +251,8 @@ export function parseArgs(): CLIOptions { stockConfidence: opts["stockConfidence"] as number, stockMaxAttempts: opts["stockMaxAttempts"] as number, verificationModel: opts["verificationModel"] as string | undefined, + direction: opts["direction"] as string | undefined, + score: opts["score"] as string | undefined, usage: false, }; } diff --git a/src/cli/cost-estimator.ts b/src/cli/cost-estimator.ts index 92a753e..a943397 100644 --- a/src/cli/cost-estimator.ts +++ b/src/cli/cost-estimator.ts @@ -105,24 +105,29 @@ export function estimateCost( musicProvider: MusicProviderKey = "bundled", gateEvaluations = 0, revisionRounds = 0, + options?: { replay?: boolean }, ): CostBreakdown { + const replay = options?.replay ?? false; const aiImageScenes = score.scenes.filter((s) => s.visual_type === "ai_image").length; const aiVideoScenes = score.scenes.filter((s) => s.visual_type === "ai_video").length; // ai_video scenes also generate a Phase 1 AI image const aiImages = aiImageScenes + aiVideoScenes; const ttsCharacters = score.scenes.reduce((sum, s) => sum + s.script_line.length, 0); // research + CD + critic + 1 per ai_image + 2 per ai_video (image prompt + motion prompt) - const llmCalls = 3 + aiImageScenes + aiVideoScenes * 2; + const baseLlmCalls = replay ? 0 : 3; + const llmCalls = baseLlmCalls + aiImageScenes + aiVideoScenes * 2; const p = LLM_PRICING[llmProvider as keyof typeof LLM_PRICING] ?? LLM_PRICING_DEFAULT; const callCost = (est: { input: number; output: number }) => est.input * p.perInputToken + est.output * p.perOutputToken; - const llmCost = - callCost(TOKEN_ESTIMATES.research) + - callCost(TOKEN_ESTIMATES.creativeDirector) + - callCost(TOKEN_ESTIMATES.critic) + - aiImages * callCost(TOKEN_ESTIMATES.imagePrompter); + // During replay, research + creative director + critic LLM calls are skipped + const llmCost = replay + ? aiImages * callCost(TOKEN_ESTIMATES.imagePrompter) + : callCost(TOKEN_ESTIMATES.research) + + callCost(TOKEN_ESTIMATES.creativeDirector) + + callCost(TOKEN_ESTIMATES.critic) + + aiImages * callCost(TOKEN_ESTIMATES.imagePrompter); // Quality gate cost: critic calls for evaluation + director calls for revision. // Split because evaluations > revisions (gate always evaluates, only revises if score < 7). diff --git a/src/index.ts b/src/index.ts index f2afafe..44f85a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,12 @@ #!/usr/bin/env node +import * as fs from "node:fs"; import { parseArgs } from "./cli/args.js"; import { showUsageReport } from "./cli/usage-report.js"; import { validateEnv } from "./cli/validate-env.js"; import { createCliCallbacks, runPipeline } from "./pipeline/orchestrator.js"; import { createProviders, createVerificationModel } from "./providers/factory.js"; +import { DirectorScore } from "./schema/director-score.js"; async function main(): Promise { const opts = parseArgs(); @@ -15,6 +17,60 @@ async function main(): Promise { return; } + // Load direction file if provided + let direction: string | undefined; + if (opts.direction) { + try { + const stat = fs.statSync(opts.direction); + if (stat.size > 10240) { + console.error(`Direction file exceeds 10KB limit (${stat.size} bytes): ${opts.direction}`); + process.exit(1); + } + const content = fs.readFileSync(opts.direction, "utf-8"); + // Check for binary content (null bytes) + if (content.includes("\0")) { + console.error(`Direction file appears to be binary, expected a text file: ${opts.direction}`); + process.exit(1); + } + if (content.trim()) { + direction = content; + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + console.error(`Direction file not found: ${opts.direction}`); + } else if ((err as NodeJS.ErrnoException).code === "EACCES") { + console.error(`Cannot read direction file: ${opts.direction}`); + } else { + console.error(`Failed to read direction file: ${err}`); + } + process.exit(1); + } + } + + // Load score.json for replay if provided + let replayScore: DirectorScore | undefined; + if (opts.score) { + try { + const raw = fs.readFileSync(opts.score, "utf-8"); + replayScore = DirectorScore.parse(JSON.parse(raw)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + console.error(`Score file not found: ${opts.score}`); + } else if (err instanceof SyntaxError) { + console.error(`Invalid JSON in score file: ${err.message}`); + } else { + console.error(`Invalid score file: ${err instanceof Error ? err.message : err}`); + } + process.exit(1); + } + + // Direction is ignored during replay (score already incorporates creative intent) + if (direction) { + console.warn("[warning] --direction is ignored when replaying from --score (score already incorporates creative intent)"); + direction = undefined; + } + } + // Validate required API keys before constructing providers validateEnv({ provider: opts.provider, @@ -74,6 +130,8 @@ async function main(): Promise { stockConfidence: opts.stockConfidence, stockMaxAttempts: opts.stockMaxAttempts, verifyModel, + direction, + replayScore, }, callbacks, ); diff --git a/src/pipeline/orchestrator.ts b/src/pipeline/orchestrator.ts index 410c451..d6beff1 100644 --- a/src/pipeline/orchestrator.ts +++ b/src/pipeline/orchestrator.ts @@ -120,6 +120,9 @@ interface RunLog { stockResolutions?: StockResolution[]; videoResolutions?: VideoResolution[]; musicResolution?: { provider: string; prompt?: string; metadata?: Record; fallback: boolean }; + direction?: string; + replay?: boolean; + replayScorePath?: string; } // ────────────────────────────────────────────────────────────────────────────── @@ -367,6 +370,18 @@ function buildPipelineWorkflow( sources: z.array(z.string()), }), execute: async ({ inputData }) => { + // Replay mode: skip research entirely + if (opts.replayScore) { + cb.onStageSkip?.("research", "Replaying from saved score"); + log.stages.push({ name: "research", duration: 0, status: "skipped" }); + return { + summary: `Topic: ${inputData.topic}`, + key_facts: [] as string[], + mood: "informative", + sources: [] as string[], + }; + } + cb.onStageStart?.("research"); const start = Date.now(); try { @@ -412,7 +427,53 @@ function buildPipelineWorkflow( cb.onStageStart?.("director"); const start = Date.now(); const videoEnabled = !opts.noVideo && (opts.videoProviders?.length ?? 0) > 0; - const directorOpts = { archetype: opts.archetype, pacing: opts.pacing, videoEnabled }; + const directorOpts = { archetype: opts.archetype, pacing: opts.pacing, videoEnabled, direction: opts.direction }; + + // ── Replay mode: use provided score, skip generation + revision ── + if (opts.replayScore) { + const score = opts.replayScore; + directorResult.score = score; + directorResult.config = getArchetype(score.archetype); + + fs.writeFileSync(scorePath, JSON.stringify(score, null, 2)); + cb.onProgress?.("director", { type: "score", score }); + + // Dry run during replay + if (opts.dryRun) { + const dur = (Date.now() - start) / 1000; + cb.onStageComplete?.("director", "Replayed from saved score", dur); + log.stages.push({ name: "creative-director", duration: dur, status: "done" }); + cb.onStageSkip?.("tts", "dry run"); + cb.onStageSkip?.("visuals", "dry run"); + cb.onStageSkip?.("assembly", "dry run"); + cb.onStageSkip?.("critic", "dry run"); + cb.onLog?.("\n--- DirectorScore (replayed) ---"); + cb.onLog?.(JSON.stringify(score, null, 2)); + directorResult.dryRunExit = true; + return { done: true }; + } + + // Cost estimation with replay flag (omits research/director/critic LLM costs) + const costBreakdown = estimateCost(score, opts.imageProvider, opts.ttsProvider, opts.videoProvider, opts.llm.id, opts.musicProviderKey, 0, 0, { replay: true }); + directorResult.costBreakdown = costBreakdown; + log.totalCost = { estimated: costBreakdown.totalCost }; + + if (cb.onCostEstimate) { + const stockSceneCount = score.scenes.filter( + (s) => s.visual_type === "stock_image" || s.visual_type === "stock_video", + ).length; + const proceed = await cb.onCostEstimate(costBreakdown, opts.imageProvider, stockSceneCount); + if (!proceed) { + directorResult.costRejected = true; + return { done: true }; + } + } + + const dur = (Date.now() - start) / 1000; + cb.onStageComplete?.("director", `Replayed: ${score.scenes.length} scenes, ${score.archetype}`, dur); + log.stages.push({ name: "creative-director", duration: dur, status: "done" }); + return { done: true }; + } // ── Generate initial DirectorScore ── const cdOutput = await generateDirectorScore(opts.llm, opts.topic, inputData, directorOpts); @@ -797,6 +858,13 @@ function buildPipelineWorkflow( return { done: false }; } + // Replay mode: skip critic (it evaluates the score text, not the rendered video) + if (opts.replayScore) { + cb.onStageSkip?.("critic", "Replaying from saved score"); + log.stages.push({ name: "critic", duration: 0, status: "skipped" }); + return { done: true }; + } + const score = directorResult.score!; cb.onStageStart?.("critic"); const start = Date.now(); @@ -854,6 +922,8 @@ export async function runPipeline( topic: opts.topic, startedAt: new Date().toISOString(), stages: [], + ...(opts.direction ? { direction: opts.direction } : {}), + ...(opts.replayScore ? { replay: true } : {}), }; // Create output directory with timestamp to avoid overwrites diff --git a/src/pipeline/utils.ts b/src/pipeline/utils.ts index 216ec97..0f018da 100644 --- a/src/pipeline/utils.ts +++ b/src/pipeline/utils.ts @@ -68,6 +68,8 @@ export interface PipelineOptions { videoProviders?: VideoProvider[]; videoProvider?: VideoProviderKey; noVideo?: boolean; + direction?: string; + replayScore?: DirectorScore; } export interface PipelineResult { From 8b70293748f646d7f532f32fa9bd8e19e790e183 Mon Sep 17 00:00:00 2001 From: Talha Jubair Siam Date: Fri, 10 Apr 2026 13:13:06 +0600 Subject: [PATCH 2/9] feat(server): thread direction and score through API, worker, and web UI Server validates direction (10KB byte limit) and score (DirectorScore schema). Worker threads both to runPipeline. Web UI adds direction textarea with character counter and score file input in Advanced panel. --- src/server.ts | 27 +++++++++++++++- src/worker.ts | 13 +++++++- web/src/hooks/useApi.ts | 2 ++ web/src/pages/HomePage.tsx | 63 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 0791274..9f26efe 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,6 +8,7 @@ import IORedis from "ioredis"; import { PACING_CONFIG } from "./agents/creative-director.js"; import { getArchetype, listArchetypes } from "./config/archetype-registry.js"; import { PLATFORMS } from "./config/platforms.js"; +import { DirectorScore } from "./schema/director-score.js"; import type { SearchProviderKey } from "./schema/providers.js"; const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6379"; @@ -177,6 +178,8 @@ interface CreateJobBody { dryRun?: boolean; noMusic?: boolean; noVideo?: boolean; + direction?: string; + score?: Record; providers?: { llm?: string; tts?: string; @@ -193,7 +196,7 @@ interface CreateJobBody { } app.post<{ Body: CreateJobBody }>("/api/v1/jobs", async (request, reply) => { - const { topic, archetype, pacing, platform, dryRun, noMusic, noVideo, providers, keys } = + const { topic, archetype, pacing, platform, dryRun, noMusic, noVideo, direction, score, providers, keys } = request.body ?? {}; if (!topic || typeof topic !== "string" || topic.trim().length === 0) { @@ -228,6 +231,26 @@ app.post<{ Body: CreateJobBody }>("/api/v1/jobs", async (request, reply) => { .send({ error: `Unknown platform: ${platform}. Available: ${validPlatforms.join(", ")}` }); } + // Validate direction text size if provided + if (direction != null) { + if (typeof direction !== "string") { + return reply.status(400).send({ error: "direction must be a string" }); + } + if (Buffer.byteLength(direction, "utf-8") > 10240) { + return reply.status(400).send({ error: "direction exceeds 10KB limit" }); + } + } + + // Validate score (DirectorScore) if provided for replay + let validatedScore: unknown | undefined; + if (score != null) { + const result = DirectorScore.safeParse(score); + if (!result.success) { + return reply.status(400).send({ error: `Invalid DirectorScore: ${result.error.message}` }); + } + validatedScore = result.data; + } + const job = await queue.add("render", { topic: topic.trim(), archetype, @@ -236,6 +259,8 @@ app.post<{ Body: CreateJobBody }>("/api/v1/jobs", async (request, reply) => { dryRun: dryRun ?? false, noMusic: noMusic === true, noVideo: noVideo === true, + ...(direction?.trim() ? { direction: direction.trim() } : {}), + ...(validatedScore ? { score: validatedScore } : {}), providers: { llm: providers?.llm ?? "anthropic", tts: providers?.tts ?? "elevenlabs", diff --git a/src/worker.ts b/src/worker.ts index d01b3ce..74ab2dc 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -6,6 +6,7 @@ import type { PipelineCallbacks, StageName } from "./pipeline/orchestrator.js"; import { runPipeline } from "./pipeline/orchestrator.js"; import { createProviders, createVerificationModel } from "./providers/factory.js"; import { validateManifest } from "./providers/music/bundled.js"; +import { DirectorScore } from "./schema/director-score.js"; import type { ImageProviderKey, LLMProviderKey, @@ -44,6 +45,8 @@ interface JobData { dryRun: boolean; noMusic?: boolean; noVideo?: boolean; + direction?: string; + score?: Record; providers: { llm: string; tts: string; @@ -96,7 +99,7 @@ function writeMeta(jobDir: string, meta: JobMeta) { const worker = new Worker( "openreels", async (job: Job) => { - const { topic, archetype, pacing, platform, dryRun, noMusic, noVideo, providers, keys } = + const { topic, archetype, pacing, platform, dryRun, noMusic, noVideo, direction, score, providers, keys } = job.data; const jobDir = path.join(JOBS_DIR, job.id!); fs.mkdirSync(jobDir, { recursive: true }); @@ -258,6 +261,12 @@ const worker = new Worker( llmKey, ); + // Parse replay score if provided + const replayScore = score ? DirectorScore.parse(score) : undefined; + + // Direction is ignored when replaying (score already incorporates creative intent) + const effectiveDirection = replayScore ? undefined : direction; + // Run the pipeline const result = await runPipeline( { @@ -282,6 +291,8 @@ const worker = new Worker( outputDir: jobDir, yes: true, verifyModel, + direction: effectiveDirection, + replayScore, }, callbacks, ); diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 0cba716..7f1d7b0 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -139,6 +139,8 @@ export interface CreateJobRequest { pacing?: string; platform?: string; dryRun?: boolean; + direction?: string; + score?: Record; providers?: { llm?: string; tts?: string; diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index 079bf44..dc37a60 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -96,6 +96,9 @@ export function HomePage() { const [musicProvider, setMusicProvider] = useState("bundled"); const [pacing, setPacing] = useState(""); const [dryRun, setDryRun] = useState(false); + const [directionText, setDirectionText] = useState(""); + const [scoreJson, setScoreJson] = useState | null>(null); + const [scoreFileName, setScoreFileName] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); const [archetypes, setArchetypes] = useState([]); @@ -136,6 +139,8 @@ export function HomePage() { pacing: pacing || undefined, platform, dryRun, + ...(directionText.trim() ? { direction: directionText.trim() } : {}), + ...(scoreJson ? { score: scoreJson } : {}), providers: { llm: llmProvider, tts: ttsProvider, @@ -408,6 +413,64 @@ export function HomePage() { + + {/* Direction & Replay */} +
+
+ +