/** * Helpers shared by the filmmaking-prompts family: the rich cinematography * suffix, genre-style resolution and the small text/artifact utilities. A * LEAF module (imports nothing from the family) so the product, Seedance and * vocab modules can use it without a cycle. Extracted verbatim from * `filmmaking-prompts.ts` (roadmap Phase 3b), which re-exports the public * names, so importers are unchanged. */ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { artifactPathFor } from './artifact-store.js'; import { audioMix, captureRealismBlock, cameraSpec, cameraProse, gradeSpec, gradeProse, lightingSpec, lightingProse, type CameraMove, type CaptureRealismOpts } from './cinematography.js'; import type { VideoProjectWorkspace } from './workspace.js'; import type { ResolvedCinemaProfile } from './cinema-profile.js'; import { STYLE_BY_ID, type VisualStyle } from './style-register.js'; // Quantified cinematography suffix appended only at detail === 'rich'. Built from // the shared cinematography emitters so the numbers stay consistent with the // multi-shot framework. Deterministic and pure. export const RICH_CAMERA_MOVE: CameraMove = { shot: 'master', lens: 35, angle: 'eye-level', movement: 'dolly', }; export function richCinematographySuffix(opts: { lightingId?: string; gradeId?: string; realism?: CaptureRealismOpts | false; /** * Cinematography register. `'numeric'` (default) keeps the legacy Kelvin / * key-angle / ratio / hue° tokens via `cameraSpec`/`lightingSpec`/`gradeSpec` * — byte-identical to before. `'prose'` swaps in the Joey 2.0 behaviour * register (`cameraProse`/`lightingProse`/`gradeProse`) which carries no * synthetic colour-math numerals. */ register?: 'prose' | 'numeric'; } = {}): string { const lightingId = opts.lightingId ?? 'neutral-studio'; const gradeId = opts.gradeId ?? 'teal-orange'; const register = opts.register ?? 'numeric'; const base = register === 'prose' ? `Cinematography: ${cameraProse(RICH_CAMERA_MOVE.movement, 'rich')}; ` + `${lightingProse(lightingId, 'rich')}; ` + `${gradeProse(gradeId, 'rich')}.` : `Cinematography: ${cameraSpec(RICH_CAMERA_MOVE, 'rich')}; ` + `${lightingSpec(lightingId, 'rich')}; ` + `${gradeSpec(gradeId, 'rich')}.`; if (opts.realism) { return `${base} ${captureRealismBlock(opts.realism, 'rich')}`; } return base; } export function richAudioSuffix(): string { return `Audio: ${audioMix('rich')}.`; } // The ai-filmmaking skill is genre-agnostic: the same skeleton renders // photoreal, Pixar 3D, anime, noir, vlog, or stylized work — style is a // swappable parameter. These blocks feed the character-sheet STYLE line, the // storyboard grid Style descriptors, and the Seedance FORMAT tone, and pick the // annotation third line (MOOD default / VOICE for vlog / STYLE for action). export interface GenreStyle { genre: string; charSheetStyle: string; gridStyleDescriptors: string; annotationThirdLine: 'MOOD' | 'VOICE' | 'STYLE'; formatTone: string; } /** * Project a register entry onto the {@link GenreStyle} shape this module has * always exposed. The register is the single source of truth for style text * (see `style-register.ts`); this keeps `GenreStyle`'s public shape and every * existing consumer byte-identical. The one field rename it bridges is * `gridDescriptors` -> `gridStyleDescriptors`. */ function toGenreStyle(style: VisualStyle): GenreStyle { return { genre: style.id, charSheetStyle: style.charSheetStyle, gridStyleDescriptors: style.gridDescriptors, annotationThirdLine: style.annotationThirdLine, formatTone: style.formatTone, }; } const GENRE_ALIASES: ReadonlyMap = new Map([ ['photorealistic', 'live-action'], ['photoreal', 'live-action'], ['cinematic', 'live-action'], ['realism', 'live-action'], ['3d', 'pixar'], ['animation', 'pixar'], ['2d', 'anime'], ['cel', 'anime'], ['vlog', 'influencer'], ['social', 'influencer'], ['ugc', 'influencer'], ['martial-arts', 'action'], ['fight', 'action'], ['combat', 'action'], ['musicvideo', 'music-video'], ['music_video', 'music-video'], ['mv', 'music-video'], ]); export function resolveGenreStyle(genre?: string): GenreStyle { if (!genre || !genre.trim()) return toGenreStyle(STYLE_BY_ID.get('live-action')!); const key = genre.trim().toLowerCase(); // Alias resolution stays FIRST. `social` is an alias to `influencer` here // even though it is also a register id in its own right (it needs one for its // multi-shot style line) — resolving the alias first is what keeps // `resolveGenreStyle('social').genre === 'influencer'`, as it has always been. const canonical = GENRE_ALIASES.get(key) ?? key; const known = STYLE_BY_ID.get(canonical); if (known) return toGenreStyle(known); // Unknown genre: pass the user's descriptor straight through (skill is // genre-agnostic), defaulting the annotation third line to MOOD. return { genre: genre.trim(), charSheetStyle: `${genre.trim()} style`, gridStyleDescriptors: genre.trim(), annotationThirdLine: 'MOOD', formatTone: genre.trim(), }; } /** * Build the {@link richCinematographySuffix} opts bag from the resolved profile. * No profile (legacy callers) → `{ realism: {} }`, byte-identical to before. */ export function richSuffixOptsFromProfile(profile: ResolvedCinemaProfile | undefined): Parameters[0] { if (!profile) { return { realism: {} }; } return { realism: profile.realism ? { haze: profile.haze, ...(profile.wet ? { wet: true } : {}) } : false, register: profile.register, ...(profile.lightingId !== undefined ? { lightingId: profile.lightingId } : {}), ...(profile.gradeId !== undefined ? { gradeId: profile.gradeId } : {}), }; } export async function readOptionalArtifact( workspace: VideoProjectWorkspace, name: Parameters[1], ): Promise { const path = artifactPathFor(workspace, name); if (!existsSync(path)) return undefined; return JSON.parse(await readFile(path, 'utf-8')) as T; } export function nextSlot(index: number): string { return `@image${index + 1}`; } export function cleanSentence(value: string): string { return value .replace(/\s+/g, ' ') .replace(/\[[^\]]+\]/g, '') .trim() .replace(/[.。]+$/g, ''); } export function formatSeconds(seconds: number): string { return `0:${String(seconds).padStart(2, '0')}`; }