diff --git a/.gitignore b/.gitignore index 420a54e..71d0228 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ dist/ .env staging_docs/ output/ +topics/* +!topics/example.md +!topics/.gitkeep .next/ docs/out/ docs/src/data/ diff --git a/README.md b/README.md index 43731f8..3485d0e 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ pnpm start "your topic" --archetype anime_illustration --provider openai | `--tts-provider ` | TTS provider (`elevenlabs`, `inworld`, `kokoro`, `gemini-tts`, `openai-tts`) | `elevenlabs` | | `--music-provider ` | Music provider (`bundled`, `lyria`) | `bundled` | | `--video-provider ` | Video provider (`gemini`, `fal`) | auto-detect | +| `-c, --context ` | Path to a context file with extra direction (see [Context files](#context-files)) | none | | `--archetype ` | Override visual archetype | LLM chooses | | `--platform ` | Target platform (`youtube`, `tiktok`, `instagram`) | `youtube` | | `--dry-run` | Output DirectorScore JSON without generating assets | off | @@ -178,6 +179,27 @@ pnpm start "your topic" --archetype anime_illustration --provider openai | `--kokoro-voice ` | Kokoro voice preset | `af_heart` | | `-y, --yes` | Auto-confirm cost estimation (Docker/CI) | off | +### Context files + +Want more control over the output? Write a context file with your angle, key points, tone, and constraints, then pass it with `--context`: + +```bash +pnpm start "the apollo 13 disaster" --context topics/apollo-brief.md +``` + +The `topics/` folder is gitignored (your briefs stay private). See `topics/example.md` for a template. + +Context is injected into the research, creative director, and critic stages so the entire pipeline follows your direction. You can use markdown or plain text — there's no required format. + +Via the API, pass `context` as a string in the request body: + +```json +{ + "topic": "the apollo 13 disaster", + "context": "Focus on the human side, not technical details..." +} +``` + ## Cost transparency Before spending any money, the pipeline shows a detailed cost breakdown and asks for confirmation: diff --git a/src/agents/creative-director.ts b/src/agents/creative-director.ts index c6328b7..82412b6 100644 --- a/src/agents/creative-director.ts +++ b/src/agents/creative-director.ts @@ -38,7 +38,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; context?: string }, ): Promise { let systemPrompt = buildDefaultPrompt(); @@ -89,7 +89,7 @@ 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. 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.`; +If over budget, cut a scene rather than cramming.${options?.context ? `\n\nCreator's additional context and direction (follow this closely):\n${options.context}` : ""}`; const maxRetries = 3; let lastError: Error | null = null; diff --git a/src/agents/critic.ts b/src/agents/critic.ts index 34f926e..3b8afa0 100644 --- a/src/agents/critic.ts +++ b/src/agents/critic.ts @@ -30,6 +30,7 @@ export async function evaluate( score: DirectorScore, topic: string, pacingOverride?: string, + context?: string, ): Promise { let systemPrompt = "You are a video quality critic. Evaluate the DirectorScore for hook strength, visual variety, pacing, script quality, and overall coherence. Score 1-10. If below 7, provide specific revision instructions targeting the weakest scene."; @@ -75,7 +76,7 @@ Evaluate pacing against these tier-specific thresholds, NOT a fixed "5-7 scenes" DirectorScore: ${JSON.stringify(score, null, 2)} -Evaluate this video plan. Score it 1-10. If it scores below 7, identify the weakest scene and provide specific revision instructions.`; +Evaluate this video plan. Score it 1-10. If it scores below 7, identify the weakest scene and provide specific revision instructions.${context ? `\n\nCreator's additional context and direction (evaluate whether the plan follows this):\n${context}` : ""}`; const result = await llm.generate({ systemPrompt, diff --git a/src/agents/research.ts b/src/agents/research.ts index 7e1f507..20a934c 100644 --- a/src/agents/research.ts +++ b/src/agents/research.ts @@ -18,7 +18,7 @@ export interface ResearchOutput { usage: LLMUsage; } -export async function research(llm: LLMProvider, topic: string): Promise { +export async function research(llm: LLMProvider, topic: string, context?: string): Promise { let systemPrompt = "You are a research assistant. Given a topic, search the web for current information and produce a structured research summary with key facts, mood/tone, and sources."; @@ -28,9 +28,14 @@ export async function research(llm: LLMProvider, topic: string): Promise", "Kokoro voice preset (e.g. af_heart, bf_emma, am_fenrir)", "af_heart") + .option("-c, --context ", "Path to a context file with additional topic details (e.g. topics/my-brief.md)") .option("-a, --archetype ", "Visual archetype override") .addOption( new Option("--pacing ", "Pacing tier override (overrides archetype default)") @@ -121,6 +123,7 @@ export function parseArgs(): CLIOptions { return { topic, + context: opts["context"] as string | undefined, provider: opts["provider"] as LLMProviderKey, imageProvider: opts["imageProvider"] as ImageProviderKey, ttsProvider: opts["ttsProvider"] as TTSProviderKey, diff --git a/src/index.ts b/src/index.ts index 7f5c608..876e887 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import * as fs from "node:fs"; import { parseArgs } from "./cli/args.js"; import { validateEnv } from "./cli/validate-env.js"; import { createCliCallbacks, runPipeline } from "./pipeline/orchestrator.js"; @@ -28,6 +29,21 @@ async function main(): Promise { kokoroVoice: opts.kokoroVoice, }); + // Read context file if provided + let context: string | undefined; + if (opts.context) { + try { + context = fs.readFileSync(opts.context, "utf-8").trim(); + if (!context) { + console.warn(`Warning: Context file "${opts.context}" is empty, ignoring.`); + context = undefined; + } + } catch (err) { + console.error(`Error reading context file "${opts.context}":`, err instanceof Error ? err.message : String(err)); + process.exit(1); + } + } + // Create CLI callbacks for terminal progress display const { callbacks, progress } = createCliCallbacks(opts.yes); @@ -40,6 +56,7 @@ async function main(): Promise { const result = await runPipeline( { topic: opts.topic, + context, llm, tts, ttsProvider: opts.ttsProvider, diff --git a/src/pipeline/orchestrator.ts b/src/pipeline/orchestrator.ts index e92c9f3..4072705 100644 --- a/src/pipeline/orchestrator.ts +++ b/src/pipeline/orchestrator.ts @@ -368,7 +368,7 @@ function buildPipelineWorkflow( cb.onStageStart?.("research"); const start = Date.now(); try { - const output = await research(opts.llm, inputData.topic); + const output = await research(opts.llm, inputData.topic, opts.context); llmUsages.push(output.usage); const dur = (Date.now() - start) / 1000; cb.onStageComplete?.("research", `${output.data.key_facts.length} facts`, dur); @@ -414,6 +414,7 @@ function buildPipelineWorkflow( archetype: opts.archetype, pacing: opts.pacing, videoEnabled, + context: opts.context, }); // Store on shared context via closure directorResult.score = cdOutput.data; @@ -729,7 +730,7 @@ function buildPipelineWorkflow( cb.onStageStart?.("critic"); const start = Date.now(); try { - const critiqueOutput = await evaluate(opts.llm, score, opts.topic, opts.pacing); + const critiqueOutput = await evaluate(opts.llm, score, opts.topic, opts.pacing, opts.context); const critique = critiqueOutput.data; llmUsages.push(critiqueOutput.usage); const dur = (Date.now() - start) / 1000; diff --git a/src/pipeline/utils.ts b/src/pipeline/utils.ts index 216ec97..cb1df65 100644 --- a/src/pipeline/utils.ts +++ b/src/pipeline/utils.ts @@ -45,6 +45,7 @@ export interface PipelineCallbacks { export interface PipelineOptions { topic: string; + context?: string; llm: LLMProvider; tts: TTSProvider; ttsProvider: TTSProviderKey; diff --git a/src/server.ts b/src/server.ts index bc6e2a8..bbde2f2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -157,6 +157,7 @@ app.get("/api/v1/providers", async () => ({ // --- Job creation --- interface CreateJobBody { topic: string; + context?: string; archetype?: string; pacing?: string; platform?: string; @@ -176,7 +177,7 @@ interface CreateJobBody { } app.post<{ Body: CreateJobBody }>("/api/v1/jobs", async (request, reply) => { - const { topic, archetype, pacing, platform, dryRun, noMusic, noVideo, providers, keys } = request.body ?? {}; + const { topic, context, archetype, pacing, platform, dryRun, noMusic, noVideo, providers, keys } = request.body ?? {}; if (!topic || typeof topic !== "string" || topic.trim().length === 0) { return reply.status(400).send({ error: "topic is required" }); @@ -210,8 +211,14 @@ app.post<{ Body: CreateJobBody }>("/api/v1/jobs", async (request, reply) => { .send({ error: `Unknown platform: ${platform}. Available: ${validPlatforms.join(", ")}` }); } + // Validate context if provided + if (context !== undefined && (typeof context !== "string" || context.trim().length > 5000)) { + return reply.status(400).send({ error: "context must be a string of 5000 characters or fewer" }); + } + const job = await queue.add("render", { topic: topic.trim(), + context: context?.trim() || undefined, archetype, pacing, platform: platform ?? "youtube", diff --git a/src/worker.ts b/src/worker.ts index 2af3e1c..4442446 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -35,6 +35,7 @@ try { interface JobData { topic: string; + context?: string; archetype?: string; pacing?: string; platform: string; @@ -84,7 +85,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 } = job.data; + const { topic, context, archetype, pacing, platform, dryRun, noMusic, noVideo, providers, keys } = job.data; const jobDir = path.join(JOBS_DIR, job.id!); fs.mkdirSync(jobDir, { recursive: true }); @@ -221,6 +222,7 @@ const worker = new Worker( const result = await runPipeline( { topic, + context, llm: providerInstances.llm, tts: providerInstances.tts, ttsProvider: providers.tts as TTSProviderKey, diff --git a/topics/.gitkeep b/topics/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/topics/example.md b/topics/example.md new file mode 100644 index 0000000..0b4c46a --- /dev/null +++ b/topics/example.md @@ -0,0 +1,39 @@ +# Example Context File + +Place your context files here to give OpenReels more direction when generating videos. + +## How to use + +Create a markdown (or plain text) file in this folder describing what you want, then reference it with the `--context` flag: + +```bash +pnpm start "the apollo 13 disaster" --context topics/my-apollo-brief.md +``` + +## What to include + +- **Angle / thesis**: What specific perspective should the video take? +- **Key points**: Must-include facts, quotes, or data points. +- **Tone**: Serious, humorous, dramatic, educational, etc. +- **Target audience**: Who is this for? +- **Call to action**: What should viewers do after watching? +- **Things to avoid**: Topics, claims, or imagery you don't want. + +## Example + +```markdown +# Apollo 13 — The Human Side + +Focus on the emotional experience of the astronauts and mission control, +not the technical details. Highlight the moment Jim Lovell saw the oxygen +venting and knew they were in trouble. + +Tone: Tense, then hopeful. End on the triumphant splashdown. + +Must include: +- "Houston, we've had a problem" (use the correct quote, not the movie version) +- The CO2 scrubber improvisation +- The 4-minute blackout during reentry + +Avoid: Conspiracy theories, movie dramatizations presented as fact. +```