From 6295a9c024b609bf4fb2e3d15324090c17d09328 Mon Sep 17 00:00:00 2001 From: sinmb79 Date: Fri, 3 Apr 2026 04:27:47 +0900 Subject: [PATCH] feat(narrative-dna): Studio DNA + Narrative Payload contract v1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add studio philosophy definitions (ghibli, jang_hang_jun, hail_mary) - Add narrative_payload contract (contracts.ts, request-schema.ts) - Add scenario engine: build-narrative-payload.ts - Add CLI entrypoints: scenario, studios - 127 tests passed, 0 failed The 4th Path: ⟨H⊕A⟩ ↦ Ω | the4thpath.com --- src/cli/index.ts | 17 +- src/cli/prompt-engine-command.ts | 2 + src/cli/render-output.ts | 2 + src/cli/render-scenario-output.ts | 18 ++ src/cli/render-studios-output.ts | 35 +++ src/cli/resolve-planning-context.ts | 5 + src/cli/run-engine-command.ts | 19 +- src/cli/scenario-engine-command.ts | 57 +++++ src/cli/studios-engine-command.ts | 50 +++++ src/domain/contracts.ts | 58 +++++ src/domain/normalize-request.ts | 18 ++ src/domain/request-schema.ts | 100 ++++++++- src/prompt/build-prompt-result.ts | 12 ++ src/scenario/build-narrative-payload.ts | 211 +++++++++++++++++++ src/scenario/load-studio-definition.ts | 32 +++ studios/ghibli/studio.json | 44 ++++ studios/hail_mary/studio.json | 44 ++++ studios/jang_hang_jun/studio.json | 44 ++++ tests/cli/prompt-command.test.ts | 13 ++ tests/cli/run-command.test.ts | 18 ++ tests/cli/scenario-command.test.ts | 29 +++ tests/cli/studios-command.test.ts | 29 +++ tests/domain/request-schema.test.ts | 30 +++ tests/fixtures/ghibli-narrative-request.json | 33 +++ 24 files changed, 908 insertions(+), 12 deletions(-) create mode 100644 src/cli/render-scenario-output.ts create mode 100644 src/cli/render-studios-output.ts create mode 100644 src/cli/scenario-engine-command.ts create mode 100644 src/cli/studios-engine-command.ts create mode 100644 src/scenario/build-narrative-payload.ts create mode 100644 src/scenario/load-studio-definition.ts create mode 100644 studios/ghibli/studio.json create mode 100644 studios/hail_mary/studio.json create mode 100644 studios/jang_hang_jun/studio.json create mode 100644 tests/cli/scenario-command.test.ts create mode 100644 tests/cli/studios-command.test.ts create mode 100644 tests/fixtures/ghibli-narrative-request.json diff --git a/src/cli/index.ts b/src/cli/index.ts index 2a207aa..6c3e830 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -11,6 +11,8 @@ import { promptEngineCommand } from "./prompt-engine-command.js"; import { publishEngineCommand } from "./publish-engine-command.js"; import { renderEngineCommand } from "./render-engine-command.js"; import { runEngineCommand } from "./run-engine-command.js"; +import { scenarioEngineCommand } from "./scenario-engine-command.js"; +import { studiosEngineCommand } from "./studios-engine-command.js"; import { wizardEngineCommand } from "./wizard-engine-command.js"; import { executeEngineCommand } from "./execute-engine-command.js"; import { ttsEngineCommand } from "./tts-engine-command.js"; @@ -25,21 +27,30 @@ const simulate = flags.includes("--simulate"); if (!command) { process.stderr.write( - "Usage: engine [request.json] [--json] [--simulate]\n", + "Usage: engine [request.json] [--json] [--simulate]\n", ); process.exit(EXIT_CODE_INTERNAL_ERROR); } const dry_run = flags.includes("--dry-run"); -const result = await executeCommand(command, positionals, { json, simulate, dry_run }); +const result = await executeCommand(command, positionals, rest, { json, simulate, dry_run }); process.stdout.write(result.output); process.exit(result.exitCode); async function executeCommand( commandName: string, positionals: string[], + rawArgs: string[], options: { json: boolean; simulate: boolean; dry_run: boolean }, ) { + if (commandName === "scenario") { + return scenarioEngineCommand(rawArgs, { json: options.json }); + } + + if (commandName === "studios") { + return studiosEngineCommand(rawArgs, { json: options.json }); + } + if (commandName === "config") { return configEngineCommand({ json: options.json }); } @@ -112,7 +123,7 @@ async function executeCommand( ) { return { exitCode: EXIT_CODE_INTERNAL_ERROR, - output: "Usage: engine [request.json] [--json] [--simulate]\n", + output: "Usage: engine [request.json] [--json] [--simulate]\n", }; } diff --git a/src/cli/prompt-engine-command.ts b/src/cli/prompt-engine-command.ts index 1022cb9..6f5a8bc 100644 --- a/src/cli/prompt-engine-command.ts +++ b/src/cli/prompt-engine-command.ts @@ -28,6 +28,7 @@ export async function promptEngineCommand( schema_version: "0.1", request_id: requestId, validation: loaded.validation, + narrative_payload: null, normalized_request: null, platform_output_spec: null, novel_shorts_plan: null, @@ -50,6 +51,7 @@ export async function promptEngineCommand( effectiveRequest: planningContext.effective_request, learningState: planningContext.learning_state, motionPlan: planningContext.motion_plan, + narrativePayload: planningContext.narrative_payload, novelShortsPlan: planningContext.novel_shorts_plan, platformOutputSpec: planningContext.platform_output_spec, routing: planningContext.routing, diff --git a/src/cli/render-output.ts b/src/cli/render-output.ts index c5d1175..793fa62 100644 --- a/src/cli/render-output.ts +++ b/src/cli/render-output.ts @@ -8,6 +8,8 @@ export function renderOutput(result: EngineRunResult, json: boolean): string { const lines = [ `Request ID: ${result.request_id}`, `Validation: ${result.validation.valid ? "valid" : "invalid"}`, + `Studio: ${result.narrative_payload?.studio_id ?? "n/a"}`, + `Scene archetype: ${result.narrative_payload?.scene_archetype ?? "n/a"}`, `Platform: ${result.platform_output_spec?.platform ?? "n/a"}`, `Effective duration: ${result.platform_output_spec?.effective_duration_sec ?? "n/a"}${result.platform_output_spec ? "s" : ""}`, `Warnings: ${result.platform_output_spec?.warnings.length ?? 0}`, diff --git a/src/cli/render-scenario-output.ts b/src/cli/render-scenario-output.ts new file mode 100644 index 0000000..d7080c4 --- /dev/null +++ b/src/cli/render-scenario-output.ts @@ -0,0 +1,18 @@ +import type { NarrativePayload } from "../domain/contracts.js"; + +export function renderScenarioOutput(result: NarrativePayload, json: boolean): string { + if (json) { + return JSON.stringify(result, null, 2); + } + + const lines = [ + `Studio: ${result.studio_id}`, + `Scene archetype: ${result.scene_archetype}`, + `Philosophy note: ${result.philosophy_note}`, + `Key prop: ${result.key_prop}`, + `Key silence: ${result.key_silence_sec}s`, + `Beat count: ${result.beats.length}`, + ]; + + return `${lines.join("\n")}\n`; +} diff --git a/src/cli/render-studios-output.ts b/src/cli/render-studios-output.ts new file mode 100644 index 0000000..82fd298 --- /dev/null +++ b/src/cli/render-studios-output.ts @@ -0,0 +1,35 @@ +import type { StudioDefinition } from "../domain/contracts.js"; + +export function renderStudiosListOutput(result: StudioDefinition[], json: boolean): string { + if (json) { + return JSON.stringify( + result.map((studio) => ({ + studio_id: studio.studio_id, + display_name: studio.display_name, + philosophy_summary: studio.philosophy_summary, + })), + null, + 2, + ); + } + + const lines = result.map( + (studio) => `${studio.studio_id}: ${studio.display_name} - ${studio.philosophy_summary}`, + ); + + return `${lines.join("\n")}\n`; +} + +export function renderStudioDetailOutput(result: StudioDefinition, json: boolean): string { + if (json) { + return JSON.stringify(result, null, 2); + } + + const lines = [ + `Studio: ${result.display_name} (${result.studio_id})`, + `Philosophy: ${result.philosophy_summary}`, + `Archetypes: ${result.scene_archetypes.map((item) => item.name).join(", ")}`, + ]; + + return `${lines.join("\n")}\n`; +} diff --git a/src/cli/resolve-planning-context.ts b/src/cli/resolve-planning-context.ts index 55da553..f22fcda 100644 --- a/src/cli/resolve-planning-context.ts +++ b/src/cli/resolve-planning-context.ts @@ -4,6 +4,7 @@ import type { ExecutionPlan, LearningState, MotionPlan, + NarrativePayload, NormalizedRequest, NovelShortsPlan, PlatformOutputSpec, @@ -24,8 +25,10 @@ import { resolvePlatformOutputSpec } from "../platform/resolve-platform-output-s import { buildExecutionPlan } from "../simulation/build-execution-plan.js"; import { simulateRecovery } from "../simulation/simulate-recovery.js"; import { resolveBrollPlan } from "../broll/resolve-broll-plan.js"; +import { resolveNarrativePayload } from "../scenario/build-narrative-payload.js"; export interface PlanningContext { + narrative_payload: NarrativePayload | null; normalized_request: NormalizedRequest; effective_request: NormalizedRequest; novel_shorts_plan: NovelShortsPlan | null; @@ -43,6 +46,7 @@ export function resolvePlanningContext(request: EngineRequest): PlanningContext const normalizedRequest = normalizeRequest(request); const novelShortsPlan = resolveNovelShortsPlan(normalizedRequest); const effectiveRequest = applyNovelIntentOverrides(normalizedRequest, novelShortsPlan); + const narrativePayload = resolveNarrativePayload(effectiveRequest.base); const platformOutputSpec = resolvePlatformOutputSpec(effectiveRequest); const motionPlan = resolveMotionPlan(effectiveRequest, platformOutputSpec); const brollPlan = resolveBrollPlan(effectiveRequest, platformOutputSpec, motionPlan); @@ -58,6 +62,7 @@ export function resolvePlanningContext(request: EngineRequest): PlanningContext const recoverySimulation = simulateRecovery(executionPlan); return { + narrative_payload: narrativePayload, normalized_request: normalizedRequest, effective_request: effectiveRequest, novel_shorts_plan: novelShortsPlan, diff --git a/src/cli/run-engine-command.ts b/src/cli/run-engine-command.ts index c9835f1..f3f1fd8 100644 --- a/src/cli/run-engine-command.ts +++ b/src/cli/run-engine-command.ts @@ -1,4 +1,4 @@ -import type { EngineRunResult } from "../domain/contracts.js"; +import type { EngineRunResult, NarrativePayload } from "../domain/contracts.js"; import { createRequestId } from "../shared/request-id.js"; import { EXIT_CODE_INTERNAL_ERROR, @@ -32,7 +32,18 @@ export async function runEngineCommand( isRecord(loaded.raw_request) && typeof loaded.raw_request['version'] === 'string' ? loaded.raw_request['version'] : '0.1', - loaded.validation, null, null, null, null, null, null, null, null, null, null, + loaded.validation, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, ), options.json, ), @@ -48,6 +59,7 @@ export async function runEngineCommand( requestId, loaded.request.version, loaded.validation, + planningContext.narrative_payload, planningContext.normalized_request, planningContext.platform_output_spec, planningContext.novel_shorts_plan, @@ -82,6 +94,7 @@ export async function runEngineCommand( null, null, null, + null, ); const output = options.json @@ -106,6 +119,7 @@ function createRunResult( requestId: string, schemaVersion: string, validation: EngineRunResult["validation"], + narrativePayload: NarrativePayload | null, normalizedRequest: EngineRunResult["normalized_request"], platformOutputSpec: EngineRunResult["platform_output_spec"], novelShortsPlan: EngineRunResult["novel_shorts_plan"], @@ -121,6 +135,7 @@ function createRunResult( schema_version: schemaVersion, request_id: requestId, validation, + narrative_payload: narrativePayload, normalized_request: normalizedRequest, platform_output_spec: platformOutputSpec, novel_shorts_plan: novelShortsPlan, diff --git a/src/cli/scenario-engine-command.ts b/src/cli/scenario-engine-command.ts new file mode 100644 index 0000000..ec4e6ca --- /dev/null +++ b/src/cli/scenario-engine-command.ts @@ -0,0 +1,57 @@ +import { buildNarrativePayload } from "../scenario/build-narrative-payload.js"; +import { EXIT_CODE_INTERNAL_ERROR, EXIT_CODE_SUCCESS } from "./exit-codes.js"; +import { renderScenarioOutput } from "./render-scenario-output.js"; + +export async function scenarioEngineCommand( + args: string[], + options: { json: boolean }, +): Promise<{ exitCode: number; output: string }> { + const studioId = readFlagValue(args, "--studio"); + const topic = readFlagValue(args, "--topic"); + + if (!studioId || !topic) { + return { + exitCode: EXIT_CODE_INTERNAL_ERROR, + output: "Usage: engine scenario --studio --topic [--subject ] [--goal ] [--emotion ] [--json]\n", + }; + } + + const subject = readFlagValue(args, "--subject") ?? topic; + const goal = readFlagValue(args, "--goal") ?? "turn tension into a clear emotional shift"; + const emotion = readFlagValue(args, "--emotion") ?? "wonder"; + + try { + const payload = buildNarrativePayload({ + studio_id: studioId as never, + topic, + subject, + goal, + emotion, + }); + + return { + exitCode: EXIT_CODE_SUCCESS, + output: renderScenarioOutput(payload, options.json), + }; + } catch (error) { + return { + exitCode: EXIT_CODE_INTERNAL_ERROR, + output: options.json + ? JSON.stringify( + { fatal_error: error instanceof Error ? error.message : "Unknown error" }, + null, + 2, + ) + : `Fatal error: ${error instanceof Error ? error.message : "Unknown error"}\n`, + }; + } +} + +function readFlagValue(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) { + return undefined; + } + + return args[index + 1]; +} diff --git a/src/cli/studios-engine-command.ts b/src/cli/studios-engine-command.ts new file mode 100644 index 0000000..0b55c31 --- /dev/null +++ b/src/cli/studios-engine-command.ts @@ -0,0 +1,50 @@ +import { listStudioDefinitions, loadStudioDefinition } from "../scenario/load-studio-definition.js"; +import { EXIT_CODE_INTERNAL_ERROR, EXIT_CODE_SUCCESS } from "./exit-codes.js"; +import { renderStudioDetailOutput, renderStudiosListOutput } from "./render-studios-output.js"; + +export async function studiosEngineCommand( + args: string[], + options: { json: boolean }, +): Promise<{ exitCode: number; output: string }> { + if (args.includes("--list")) { + return { + exitCode: EXIT_CODE_SUCCESS, + output: renderStudiosListOutput(listStudioDefinitions(), options.json), + }; + } + + const studioId = readFlagValue(args, "--show"); + if (!studioId) { + return { + exitCode: EXIT_CODE_INTERNAL_ERROR, + output: "Usage: engine studios --list [--json] | engine studios --show [--json]\n", + }; + } + + try { + return { + exitCode: EXIT_CODE_SUCCESS, + output: renderStudioDetailOutput(loadStudioDefinition(studioId as never), options.json), + }; + } catch (error) { + return { + exitCode: EXIT_CODE_INTERNAL_ERROR, + output: options.json + ? JSON.stringify( + { fatal_error: error instanceof Error ? error.message : "Unknown error" }, + null, + 2, + ) + : `Fatal error: ${error instanceof Error ? error.message : "Unknown error"}\n`, + }; + } +} + +function readFlagValue(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) { + return undefined; + } + + return args[index + 1]; +} diff --git a/src/domain/contracts.ts b/src/domain/contracts.ts index ecee7ee..a5c0e65 100644 --- a/src/domain/contracts.ts +++ b/src/domain/contracts.ts @@ -1,5 +1,8 @@ export type Platform = "youtube_shorts" | "tiktok" | "instagram_reels"; +export const STUDIO_IDS = ["ghibli", "hail_mary", "jang_hang_jun"] as const; +export type StudioId = (typeof STUDIO_IDS)[number]; + export type BudgetTier = "low" | "balanced" | "high"; export type QualityTier = "low" | "balanced" | "premium"; @@ -43,6 +46,58 @@ export interface EngineOutput { type: string; } +export interface EmotionalTexture { + tension: number; + wonder: number; + warmth: number; + silence: number; +} + +export interface NarrativeChecks { + contrast: boolean; + specificity: boolean; + subtext: boolean; + forbidden_clear: boolean; +} + +export interface NarrativeBeat { + beat_id: string; + label: string; + scene: string; + subtext: string; + emotional_texture: EmotionalTexture; + philosophy_note: string; +} + +export interface NarrativePayload { + studio_id: StudioId; + scene_archetype: string; + philosophy_note: string; + emotional_texture: EmotionalTexture; + narrative_checks: NarrativeChecks; + key_prop: string; + key_silence_sec: number; + beats: NarrativeBeat[]; +} + +export interface StudioSceneArchetype { + name: string; + meaning: string; + philosophy_note: string; + keywords?: string[]; + default_key_prop?: string; + emotional_texture: EmotionalTexture; +} + +export interface StudioDefinition { + studio_id: StudioId; + display_name: string; + philosophy_summary: string; + core_beliefs: string[]; + forbidden: string[]; + scene_archetypes: StudioSceneArchetype[]; +} + export interface LearningHistory { completed_outputs: number; accepted_suggestions: number; @@ -67,11 +122,13 @@ export interface NovelProject { export interface EngineRequest { version: string; + studio_id?: StudioId; intent: EngineIntent; constraints: EngineConstraints; style: EngineStyle; backend: EngineBackend; output: EngineOutput; + narrative_payload?: NarrativePayload; learning_history?: LearningHistory; novel_project?: NovelProject; } @@ -440,6 +497,7 @@ export interface EngineRunResult { schema_version: string; request_id: string; validation: ValidationResult; + narrative_payload?: NarrativePayload | null; normalized_request: NormalizedRequest | null; platform_output_spec: PlatformOutputSpec | null; novel_shorts_plan: NovelShortsPlan | null; diff --git a/src/domain/normalize-request.ts b/src/domain/normalize-request.ts index b0ddecc..15bd910 100644 --- a/src/domain/normalize-request.ts +++ b/src/domain/normalize-request.ts @@ -46,6 +46,24 @@ export function normalizeRequest(request: EngineRequest): NormalizedRequest { }, } : {}), + ...(request.studio_id ? { studio_id: request.studio_id } : {}), + ...(request.narrative_payload + ? { + narrative_payload: { + ...request.narrative_payload, + scene_archetype: request.narrative_payload.scene_archetype.trim(), + philosophy_note: request.narrative_payload.philosophy_note.trim(), + key_prop: request.narrative_payload.key_prop.trim(), + beats: request.narrative_payload.beats.map((beat) => ({ + ...beat, + label: beat.label.trim(), + scene: beat.scene.trim(), + subtext: beat.subtext.trim(), + philosophy_note: beat.philosophy_note.trim(), + })), + }, + } + : {}), }, derived: { resolved_platform_profile: request.intent.platform, diff --git a/src/domain/request-schema.ts b/src/domain/request-schema.ts index 86b6999..b7de64c 100644 --- a/src/domain/request-schema.ts +++ b/src/domain/request-schema.ts @@ -1,10 +1,11 @@ -import type { - BudgetTier, - EngineRequest, - Platform, - PreferredEngine, - QualityTier, - ValidationResult, +import { + STUDIO_IDS, + type BudgetTier, + type EngineRequest, + type Platform, + type PreferredEngine, + type QualityTier, + type ValidationResult, } from "./contracts.js"; import { createEngineError } from "./errors.js"; @@ -22,6 +23,7 @@ const SUPPORTED_ENGINES = new Set([ "sora", "premium", ]); +const SUPPORTED_STUDIOS = new Set(STUDIO_IDS); export function validateEngineRequest(value: unknown): ValidationResult { const errors = validateRequest(value); @@ -45,11 +47,13 @@ function validateRequest(value: unknown) { const errors = [ ...validateString(value.version, "version"), + ...validateOptionalEnum(value.studio_id, "studio_id", SUPPORTED_STUDIOS), ...validateIntent(value.intent), ...validateConstraints(value.constraints), ...validateStyle(value.style), ...validateBackend(value.backend), ...validateOutput(value.output), + ...validateNarrativePayload(value.narrative_payload), ...validateLearningHistory(value.learning_history), ...validateNovelProject(value.novel_project), ]; @@ -157,6 +161,27 @@ function validateLearningHistory(value: unknown) { ]; } +function validateNarrativePayload(value: unknown) { + if (typeof value === "undefined") { + return []; + } + + if (!isRecord(value)) { + return [missingField("narrative_payload")]; + } + + return [ + ...validateEnum(value.studio_id, "narrative_payload.studio_id", SUPPORTED_STUDIOS), + ...validateString(value.scene_archetype, "narrative_payload.scene_archetype"), + ...validateString(value.philosophy_note, "narrative_payload.philosophy_note"), + ...validateNarrativeTexture(value.emotional_texture, "narrative_payload.emotional_texture"), + ...validateNarrativeChecks(value.narrative_checks, "narrative_payload.narrative_checks"), + ...validateString(value.key_prop, "narrative_payload.key_prop"), + ...validatePositiveInteger(value.key_silence_sec, "narrative_payload.key_silence_sec"), + ...validateNarrativeBeats(value.beats, "narrative_payload.beats"), + ]; +} + function validateNovelProject(value: unknown) { if (typeof value === "undefined") { return []; @@ -226,6 +251,18 @@ function validateOptionalBoolean(value: unknown, field: string) { return [missingField(field)]; } +function validateOptionalEnum( + value: unknown, + field: string, + validValues: Set, +) { + if (typeof value === "undefined") { + return []; + } + + return validateEnum(value, field, validValues); +} + function validateEnum(value: unknown, field: string, validValues: Set) { if (typeof value === "string" && validValues.has(value as T)) { return []; @@ -242,6 +279,55 @@ function validateNovelMode(value: unknown, field: string) { ); } +function validateNarrativeTexture(value: unknown, field: string) { + if (!isRecord(value)) { + return [missingField(field)]; + } + + return [ + ...validateRangeNumber(value.tension, `${field}.tension`, 0, 1), + ...validateRangeNumber(value.wonder, `${field}.wonder`, 0, 1), + ...validateRangeNumber(value.warmth, `${field}.warmth`, 0, 1), + ...validateRangeNumber(value.silence, `${field}.silence`, 0, 1), + ]; +} + +function validateNarrativeChecks(value: unknown, field: string) { + if (!isRecord(value)) { + return [missingField(field)]; + } + + return [ + ...validateBoolean(value.contrast, `${field}.contrast`), + ...validateBoolean(value.specificity, `${field}.specificity`), + ...validateBoolean(value.subtext, `${field}.subtext`), + ...validateBoolean(value.forbidden_clear, `${field}.forbidden_clear`), + ]; +} + +function validateNarrativeBeats(value: unknown, field: string) { + if (!Array.isArray(value)) { + return [missingField(field)]; + } + + return value.flatMap((entry, index) => validateNarrativeBeat(entry, `${field}[${index}]`)); +} + +function validateNarrativeBeat(value: unknown, field: string) { + if (!isRecord(value)) { + return [missingField(field)]; + } + + return [ + ...validateString(value.beat_id, `${field}.beat_id`), + ...validateString(value.label, `${field}.label`), + ...validateString(value.scene, `${field}.scene`), + ...validateString(value.subtext, `${field}.subtext`), + ...validateNarrativeTexture(value.emotional_texture, `${field}.emotional_texture`), + ...validateString(value.philosophy_note, `${field}.philosophy_note`), + ]; +} + function validateRangeNumber( value: unknown, field: string, diff --git a/src/prompt/build-prompt-result.ts b/src/prompt/build-prompt-result.ts index a2f7930..5c2d8bb 100644 --- a/src/prompt/build-prompt-result.ts +++ b/src/prompt/build-prompt-result.ts @@ -2,6 +2,7 @@ import type { BrollPlan, LearningState, MotionPlan, + NarrativePayload, NormalizedRequest, NovelShortsPlan, PlatformOutputSpec, @@ -15,6 +16,7 @@ export function buildPromptResult(input: { effectiveRequest: NormalizedRequest; learningState: LearningState; motionPlan: MotionPlan; + narrativePayload?: NarrativePayload | null; platformOutputSpec: PlatformOutputSpec; routing: RoutingDecision; scoring: ScoringResult; @@ -25,6 +27,7 @@ export function buildPromptResult(input: { effectiveRequest, learningState, motionPlan, + narrativePayload, novelShortsPlan, platformOutputSpec, routing, @@ -45,10 +48,12 @@ export function buildPromptResult(input: { platformOutputSpec, motionPlan, brollPlan, + narrativePayload ?? null, novelShortsPlan, ), negative_prompt: "unsafe, graphic, policy-violating content", style_descriptor: [ + `studio: ${narrativePayload?.studio_id ?? effectiveRequest.base.studio_id ?? "default"}`, `${effectiveRequest.base.style.pacing_profile} pacing`, `${effectiveRequest.base.style.camera_language} camera`, `${effectiveRequest.base.intent.theme} tone`, @@ -69,6 +74,7 @@ function buildMainPrompt( platformOutputSpec: PlatformOutputSpec, motionPlan: MotionPlan, brollPlan: BrollPlan, + narrativePayload: NarrativePayload | null, novelShortsPlan: NovelShortsPlan | null, ): string { const sections = [ @@ -82,6 +88,12 @@ function buildMainPrompt( `Hook B-roll concept: ${brollPlan.segments[0]?.concept ?? "n/a"}.`, ]; + if (narrativePayload) { + sections.push(`Scene archetype: ${narrativePayload.scene_archetype}.`); + sections.push(`Philosophy note: ${narrativePayload.philosophy_note}.`); + sections.push(`Key prop: ${narrativePayload.key_prop}.`); + } + if (novelShortsPlan) { sections.push(`Novel highlight: ${novelShortsPlan.highlight_candidate}.`); } diff --git a/src/scenario/build-narrative-payload.ts b/src/scenario/build-narrative-payload.ts new file mode 100644 index 0000000..3b20457 --- /dev/null +++ b/src/scenario/build-narrative-payload.ts @@ -0,0 +1,211 @@ +import type { + EmotionalTexture, + EngineRequest, + NarrativeBeat, + NarrativeChecks, + NarrativePayload, + StudioDefinition, + StudioId, + StudioSceneArchetype, +} from "../domain/contracts.js"; +import { loadStudioDefinition } from "./load-studio-definition.js"; + +interface NarrativeInput { + studio_id: StudioId; + topic: string; + subject: string; + goal: string; + emotion: string; +} + +const STOP_WORDS = new Set([ + "a", + "an", + "and", + "the", + "with", + "into", + "from", + "that", + "this", + "their", + "first", + "time", +]); + +export function resolveNarrativePayload(request: EngineRequest): NarrativePayload | null { + if (request.narrative_payload) { + return request.narrative_payload; + } + + if (!request.studio_id) { + return null; + } + + return buildNarrativePayload({ + studio_id: request.studio_id, + topic: request.intent.topic, + subject: request.intent.subject, + goal: request.intent.goal, + emotion: request.intent.emotion, + }); +} + +export function buildNarrativePayload(input: NarrativeInput): NarrativePayload { + const studio = loadStudioDefinition(input.studio_id); + const archetype = selectSceneArchetype(studio, input); + const emotionalTexture = buildEmotionalTexture(archetype.emotional_texture, input.emotion); + const keyProp = archetype.default_key_prop || deriveKeyProp(input.topic, input.subject); + const beats = buildBeats(input, archetype, emotionalTexture, keyProp); + const narrativeChecks = buildNarrativeChecks(beats, studio); + + return { + studio_id: studio.studio_id, + scene_archetype: archetype.name, + philosophy_note: archetype.philosophy_note, + emotional_texture: emotionalTexture, + narrative_checks: narrativeChecks, + key_prop: keyProp, + key_silence_sec: deriveKeySilenceSeconds(emotionalTexture), + beats, + }; +} + +function selectSceneArchetype( + studio: StudioDefinition, + input: NarrativeInput, +): StudioSceneArchetype { + const haystack = `${input.topic} ${input.goal} ${input.emotion}`.toLowerCase(); + const scored = studio.scene_archetypes.map((archetype) => ({ + archetype, + score: (archetype.keywords || []).reduce( + (total, keyword) => total + (haystack.includes(keyword.toLowerCase()) ? 1 : 0), + 0, + ), + })); + + scored.sort((left, right) => right.score - left.score); + const selected = scored[0]?.archetype ?? studio.scene_archetypes[0]; + if (!selected) { + throw new Error(`Studio ${studio.studio_id} has no scene archetypes`); + } + + return selected; +} + +function buildEmotionalTexture(base: EmotionalTexture, emotion: string): EmotionalTexture { + const loweredEmotion = emotion.toLowerCase(); + const texture = { ...base }; + + if (loweredEmotion.includes("wonder")) { + texture.wonder = clamp(texture.wonder + 0.05); + } + + if (loweredEmotion.includes("warm")) { + texture.warmth = clamp(texture.warmth + 0.1); + } + + if (loweredEmotion.includes("fear") || loweredEmotion.includes("tension")) { + texture.tension = clamp(texture.tension + 0.1); + } + + return texture; +} + +function buildBeats( + input: NarrativeInput, + archetype: StudioSceneArchetype, + emotionalTexture: EmotionalTexture, + keyProp: string, +): NarrativeBeat[] { + const subject = input.subject.trim(); + const topic = input.topic.trim(); + const goal = input.goal.trim(); + + return [ + { + beat_id: "beat_1", + label: "hook", + scene: `${subject} freezes for a breath as ${topic}. ${keyProp} stays close in hand while the frame holds.`, + subtext: `${goal} begins before anyone can explain what is happening.`, + emotional_texture: { + tension: clamp(emotionalTexture.tension + 0.1), + wonder: clamp(emotionalTexture.wonder - 0.1), + warmth: emotionalTexture.warmth, + silence: clamp(emotionalTexture.silence + 0.1), + }, + philosophy_note: archetype.philosophy_note, + }, + { + beat_id: "beat_2", + label: "encounter", + scene: `${subject} keeps watching instead of fleeing, letting the unfamiliar detail of the moment come into focus around the ${keyProp}.`, + subtext: archetype.meaning, + emotional_texture: { ...emotionalTexture }, + philosophy_note: archetype.philosophy_note, + }, + { + beat_id: "beat_3", + label: "shift", + scene: `${subject} takes one careful step toward ${goal}, and the ${keyProp} turns from a shield into a witness.`, + subtext: "The change is small on the surface, but the heart of the scene has already moved.", + emotional_texture: { + tension: clamp(emotionalTexture.tension - 0.25), + wonder: clamp(emotionalTexture.wonder + 0.05), + warmth: clamp(emotionalTexture.warmth + 0.35), + silence: clamp(emotionalTexture.silence - 0.1), + }, + philosophy_note: archetype.philosophy_note, + }, + ]; +} + +function buildNarrativeChecks( + beats: NarrativeBeat[], + studio: StudioDefinition, +): NarrativeChecks { + const openingBeat = beats[0]; + const closingBeat = beats[2] ?? beats[beats.length - 1]; + const combinedText = beats + .flatMap((beat) => [beat.scene, beat.subtext, beat.philosophy_note]) + .join(" ") + .toLowerCase(); + + return { + contrast: !!openingBeat + && !!closingBeat + && Math.abs(openingBeat.emotional_texture.tension - closingBeat.emotional_texture.tension) >= 0.2, + specificity: beats.every((beat) => beat.scene.split(/\s+/).length >= 12), + subtext: beats.every( + (beat) => beat.subtext.trim().length > 0 && !beat.scene.includes(beat.subtext), + ), + forbidden_clear: studio.forbidden.every( + (phrase) => !combinedText.includes(phrase.toLowerCase()), + ), + }; +} + +function deriveKeyProp(topic: string, subject: string): string { + const token = `${topic} ${subject}` + .toLowerCase() + .split(/[^a-z0-9가-힣]+/) + .find((value) => value.length >= 4 && !STOP_WORDS.has(value)); + + return token ? `${token} keepsake` : "keepsake charm"; +} + +function deriveKeySilenceSeconds(texture: EmotionalTexture): number { + if (texture.silence >= 0.7) { + return 4; + } + + if (texture.silence >= 0.45) { + return 3; + } + + return 2; +} + +function clamp(value: number): number { + return Math.max(0, Math.min(1, Number(value.toFixed(2)))); +} diff --git a/src/scenario/load-studio-definition.ts b/src/scenario/load-studio-definition.ts new file mode 100644 index 0000000..5d210ee --- /dev/null +++ b/src/scenario/load-studio-definition.ts @@ -0,0 +1,32 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import * as path from "node:path"; + +import type { StudioDefinition, StudioId } from "../domain/contracts.js"; + +const STUDIO_ROOT = path.resolve(process.cwd(), "studios"); + +export function listStudioDefinitions(): StudioDefinition[] { + if (!existsSync(STUDIO_ROOT)) { + return []; + } + + return readdirSync(STUDIO_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => loadStudioDefinition(entry.name as StudioId)); +} + +export function loadStudioDefinition(studioId: StudioId): StudioDefinition { + const studioPath = path.resolve(STUDIO_ROOT, studioId, "studio.json"); + + if (!existsSync(studioPath)) { + throw new Error(`Unknown studio definition: ${studioId}`); + } + + const parsed = JSON.parse(readFileSync(studioPath, "utf8")) as StudioDefinition; + + if (!parsed?.studio_id || !Array.isArray(parsed.scene_archetypes)) { + throw new Error(`Invalid studio definition: ${studioId}`); + } + + return parsed; +} diff --git a/studios/ghibli/studio.json b/studios/ghibli/studio.json new file mode 100644 index 0000000..d02ff2b --- /dev/null +++ b/studios/ghibli/studio.json @@ -0,0 +1,44 @@ +{ + "studio_id": "ghibli", + "display_name": "Ghibli Studio", + "philosophy_summary": "Fear softens into wonder through patient attention, quiet observation, and small human gestures.", + "core_beliefs": [ + "People are not easy, but they are understandable if you keep looking.", + "Wonder is born from attention, not spectacle.", + "Nature is not an enemy to conquer but a presence to listen to." + ], + "forbidden": [ + "didactic exposition", + "explicit moral labeling", + "shock-for-shock", + "fast-cut emotional forcing" + ], + "scene_archetypes": [ + { + "name": "낯선 존재와의 첫 만남", + "meaning": "Fear slowly becomes wonder as the character stays present long enough to see clearly.", + "philosophy_note": "두려움이 천천히 경이로움으로 바뀌는 과정", + "keywords": ["first", "meet", "spirit", "forest", "strange", "encounter"], + "default_key_prop": "wind-worn satchel", + "emotional_texture": { + "tension": 0.6, + "wonder": 0.8, + "warmth": 0.2, + "silence": 0.5 + } + }, + { + "name": "하늘을 올려다보는 순간", + "meaning": "The world opens wider than the character expected, and scale becomes emotional.", + "philosophy_note": "작은 존재가 넓은 세계를 올려다보며 마음의 크기가 바뀌는 순간", + "keywords": ["sky", "cloud", "wind", "flight", "open"], + "default_key_prop": "paper glider", + "emotional_texture": { + "tension": 0.2, + "wonder": 0.9, + "warmth": 0.5, + "silence": 0.6 + } + } + ] +} diff --git a/studios/hail_mary/studio.json b/studios/hail_mary/studio.json new file mode 100644 index 0000000..4efa301 --- /dev/null +++ b/studios/hail_mary/studio.json @@ -0,0 +1,44 @@ +{ + "studio_id": "hail_mary", + "display_name": "Project Hail Mary", + "philosophy_summary": "Science becomes emotional when the impossible is solved through curiosity, pressure, and companionship.", + "core_beliefs": [ + "The universe is indifferent, but people can still be brave.", + "Curiosity is stronger than fear when a problem becomes tangible.", + "Humor keeps tension from breaking the human core of the scene." + ], + "forbidden": [ + "vague science magic", + "flat hero worship", + "empty technobabble", + "instant emotional payoff" + ], + "scene_archetypes": [ + { + "name": "이해 불가능한 문제를 푸는 순간", + "meaning": "Pressure focuses the mind until understanding arrives one piece at a time.", + "philosophy_note": "압박 속에서 이해가 한 조각씩 쌓이며 불가능이 문제로 바뀌는 순간", + "keywords": ["problem", "solve", "equation", "signal", "system"], + "default_key_prop": "marker-stained notebook", + "emotional_texture": { + "tension": 0.85, + "wonder": 0.55, + "warmth": 0.15, + "silence": 0.35 + } + }, + { + "name": "낯선 지성과의 협업", + "meaning": "Fear relaxes because shared problem-solving creates trust before language fully does.", + "philosophy_note": "두려움보다 문제 해결의 리듬이 먼저 우정을 만든다", + "keywords": ["alien", "partner", "collaboration", "friend", "signal"], + "default_key_prop": "hand-drawn orbital map", + "emotional_texture": { + "tension": 0.65, + "wonder": 0.85, + "warmth": 0.35, + "silence": 0.25 + } + } + ] +} diff --git a/studios/jang_hang_jun/studio.json b/studios/jang_hang_jun/studio.json new file mode 100644 index 0000000..354bbf5 --- /dev/null +++ b/studios/jang_hang_jun/studio.json @@ -0,0 +1,44 @@ +{ + "studio_id": "jang_hang_jun", + "display_name": "Jang Hang-jun Studio", + "philosophy_summary": "Ordinary routines reveal moral weight when humor, dignity, and small choices share the same frame.", + "core_beliefs": [ + "Meaning lives in how people endure everyday pressure together.", + "Meals, errands, and pauses can carry more truth than declarations.", + "Warmth and melancholy should sit beside each other without being resolved too quickly." + ], + "forbidden": [ + "clean heroic framing", + "sentimentality by force", + "villain simplification", + "instant catharsis" + ], + "scene_archetypes": [ + { + "name": "밥상 위의 거리감", + "meaning": "A shared table exposes the emotional distance people can no longer hide.", + "philosophy_note": "일상의 가장 평범한 자리에서 관계의 진짜 온도가 드러나는 순간", + "keywords": ["meal", "table", "family", "dinner", "workplace"], + "default_key_prop": "half-finished soup bowl", + "emotional_texture": { + "tension": 0.55, + "wonder": 0.15, + "warmth": 0.65, + "silence": 0.7 + } + }, + { + "name": "비켜갈 수 없는 선택", + "meaning": "The decision matters because the situation stays ordinary while the heart becomes heavy.", + "philosophy_note": "거대한 사건이 아니라 평범한 상황이 사람을 더 어렵게 만든다", + "keywords": ["choice", "salary", "resign", "promise", "responsibility"], + "default_key_prop": "creased pay envelope", + "emotional_texture": { + "tension": 0.75, + "wonder": 0.1, + "warmth": 0.45, + "silence": 0.6 + } + } + ] +} diff --git a/tests/cli/prompt-command.test.ts b/tests/cli/prompt-command.test.ts index d2e11a8..bb7ceab 100644 --- a/tests/cli/prompt-command.test.ts +++ b/tests/cli/prompt-command.test.ts @@ -42,3 +42,16 @@ test("prints human-readable prompt output", () => { assert.match(result.stdout, /Engine: local/); assert.match(result.stdout, /Main prompt:/); }); + +test("includes narrative DNA guidance in prompt output when studio_id is present", () => { + const result = runCli(["prompt", "tests/fixtures/ghibli-narrative-request.json", "--json"]); + const parsed = JSON.parse(result.stdout) as { + main_prompt?: string; + style_descriptor?: string; + }; + + assert.equal(result.exitCode, 0); + assert.match(parsed.main_prompt ?? "", /Scene archetype:/); + assert.match(parsed.main_prompt ?? "", /Philosophy note:/); + assert.match(parsed.style_descriptor ?? "", /studio: ghibli/); +}); diff --git a/tests/cli/run-command.test.ts b/tests/cli/run-command.test.ts index b8bb52b..27ed063 100644 --- a/tests/cli/run-command.test.ts +++ b/tests/cli/run-command.test.ts @@ -127,3 +127,21 @@ test("prints platform summary lines in human-readable output", () => { assert.match(result.stdout, /Effective duration: 20s/); assert.match(result.stdout, /Warnings: 0/); }); + +test("includes a synthesized narrative_payload in run --json output when studio_id is present", () => { + const result = runCli(["run", "tests/fixtures/ghibli-narrative-request.json", "--json"]); + const parsed = JSON.parse(result.stdout) as { + narrative_payload?: { + studio_id?: string; + scene_archetype?: string; + narrative_checks?: { forbidden_clear?: boolean }; + beats?: unknown[]; + }; + }; + + assert.equal(result.exitCode, 0); + assert.equal(parsed.narrative_payload?.studio_id, "ghibli"); + assert.equal(typeof parsed.narrative_payload?.scene_archetype, "string"); + assert.equal(parsed.narrative_payload?.narrative_checks?.forbidden_clear, true); + assert.equal(Array.isArray(parsed.narrative_payload?.beats), true); +}); diff --git a/tests/cli/scenario-command.test.ts b/tests/cli/scenario-command.test.ts new file mode 100644 index 0000000..adc87b7 --- /dev/null +++ b/tests/cli/scenario-command.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; + +import { runCli } from "../helpers/run-cli.js"; + +test("prints the narrative payload contract for scenario --json", () => { + const result = runCli([ + "scenario", + "--studio", + "ghibli", + "--topic", + "A lonely traveler meets a strange forest spirit for the first time", + "--json", + ]); + const parsed = JSON.parse(result.stdout) as { + studio_id?: string; + scene_archetype?: string; + philosophy_note?: string; + emotional_texture?: { wonder?: number }; + beats?: unknown[]; + }; + + assert.equal(result.exitCode, 0); + assert.equal(parsed.studio_id, "ghibli"); + assert.equal(typeof parsed.scene_archetype, "string"); + assert.equal(typeof parsed.philosophy_note, "string"); + assert.equal(typeof parsed.emotional_texture?.wonder, "number"); + assert.equal(Array.isArray(parsed.beats), true); +}); diff --git a/tests/cli/studios-command.test.ts b/tests/cli/studios-command.test.ts new file mode 100644 index 0000000..81d91de --- /dev/null +++ b/tests/cli/studios-command.test.ts @@ -0,0 +1,29 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; + +import { runCli } from "../helpers/run-cli.js"; + +test("prints the available studio catalog for studios --list --json", () => { + const result = runCli(["studios", "--list", "--json"]); + const parsed = JSON.parse(result.stdout) as Array<{ studio_id?: string }>; + + assert.equal(result.exitCode, 0); + assert.equal(Array.isArray(parsed), true); + assert.equal(parsed.some((entry) => entry.studio_id === "ghibli"), true); + assert.equal(parsed.some((entry) => entry.studio_id === "hail_mary"), true); + assert.equal(parsed.some((entry) => entry.studio_id === "jang_hang_jun"), true); +}); + +test("prints a studio definition for studios --show --json", () => { + const result = runCli(["studios", "--show", "ghibli", "--json"]); + const parsed = JSON.parse(result.stdout) as { + studio_id?: string; + display_name?: string; + scene_archetypes?: unknown[]; + }; + + assert.equal(result.exitCode, 0); + assert.equal(parsed.studio_id, "ghibli"); + assert.equal(typeof parsed.display_name, "string"); + assert.equal(Array.isArray(parsed.scene_archetypes), true); +}); diff --git a/tests/domain/request-schema.test.ts b/tests/domain/request-schema.test.ts index 2b08906..686b3ea 100644 --- a/tests/domain/request-schema.test.ts +++ b/tests/domain/request-schema.test.ts @@ -43,3 +43,33 @@ test("accepts optional novel_project when it is well formed", async () => { assert.equal(result.valid, true); assert.equal(result.errors.length, 0); }); + +test("rejects unknown studio_id values", async () => { + const request = await loadFixture>("ghibli-narrative-request.json"); + request.studio_id = "unknown_studio"; + + const result = validateEngineRequest(request); + + assert.equal(result.valid, false); + assert.equal(result.errors.some((error) => error.message.includes("studio_id")), true); +}); + +test("rejects malformed narrative_payload objects when they are present", async () => { + const request = await loadFixture>("ghibli-narrative-request.json"); + request.narrative_payload = { + studio_id: "ghibli", + scene_archetype: "낯선 존재와의 첫 만남", + narrative_checks: { + contrast: true, + }, + beats: [], + }; + + const result = validateEngineRequest(request); + + assert.equal(result.valid, false); + assert.equal( + result.errors.some((error) => error.message.includes("narrative_payload")), + true, + ); +}); diff --git a/tests/fixtures/ghibli-narrative-request.json b/tests/fixtures/ghibli-narrative-request.json new file mode 100644 index 0000000..a9ce126 --- /dev/null +++ b/tests/fixtures/ghibli-narrative-request.json @@ -0,0 +1,33 @@ +{ + "version": "0.1", + "studio_id": "ghibli", + "intent": { + "topic": "A lonely traveler meets a strange forest spirit for the first time", + "subject": "young traveler and forest spirit", + "goal": "turn uncertainty into wonder", + "emotion": "wonder", + "platform": "youtube_shorts", + "theme": "fantasy_encounter", + "duration_sec": 30 + }, + "constraints": { + "language": "en", + "budget_tier": "balanced", + "quality_tier": "balanced", + "visual_consistency_required": true, + "content_policy_safe": true + }, + "style": { + "hook_type": "mystery", + "pacing_profile": "gentle_rise", + "caption_style": "cinematic_minimal", + "camera_language": "gentle_push_in" + }, + "backend": { + "preferred_engine": "local", + "allow_fallback": true + }, + "output": { + "type": "video_prompt" + } +}