/** * Brand Definition — the locked brand system artifact for the brand-agency skill. * * The brand-level sibling of {@link ./project-blueprint}: brand-dna.json is * extraction EVIDENCE (from a website), brand-definition.json is the locked * DECISION (the brand system), project-blueprint.json is per-project visual * DIRECTION. They layer dna → definition → blueprint; none replaces another. * * Generation is a creative LLM task (the `brand-agency` skill authors the * JSON). This module is the deterministic half: validate the skill-authored * JSON (strict on required sections and palette hex values, lenient on * sub-fields), normalize it to the canonical artifact shape, and persist it as * a managed, history-tracked artifact. The {@link ./filmmaking-prompts} * composer reads it back (graceful when absent) to append a prose BRAND line * to every scene packet. */ import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { VclawError } from './errors.js'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { resolveProjectWorkspace } from './workspace.js'; import type { VideoProjectWorkspace } from './workspace.js'; export interface BrandColor { /** #RRGGBB — strictly validated; the one place leniency is wrong. */ hex: string; /** Prompt-safe color name (e.g. "electric blue") — used by the BRAND line. */ name?: string; } export const BRAND_PALETTE_ROLES = [ 'primary', 'secondary', 'tertiaryBackground', 'darkTypographyAccent', 'metallic', 'contrast', ] as const; export type BrandPaletteRole = (typeof BRAND_PALETTE_ROLES)[number]; export type BrandPalette = Record; export interface BrandTaglines { functional: string; emotional: string; community: string; } export interface BrandVoice { rules: string; wordsWeUse: string[]; wordsWeNeverUse: string[]; tone: string; } export interface BrandTypographyLevel { /** wordmark | display | headline | body | caption (lenient — free string). */ level: string; font: string; weight: string; } export interface BrandThemeWeek { week: number; theme: string; } export interface BrandMasterAsset { imagePath: string; /** go-bananas product-reference id — drives the image-kit generations. */ gbProductRef?: string; /** Google Flow Character name — `@`-mentionable on veo-useapi scenes. */ flowCharacterName?: string; verified: boolean; verifiedAt?: string; } export interface BrandDefinitionArtifact { schemaVersion: 1; projectSlug: string; generatedAt: string; source: 'brand-agency'; brandName: string; positioning: string; taglines: BrandTaglines; voice: BrandVoice; palette: BrandPalette; typography: BrandTypographyLevel[]; themeMap: BrandThemeWeek[]; masterAsset?: BrandMasterAsset; } // --- coercion helpers (module-private by codebase convention; see project-blueprint.ts) --- function asObject(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function asString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((entry) => asString(entry)).filter(Boolean); } const HEX_RE = /^#[0-9a-fA-F]{6}$/; /** * Coerce a palette object into the canonical role→color shape, appending a * problem per role whose hex is not "#RRGGBB". The returned hex values are * only meaningful when no problems were appended — on any problem, * {@link validateBrandDefinition} throws before the coerced-but-invalid * output can be used. */ function normalizePalette(o: Record, problems: string[]): BrandPalette { const out = {} as Record; for (const role of BRAND_PALETTE_ROLES) { const entry = asObject(o[role]); const hex = asString(entry?.hex); if (!HEX_RE.test(hex)) { problems.push(`palette.${role} must carry hex "#RRGGBB" (got: ${JSON.stringify(entry?.hex ?? null)})`); } const name = asString(entry?.name); out[role] = { hex, ...(name ? { name } : {}) }; } return out; } function normalizeTypography(value: unknown): BrandTypographyLevel[] { if (!Array.isArray(value)) return []; return value.flatMap((entry) => { const o = asObject(entry); if (!o) return []; const level = asString(o.level); const font = asString(o.font); const weight = asString(o.weight); return level && font ? [{ level, font, weight }] : []; }); } function normalizeThemeMap(value: unknown): BrandThemeWeek[] { if (!Array.isArray(value)) return []; return value.flatMap((entry, index) => { const o = asObject(entry); if (!o) return []; const theme = asString(o.theme); if (!theme) return []; const week = typeof o.week === 'number' && Number.isFinite(o.week) ? o.week : index + 1; return [{ week, theme }]; }); } function normalizeMasterAsset(value: unknown): BrandMasterAsset | undefined { const o = asObject(value); if (!o) return undefined; const gbProductRef = asString(o.gbProductRef); const flowCharacterName = asString(o.flowCharacterName); const verifiedAt = asString(o.verifiedAt); return { imagePath: asString(o.imagePath), ...(gbProductRef ? { gbProductRef } : {}), ...(flowCharacterName ? { flowCharacterName } : {}), verified: o.verified === true, ...(verifiedAt ? { verifiedAt } : {}), }; } export interface ValidateBrandDefinitionOptions { projectSlug: string; generatedAt?: string; } /** * Validate + normalize a skill-authored brand-definition JSON into the * canonical {@link BrandDefinitionArtifact}. Strict on required sections AND * on palette hex values (#RRGGBB); lenient on other sub-fields (missing * strings → ''). Throws a single `invalid_flag_value` VclawError listing every * problem so the operator can fix them all at once. Pure (apart from the * injected generatedAt). */ export function validateBrandDefinition( input: unknown, options: ValidateBrandDefinitionOptions, ): BrandDefinitionArtifact { const root = asObject(input); if (!root) { throw new VclawError('invalid_flag_value', 'brand definition must be a JSON object', {}); } const problems: string[] = []; const brandName = asString(root.brandName); const positioning = asString(root.positioning); const taglines = asObject(root.taglines); const voice = asObject(root.voice); const palette = asObject(root.palette); const typography = normalizeTypography(root.typography); const themeMap = normalizeThemeMap(root.themeMap); if (!brandName) problems.push('brandName'); if (!positioning) problems.push('positioning'); if (!taglines) problems.push('taglines'); if (!voice) problems.push('voice'); if (!palette) problems.push('palette'); if (typography.length === 0) problems.push('typography (needs at least one {level, font, weight})'); if (themeMap.length === 0) problems.push('themeMap (needs at least one {week, theme})'); const normalizedPalette = palette ? normalizePalette(palette, problems) : null; if (problems.length > 0 || normalizedPalette === null) { throw new VclawError( 'invalid_flag_value', `brand definition is missing/invalid: ${problems.join(', ')}`, { problems }, ); } const masterAsset = normalizeMasterAsset(root.masterAsset); return { schemaVersion: 1, projectSlug: options.projectSlug, generatedAt: options.generatedAt ?? new Date().toISOString(), source: 'brand-agency', brandName, positioning, taglines: { functional: asString(taglines!.functional), emotional: asString(taglines!.emotional), community: asString(taglines!.community), }, voice: { rules: asString(voice!.rules), wordsWeUse: asStringArray(voice!.wordsWeUse), wordsWeNeverUse: asStringArray(voice!.wordsWeNeverUse), tone: asString(voice!.tone), }, palette: normalizedPalette, typography, themeMap, ...(masterAsset ? { masterAsset } : {}), }; } /** * Read + JSON-parse a brand-definition file. Throws `invalid_flag_value` on a * missing file or malformed JSON so the CLI surfaces a clear exit-1 error. */ export async function loadBrandDefinitionJson(path: string): Promise { if (!existsSync(path)) { throw new VclawError('invalid_flag_value', `brand definition file not found: ${path}`, { path }); } const raw = await readFile(path, 'utf-8'); try { return JSON.parse(raw); } catch (error) { throw new VclawError('invalid_flag_value', `brand definition is not valid JSON: ${path}`, { path, cause: error instanceof Error ? error.message : String(error), }); } } /** Persist a validated brand definition as the managed, history-tracked artifact. */ export async function writeBrandDefinition( workspace: VideoProjectWorkspace, artifact: BrandDefinitionArtifact, ): Promise { return writeArtifact(workspace, 'brand-definition', artifact); } /** * Read `artifacts/brand-definition.json`. Returns null when absent (graceful — * the composer then behaves byte-identically to today). Mirrors * {@link readProjectBlueprint}. */ export async function readBrandDefinition( root: string, slug: string, ): Promise { const workspace = resolveProjectWorkspace(slug, root); const path = artifactPathFor(workspace, 'brand-definition'); if (!existsSync(path)) return null; return JSON.parse(await readFile(path, 'utf-8')) as BrandDefinitionArtifact; }