Skip to content

Commit 2247b15

Browse files
45ckclaude
andcommitted
Add VideoSpec structural comparator + blueprint compliance checker
- src/videointel/compare.ts: compareVideoSpecs() scores structural fidelity across 8 dimensions (duration, scene count, scene durations, pacing, narrative arc, audio profile, caption density, transcript). Returns weighted aggregate 0-1 score. - src/script/blueprint-compliance.ts: checkBlueprintCompliance() validates that generated scripts honour blueprint constraints (scene count, duration, CTA, hook, pacing). Fills the gap where blueprint context is prompt-injected but never validated post-generation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f2eb7cc commit 2247b15

3 files changed

Lines changed: 561 additions & 0 deletions

File tree

src/script/blueprint-compliance.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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

Comments
 (0)