|
| 1 | +/** |
| 2 | + * Blueprint Compliance Checker |
| 3 | + * |
| 4 | + * Validates that a generated script honours the structural constraints |
| 5 | + * from a VideoBlueprintV1. Currently the pipeline injects blueprint |
| 6 | + * context as prompt text only — there is no post-generation validation. |
| 7 | + * This module fills that gap. |
| 8 | + * |
| 9 | + * @module script |
| 10 | + */ |
| 11 | +import type { ScriptOutput } from './schema'; |
| 12 | +import type { VideoBlueprintV1 } from '../domain'; |
| 13 | + |
| 14 | +/* ------------------------------------------------------------------ */ |
| 15 | +/* Types */ |
| 16 | +/* ------------------------------------------------------------------ */ |
| 17 | + |
| 18 | +export type ComplianceStatus = 'pass' | 'warn' | 'fail'; |
| 19 | + |
| 20 | +export interface ComplianceCheck { |
| 21 | + name: string; |
| 22 | + status: ComplianceStatus; |
| 23 | + expected?: string; |
| 24 | + actual?: string; |
| 25 | + note?: string; |
| 26 | +} |
| 27 | + |
| 28 | +export interface BlueprintComplianceReport { |
| 29 | + checks: ComplianceCheck[]; |
| 30 | + pass: number; |
| 31 | + warn: number; |
| 32 | + fail: number; |
| 33 | +} |
| 34 | + |
| 35 | +export interface ComplianceOptions { |
| 36 | + /** Acceptable scene count delta (default: 1). */ |
| 37 | + sceneCountDelta?: number; |
| 38 | + /** Acceptable duration ratio deviation (default: 0.25 = ±25%). */ |
| 39 | + durationTolerance?: number; |
| 40 | +} |
| 41 | + |
| 42 | +/* ------------------------------------------------------------------ */ |
| 43 | +/* Helpers */ |
| 44 | +/* ------------------------------------------------------------------ */ |
| 45 | + |
| 46 | +const WORDS_PER_SECOND = 2.5; // conservative spoken-word estimate |
| 47 | + |
| 48 | +function estimateScriptDuration(script: ScriptOutput): number { |
| 49 | + // Use metadata if available |
| 50 | + if (script.meta?.estimatedDuration && script.meta.estimatedDuration > 0) { |
| 51 | + return script.meta.estimatedDuration; |
| 52 | + } |
| 53 | + // Fall back to word count estimation |
| 54 | + const totalWords = script.scenes.reduce( |
| 55 | + (sum, s) => sum + s.text.split(/\s+/).filter(Boolean).length, |
| 56 | + 0 |
| 57 | + ); |
| 58 | + return totalWords / WORDS_PER_SECOND; |
| 59 | +} |
| 60 | + |
| 61 | +function ok(name: string, expected: string, actual: string): ComplianceCheck { |
| 62 | + return { name, status: 'pass', expected, actual }; |
| 63 | +} |
| 64 | + |
| 65 | +function warn(name: string, expected: string, actual: string, note: string): ComplianceCheck { |
| 66 | + return { name, status: 'warn', expected, actual, note }; |
| 67 | +} |
| 68 | + |
| 69 | +function fail(name: string, expected: string, actual: string): ComplianceCheck { |
| 70 | + return { name, status: 'fail', expected, actual }; |
| 71 | +} |
| 72 | + |
| 73 | +/* ------------------------------------------------------------------ */ |
| 74 | +/* Individual checks */ |
| 75 | +/* ------------------------------------------------------------------ */ |
| 76 | + |
| 77 | +function checkSceneCount( |
| 78 | + script: ScriptOutput, |
| 79 | + blueprint: VideoBlueprintV1, |
| 80 | + opts: Required<ComplianceOptions> |
| 81 | +): ComplianceCheck { |
| 82 | + const expected = blueprint.scene_slots.length; |
| 83 | + const actual = script.scenes.length; |
| 84 | + const delta = Math.abs(actual - expected); |
| 85 | + if (delta === 0) return ok('Scene count', String(expected), String(actual)); |
| 86 | + if (delta <= opts.sceneCountDelta) |
| 87 | + return warn('Scene count', String(expected), String(actual), `delta ${delta} within tolerance`); |
| 88 | + return fail('Scene count', String(expected), String(actual)); |
| 89 | +} |
| 90 | + |
| 91 | +function checkDuration( |
| 92 | + script: ScriptOutput, |
| 93 | + blueprint: VideoBlueprintV1, |
| 94 | + opts: Required<ComplianceOptions> |
| 95 | +): ComplianceCheck { |
| 96 | + const target = blueprint.pacing_profile.target_duration; |
| 97 | + const estimated = estimateScriptDuration(script); |
| 98 | + const ratio = target > 0 ? Math.abs(estimated - target) / target : 0; |
| 99 | + const fmt = (n: number) => `${n.toFixed(1)}s`; |
| 100 | + if (ratio <= 0.1) |
| 101 | + return ok('Duration', fmt(target), fmt(estimated)); |
| 102 | + if (ratio <= opts.durationTolerance) |
| 103 | + return warn('Duration', fmt(target), fmt(estimated), `${(ratio * 100).toFixed(0)}% deviation`); |
| 104 | + return fail('Duration', fmt(target), fmt(estimated)); |
| 105 | +} |
| 106 | + |
| 107 | +function checkCTA(script: ScriptOutput, blueprint: VideoBlueprintV1): ComplianceCheck { |
| 108 | + if (!blueprint.narrative_structure.has_cta) { |
| 109 | + return ok('CTA', 'not required', script.cta ? 'present' : 'absent'); |
| 110 | + } |
| 111 | + // Blueprint requires CTA — check script |
| 112 | + if (script.cta && script.cta.trim().length > 0) { |
| 113 | + return ok('CTA', 'required', 'present'); |
| 114 | + } |
| 115 | + // Check last scene for CTA-like content |
| 116 | + const lastScene = script.scenes[script.scenes.length - 1]; |
| 117 | + if (lastScene) { |
| 118 | + const lower = lastScene.text.toLowerCase(); |
| 119 | + const ctaSignals = ['follow', 'subscribe', 'like', 'comment', 'share', 'check out', 'link']; |
| 120 | + if (ctaSignals.some((s) => lower.includes(s))) { |
| 121 | + return warn('CTA', 'required', 'implicit in last scene', 'CTA language in final scene'); |
| 122 | + } |
| 123 | + } |
| 124 | + return fail('CTA', 'required', 'absent'); |
| 125 | +} |
| 126 | + |
| 127 | +function checkHook(script: ScriptOutput, blueprint: VideoBlueprintV1): ComplianceCheck { |
| 128 | + const hookDur = blueprint.narrative_structure.hook_duration; |
| 129 | + if (hookDur <= 0) { |
| 130 | + return ok('Hook', 'not required', script.hook ? 'present' : 'absent'); |
| 131 | + } |
| 132 | + if (script.hook && script.hook.trim().length > 0) { |
| 133 | + return ok('Hook', 'required', 'present'); |
| 134 | + } |
| 135 | + // Check first scene for hook-like brevity |
| 136 | + const firstScene = script.scenes[0]; |
| 137 | + if (firstScene) { |
| 138 | + const wordCount = firstScene.text.split(/\s+/).filter(Boolean).length; |
| 139 | + const estDur = wordCount / WORDS_PER_SECOND; |
| 140 | + if (estDur <= hookDur * 2) { |
| 141 | + return warn('Hook', `required (~${hookDur.toFixed(1)}s)`, `first scene ~${estDur.toFixed(1)}s`, 'short opening scene'); |
| 142 | + } |
| 143 | + } |
| 144 | + return fail('Hook', `required (~${hookDur.toFixed(1)}s)`, 'absent'); |
| 145 | +} |
| 146 | + |
| 147 | +function checkPacing( |
| 148 | + script: ScriptOutput, |
| 149 | + blueprint: VideoBlueprintV1 |
| 150 | +): ComplianceCheck { |
| 151 | + const expected = blueprint.pacing_profile.classification; |
| 152 | + if (!expected) return ok('Pacing', 'not specified', 'n/a'); |
| 153 | + |
| 154 | + // Estimate per-scene durations |
| 155 | + const sceneDurs = script.scenes.map((s) => { |
| 156 | + if (s.duration && s.duration > 0) return s.duration; |
| 157 | + return s.text.split(/\s+/).filter(Boolean).length / WORDS_PER_SECOND; |
| 158 | + }); |
| 159 | + const avg = sceneDurs.length > 0 |
| 160 | + ? sceneDurs.reduce((a, b) => a + b, 0) / sceneDurs.length |
| 161 | + : 0; |
| 162 | + |
| 163 | + let actual: string; |
| 164 | + if (avg < 1.0) actual = 'very_fast'; |
| 165 | + else if (avg < 2.0) actual = 'fast'; |
| 166 | + else if (avg < 4.0) actual = 'moderate'; |
| 167 | + else actual = 'slow'; |
| 168 | + |
| 169 | + if (actual === expected) return ok('Pacing', expected, actual); |
| 170 | + // Ordinal distance |
| 171 | + const order = ['very_fast', 'fast', 'moderate', 'slow']; |
| 172 | + const dist = Math.abs(order.indexOf(actual) - order.indexOf(expected)); |
| 173 | + if (dist <= 1) return warn('Pacing', expected, actual, `adjacent pacing (avg ${avg.toFixed(1)}s/scene)`); |
| 174 | + return fail('Pacing', expected, actual); |
| 175 | +} |
| 176 | + |
| 177 | +/* ------------------------------------------------------------------ */ |
| 178 | +/* Main entry */ |
| 179 | +/* ------------------------------------------------------------------ */ |
| 180 | + |
| 181 | +/** |
| 182 | + * Check whether a generated script honours the blueprint constraints. |
| 183 | + * |
| 184 | + * @param script The generated script output. |
| 185 | + * @param blueprint The blueprint that guided generation. |
| 186 | + * @param options Optional tolerance overrides. |
| 187 | + */ |
| 188 | +export function checkBlueprintCompliance( |
| 189 | + script: ScriptOutput, |
| 190 | + blueprint: VideoBlueprintV1, |
| 191 | + options?: ComplianceOptions |
| 192 | +): BlueprintComplianceReport { |
| 193 | + const opts: Required<ComplianceOptions> = { |
| 194 | + sceneCountDelta: options?.sceneCountDelta ?? 1, |
| 195 | + durationTolerance: options?.durationTolerance ?? 0.25, |
| 196 | + }; |
| 197 | + |
| 198 | + const checks: ComplianceCheck[] = [ |
| 199 | + checkSceneCount(script, blueprint, opts), |
| 200 | + checkDuration(script, blueprint, opts), |
| 201 | + checkCTA(script, blueprint), |
| 202 | + checkHook(script, blueprint), |
| 203 | + checkPacing(script, blueprint), |
| 204 | + ]; |
| 205 | + |
| 206 | + return { |
| 207 | + checks, |
| 208 | + pass: checks.filter((c) => c.status === 'pass').length, |
| 209 | + warn: checks.filter((c) => c.status === 'warn').length, |
| 210 | + fail: checks.filter((c) => c.status === 'fail').length, |
| 211 | + }; |
| 212 | +} |
0 commit comments