diff --git a/CHANGELOG.md b/CHANGELOG.md index de19fe1..20c9bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to OpenReels will be documented in this file. +## [0.18.0] - 2026-04-10 + +### Added +- **Creative direction file** (`--direction `): attach a free-form markdown brief describing visual style, script notes, music mood, and scene ideas. The AI reads it like a human editor would, constraining the DirectorScore while preserving creative judgment. Works across CLI, API (`direction` field), and web UI (textarea in Advanced panel). +- **Score replay** (`--score `): reuse a previous `score.json` to skip research, director, and critic stages. Re-renders with different provider config without regenerating the creative plan. Saves $0.50-2.00 and 10-30 seconds per replay. +- **Replay-aware cost estimation**: cost estimates during replay accurately omit skipped LLM calls (research, creative director, critic), so users see the true execution cost. +- **Direction provenance in log.json**: direction text, replay flag, and source score path are recorded in the run log for auditability. +- **Example creative brief**: `examples/direction-brief.md` demonstrates the direction file format with a deep-sea exploration theme. + +### For contributors +- `PipelineOptions` has new optional fields: `direction?: string` and `replayScore?: DirectorScore`. +- `estimateCost()` accepts an optional `{ replay?: boolean }` options object as the last parameter. +- `CreateJobBody` (server) and `CreateJobRequest` (web) support `direction` and `score` fields. + ## [0.17.0] - 2026-04-10 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index c8681c1..30096da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,7 @@ web/ # React + Tailwind SPA (Vite) hooks/ # useApi, useSSE components/ # Layout, StageCard, pipeline/, shadcn/ui lib/ # scene-assets, utils +examples/ # direction-brief.md (sample creative brief for --direction) prompts/ # system prompts for each agent + playbook fixtures/ # sample DirectorScore JSONs ``` diff --git a/README.md b/README.md index 5390072..c58b11b 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,12 @@ pnpm start "your topic" --dry-run # Specific archetype and provider combo pnpm start "your topic" --archetype anime_illustration --provider openai + +# Creative direction file (guide the AI with a brief) +pnpm start "deep sea exploration" --direction examples/direction-brief.md + +# Replay from a previous score (skip research + director, re-render) +pnpm start "your topic" --score output/2026-04-10-111939-.../score.json ``` ### API keys @@ -180,6 +186,8 @@ pnpm start "your topic" --archetype anime_illustration --provider openai | `--stock-max-attempts ` | Max stock API calls per scene | `4` | | `--video-model ` | Video model override | provider default | | `--kokoro-voice ` | Kokoro voice preset | `af_heart` | +| `--direction ` | Creative brief file (markdown) to guide the AI | — | +| `--score ` | Replay from a saved `score.json`, skipping research + director | — | | `-y, --yes` | Auto-confirm cost estimation (Docker/CI) | off | ## Cost transparency diff --git a/TODOS.md b/TODOS.md index 3f0697a..a24a1e6 100644 --- a/TODOS.md +++ b/TODOS.md @@ -61,7 +61,7 @@ **Priority:** P3 Deferred from plan: tsensei-main-design-20260401-202558.md -- [ ] **Retry from failed stage** — Add POST /api/v1/jobs/:id/retry endpoint and pipeline resume capability. Requires: orchestrator accepts "start from stage" parameter, worker loads prior stage artifacts from disk, new API endpoint creates a re-run job. UI shows retry button in failed state. +- [ ] **Retry from failed stage** — Add POST /api/v1/jobs/:id/retry endpoint and pipeline resume capability. Requires: orchestrator accepts "start from stage" parameter, worker loads prior stage artifacts from disk, new API endpoint creates a re-run job. UI shows retry button in failed state. Note: score replay (`--score`, v0.18.0) partially covers the "rerun from known-good score" case. The general retry system should subsume `--score` for API/UI while `--score` remains a CLI convenience. **Priority:** P2 Deferred from plan: Pipeline Page UI/UX Redesign (eng review: no backend support exists) diff --git a/VERSION b/VERSION index 07feb82..47d04a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.17.0 \ No newline at end of file +0.18.0 \ No newline at end of file diff --git a/docs/content/docs/api/rest.mdx b/docs/content/docs/api/rest.mdx index ab8774e..805e812 100644 --- a/docs/content/docs/api/rest.mdx +++ b/docs/content/docs/api/rest.mdx @@ -108,6 +108,8 @@ Creates a new video generation job and adds it to the processing queue. | `dryRun` | boolean | No | `false` | Skip rendering, produce only the DirectorScore | | `noMusic` | boolean | No | `false` | Skip music generation/selection | | `noVideo` | boolean | No | `false` | Skip AI video generation for scenes | +| `direction` | string | No | — | Creative direction text (free-form markdown, max 10KB). Guides the AI's DirectorScore generation. | +| `score` | object | No | — | A previous DirectorScore JSON for replay. Skips research, director, and critic stages. Must be a valid DirectorScore schema. | | `providers` | object | No | see defaults | Provider selection per category | | `keys` | object | No | `{}` | BYOK (Bring Your Own Key) API keys | @@ -133,6 +135,9 @@ Creates a new video generation job and adds it to the processing queue. | `400` | Unknown `archetype` | | `400` | Unknown `pacing` tier | | `400` | Unknown `platform` | +| `400` | `direction` exceeds 10KB | +| `400` | `direction` is not a string | +| `400` | `score` is not a valid DirectorScore schema | --- diff --git a/docs/content/docs/getting-started/cli.mdx b/docs/content/docs/getting-started/cli.mdx index 2e2cf1f..55a6af0 100644 --- a/docs/content/docs/getting-started/cli.mdx +++ b/docs/content/docs/getting-started/cli.mdx @@ -44,6 +44,8 @@ The topic is a required positional argument. Everything else is optional. | `--stock-max-attempts ` | Max stock API calls per scene | `4` | | `--verification-model ` | Model override for stock verification VLM | provider default | | `--kokoro-voice ` | Kokoro voice preset (e.g. `af_heart`, `bf_emma`, `am_fenrir`) | `af_heart` | +| `--direction ` | Creative brief file (markdown) to guide the AI's creative decisions | — | +| `--score ` | Replay from a saved `score.json`, skipping research and director stages | — | | `-y, --yes` | Auto-confirm cost estimation prompt (for Docker/CI) | off | ## Provider meta-flags @@ -120,6 +122,26 @@ Generate only AI images and stock footage, no AI video: pnpm start "your topic" --no-video ``` +### Creative direction file + +Guide the AI with a free-form creative brief. Describe visual style, mood, script notes, scene ideas, and music preferences in a markdown file: + +```bash +pnpm start "deep sea exploration" --direction examples/direction-brief.md +``` + +The AI reads the brief like a human editor would, honoring your constraints while still exercising creative judgment. See `examples/direction-brief.md` for a sample. + +### Replay from a saved score + +If a previous run produced a great DirectorScore but downstream stages failed (e.g., TTS generated wrong-duration audio), replay from the saved score without re-running research and director: + +```bash +pnpm start "your topic" --score output/2026-04-10-.../score.json +``` + +This skips research, director, and critic stages, saving $0.50-2.00 and 10-30 seconds. The topic argument is still required for the output directory name but is otherwise unused during replay. + ## Cost estimation Before spending any money, the CLI shows a detailed cost breakdown and asks for confirmation: diff --git a/docs/content/docs/getting-started/quickstart.mdx b/docs/content/docs/getting-started/quickstart.mdx index 9626844..45d0af2 100644 --- a/docs/content/docs/getting-started/quickstart.mdx +++ b/docs/content/docs/getting-started/quickstart.mdx @@ -69,6 +69,12 @@ pnpm start "5 stoic lessons" --provider local # Dry run — outputs the DirectorScore JSON without generating any assets pnpm start "your topic" --dry-run + +# Creative direction — guide the AI with a brief +pnpm start "deep sea exploration" --direction examples/direction-brief.md + +# Replay — reuse a previous score, re-render with different config +pnpm start "your topic" --score output/2026-04-10-.../score.json ``` See [CLI Usage](/docs/getting-started/cli) for all available flags. diff --git a/docs/content/docs/getting-started/web-ui.mdx b/docs/content/docs/getting-started/web-ui.mdx index 9c33a6a..dc980ba 100644 --- a/docs/content/docs/getting-started/web-ui.mdx +++ b/docs/content/docs/getting-started/web-ui.mdx @@ -43,6 +43,8 @@ Click **Advanced** to expand the full provider panel: - **Music Provider** — Bundled (free) or Lyria 3 Pro ($0.08/track) - **Style Override** — Same as the archetype gallery, as a dropdown - **Dry Run** — Toggle on to output the DirectorScore JSON without generating assets or spending money +- **Creative Direction** — Free-form textarea where you can describe your creative vision: visual style, mood, script notes, scene ideas, music preferences. The AI reads it like a brief from a producer. A character counter shows usage against the 10,000 character limit. +- **Replay from Score** — Upload a previous `score.json` file to skip research and director stages. Useful when a previous run produced a great creative plan but downstream stages failed. ### 4. Generate diff --git a/docs/content/docs/pipeline/creative-director.mdx b/docs/content/docs/pipeline/creative-director.mdx index 0bd6a00..4b006d3 100644 --- a/docs/content/docs/pipeline/creative-director.mdx +++ b/docs/content/docs/pipeline/creative-director.mdx @@ -92,6 +92,22 @@ The full content playbook (`prompts/playbook.md`) is injected into the Creative - CTA patterns for the final scene - Audience psychology triggers +## Creative direction file + +The `--direction ` flag (or API `direction` field) lets you attach a free-form markdown brief that influences how the Director generates the score. The direction text is appended to the user message as a `## Creative Direction (from the producer)` section. + +Direction is injected into both the initial generation and the revision prompts, so the critic/revise loop stays aligned with your creative intent. The AI treats it like a creative brief from a producer: it honors your constraints on visual style, mood, script notes, and scene ideas while still exercising judgment on anything you didn't specify. + +An empty direction file is treated as no direction. Direction is ignored when replaying from `--score` (the score already incorporates creative intent from its original generation). + +See `examples/direction-brief.md` for a sample brief. + +## Score replay + +When `--score ` is provided, the Director stage skips LLM generation entirely. Instead, it loads the provided `score.json`, populates the shared closure state (archetype config, cost breakdown), and passes the score to downstream stages. + +The revision loop is skipped completely. Cost estimation runs with a `replay: true` flag that omits the research, director, and critic LLM cost terms, giving users an accurate estimate of only the execution costs (TTS, images, video, music). + ## Retry logic The Director has a built-in retry loop (up to 3 attempts). If the LLM output fails Zod validation (wrong field types, golden rule violation, too few scenes), the error message is appended to the next attempt's prompt so the LLM can self-correct. Token usage is accumulated across retries. diff --git a/docs/content/docs/pipeline/critic.mdx b/docs/content/docs/pipeline/critic.mdx index 54a3527..d2b0cf2 100644 --- a/docs/content/docs/pipeline/critic.mdx +++ b/docs/content/docs/pipeline/critic.mdx @@ -95,6 +95,10 @@ The Critic's system prompt is augmented with two sections extracted from the pla This ensures the Critic evaluates against the same standards the Creative Director was instructed to follow. +## Skipping during replay + +When replaying from a saved score (`--score`), the final Critic stage is skipped entirely. The Critic evaluates the DirectorScore text plan, not the rendered video, so re-evaluating an already-accepted score provides no value. This saves one LLM call (~$0.03-0.10). The stage emits an `onStageSkip` event with reason "Replaying from saved score." + ## Graceful degradation If the Critic evaluation fails (LLM error, parsing failure), the stage is skipped rather than crashing the pipeline. The rendered video is still usable -- it just lacks a quality score. The failure is logged in `log.json`. diff --git a/docs/content/docs/pipeline/overview.mdx b/docs/content/docs/pipeline/overview.mdx index b010936..020aeef 100644 --- a/docs/content/docs/pipeline/overview.mdx +++ b/docs/content/docs/pipeline/overview.mdx @@ -86,11 +86,41 @@ After the Director stage produces the DirectorScore, the pipeline computes an es After all stages complete, actual LLM token usage is aggregated and the real cost is computed and reported. +## Score replay + +If you already have a `score.json` from a previous run, you can replay it with `--score ` (CLI) or the `score` field (API). This skips three stages: + +``` +score.json (loaded) + | + v +Director ---- populates closure bindings, runs cost estimation (no LLM calls) + | + v +TTS --------- generates voiceover from the replayed score's script lines + | + v +Visuals ----- resolves assets and music + | + v +Assembly ---- renders the final video +``` + +Research and Critic are skipped entirely. The Director step still runs to populate the shared state that downstream stages depend on (archetype config, cost breakdown), but it uses the provided score directly instead of calling the LLM. Cost estimates during replay accurately omit the skipped LLM calls. + +This is useful when a previous run produced a great creative plan but downstream stages failed (wrong audio duration, image generation error, etc.). Replay saves $0.50-2.00 and 10-30 seconds per run. + +## Creative direction + +The `--direction ` flag lets you attach a free-form markdown brief that influences the DirectorScore generation. The AI reads it like a creative brief from a producer, honoring your constraints on visual style, mood, script notes, and scene ideas while still exercising creative judgment on anything not specified. + +Direction text is injected into both the initial generation and revision prompts, so the critic/revise loop stays aligned with your intent. See `examples/direction-brief.md` for a sample. + ## Early exits The pipeline supports two early exit paths: -- **Dry run** (`--dry-run`) -- stops after the Director stage (including the quality gate revision loop) and prints the final DirectorScore JSON. No assets are generated. +- **Dry run** (`--dry-run`) -- stops after the Director stage (including the quality gate revision loop) and prints the final DirectorScore JSON. No assets are generated. Works with both `--direction` and `--score`. - **Cost rejection** -- if the user declines at the cost estimate prompt, the pipeline exits cleanly. Both paths still write `log.json` to the output directory. diff --git a/docs/content/docs/pipeline/research.mdx b/docs/content/docs/pipeline/research.mdx index 3421f1d..98e9a40 100644 --- a/docs/content/docs/pipeline/research.mdx +++ b/docs/content/docs/pipeline/research.mdx @@ -50,6 +50,10 @@ The research system prompt (`prompts/researcher.md`) instructs the agent to: 4. Focus on **vivid, lesser-known details** for timeless topics (history, science) 5. Note when web search returns nothing useful and knowledge is used instead +## Skipping during replay + +When replaying from a saved score (`--score`), the Research stage is skipped entirely. It returns the same minimal fallback as the web search failure case (below) and emits an `onStageSkip` event with reason "Replaying from saved score." No LLM call is made. The research output is unused since the Director stage also short-circuits during replay. + ## Graceful degradation If web search fails entirely (network error, provider unavailable), the Research stage does not crash the pipeline. Instead it returns a minimal fallback: diff --git a/examples/direction-brief.md b/examples/direction-brief.md new file mode 100644 index 0000000..c1169e7 --- /dev/null +++ b/examples/direction-brief.md @@ -0,0 +1,26 @@ +# Creative Brief: Deep Sea Exploration + +## Visual Style +Dark, cinematic documentary feel. Think BBC Planet Earth meets Interstellar. +Use moody_cinematic or cinematic_documentary archetype. Deep blues, bioluminescent +accents. Prefer real stock footage of ocean depths over AI-generated underwater scenes. + +## Mood & Tone +Awe mixed with unease. The ocean is beautiful AND terrifying. +Start with wonder, build to something more unsettling, end with perspective. + +## Scene Ideas +- Open with a slow zoom into dark water. No narration for the first beat, just music. +- Use text_card for a shocking statistic ("We've explored less than 5% of the ocean"). +- Show bioluminescent creatures mid-video for the "whoa" moment. +- End on Earth from space, pulling back to show how much is water. + +## Script Notes +- Use "we" not "you" for narration. Feels more communal. +- No rhetorical questions in the hook. State a fact that stops the scroll. +- Keep sentences short. 8-12 words each. Let the visuals breathe. +- End with a thought-provoking statement, not a call-to-action. + +## Music +mysterious_ambient or dark_cinematic. No upbeat pop. The music should feel +like pressure, like depth. Something that makes you hold your breath. 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.test.ts b/src/cli/cost-estimator.test.ts index 3055acb..b0afd36 100644 --- a/src/cli/cost-estimator.test.ts +++ b/src/cli/cost-estimator.test.ts @@ -196,6 +196,25 @@ describe("estimateCost", () => { const result = estimateCost(score, "gemini", "elevenlabs", undefined, "openai-compatible"); expect(result.llmCost).toBe(0); }); + + it("zeroes research/director/critic LLM costs in replay mode", () => { + const score = makeScore([ + { visual_type: "ai_image", script_line: "Test" }, + { visual_type: "stock_video", script_line: "Test two" }, + ]); + const full = estimateCost(score); + const replay = estimateCost( + score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 0, 0, { replay: true }, + ); + // Replay LLM cost should only include image prompter (1 ai_image = 1 call) + expect(replay.llmCost).toBeLessThan(full.llmCost); + // Base LLM calls: 0 (replay) vs 3 (full) + expect(replay.details.llmCalls).toBe(1); // only 1 image prompter call + expect(full.details.llmCalls).toBe(4); // 3 base + 1 image prompter + // Non-LLM costs should be identical + expect(replay.ttsCost).toBe(full.ttsCost); + expect(replay.imageCost).toBe(full.imageCost); + }); }); describe("formatCostEstimate", () => { 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..0082c40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,13 @@ #!/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 { getArchetype } from "./config/archetype-registry.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 +18,62 @@ 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)); + // Validate archetype exists in the registry + getArchetype(replayScore.archetype); + } 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 +133,8 @@ async function main(): Promise { stockConfidence: opts.stockConfidence, stockMaxAttempts: opts.stockMaxAttempts, verifyModel, + direction, + replayScore, }, callbacks, ); diff --git a/src/pipeline/direction-file.test.ts b/src/pipeline/direction-file.test.ts new file mode 100644 index 0000000..7aa5e88 --- /dev/null +++ b/src/pipeline/direction-file.test.ts @@ -0,0 +1,88 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +describe("direction file validation (CLI-level logic)", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openreels-direction-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("reads a valid markdown direction file", () => { + const filePath = path.join(tmpDir, "brief.md"); + fs.writeFileSync(filePath, "# My Brief\nUse cinematic style\n"); + const content = fs.readFileSync(filePath, "utf-8"); + expect(content).toContain("cinematic"); + expect(content.trim().length).toBeGreaterThan(0); + }); + + it("rejects a file exceeding 10KB", () => { + const filePath = path.join(tmpDir, "large.md"); + fs.writeFileSync(filePath, "x".repeat(10241)); + const stat = fs.statSync(filePath); + expect(stat.size).toBeGreaterThan(10240); + }); + + it("detects binary content via null byte check", () => { + const filePath = path.join(tmpDir, "binary.bin"); + const buf = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x00, 0x57, 0x6f, 0x72, 0x6c, 0x64]); + fs.writeFileSync(filePath, buf); + const content = fs.readFileSync(filePath, "utf-8"); + expect(content.includes("\0")).toBe(true); + }); + + it("treats empty file as no direction", () => { + const filePath = path.join(tmpDir, "empty.md"); + fs.writeFileSync(filePath, ""); + const content = fs.readFileSync(filePath, "utf-8"); + expect(content.trim()).toBe(""); + }); + + it("treats whitespace-only file as no direction", () => { + const filePath = path.join(tmpDir, "whitespace.md"); + fs.writeFileSync(filePath, " \n\t\n "); + const content = fs.readFileSync(filePath, "utf-8"); + expect(content.trim()).toBe(""); + }); + + it("throws ENOENT for non-existent file", () => { + expect(() => fs.readFileSync(path.join(tmpDir, "nope.md"), "utf-8")).toThrow(); + }); + + it("validates 10KB limit using byte length for CJK content", () => { + // CJK characters are 3 bytes each in UTF-8 + // 3414 CJK chars = 10242 bytes > 10240 limit + const filePath = path.join(tmpDir, "cjk.md"); + const cjkContent = "\u4e16".repeat(3414); + fs.writeFileSync(filePath, cjkContent); + const stat = fs.statSync(filePath); + expect(stat.size).toBeGreaterThan(10240); + }); +}); + +describe("direction injection into creative director", () => { + it("appends direction section to generateDirectorScore prompt", async () => { + // Import the module to verify direction is accepted in the options type + const { generateDirectorScore } = await import("../agents/creative-director.js"); + // Type check: direction is a valid option key + const opts: Parameters[3] = { + direction: "Use noir style", + }; + expect(opts.direction).toBe("Use noir style"); + }); + + it("appends direction section to reviseDirectorScore prompt", async () => { + const { reviseDirectorScore } = await import("../agents/creative-director.js"); + // Type check: direction is a valid option key + const opts: Parameters[5] = { + direction: "Keep it dark", + }; + expect(opts.direction).toBe("Keep it dark"); + }); +}); diff --git a/src/pipeline/orchestrator.ts b/src/pipeline/orchestrator.ts index 410c451..a55be20 100644 --- a/src/pipeline/orchestrator.ts +++ b/src/pipeline/orchestrator.ts @@ -120,6 +120,8 @@ interface RunLog { stockResolutions?: StockResolution[]; videoResolutions?: VideoResolution[]; musicResolution?: { provider: string; prompt?: string; metadata?: Record; fallback: boolean }; + direction?: string; + replay?: boolean; } // ────────────────────────────────────────────────────────────────────────────── @@ -367,6 +369,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 +426,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 +857,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 +921,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/score-replay.test.ts b/src/pipeline/score-replay.test.ts new file mode 100644 index 0000000..d161ad7 --- /dev/null +++ b/src/pipeline/score-replay.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { DirectorScore } from "../schema/director-score.js"; +import { estimateCost } from "../cli/cost-estimator.js"; + +const VALID_SCORE = { + emotional_arc: "curiosity-to-understanding", + archetype: "cinematic_documentary", + music_mood: "mysterious_ambient", + scenes: [ + { + visual_type: "ai_image", + visual_prompt: "Deep sea creatures glowing", + motion: "zoom_in", + script_line: "The ocean hides secrets we cannot imagine.", + transition: "crossfade", + }, + { + visual_type: "stock_video", + visual_prompt: "Underwater footage of coral reef", + motion: "pan_right", + script_line: "Coral reefs are the rainforests of the sea.", + transition: "slide_left", + }, + { + visual_type: "text_card", + visual_prompt: "UNEXPLORED DEPTHS", + motion: "static", + script_line: "We have explored less than five percent of the ocean floor.", + transition: null, + }, + ], +}; + +describe("score replay validation", () => { + it("parses a valid DirectorScore from JSON", () => { + const result = DirectorScore.parse(VALID_SCORE); + expect(result.archetype).toBe("cinematic_documentary"); + expect(result.scenes).toHaveLength(3); + }); + + it("rejects invalid JSON schema (missing scenes)", () => { + expect(() => + DirectorScore.parse({ + emotional_arc: "test", + archetype: "cinematic_documentary", + music_mood: "epic_cinematic", + }), + ).toThrow(); + }); + + it("rejects score with too few scenes", () => { + expect(() => + DirectorScore.parse({ + emotional_arc: "test", + archetype: "cinematic_documentary", + music_mood: "epic_cinematic", + scenes: [ + { + visual_type: "ai_image", + visual_prompt: "test", + motion: "static", + script_line: "test", + transition: null, + }, + ], + }), + ).toThrow(); + }); + + it("rejects score violating golden rule (3 consecutive same visual_type)", () => { + expect(() => + DirectorScore.parse({ + emotional_arc: "test", + archetype: "cinematic_documentary", + music_mood: "epic_cinematic", + scenes: [ + { visual_type: "ai_image", visual_prompt: "a", motion: "static", script_line: "a", transition: null }, + { visual_type: "ai_image", visual_prompt: "b", motion: "static", script_line: "b", transition: null }, + { visual_type: "ai_image", visual_prompt: "c", motion: "static", script_line: "c", transition: null }, + ], + }), + ).toThrow(); + }); + + it("accepts score with invalid archetype name (Zod accepts any string)", () => { + const result = DirectorScore.parse({ + ...VALID_SCORE, + archetype: "nonexistent_style", + }); + expect(result.archetype).toBe("nonexistent_style"); + }); +}); + +describe("score replay cost estimation", () => { + it("zeroes research/director/critic LLM costs in replay mode", () => { + const score = DirectorScore.parse(VALID_SCORE); + const fullCost = estimateCost(score); + const replayCost = estimateCost( + score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 0, 0, { replay: true }, + ); + + // Replay cost should be lower (no research+CD+critic LLM calls) + expect(replayCost.llmCost).toBeLessThan(fullCost.llmCost); + // Base LLM calls: replay has 0 base calls (only image prompter calls) + expect(replayCost.details.llmCalls).toBeLessThan(fullCost.details.llmCalls); + // TTS cost should be the same + expect(replayCost.ttsCost).toBe(fullCost.ttsCost); + // Image cost should be the same + expect(replayCost.imageCost).toBe(fullCost.imageCost); + }); + + it("replay mode still includes image prompter LLM costs", () => { + const score = DirectorScore.parse(VALID_SCORE); + const replayCost = estimateCost( + score, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 0, 0, { replay: true }, + ); + // 1 ai_image scene = 1 image prompter call, so llmCost > 0 + expect(replayCost.llmCost).toBeGreaterThan(0); + }); + + it("replay mode with no AI images has zero LLM cost", () => { + const noAiScore = DirectorScore.parse({ + ...VALID_SCORE, + scenes: [ + { visual_type: "stock_video", visual_prompt: "a", motion: "static", script_line: "line 1", transition: null }, + { visual_type: "text_card", visual_prompt: "b", motion: "static", script_line: "line 2", transition: null }, + { visual_type: "stock_image", visual_prompt: "c", motion: "static", script_line: "line 3", transition: null }, + ], + }); + const replayCost = estimateCost( + noAiScore, "gemini", "elevenlabs", undefined, "anthropic", "bundled", 0, 0, { replay: true }, + ); + expect(replayCost.llmCost).toBe(0); + }); +}); + +describe("direction + score conflict", () => { + it("direction and score are independent fields in PipelineOptions", () => { + const opts = { + direction: "some direction", + replayScore: DirectorScore.parse(VALID_SCORE), + }; + expect(opts.direction).toBeDefined(); + expect(opts.replayScore).toBeDefined(); + }); +}); diff --git a/src/pipeline/server-direction-score.test.ts b/src/pipeline/server-direction-score.test.ts new file mode 100644 index 0000000..7599a5c --- /dev/null +++ b/src/pipeline/server-direction-score.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { DirectorScore } from "../schema/director-score.js"; + +const VALID_SCORE = { + emotional_arc: "curiosity-to-understanding", + archetype: "cinematic_documentary", + music_mood: "mysterious_ambient", + scenes: [ + { + visual_type: "ai_image", + visual_prompt: "Deep sea creatures glowing", + motion: "zoom_in", + script_line: "The ocean hides secrets we cannot imagine.", + transition: "crossfade", + }, + { + visual_type: "stock_video", + visual_prompt: "Underwater footage of coral reef", + motion: "pan_right", + script_line: "Coral reefs are the rainforests of the sea.", + transition: "slide_left", + }, + { + visual_type: "text_card", + visual_prompt: "UNEXPLORED DEPTHS", + motion: "static", + script_line: "We have explored less than five percent of the ocean floor.", + transition: null, + }, + ], +}; + +describe("server-level direction validation", () => { + it("accepts direction text within 10KB", () => { + const direction = "Use cinematic style with dark tones"; + const byteLen = Buffer.byteLength(direction, "utf-8"); + expect(byteLen).toBeLessThanOrEqual(10240); + }); + + it("rejects direction text exceeding 10KB in bytes", () => { + const direction = "x".repeat(10241); + const byteLen = Buffer.byteLength(direction, "utf-8"); + expect(byteLen).toBeGreaterThan(10240); + }); + + it("correctly measures CJK direction text in bytes (not chars)", () => { + // Each CJK character is 3 bytes in UTF-8 + const cjkDirection = "\u4e16".repeat(3414); // 3414 * 3 = 10242 bytes + const charLen = cjkDirection.length; + const byteLen = Buffer.byteLength(cjkDirection, "utf-8"); + expect(charLen).toBe(3414); + expect(byteLen).toBe(10242); + expect(byteLen).toBeGreaterThan(10240); + }); +}); + +describe("server-level score validation", () => { + it("accepts a valid DirectorScore via safeParse", () => { + const result = DirectorScore.safeParse(VALID_SCORE); + expect(result.success).toBe(true); + }); + + it("rejects an invalid DirectorScore via safeParse", () => { + const result = DirectorScore.safeParse({ + emotional_arc: "test", + // missing required fields + }); + expect(result.success).toBe(false); + }); + + it("rejects a score with invalid music_mood value", () => { + const result = DirectorScore.safeParse({ + ...VALID_SCORE, + music_mood: "invalid_mood", + }); + expect(result.success).toBe(false); + }); + + it("rejects a score with invalid visual_type in a scene", () => { + const result = DirectorScore.safeParse({ + ...VALID_SCORE, + scenes: [ + { + visual_type: "hologram", + visual_prompt: "test", + motion: "static", + script_line: "test", + transition: null, + }, + ...VALID_SCORE.scenes.slice(1), + ], + }); + expect(result.success).toBe(false); + }); +}); 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 { diff --git a/src/server.ts b/src/server.ts index 0791274..ad5b161 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,34 @@ 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}` }); + } + // Validate archetype exists in the registry (Zod accepts any string, but getArchetype throws for unknown names) + try { + getArchetype((result.data as { archetype: string }).archetype); + } catch { + return reply.status(400).send({ + error: `Score references unknown archetype: ${(result.data as { archetype: string }).archetype}`, + }); + } + validatedScore = result.data; + } + const job = await queue.add("render", { topic: topic.trim(), archetype, @@ -236,6 +267,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..7684748 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,66 @@ export function HomePage() { + + {/* Direction & Replay */} +
+
+ +