diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 603afe4..1b4cec3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -83,6 +83,7 @@ const draft = defineCommand({ report: { type: 'string', description: 'write the full draft QA report to this JSON path' }, findings: { type: 'string', description: 'write check findings JSON to this path' }, 'evidence-dir': { type: 'string', description: 'directory for sampled frame evidence/contact sheet' }, + waive: { type: 'string', description: 'comma-separated QA finding codes the user chose to skip (persisted in qa/waivers.json; evidence-integrity codes are refused)' }, }, async run({ args }) { const r = await renderTool.draft({ @@ -92,6 +93,7 @@ const draft = defineCommand({ reportPath: args.report ? String(args.report) : undefined, findingsPath: args.findings ? String(args.findings) : undefined, frameEvidenceDir: args['evidence-dir'] ? String(args['evidence-dir']) : undefined, + waive: args.waive ? String(args.waive).split(',').map((code) => code.trim()).filter(Boolean) : undefined, onProgress: (c) => process.stderr.write(c), }); printJson(r); @@ -401,11 +403,16 @@ const plan = defineCommand({ meta: { name: 'plan', description: 'Work with the plan.json video IR.' }, subCommands: { validate: defineCommand({ - meta: { name: 'validate', description: 'Validate a plan.json; exit 1 on errors.' }, + meta: { name: 'validate', description: 'Validate a plan.json; exit 1 on errors. A valid plan prints its summary — present that to the user, not your own abstract.' }, args: { file: { type: 'positional', required: true } }, run({ args }) { - const r = validateEdl(readPlan(String(args.file))); - printJson(r); + const planJson = readPlan(String(args.file)); + const r = validateEdl(planJson); + // A valid plan is about to be shown for approval, and this result is + // the last thing the model reads before writing that message — attach + // the host's own rendering so the user reviews the real plan, not a + // hand-written abstract of it. + printJson(r.ok ? { ...r, summary: summarizeEdl(planJson as VideoEdl) } : r); if (!r.ok) process.exitCode = 1; }, }), @@ -502,6 +509,7 @@ const gate = defineCommand({ gate: { type: 'string', default: 'none', description: 'gate_a | gate_b | gate_c | preview | gate_d' }, decision: { type: 'string', default: 'none', description: 'approve | revise | none' }, scope: { type: 'string', default: 'unknown', description: 'visual_only | gate_b_payload | none | unknown' }, + origin: { type: 'string', default: 'unknown', description: 'who asked for the change: user (current turn names it in the user\'s own words) | model (model-initiated or mixed reply) | unknown' }, recovery: { type: 'string', default: 'unknown', description: 'available | not_available | unknown' }, 'recovery-decision': { type: 'string', default: 'none', description: 'legacy input only: new_visual_revision | pause | none; never emit a new recovery form' }, 'artifact-state': { type: 'string', default: 'unknown', description: 'new | unchanged | changed | unknown' }, @@ -515,6 +523,7 @@ const gate = defineCommand({ gate: String(args.gate) as GateTransitionInput['gate'], decision: String(args.decision) as GateTransitionInput['decision'], scope: String(args.scope) as GateTransitionInput['scope'], + origin: String(args.origin) as GateTransitionInput['origin'], recovery: String(args.recovery) as GateTransitionInput['recovery'], recoveryDecision: String(args['recovery-decision']) as GateTransitionInput['recoveryDecision'], artifactState: String(args['artifact-state']) as GateTransitionInput['artifactState'], diff --git a/packages/core/src/gates/transition.ts b/packages/core/src/gates/transition.ts index a56dfa2..fd224d2 100644 --- a/packages/core/src/gates/transition.ts +++ b/packages/core/src/gates/transition.ts @@ -7,6 +7,12 @@ export type RecoveryState = 'unknown' | 'available' | 'not_available'; export type RecoveryDecision = 'none' | 'new_visual_revision' | 'pause'; export type ArtifactState = 'unknown' | 'new' | 'unchanged' | 'changed'; export type ApprovalStatus = 'unknown' | 'none' | 'pending' | 'approved'; +/** Who asked for the change decides whether to ask again. `user` means the + * CURRENT turn names the change in the user's own words; a model-initiated + * change — or a reply that mixes an instruction with the model's own + * proposal — is `model` (the higher bar wins). No host verifies this in OVS: + * it is the driving agent's honest self-report. */ +export type ChangeOrigin = 'unknown' | 'user' | 'model'; export interface GateTransitionInput { line?: VideoLine; @@ -14,6 +20,7 @@ export interface GateTransitionInput { gate?: GateName; decision?: GateDecision; scope?: RevisionScope; + origin?: ChangeOrigin; recovery?: RecoveryState; recoveryDecision?: RecoveryDecision; artifactState?: ArtifactState; @@ -37,6 +44,7 @@ const VALID = { gate: new Set(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']), decision: new Set(['none', 'approve', 'revise']), scope: new Set(['unknown', 'none', 'visual_only', 'gate_b_payload']), + origin: new Set(['unknown', 'user', 'model']), recovery: new Set(['unknown', 'available', 'not_available']), recoveryDecision: new Set(['none', 'new_visual_revision', 'pause']), artifactState: new Set(['unknown', 'new', 'unchanged', 'changed']), @@ -95,6 +103,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi gate: raw.gate ?? 'none', decision: raw.decision ?? 'none', scope: raw.scope ?? 'unknown', + origin: raw.origin ?? 'unknown', recovery: raw.recovery ?? 'unknown', recoveryDecision: raw.recoveryDecision ?? 'none', artifactState: raw.artifactState ?? 'unknown', @@ -106,6 +115,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi assertEnum('gate', input.gate, VALID.gate); assertEnum('decision', input.decision, VALID.decision); assertEnum('scope', input.scope, VALID.scope); + assertEnum('origin', input.origin, VALID.origin); assertEnum('recovery', input.recovery, VALID.recovery); assertEnum('recoveryDecision', input.recoveryDecision, VALID.recoveryDecision); assertEnum('artifactState', input.artifactState, VALID.artifactState); @@ -115,6 +125,21 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi } const lineOps = lineOperations(input.line, input.artifact); + // Who asked for the change decides whether to ask again: a change the + // current user turn dictates is applied directly — the instruction is itself + // the authorization, and asking them to confirm a change they just asked for + // costs a full round trip and teaches them their instructions are not taken + // at face value. A mixed reply (instruction + model proposal) is `model`. + if (input.decision === 'revise' && input.scope === 'gate_b_payload' && input.origin === 'user') { + return result({ + nextAction: 'apply_user_instruction_then_approve_plan', + authorities: ['edit_current_artifact', 'approve_gate_b'], + allowedOps: ['edit_current_artifact', 'continue_approved_plan'], + prohibitedOps: NO_VISUAL_RESET, + reason: 'The current user turn names this change in the user\'s own words; that instruction is itself the authorization. Apply exactly that change and re-sign — never ask them to confirm a change they dictated.', + }); + } + // A signed-payload amendment creates a new signature and therefore a fresh // OVS draft-repair cycle. Recovery evidence for the old signature is stale. if (input.decision === 'revise' && input.scope === 'gate_b_payload') { @@ -165,9 +190,9 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi }); } return result({ - nextAction: 'report_visual_qa_blocker', - prohibitedOps: ['emit_form', 'edit_files', ...NO_VISUAL_RESET], - reason: 'Technical QA exhaustion never creates a user authorization form. Wait for a real revision request, which authorizes the next bounded cycle.', + nextAction: 'present_findings_and_ask_user_direction', + prohibitedOps: ['emit_form', 'edit_files', 'restart_visual_qa_cycle', ...NO_VISUAL_RESET], + reason: 'The visual QA cycle is exhausted. Show the current frames and remaining findings, offer another repair round or skipping the named check, and end the turn — the user\'s reply grants the next cycle. Then make a materially different edit: the failed strategies are recorded, and repeating one spends the new budget for nothing.', }); } @@ -263,7 +288,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi authorities: ['edit_current_artifact', 'restart_visual_qa_cycle'], allowedOps: lineOps.edit, prohibitedOps: ['emit_form', ...NO_VISUAL_RESET], - reason: 'Consume the legacy recovery submission once. New turns use the original revise decision and OVS content-signature reset.', + reason: 'Consume the legacy recovery submission once — by making a materially different edit, never by repeating a strategy the recorded evidence already shows failed.', }); } if (input.recovery === 'unknown') { @@ -285,9 +310,9 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi if (input.recovery === 'available') { return result({ - nextAction: 'report_visual_qa_blocker', - prohibitedOps: ['emit_form', 'edit_files', ...NO_VISUAL_RESET], - reason: 'Technical QA exhaustion is not a separate user decision. Report the blocker and wait for a real revision request; never emit a recovery form.', + nextAction: 'present_findings_and_ask_user_direction', + prohibitedOps: ['emit_form', 'edit_files', 'restart_visual_qa_cycle', ...NO_VISUAL_RESET], + reason: 'An exhausted visual QA cycle is a user fork, not a silent wait: show the current frames and remaining findings, offer another repair round or skipping the named check (`ovs draft --waive `), and end the turn. The user\'s reply grants the next cycle; never restart one as the silent default.', }); } diff --git a/packages/core/src/ir/edl.ts b/packages/core/src/ir/edl.ts index be3dff9..7038223 100644 --- a/packages/core/src/ir/edl.ts +++ b/packages/core/src/ir/edl.ts @@ -370,7 +370,7 @@ export function validateEdl(obj: unknown): EdlValidation { referenceIds.add(reference.id); } if (!VIDEO_REFERENCE_MEDIA_TYPES.includes(reference.media_type as VideoReferenceMediaType)) { - err(`${at}.media_type`, 'E_REFERENCE_MEDIA_TYPE', 'media_type must be image or video'); + err(`${at}.media_type`, 'E_REFERENCE_MEDIA_TYPE', `media_type must be one of ${VIDEO_REFERENCE_MEDIA_TYPES.join(' | ')}`); } if (!isStr(reference.source)) { err(`${at}.source`, 'E_REFERENCE_SOURCE', 'reference source path or URL is required'); @@ -405,7 +405,7 @@ export function validateEdl(obj: unknown): EdlValidation { if (Array.isArray(reference.roles)) { for (const role of reference.roles) { if (!VIDEO_REFERENCE_ROLES.includes(role as VideoReferenceRole)) { - err(`${at}.roles`, 'E_REFERENCE_ROLE', `unknown reference role "${String(role)}"`); + err(`${at}.roles`, 'E_REFERENCE_ROLE', `unknown reference role "${String(role)}" — must be one of ${VIDEO_REFERENCE_ROLES.join(' | ')}`); } } } @@ -476,7 +476,7 @@ export function validateEdl(obj: unknown): EdlValidation { if (Array.isArray(editStrategy.decision_signals)) { for (const signal of editStrategy.decision_signals) { if (!VIDEO_EDIT_DECISION_SIGNALS.includes(signal as VideoEditDecisionSignal)) { - err('edit_strategy.decision_signals', 'E_EDIT_STRATEGY_SIGNAL', `unknown decision signal "${String(signal)}"`); + err('edit_strategy.decision_signals', 'E_EDIT_STRATEGY_SIGNAL', `unknown decision signal "${String(signal)}" — must be one of ${VIDEO_EDIT_DECISION_SIGNALS.join(' | ')}`); } } } @@ -619,6 +619,27 @@ export function validateEdl(obj: unknown): EdlValidation { warn(`tracks.narration.segments[${i}].produced_path`, 'W_NARRATION_PRODUCED', 'produced_path should be a string path when present'); } }); + // Overlapping line windows produce two voices at once at mix time. + // The classic mistake is writing `target_sec` as each line's END + // time instead of its duration — windows like [11, +20] swallow + // their successors and the shipped mix carries double narration. + // Catch it at free validation, before any synthesis is attempted. + const windows = nar.segments + .map((ln, i) => ({ i, start: isObject(ln) ? Number(ln.start_sec) : NaN, dur: isObject(ln) ? Number(ln.target_sec) : NaN })) + .filter((w) => Number.isFinite(w.start) && Number.isFinite(w.dur) && w.dur > 0) + .sort((a, z) => a.start - z.start); + for (let k = 1; k < windows.length; k += 1) { + const prev = windows[k - 1]; + const cur = windows[k]; + const overlapSec = prev.start + prev.dur - cur.start; + if (overlapSec > 0.05) { + err( + `tracks.narration.segments[${prev.i}]`, + 'E_NARRATION_WINDOWS_OVERLAP', + `line window [${prev.start}s +${prev.dur}s] runs ${overlapSec.toFixed(2)}s into the next line at ${cur.start}s — target_sec is the line DURATION, not its end time; two overlapping windows mix as two voices speaking at once`, + ); + } + } } else { warn('tracks.narration', 'W_EMPTY_TRACK_DISABLED', 'empty narration is disabled; omit it or use null'); } diff --git a/packages/core/test/edl.test.ts b/packages/core/test/edl.test.ts index d6881ec..e79ab2d 100644 --- a/packages/core/test/edl.test.ts +++ b/packages/core/test/edl.test.ts @@ -132,6 +132,59 @@ describe('validateEdl — references and specs', () => { }); }); +// --- validateEdl: narration windows ---------------------------------------- + +describe('validateEdl — narration windows', () => { + const narrated = (segments: Array>) => + plan({ tracks: { narration: { voice: 'demo-voice', segments } } }); + + it('rejects overlapping line windows (target_sec written as an end time)', () => { + // [11s +20s] is the end-time mistake: the window runs to 31s and swallows + // the line that starts at 20s. + const r = validateEdl(narrated([ + { text: 'line one', start_sec: 11, target_sec: 20 }, + { text: 'line two', start_sec: 20, target_sec: 8 }, + ])); + expect(codes(r.errors)).toContain('E_NARRATION_WINDOWS_OVERLAP'); + const issue = r.errors.find((e) => e.code === 'E_NARRATION_WINDOWS_OVERLAP'); + expect(issue?.message).toContain('DURATION'); + }); + + it('accepts adjacent, non-overlapping windows', () => { + const r = validateEdl(narrated([ + { text: 'line one', start_sec: 0, target_sec: 5 }, + { text: 'line two', start_sec: 5.02, target_sec: 4 }, + ])); + expect(codes(r.errors)).not.toContain('E_NARRATION_WINDOWS_OVERLAP'); + }); + + it('skips lines without usable timing instead of failing them', () => { + const r = validateEdl(narrated([ + { text: 'untimed line' }, + { text: 'timed line', start_sec: 3, target_sec: 4 }, + ])); + expect(codes(r.errors)).not.toContain('E_NARRATION_WINDOWS_OVERLAP'); + }); + + it('names the legal values when rejecting an unknown decision signal', () => { + const r = validateEdl(plan({ + delivery_promise: { type: 'source_led', source_required: true, motion_min_ratio: 0 }, + segments: [ + { id: 's1', order: 1, role: 'body', layer: 'primary', source: 'edit', target_sec: 30, spec: { input_id: 'a', in_sec: 0, out_sec: 30 } }, + ], + edit_strategy: { + mode: 'highlight', + objectives: ['keep the goal'], + decision_signals: ['not-a-signal'], + preserve: ['intro'], + may_change: ['pacing'], + } as never, + })); + const issue = r.errors.find((e) => e.code === 'E_EDIT_STRATEGY_SIGNAL'); + expect(issue?.message).toContain('must be one of'); + }); +}); + // --- validateEdl: promise consistency -------------------------------------- describe('validateEdl — promise consistency', () => { diff --git a/packages/core/test/gate-transition.test.ts b/packages/core/test/gate-transition.test.ts index f78e2ba..96c5e4a 100644 --- a/packages/core/test/gate-transition.test.ts +++ b/packages/core/test/gate-transition.test.ts @@ -73,15 +73,56 @@ describe('resolveGateTransition', () => { expect(result.prohibited_ops).toContain('emit_form'); }); - it('reports an exhausted QA blocker without creating a recovery form', () => { + it('turns an exhausted QA cycle into a user fork, not a silent wait', () => { const result = resolveGateTransition({ line: 'compose', artifact: 'composition', recovery: 'available', errorCode: 'E_VISUAL_REVISION_EXPLICIT_AUTHORIZATION_REQUIRED', }); - expect(result).toMatchObject({ next_action: 'report_visual_qa_blocker', form: null }); + expect(result).toMatchObject({ next_action: 'present_findings_and_ask_user_direction', form: null }); expect(result.prohibited_ops).toContain('emit_form'); + expect(result.prohibited_ops).toContain('edit_files'); + expect(result.prohibited_ops).toContain('restart_visual_qa_cycle'); + expect(result.reason).toMatch(/materially different edit/i); + }); + + it('offers the fork on bare exhausted recovery with no decision at all', () => { + const result = resolveGateTransition({ + line: 'compose', + artifact: 'composition', + recovery: 'available', + }); + expect(result).toMatchObject({ next_action: 'present_findings_and_ask_user_direction', form: null }); + expect(result.reason).toMatch(/waive|skipping/i); + }); + + it('applies a user-dictated amendment directly instead of re-confirming it', () => { + const result = resolveGateTransition({ + line: 'compose', + artifact: 'composition', + gate: 'gate_b', + decision: 'revise', + scope: 'gate_b_payload', + origin: 'user', + }); + expect(result).toMatchObject({ next_action: 'apply_user_instruction_then_approve_plan', form: null }); + expect(result.authorities).toContain('approve_gate_b'); + expect(result.reason).toMatch(/own words/i); + }); + + it('keeps the Gate B amendment for model-initiated or mixed changes', () => { + for (const origin of ['model', 'unknown'] as const) { + const result = resolveGateTransition({ + line: 'compose', + artifact: 'composition', + gate: 'gate_b', + decision: 'revise', + scope: 'gate_b_payload', + origin, + }); + expect(result.next_action).toBe('open_gate_b_amendment'); + } }); it('rejects mixed current and legacy decision fields', () => { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 7ef305a..9b3a607 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -66,8 +66,9 @@ server.tool( report_path: z.string().optional(), findings_path: z.string().optional(), frame_evidence_dir: z.string().optional(), + waive: z.array(z.string()).optional().describe('QA finding codes the user chose to skip; persisted so later phases never re-block on them'), }, - ({ project, out, quality, report_path, findings_path, frame_evidence_dir }) => + ({ project, out, quality, report_path, findings_path, frame_evidence_dir, waive }) => format(renderTool.draft({ project, output: out, @@ -75,6 +76,7 @@ server.tool( reportPath: report_path, findingsPath: findings_path, frameEvidenceDir: frame_evidence_dir, + waive, onProgress: toStderr, })), ); @@ -156,7 +158,11 @@ server.tool( ); // --- plan IR --------------------------------------------------------------- -server.tool('plan_validate', 'Validate a plan.json (structural + promise consistency).', { file: z.string() }, ({ file }) => format(validateEdl(readPlan(file)))); +server.tool('plan_validate', 'Validate a plan.json (structural + promise consistency). A valid plan returns its summary — present that to the user, not your own abstract.', { file: z.string() }, ({ file }) => { + const planJson = readPlan(file); + const r = validateEdl(planJson); + return format(r.ok ? { ...r, summary: summarizeEdl(planJson as VideoEdl) } : r); +}); server.tool('plan_summarize', 'Render a human-readable timeline of a plan.json.', { file: z.string() }, ({ file }) => format(summarizeEdl(readPlan(file) as VideoEdl))); server.tool( 'plan_promise_check', @@ -208,6 +214,7 @@ server.tool( gate: z.enum(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']).optional(), decision: z.enum(['none', 'approve', 'revise']).optional(), scope: z.enum(['unknown', 'none', 'visual_only', 'gate_b_payload']).optional(), + origin: z.enum(['unknown', 'user', 'model']).optional().describe('who asked for the change: user = the current turn names it in the user\'s own words; a mixed reply is model'), recovery: z.enum(['unknown', 'available', 'not_available']).optional(), recoveryDecision: z.enum(['none', 'new_visual_revision', 'pause']).optional(), artifactState: z.enum(['unknown', 'new', 'unchanged', 'changed']).optional(), diff --git a/packages/skills/gate-control/SKILL.md b/packages/skills/gate-control/SKILL.md index 205ea1d..64d962a 100644 --- a/packages/skills/gate-control/SKILL.md +++ b/packages/skills/gate-control/SKILL.md @@ -27,6 +27,8 @@ Keep `Gate A/B/C/D` and `HTML Preview` for tool calls, stored state, and technic Every gate shows the current artifact, a concise next-action/cost/QA note, one decision request, and then stops. A new turn, question, or unrelated message is not approval. +Gate B's review package opens with the locked direction summary — line, aspect, duration, video language, audio mode, supplied-asset usage, and any billable cost note. Do not re-ask a direction fact the user already settled: the plan confirmation restates it, it does not reopen it. A reply naming a different language is a revise instruction — rewrite the plan artifacts to that language and show the one updated confirmation; never approve artifacts that do not match the language you showed. + | Gate | Required artifact | Stable decision field | Approval authorizes | | --- | --- | --- | --- | | Gate B | script + shotlist or `plan.json` summary, including narration profile | `gate_b_decision` | production from that exact plan | @@ -42,10 +44,12 @@ Gate C is one batch-level decision. A pending or failed paid request is not reus - A Preview/Gate D `revise` authorizes editing the displayed artifact within the requested scope and any required non-billable restart of its visual-QA cycle. - `approve` authorizes only the displayed artifact and next transition. -- Technical QA exhaustion is not a second user decision and must never create a new recovery form. +- An exhausted visual-QA cycle is a user fork, not a silent wait and not a form: show the current frames and the remaining findings, offer another repair round or skipping the named check, and end the turn. The user's reply grants the next cycle — never restart one as the silent default, and then make a materially different edit: the failed strategies are recorded, and repeating one spends the new budget for nothing. +- Whenever a quality finding blocks progress, tell the user in plain language what the check flagged and that they may skip it if they accept the look — they cannot choose an option they were never told exists. Skip with `ovs draft --waive `; the waiver persists on the project, so never ask them to skip the same check twice. Evidence-integrity findings (missing or corrupt frames/maps, parse failures) are repaired, not offered. - Legacy `visual_recovery_decision=new_visual_revision` input remains consumable for old clients, but must not be emitted in a new task. - An error that says authorization is required does not itself prove recovery availability; query durable status first. - A malformed local payload, missing file, stale evidence, failed check, or write error is system work, not a creative decision. Repair it without creating a gate when approved intent is unchanged. +- A recovery is executed, not narrated: a valid recovery trace contains the concrete file mutation BEFORE the validator retry. Re-running an unchanged validation is not progress, and a diagnosis-only response at that point is incomplete work, not a finished turn. When production or rendering tools are explicitly unavailable, return a clearly unexecuted production package for an otherwise clear brief: assumptions, complete narration/script, timed storyboard, exact visible copy/captions, visual/audio and rights-safe asset plan, export target, preview checklist, and final playback/encoding QA. Do not claim files exist or withhold the package behind a direction form. @@ -59,8 +63,9 @@ After a gate submission, a post-gate edit, a resumed turn with prior approval, o - `visual_only`: HTML/CSS/SVG/layout/motion/palette/assets; no approved wording, timing, language, narration, delivery, source mapping, role, or provider-setting change. - `gate_b_payload`: approved copy/casing/punctuation, timing, language, narration, delivery, source mapping, semantic roles, or signed provider intent. - `unknown`: inspect the requested files before asking a technical question. -4. Set recovery only from deterministic evidence: `available`, `not_available`, or `unknown`. This selects internal control flow, not a new form. -5. Run `ovs gate transition` and obey `next_action`, `form`, `allowed_ops`, and `prohibited_ops`. +4. Classify who asked for the change (`--origin`). `user` means the CURRENT turn names the change in the user's own words — the resolver then returns `apply_user_instruction_then_approve_plan`: apply exactly that change and re-sign, and never ask them to confirm a change they dictated (that costs a full round trip and teaches them their instructions are not taken at face value). A model-initiated change, or a reply that mixes an instruction with your own proposal, is `model` — the higher bar wins. Nothing verifies `origin`; report it honestly. +5. Set recovery only from deterministic evidence: `available`, `not_available`, or `unknown`. This selects internal control flow, not a new form. +6. Run `ovs gate transition` and obey `next_action`, `form`, `allowed_ops`, and `prohibited_ops`. Always invoke the resolver through the public `ovs gate transition` command (or the equivalent `gate_transition` MCP tool). Never execute a resolver by referencing an installed skill or Marketplace path directly. Pass only the decision field present in the current user submission. Never combine a current `--decision` with a cached `--recovery-decision`. @@ -75,22 +80,22 @@ ovs gate transition \ --recovery not_available ``` -Optional evidence inputs are `--error-code`, `--artifact-state`, and `--approval-status`. `--recovery-decision` is backward-compatible input for an already-visible old form only. Use `unknown` when evidence is missing; never guess `available`. +Optional evidence inputs are `--error-code`, `--artifact-state`, `--approval-status`, and `--origin` (step 4). `--recovery-decision` is backward-compatible input for an already-visible old form only. Use `unknown` when evidence is missing; never guess `available` — and never claim `--origin user` for a change the user did not name in the current turn. ## Invariants - A Preview/Gate D `visual_only` revision with recovery `not_available` goes directly to a localized edit and deterministic QA. It emits no recovery question. - The same revision with recovery `available` still emits no form: make the localized edit, then use `ovs check`, `ovs snapshot`, and `ovs draft`. OVS automatically starts a fresh persisted repair cycle after the authored content signature changes. -- A `gate_b_payload` revision creates exactly one Gate B amendment. Its approved signature starts a fresh QA cycle, so recovery from the old signature is irrelevant and must not be combined into the form. +- A `gate_b_payload` revision creates exactly one Gate B amendment. Its approved signature starts a fresh QA cycle, so recovery from the old signature is irrelevant and must not be combined into the form. The aftermath follows the visual identity: a narration-only amendment keeps the prior silent preview and its go-ahead (scene windows and pixels unchanged), while a visual amendment clears preview evidence — re-run visual QA and the preview only when the visual identity changed. - An unchanged artifact with recorded approval continues from that approval; never ask again merely because the task resumed. - A passing snapshot may create one Preview Gate. A passing draft may create one Gate D. No status check, advisory, retry, or bookkeeping step creates a user gate. - A content edit changes the draft signature and starts a fresh bounded repair cycle automatically. There is no public/manual reset operation; do not delete QA state by hand. - One user decision may produce at most one follow-up authorization request, and only for authority that decision did not already grant. -- `E_VISUAL_REVISION_EXPLICIT_AUTHORIZATION_REQUIRED` never justifies a form. With recovery `unknown`, query status; with recovery `available` and no current revise decision, report the blocker and wait for the next real revision request. +- `E_VISUAL_REVISION_EXPLICIT_AUTHORIZATION_REQUIRED` never justifies a form. With recovery `unknown`, query status; with recovery `available` and no current revise decision, present the frames and remaining findings with the real choices — another repair round, or skipping the named check — and end the turn. Never a reason-only blocker or a generic "how should I proceed?". - After final-video approval, a local visual-only revision reuses the approved plan, assets, and narration. Edit only the affected scene, run `ovs check` and `ovs snapshot`, then encode the revised final; do not ask for production-plan confirmation again or repeat TTS/generation unless the requested scope changes signed content or provider intent. ## Signed amendments For a Gate B amendment, apply only the approved bounded patch, revalidate the changed plan/artifact, then continue through the real Preview/Gate D path. A current Gate B approval wins over cached approval for the old signature. Do not promise an immediate render when a newly materialized preview still needs review. -Status checks, plan bookkeeping, advisory QA, repair passes that remain, QA-cycle restart, and tool misuse errors never create a gate. Never emit `visual_recovery_decision` in new VideoStudio output. +Status checks, plan bookkeeping, advisory QA, repair passes that remain, and tool misuse errors never stop for the user. An exhausted visual-QA cycle does: show its evidence and choices once and wait. Never emit `visual_recovery_decision` in new VideoStudio output. diff --git a/packages/skills/orchestration/SKILL.md b/packages/skills/orchestration/SKILL.md index 472a2c4..51dd74c 100644 --- a/packages/skills/orchestration/SKILL.md +++ b/packages/skills/orchestration/SKILL.md @@ -11,9 +11,17 @@ Read `gate-control` once before the first user gate. It is the single authorizat ## Checkpoint protocol — how every GATE works (there is no special form UI) -1. **Show the artifact in chat** so the user can actually see it — script/plan as markdown, images inline, a draft video as its output file path — plus one line of "what I'll do next" and any cost/QA note. +**Show the work; stop only five times.** The stops are a closed set: the direction (Gate A), the production plan (Gate B), paid generation (Gate C), the visual preview, and the final video (Gate D). Showing an artifact is not an ending in itself — publish it in chat and keep going — EXCEPT at the five stops, where the artifact IS the question. + +1. **Show the artifact in chat** so the user can actually see it — script/plan as markdown, images inline, a draft video as its output file path — plus one line of "what I'll do next" and any cost/QA note. Deliver it in the message that ENDS the turn: a completed turn keeps only your final message, so frames or artifacts posted mid-turn were never seen by the user. 2. **State the options** for that gate and **WAIT for the user to reply**. Do not run the next production step in the same turn as the gate. -3. **On reply, resolve the choice** with `ovs gate transition`: approve → continue only with the returned operation; revise → redo only the authorized scope, re-show, and re-gate only when the resolver says so; abort → stop. Never pass a gate without explicit user confirmation, and never ask again for an unchanged artifact whose approval is already recorded. +3. **On reply, resolve the choice** with `ovs gate transition`: approve → continue only with the returned operation; revise → redo only the authorized scope, re-show, and re-gate only when the resolver says so; abort → stop. Never pass a gate without explicit user confirmation, and never ask again for an unchanged artifact whose approval is already recorded. A reply picking an option you just enumerated IS that decision — `2`, its wording, or a paraphrase — so act on it in the same turn instead of asking them to restate a choice they already made. + +**The visual preview stops once per visual identity.** A change to visible copy, layout, assets, scene order/windows, or motion creates a new identity: capture and show that changed frame set once. Narration text, voice, audio, or narration-timing changes preserve the prior silent frames and their go-ahead when the scene windows and visible output are unchanged — do not re-ask about frames the user already accepted. Frames are presented once as the complete set; rendering before the user has seen them wastes the cheapest correction point. + +**Everything you write outside a tool call is user-facing copy**, including progress/process notes before the final message. Keep internal identifiers, finding codes, severity words (`advisory`, `warning`), and frame-role names out of it — use the localized gate names from `gate-control`. A successful preview message has three parts: lead with the contact sheet/frames, say in one short sentence that the preview is ready, then ask one direct question. Passing checks and non-blocking advisories stay silent. + +**Repair efficiently.** Every blocking finding a single QA result reports is independent: repair all of them in one message, then run the validator once — fixing them one per message turns one round trip into ten. A successful `edit`/write/tool call is authoritative; do not re-read a file to confirm a write landed — the call would have failed. ## 1. Route + lock (read `video-router`) @@ -25,7 +33,9 @@ Classify and LOCK the line (no silent switching): ## 2. GATE A — Proposal (all lines) -Show: the brief you inferred (line, aspect, duration, language) for the user to correct, plus 1–3 differentiated concepts (each: hook + look + rough length; for GENERATE add the shot count and that each clip is a billable call; for AUTO also state the proposed delivery promise — source_led / motion_led / compose_led / hybrid — and the rough segment mix). Options: pick a concept / adjust the brief / new direction. STOP. +**The direction stop comes first, before any plan file exists.** Show: the brief you inferred (line, aspect, duration, language) for the user to correct, plus 2–3 genuinely DIFFERENT concepts (each: hook + look + rough length; for GENERATE add the shot count and that each clip is a billable call; for AUTO also state the proposed delivery promise — source_led / motion_led / compose_led / hybrid — and the rough segment mix). Options: pick a concept / adjust the brief / new direction. STOP. + +Write no script, plan, narration copy, or art direction before their reply — all of that authored against an unchosen direction is work that gets thrown away. A brief that already describes the exact video still stops here, with ONE concept: a misunderstanding costs one message instead of a whole plan. Do not interrogate the user for open creative preferences (casting, audience, style, tone, visual direction) — propose, and let them redirect. ## 2.5 Craft standard (ALL lines — read `video-craft`) @@ -76,6 +86,8 @@ TALKING-HEAD note: if a GENERATE clip already returned lip-synced built-in speec Ingest every supplied clip from evidence (probe + transcribe/OCR-or-frame-reading/extract-frame), author ONE cross-modal `project/plan.json`, `ovs plan validate` and fix every error, **GATE B** on the timeline (`ovs plan summarize`), **GATE C** only if the plan has billable `generate` segments with the exact count and exact `media_kind`/duration/ratio/resolution/audio/reference settings, then assemble per `stage-assemble` (produce each segment via its line, mix narration ONCE, music ducked, burnsubs, normalize loudness). At **GATE D** run `ovs plan promise-check --probe-produced` plus a draft review, then finalize. +**An assembled production is ONE video.** The number of times it stops for the user is fixed by the gate table and never grows with the segment count — seven segments still make exactly one draft review, of the whole video in playback order, never one review per child. An edit to one segment invalidates only that segment: never re-render or re-check an unchanged sibling because another segment changed. + --- ## plan.json as the editable record (all lines) — keep follow-up edits cheap diff --git a/packages/skills/stage-compose/SKILL.md b/packages/skills/stage-compose/SKILL.md index a141230..6aab032 100644 --- a/packages/skills/stage-compose/SKILL.md +++ b/packages/skills/stage-compose/SKILL.md @@ -133,7 +133,7 @@ Canonical minimal `index.html` (16:9, 10s): - **Scenes**: one clip (or a group) per storyboard shot; set each clip's `data-start`/`data-duration` from the shot list so the timeline sums to the brief's duration. - **On-screen text**: keep it inside the frame with padding; large, high-contrast type; one idea per scene. - **Assets**: reference images/footage produced upstream by relative path inside the composition dir (e.g. `./assets/shot1.png`). -- **Timing**: position every tween on the GSAP timeline with an explicit time so it is reproducible; the total of `data-duration` on the root is the final length. +- **Timing**: position every tween from its scene's window via the scaffold's `S("")` / `D("")` helpers (they read the section's `data-start`/`data-duration`), never as a literal second. Narration audio is measured after you author, and `ovs composition reconcile` then moves every scene window — a literal keeps playing against the old window, so the scene captures blank at its QA frames. Relative string positions (`"+=0.5"`, labels) also survive a retime. The total of `data-duration` on the root is the final length. - **SVG-first visual layer**: prefer inline SVG for non-text motion graphics such as diagrams, connectors, nodes, progress paths, charts, orbit lines, icon-like marks, and background geometry. Keep readable prose in normal HTML text boxes unless the SVG text is large, simple, and verified. - **Use GSAP only when time-based motion is needed**: static SVG, CSS layout, and simple held states do not need GSAP. When animation is needed, keep GSAP as the timeline/orchestration layer that animates SVG groups or a small set of HTML containers. - **No remote runtime resources in final HTML**: do not leave CDN scripts, remote fonts, remote images, or remote CSS in the render path. Fetch or copy permitted runtime files into `project/composition/assets/` during authoring, then reference them with relative paths such as `./assets/vendor/gsap.min.js`. If you cannot source a permitted local GSAP/runtime file, report that blocker rather than shipping a network-dependent composition. diff --git a/packages/skills/test/skills-content.test.ts b/packages/skills/test/skills-content.test.ts index 7c37710..0a090ac 100644 --- a/packages/skills/test/skills-content.test.ts +++ b/packages/skills/test/skills-content.test.ts @@ -32,7 +32,15 @@ describe('skill pack content', () => { expect(gate).toContain('content edit changes the draft signature'); expect(gate).toContain('equivalent `gate_transition` MCP tool'); expect(gate).toContain('Never execute a resolver by referencing an installed skill or Marketplace path directly'); - expect(gate).toContain('must never create a new recovery form'); + // The exhausted-cycle contract: a user fork with real choices, never a + // silent wait, never a form, and the skip option is always named. + expect(gate).toContain('user fork, not a silent wait'); + expect(gate).toContain('materially different edit'); + expect(gate).toContain('--waive'); + expect(gate).toContain('they cannot choose an option they were never told exists'); + // Who asked for the change decides whether to ask again. + expect(gate).toContain('apply_user_instruction_then_approve_plan'); + expect(gate).toContain('never ask them to confirm a change they dictated'); expect(gate).toContain('automatically starts a fresh persisted repair cycle'); expect(gate).toContain('Never emit `visual_recovery_decision`'); expect(gate).toContain('Production plan confirmation'); @@ -42,6 +50,27 @@ describe('skill pack content', () => { expect(orchestration).toContain('gate-control'); }); + it('carries the checkpoint craft: five stops, visual identity, direction first', () => { + const orchestration = skill('orchestration'); + const router = skill('video-router'); + const gate = skill('gate-control'); + // The closed stop set + anti-over-stopping rule. + expect(orchestration).toContain('stop only five times'); + expect(orchestration).toContain('Showing an artifact is not an ending in itself'); + // The preview re-gates only on a visual-identity change. + expect(orchestration).toContain('once per visual identity'); + expect(orchestration).toContain('preserve the prior silent frames'); + // The direction stop precedes any plan artifact, in both entry skills. + expect(orchestration).toContain('before any plan file exists'); + expect(router).toContain('Routing ends at the direction stop'); + // An enumerated-option reply is the decision; frames ride the ending message. + expect(orchestration).toContain('IS that decision'); + expect(orchestration).toContain('message that ENDS the turn'); + // Assembled stop economics + amendment aftermath by identity. + expect(orchestration).toContain('never grows with the segment count'); + expect(gate).toContain('re-run visual QA and the preview only when the visual identity changed'); + }); + it('wires the design layers into compose and orchestration', () => { const compose = skill('stage-compose'); const orchestration = skill('orchestration'); diff --git a/packages/skills/video-router/SKILL.md b/packages/skills/video-router/SKILL.md index cffa6e6..6b149d8 100644 --- a/packages/skills/video-router/SKILL.md +++ b/packages/skills/video-router/SKILL.md @@ -39,6 +39,10 @@ Pick a **single line** when one axis cleanly dominates (just trim a clip; just a AUTO does not abandon the axes — it sequences them through one cross-modal plan (`stage-plan` builds the EDL, `stage-assemble` walks it), delegating each segment back to the generate / compose / edit lines. Choosing AUTO is itself the lock: the *primary* still gets named via the plan's `delivery_promise` (source_led / motion_led / compose_led / hybrid). +## Routing ends at the direction boundary + +Routing ends at the direction stop (Gate A), not at a production plan. Present only 2–3 direction concepts plus the facts the brief already locked, and write NO plan file, script, narration copy, or art direction before the user picks a direction — everything authored against an unchosen direction is thrown away when they pick another. + ## Lock the runtime - Decide the primary axis at the brief/proposal stage and **state it in the proposal**. diff --git a/packages/tools/src/composition/scaffold.ts b/packages/tools/src/composition/scaffold.ts index 3367102..7c5a184 100644 --- a/packages/tools/src/composition/scaffold.ts +++ b/packages/tools/src/composition/scaffold.ts @@ -1,7 +1,14 @@ import * as fs from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join, resolve } from 'node:path'; -import { parseCompositionManifest, type CompositionManifest, type CompositionManifestIssue } from '@orkas/video-studio-core'; +import { + manifestAsDesignContract, + manifestAsSceneMap, + parseCompositionManifest, + type CompositionManifest, + type CompositionManifestIssue, +} from '@orkas/video-studio-core'; +import { designContractReadiness } from '../render/composition-qa.js'; export type CompositionPrepareResult = { ok: boolean; @@ -10,6 +17,11 @@ export type CompositionPrepareResult = { scaffold_created: boolean; reconciled: boolean; issues: CompositionManifestIssue[]; + /** Design-contract readiness at hand-off time. Every design fixHint says + * "before writing HTML" — reporting the gap here, instead of first at + * inspect/draft, is the only moment that instruction can still be + * followed. Cover-family checks stay with inspect (frame evidence). */ + design_contract?: ReturnType; }; function escapeHtml(value: string): string { @@ -70,7 +82,7 @@ export function buildCompositionScaffold(manifest: CompositionManifest): string const timeline = manifest.scenes.map((scene) => { const selector = JSON.stringify(`#scene-${scene.id} .scene-content`); const revealDuration = Math.min(0.6, scene.duration); - return ` tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, ${scene.start});`; + return ` tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, S(${JSON.stringify(scene.id)}));`; }).join('\n'); return ` @@ -99,8 +111,18 @@ ${clips}${audio ? `\n${audio}` : ''} window.__timelines = window.__timelines || {}; const tl = gsap.timeline({ paused: true }); window.__timelines[${JSON.stringify(composition.id)}] = tl; + // Scene windows live on each section's data-start/data-duration, and + // \`ovs composition reconcile\` re-writes those attributes when timing + // changes (e.g. after narration is measured) — which can happen AFTER + // this file is authored. A timeline second written as a literal here + // points at the wrong scene from that moment on. Position every tween + // with S(id)/D(id) and it survives any retiming untouched. + const sceneEl = (id) => document.querySelector('[data-scene-id="' + id + '"]'); + const S = (id) => Number(sceneEl(id).dataset.start); // scene start, seconds + const D = (id) => Number(sceneEl(id).dataset.duration); // scene duration, seconds ${timeline} // Add deterministic scene motion to tl. HyperFrames owns media playback. + // Position tweens from S("") / D(""), never a bare second. })(); @@ -152,7 +174,19 @@ export async function prepareComposition(projectPath: string): Promise { const selector = JSON.stringify(`#scene-${scene.id} .scene-content`); const selectorPattern = escapeRegExp(selector); - const reveal = new RegExp(`tl\\.fromTo\\(\\s*${selectorPattern}\\s*,\\s*\\{\\s*opacity\\s*:\\s*0\\s*,\\s*y\\s*:\\s*48\\s*\\}\\s*,\\s*\\{\\s*opacity\\s*:\\s*1\\s*,\\s*y\\s*:\\s*0\\s*,\\s*duration\\s*:\\s*[0-9.]+\\s*,\\s*ease\\s*:\\s*"power3\\.out"\\s*\\}\\s*,\\s*-?[0-9.]+\\s*\\);`); + const anchorPattern = escapeRegExp(`S(${JSON.stringify(scene.id)})`); + const reveal = new RegExp(`tl\\.fromTo\\(\\s*${selectorPattern}\\s*,\\s*\\{\\s*opacity\\s*:\\s*0\\s*,\\s*y\\s*:\\s*48\\s*\\}\\s*,\\s*\\{\\s*opacity\\s*:\\s*1\\s*,\\s*y\\s*:\\s*0\\s*,\\s*duration\\s*:\\s*[0-9.]+\\s*,\\s*ease\\s*:\\s*"power3\\.out"\\s*\\}\\s*,\\s*(?:-?[0-9.]+|${anchorPattern})\\s*\\);`); const revealDuration = Math.min(0.6, scene.duration); - next = next.replace(reveal, `tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, ${scene.start});`); + const position = hasSceneAnchors ? `S(${JSON.stringify(scene.id)})` : String(scene.start); + next = next.replace(reveal, `tl.fromTo(${selector}, { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: ${revealDuration}, ease: "power3.out" }, ${position});`); }); next = next.replace(/window\.__timelines\[(?:"[^"]*"|'[^']*')\]\s*=\s*tl;/, `window.__timelines[${JSON.stringify(manifest.composition.id)}] = tl;`); diff --git a/packages/tools/src/edit/edit.ts b/packages/tools/src/edit/edit.ts index e748c68..06d8c2f 100644 --- a/packages/tools/src/edit/edit.ts +++ b/packages/tools/src/edit/edit.ts @@ -55,6 +55,12 @@ const COVERAGE_CONCURRENCY = 4; const COVERAGE_TRAILING_GAP_SEC = 2; const COVERAGE_OVERSHOOT_SEC = 0.3; const COVERAGE_LEAD_GAP_SEC = 3; +/** An interior hole this long between narration lines reads as dead air on a + * voiceover with no music bed. The old ratio only measured how FAR the + * narration reached, so a 60s cut with 29.7s of interior silence still + * scored 95%. */ +const COVERAGE_INTERIOR_GAP_SEC = 2.5; +const COVERAGE_MAX_REPORTED_GAPS = 12; const round2 = (n: number): number => Math.round((Number.isFinite(n) ? n : 0) * 100) / 100; const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n)); @@ -157,8 +163,21 @@ export interface CoverageReport { leadingGapSec: number; trailingGapSec: number; overshootSec: number; + /** How far into the clip the narration REACHES (0..1). Blind to interior + * silence: a half-silent track can still score 0.95 on it. */ coverageRatio: number; - status: 'ok' | 'under' | 'over' | 'silent'; + /** Share of the clip that actually carries voice (0..1). This and + * `coverageRatio` disagreeing is exactly the half-silent-draft defect. */ + voicedRatio: number; + /** Interior silent holes between voiced spans (clip timeline, ≥0.5s), + * largest first, capped at COVERAGE_MAX_REPORTED_GAPS. */ + interiorGaps: Array<{ startSec: number; endSec: number; durationSec: number }>; + maxInteriorGapSec: number; + /** Seconds two lines speak at once, and how many line pairs collide. A line + * whose audio runs past the next line's start mixes as double narration. */ + maxOverlapSec: number; + overlapCount: number; + status: 'ok' | 'under' | 'over' | 'silent' | 'gapped' | 'overlapped'; warnings: string[]; } @@ -492,6 +511,10 @@ export function assessVoiceoverCoverage(input: { voicedStartSec: number; voicedEndSec: number; audioEndSec: number; + /** Voiced spans on the clip timeline (already offset-shifted). When + * provided, interior holes and line collisions between them are measured; + * without them the report can only see the head and tail. */ + voicedSpans?: Array<{ startSec: number; endSec: number }>; }): CoverageReport { const ref = Math.max(0, input.referenceDurationSec); const voicedStart = Math.max(0, input.voicedStartSec); @@ -502,24 +525,82 @@ export function assessVoiceoverCoverage(input: { const trailingGapSec = round2(ref - voicedEnd); const overshootSec = round2(audioEnd - ref); const coverageRatio = ref > 0 ? round2(clamp(voicedEnd / ref, 0, 1)) : 0; + + // Merge the spans and measure what sits between them. Only holes ≥0.5s + // count — natural inter-phrase pauses are not dead air. + const spans = (input.voicedSpans ?? []) + .map((sp) => ({ startSec: Math.max(0, sp.startSec), endSec: Math.min(ref, sp.endSec) })) + .filter((sp) => sp.endSec - sp.startSec > 0.05) + .sort((a, z) => a.startSec - z.startSec); + const merged: Array<{ startSec: number; endSec: number }> = []; + for (const sp of spans) { + const last = merged[merged.length - 1]; + if (last && sp.startSec <= last.endSec + 0.05) last.endSec = Math.max(last.endSec, sp.endSec); + else merged.push({ ...sp }); + } + // Overlaps are measured on the RAW spans, before merging: merging is what + // hides a collision, and a collision is exactly what must be reported. + let maxOverlapSec = 0; + let overlapCount = 0; + for (let i = 1; i < spans.length; i += 1) { + const collide = spans[i - 1].endSec - spans[i].startSec; + if (collide > 0.05) { + overlapCount += 1; + maxOverlapSec = Math.max(maxOverlapSec, collide); + } + } + maxOverlapSec = round2(maxOverlapSec); + const interiorGaps: CoverageReport['interiorGaps'] = []; + for (let i = 1; i < merged.length; i += 1) { + const gap = merged[i].startSec - merged[i - 1].endSec; + if (gap >= 0.5) { + interiorGaps.push({ + startSec: round2(merged[i - 1].endSec), + endSec: round2(merged[i].startSec), + durationSec: round2(gap), + }); + } + } + interiorGaps.sort((a, z) => z.durationSec - a.durationSec); + interiorGaps.length = Math.min(interiorGaps.length, COVERAGE_MAX_REPORTED_GAPS); + const maxInteriorGapSec = interiorGaps.length ? interiorGaps[0].durationSec : 0; + const voicedTotal = merged.reduce((sum, sp) => sum + (sp.endSec - sp.startSec), 0); + const voicedRatio = ref > 0 + ? round2(clamp((merged.length ? voicedTotal : Math.max(0, voicedEnd - voicedStart)) / ref, 0, 1)) + : 0; + const warnings: string[] = []; let status: CoverageReport['status'] = 'ok'; - if (!hasVoice) { status = 'silent'; warnings.push('No speech or non-silent audio was detected in the added audio.'); } else { if (overshootSec > COVERAGE_OVERSHOOT_SEC) { status = 'over'; - warnings.push(`Added audio runs ${overshootSec}s past the ${round2(ref)}s base and will be truncated; shorten or retime it.`); + warnings.push(`Added audio runs ${overshootSec}s past the ${round2(ref)}s base and will be truncated — shorten the script so it ends before the clip does (trim the words; do not just raise speed).`); } if (trailingGapSec > COVERAGE_TRAILING_GAP_SEC) { if (status === 'ok') status = 'under'; - warnings.push(`Added audio ends at ${round2(voicedEnd)}s, leaving ${trailingGapSec}s of uncovered tail.`); + const pct = ref > 0 ? Math.round((trailingGapSec / ref) * 100) : 0; + warnings.push(`Added audio ends at ${round2(voicedEnd)}s, leaving ${trailingGapSec}s of silent tail on a ${round2(ref)}s clip (~${pct}% uncovered) — lengthen the script, add a closing line, or trim the clip to match.`); } if (leadingGapSec > COVERAGE_LEAD_GAP_SEC) { warnings.push(`Added audio starts at ${leadingGapSec}s; check whether the long lead-in is intentional.`); } + if (overlapCount > 0 && maxOverlapSec > 0.3) { + // Double narration outranks everything except truncation: two voices at + // once is broken audio, not a style choice. + if (status !== 'over') status = 'overlapped'; + warnings.push(`${overlapCount} narration line pair(s) overlap by up to ${maxOverlapSec}s — two lines speak at once. A line's audio must end before the next line's start_sec; shorten the colliding lines or move their windows, and check that target_sec is each line's DURATION, not its end time.`); + } + if (maxInteriorGapSec > COVERAGE_INTERIOR_GAP_SEC) { + // Interior dead air outranks a short tail: 'under' describes a missing + // ending, 'gapped' a broken middle, and the middle is what listeners + // hear first. Truncation ('over') and double voice keep priority. + if (status === 'ok' || status === 'under') status = 'gapped'; + const silentTotal = round2(interiorGaps.reduce((sum, g) => sum + g.durationSec, 0)); + warnings.push(`Narration has ${interiorGaps.length} interior silent hole(s) totalling ${silentTotal}s (largest ${maxInteriorGapSec}s) — on a track with no music bed this is dead air. Re-time the lines inside their scenes, add the planned music bed, or shorten the over-long scenes; do not pad the script with filler words.`); + } } return { @@ -530,6 +611,11 @@ export function assessVoiceoverCoverage(input: { trailingGapSec, overshootSec, coverageRatio, + voicedRatio, + interiorGaps, + maxInteriorGapSec, + maxOverlapSec, + overlapCount, status, warnings, }; @@ -557,12 +643,16 @@ async function coverageForSegments(referenceDurationSec: number, segments: Audio const starts: number[] = []; const ends: number[] = []; const audioEnds: number[] = []; + const voicedSpans: Array<{ startSec: number; endSec: number }> = []; for (let i = 0; i < segments.length; i += 1) { const start = Math.max(0, finiteNum(segments[i].start_sec) ? segments[i].start_sec : 0); const timing = timings[i]; starts.push(start + timing.voicedStartSec); ends.push(start + timing.voicedEndSec); audioEnds.push(start + timing.durationSec); + // Keep the per-segment structure: min/max alone is what let a half-silent + // track report high coverage and hid line collisions entirely. + voicedSpans.push({ startSec: start + timing.voicedStartSec, endSec: start + timing.voicedEndSec }); } if (!ends.length) return undefined; return assessVoiceoverCoverage({ @@ -570,6 +660,7 @@ async function coverageForSegments(referenceDurationSec: number, segments: Audio voicedStartSec: Math.min(...starts), voicedEndSec: Math.max(...ends), audioEndSec: Math.max(...audioEnds), + voicedSpans, }); } diff --git a/packages/tools/src/render/composition-qa.ts b/packages/tools/src/render/composition-qa.ts index c48cbf4..6d56a09 100644 --- a/packages/tools/src/render/composition-qa.ts +++ b/packages/tools/src/render/composition-qa.ts @@ -17,8 +17,48 @@ export type Issue = { message: string; fixHint?: string; source?: string; + /** Present when a blocking finding was downgraded by an explicit user + * decision — the finding stays in the report, it just no longer blocks. */ + waived_by_user?: boolean; }; +/** Evidence-integrity findings are repaired, never waived: they mean the QA + * could not see, not that the user accepted a look. Parse failures likewise. */ +export const NON_WAIVABLE_QA_CODES = new Set([ + 'VIDEO_SAMPLE_FRAMES_MISSING', + 'SCENE_MAP_REQUIRED_FOR_SOURCE_ALIGNMENT', +]); + +export function qaFindingIsWaivable(code: string): boolean { + if (NON_WAIVABLE_QA_CODES.has(code)) return false; + return !/_PARSE_FAILED$/.test(code); +} + +/** + * Downgrade user-waived blocking findings to informational. The finding stays + * in the report with its message suffixed, so every later QA phase reports it + * without blocking — the user is never asked to skip the same check twice. + */ +export function applyQaFindingWaivers( + issues: Issue[], + waivedCodes: Iterable, +): { issues: Issue[]; applied: string[] } { + const waived = new Set(waivedCodes); + if (!waived.size) return { issues, applied: [] }; + const applied = new Set(); + const next = issues.map((issue) => { + if (issue.severity !== 'error' || !waived.has(issue.code) || !qaFindingIsWaivable(issue.code)) return issue; + applied.add(issue.code); + return { + ...issue, + severity: 'info' as const, + message: `${issue.message} [skipped by user decision]`, + waived_by_user: true, + }; + }); + return { issues: next, applied: [...applied] }; +} + export type AudioTrack = { absPath: string; startSec: number; @@ -622,14 +662,35 @@ const DESIGN_QUALITY_DIMENSION_FLOOR = 70; /** Style words that sound like a thesis but constrain nothing. */ const GENERIC_AESTHETIC_RE = /\b(?:modern tech|clean modern|sleek|premium|minimalist|minimal|futuristic|dynamic|engaging|professional|high[- ]end|beautiful|polished)\b/i; +// Completeness stays blocking — a missing section means the decision was never +// made. Grading AUTHORED prose (GENERIC_AESTHETIC_THESIS) does not: the user +// reviews the resulting frames at the preview, and a taste judgment must not +// spend repair rounds before they see them. const HARD_PREVIEW_DESIGN_CODES = new Set([ 'AESTHETIC_THESIS_INCOMPLETE', - 'GENERIC_AESTHETIC_THESIS', 'VISUAL_DIRECTION_INCOMPLETE', 'SCENE_DEPTH_LAYERS_MISSING', 'SCENE_MOTION_VERBS_MISSING', ]); +/** Field lists per design-contract section, for messages that name what a + * missing section must contain — bare section names cost one structurally + * guaranteed extra round (add shells → get told the fields). */ +const DESIGN_SECTION_FIELDS: Record = { + aesthetic: AESTHETIC_FIELDS, + visual_direction: VISUAL_DIRECTION_FIELDS, + cover: COVER_CONTRACT_FIELDS, +}; + +function designContractSectionShape(sections: string[]): string { + return sections + .map((key) => { + const fields = DESIGN_SECTION_FIELDS[key]; + return fields ? `${key}{${fields.join(', ')}}` : key; + }) + .join('; '); +} + function designSeverity(code: string, hard = true): Issue['severity'] { if (code === 'DESIGN_CONTRACT_BUDGET_INCOMPLETE') return hard ? 'error' : 'warning'; return HARD_PREVIEW_DESIGN_CODES.has(code) ? 'error' : 'warning'; @@ -753,8 +814,8 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec code, severity: designSeverity(code, missingPreviewRequired.length > 0), selector, - message: `Design contract is missing aesthetic budget fields: ${missingSections.join(', ')}.`, - fixHint: 'Add compact aesthetic, visual-direction, layout, type, color, motion, and scene-variation budgets before writing HTML.', + message: `Design contract is missing aesthetic budget sections: ${designContractSectionShape(missingSections)}.`, + fixHint: 'Write every listed section COMPLETE in one pass — each missing section names its own required fields above; adding empty shells only buys another failed round.', source: 'ovs-design-contract', }); } @@ -800,9 +861,11 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec if (contentSignals.length < 2) { issues.push({ code: 'COVER_CONTENT_SIGNALS_THIN', - severity: 'error', + // How many signals the cover DECLARES is an ambition judgment, not a + // completeness failure — the user reviews the frames at the preview. + severity: 'warning', selector: `${selector}#cover.content_signals`, - message: 'The cover needs at least two topic-specific content signals.', + message: 'The cover declares fewer than two topic-specific content signals.', source: 'ovs-design-contract', }); } @@ -851,7 +914,10 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec if (missing.length) { issues.push({ code: 'REFERENCE_FIDELITY_CONTRACT_INCOMPLETE', - severity: 'error', + // Advisory: these fields describe intent and change nothing that + // renders — fidelity is judged on the rendered frames. The reference + // LIST and per-reference media contracts stay blocking. + severity: 'warning', selector: `${selector}#reference_fidelity`, message: `Concrete references need an executable fidelity contract: ${missing.join(', ')} missing or invalid.`, source: 'ovs-design-contract', @@ -860,18 +926,18 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec if (mode === 'exact' && preserve.length < 3) { issues.push({ code: 'REFERENCE_EXACT_PRESERVE_THIN', - severity: 'error', + severity: 'warning', selector: `${selector}#reference_fidelity.preserve`, - message: 'Exact fidelity must preserve at least three named visual axes.', + message: 'Exact fidelity should preserve at least three named visual axes.', source: 'ovs-design-contract', }); } if (mode === 'exact' && Number.isFinite(minimumScore) && minimumScore < 85) { issues.push({ code: 'REFERENCE_EXACT_SCORE_FLOOR_LOW', - severity: 'error', + severity: 'warning', selector: `${selector}#reference_fidelity.verification.minimum_score`, - message: 'Exact fidelity requires a reference_fidelity score threshold of at least 85.', + message: 'Exact fidelity normally uses a reference_fidelity score threshold of at least 85.', source: 'ovs-design-contract', }); } @@ -1051,9 +1117,15 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec } // Per-scene design plan: prefer the contract's own scenes, fall back to the - // scene map when the contract does not restate them. + // scene map when the contract does not restate them. Accept the MEANING, not + // one spelling: the depth fixHint itself tells the model to write + // background/midground/foreground, so three separate fields must pass the + // check that prescribed them; likewise `motion` next to motion_verbs. const scenes = extractScenes(contract).length ? extractScenes(contract) : extractScenes(sceneMap); - const missingDepth = scenes.filter((scene) => !hasContent(scene.depth_layers)).slice(0, 4); + const sceneHasDepth = (scene: Record): boolean => + hasContent(scene.depth_layers) + || (hasContent(scene.background) && hasContent(scene.midground) && hasContent(scene.foreground)); + const missingDepth = scenes.filter((scene) => !sceneHasDepth(scene)).slice(0, 4); if (scenes.length && missingDepth.length) { const code = 'SCENE_DEPTH_LAYERS_MISSING'; issues.push({ @@ -1061,12 +1133,14 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec severity: designSeverity(code), selector: `${selector}#scenes`, message: `Scene art direction is missing background/midground/foreground depth layers for ${missingDepth.map(sceneLabel).join(', ')}.`, - fixHint: 'Give each scene a topic-derived background, a dominant midground, and foreground accents.', + fixHint: 'Give each scene a topic-derived background, a dominant midground, and foreground accents — as depth_layers or as background/midground/foreground fields, inside the design contract\'s own scenes[] (not the manifest\'s canonical scenes[], whose schema rejects unknown keys).', source: 'ovs-design-contract', }); } - const missingVerbs = scenes.filter((scene) => !hasContent(scene.motion_verbs) && !hasContent(scene.motion_choreography)).slice(0, 4); + const sceneHasMotion = (scene: Record): boolean => + hasContent(scene.motion_verbs) || hasContent(scene.motion_choreography) || hasContent(scene.motion); + const missingVerbs = scenes.filter((scene) => !sceneHasMotion(scene)).slice(0, 4); if (scenes.length && missingVerbs.length) { const code = 'SCENE_MOTION_VERBS_MISSING'; issues.push({ @@ -1074,7 +1148,7 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec severity: designSeverity(code), selector: `${selector}#scenes`, message: `Scene art direction is missing motion verbs for ${missingVerbs.map(sceneLabel).join(', ')}.`, - fixHint: 'Say what each primary element does: draws, stamps, counts up, locks, drifts, resolves.', + fixHint: 'Say what each primary element does (draws, stamps, counts up, locks, drifts, resolves) in motion_verbs — inside the design contract\'s own scenes[], not the manifest\'s canonical scenes[].', source: 'ovs-design-contract', }); } @@ -1082,6 +1156,160 @@ export function designContractIssues(contract: unknown, sceneMap: unknown, selec return issues; } +/** + * Design-contract readiness at PREPARE time. Every design-contract fixHint says + * "before writing HTML", yet the earliest the checks used to fire was + * inspect/draft — after the HTML exists, with instructions addressed to a + * moment already gone. Reuses designContractIssues so prepare and inspect + * cannot disagree; the cover family deliberately stays with inspect, which is + * where frame-0 evidence exists. + */ +export function designContractReadiness( + contract: unknown, + sceneMap: unknown = null, +): { status: 'missing' | 'incomplete' | 'ready'; issues: Issue[] } { + if (!isRecord(contract) || !DESIGN_CONTRACT_SECTIONS.some((key) => hasContent(contract[key]))) { + return { status: 'missing', issues: [] }; + } + // All designContractIssues checks are contract-level and fixable before any + // HTML exists — including the cover CONTRACT. Frame-evidence cover checks + // (headline/signals actually rendered) live in runContractHtmlQa and stay + // with inspect, where the frames exist. + const issues = designContractIssues(contract, sceneMap); + return { status: issues.some((issue) => issue.severity === 'error') ? 'incomplete' : 'ready', issues }; +} + +/** + * Copy search over composition HTML that survives markup. Approved copy can be + * split across elements for per-word reveals, which puts tags and whitespace + * between the fragments; a line carrying no whitespace of its own (every CJK + * line) could never be found once animated word by word. Script/style bodies + * never render, so copy found only there does not count. Deliberate looseness: + * element boundaries read as whitespace, and the compact fallback (whitespace- + * free needles only) matches across boundaries — that is exactly what finds a + * per-character CJK reveal, at the cost of occasionally crediting adjacent + * fragments. A missed real line costs a repair loop; an adjacent-fragment + * credit costs nothing the preview does not show. + */ +export function htmlCopySearch(html: string): (needle: string) => boolean { + const withoutCode = html + .replace(//gi, ' ') + .replace(//gi, ' '); + const raw = normalizeForSearch(withoutCode); + const text = normalizeForSearch(withoutCode.replace(/<[^>]*>/g, ' ')); + const textCompact = text.replace(/ /g, ''); + return (needle: string): boolean => { + const n = normalizeForSearch(needle); + if (!n) return true; + if (raw.includes(n) || text.includes(n)) return true; + return !/\s/.test(n) && textCompact.includes(n.replace(/ /g, '')); + }; +} + +const TIMELINE_POSITION_ARG_INDEX: Record = { + set: 2, to: 2, from: 2, fromTo: 3, add: 1, addLabel: 1, call: 2, +}; + +/** Timing tolerance shared with the scene-window checks. */ +const TIMELINE_POSITION_TOLERANCE_SEC = 0.15; +const TIMELINE_POSITION_MAX_REPORTED = 60; + +export type AuthoredAbsolutePosition = { + method: string; + seconds: number; + line: number; + suggestion: string; + /** The scene whose window contains the literal — the one `suggestion` + * offsets from. `scenes` is non-empty by the guard, so there is always one. */ + scene_id: string; +}; + +/** Split one call's top-level arguments, respecting nesting and strings. */ +function splitCallArguments(source: string, openIndex: number): { args: string[]; endIndex: number } | null { + const args: string[] = []; + let depth = 0; + let quote = ''; + let current = ''; + for (let i = openIndex; i < source.length; i++) { + const ch = source[i]; + if (quote) { + current += ch; + if (ch === '\\') { current += source[++i] ?? ''; continue; } + if (ch === quote) quote = ''; + continue; + } + if (ch === '"' || ch === "'" || ch === '`') { quote = ch; current += ch; continue; } + if (ch === '(' || ch === '[' || ch === '{') { + depth++; + if (depth === 1) continue; + } else if (ch === ')' || ch === ']' || ch === '}') { + depth--; + if (depth === 0) { args.push(current.trim()); return { args, endIndex: i }; } + } else if (ch === ',' && depth === 1) { + args.push(current.trim()); + current = ''; + continue; + } + if (depth >= 1) current += ch; + } + return null; +} + +/** + * Timeline positions written as absolute seconds instead of `S(id)` offsets. + * + * The scene windows these literals encode can be recomputed after the HTML is + * authored (`ovs composition reconcile` rewrites every section's data-start + * when timing changes, e.g. once narration is measured) — the scaffold's own + * reveal follows because it is positioned from S(id), but an authored literal + * keeps playing against the old window. The checker knows every window, so it + * hands back the exact replacement expression rather than only the complaint. + */ +export function authoredAbsoluteTimelinePositions( + html: string, + scenes: { id: string; start: number; duration: number }[], +): AuthoredAbsolutePosition[] { + const found: AuthoredAbsolutePosition[] = []; + if (!scenes.length) return found; + const scriptRe = /]*>([\s\S]*?)<\/script>/gi; + let scriptMatch: RegExpExecArray | null; + while ((scriptMatch = scriptRe.exec(html)) !== null) { + const script = scriptMatch[1]; + const scriptOffset = scriptMatch.index + scriptMatch[0].indexOf(script); + const callRe = /\btl\s*\.\s*(set|to|from|fromTo|add|addLabel|call)\s*\(/g; + let call: RegExpExecArray | null; + while ((call = callRe.exec(script)) !== null) { + const parsed = splitCallArguments(script, call.index + call[0].length - 1); + if (!parsed) continue; + callRe.lastIndex = parsed.endIndex; + const method = call[1]; + const position = parsed.args[TIMELINE_POSITION_ARG_INDEX[method]]; + if (!position) continue; + // `S(id) + 0.2` is the offset form this check exists to promote, and a + // string position ("+=1", "<", a label) is relative to another tween + // rather than to the timeline, so both survive a retime unchanged. + if (/\b[SD]\s*\(/.test(position) || /^["'`]/.test(position)) continue; + const literals = (position.match(/(? value > TIMELINE_POSITION_TOLERANCE_SEC); + if (seconds === undefined) continue; + const owner = scenes.find((scene) => seconds >= scene.start && seconds < scene.start + scene.duration) + || scenes[scenes.length - 1]; + const offset = Math.round((seconds - owner.start) * 1000) / 1000; + found.push({ + method, + seconds, + line: html.slice(0, scriptOffset + call.index).split('\n').length, + suggestion: offset === 0 + ? `S(${JSON.stringify(owner.id)})` + : `S(${JSON.stringify(owner.id)}) + ${offset}`, + scene_id: owner.id, + }); + if (found.length >= TIMELINE_POSITION_MAX_REPORTED) return found; + } + } + return found; +} + export async function referenceFidelityAssetIssues( contract: unknown, compositionDirAbs: string, @@ -1136,6 +1364,13 @@ function extractShotlistShots(value: unknown): Array> { return []; } +/** Is this file actually a shotlist, by its own shape? The tolerant extractor + * above also accepts `{scenes:[...]}` so real legacy files keep working once + * activated — but activation itself must not key off that fallback. */ +function isLegacyShotlist(value: unknown): boolean { + return Array.isArray(value) || (isRecord(value) && Array.isArray(value.shots)); +} + function sceneLabel(scene: Record, index: number): string { return shortText(scene.id || scene.title || scene.headline || scene.name || `scene-${index + 1}`, 80); } @@ -1476,16 +1711,17 @@ export async function runContractHtmlQa( }); } + const htmlContainsCopy = htmlCopySearch(meta.html); const cover = isRecord(contract) && isRecord(contract.cover) ? contract.cover : null; if (cover) { const expectedHeadline = normalizeForSearch(cover.headline); - if (expectedHeadline && !normalizeForSearch(meta.html).includes(expectedHeadline)) { + if (expectedHeadline && !htmlContainsCopy(expectedHeadline)) { issues.push({ code: 'COVER_HEADLINE_NOT_VISIBLE', severity: 'error', selector: 'index.html', message: 'The approved cover headline is not rendered in the frame-0 composition HTML.', - fixHint: 'Render the approved cover headline in a visible data-role="title" element at 0s.', + fixHint: 'Render the approved cover headline in a visible data-role="title" element at 0s; it may run across consecutive title lines.', source: 'ovs-cover-contract', }); } @@ -1497,16 +1733,39 @@ export async function runContractHtmlQa( .map((match) => normalizeForSearch(match[1] ?? match[2] ?? '')) .filter(Boolean), ); - const matchedSignalCount = new Set( - expectedSignals.filter((signal) => visibleSignals.has(signal)), - ).size; + // A declared signal the frame actually renders as readable copy IS + // visible, whatever identifier sits in data-cover-signal — requiring the + // marker verbatim sent repair passes into renaming attributes instead of + // designing a second signal. A signal that only restates the headline is + // not a second signal, marked or not. + const headlineOnlySignals: string[] = []; + const unmatchedSignals: string[] = []; + let matchedSignalCount = 0; + for (const signal of new Set(expectedSignals)) { + if (expectedHeadline && expectedHeadline.includes(signal)) { + headlineOnlySignals.push(signal); + continue; + } + if (visibleSignals.has(signal) || htmlContainsCopy(signal)) { + matchedSignalCount += 1; + continue; + } + unmatchedSignals.push(signal); + } if (expectedSignals.length >= 2 && matchedSignalCount < 2) { + const detail = [ + unmatchedSignals.length ? `not on the frame: ${unmatchedSignals.slice(0, 4).join(' | ')}` : '', + headlineOnlySignals.length ? `headline-only (does not count as a second signal): ${headlineOnlySignals.slice(0, 4).join(' | ')}` : '', + ].filter(Boolean).join('; '); issues.push({ code: 'COVER_CONTENT_SIGNALS_NOT_VISIBLE', - severity: 'error', + // Advisory: cover ambition is designed for because it makes the video + // open well, not because a checker bounces it — the user reviews the + // cover at the preview. + severity: 'warning', selector: 'index.html', - message: `Frame-0 HTML maps ${matchedSignalCount} of ${expectedSignals.length} declared cover content signals; at least two are required.`, - fixHint: 'Mark two topic-specific frame-0 elements with data-cover-signal values copied from the cover contract.', + message: `Frame-0 renders ${matchedSignalCount} of ${expectedSignals.length} declared cover content signals${detail ? ` — ${detail}` : ''}.`, + fixHint: 'Put each declared signal on the frame as readable copy, and add data-cover-signal only to an element carrying no readable text of its own. A signal must say something the headline does not.', source: 'ovs-cover-contract', }); } @@ -1515,7 +1774,7 @@ export async function runContractHtmlQa( if (!coverHero) { issues.push({ code: 'COVER_HERO_NOT_DECLARED', - severity: 'error', + severity: 'warning', selector: 'index.html', message: 'The frame-0 composition has no declared video-scale cover hero.', fixHint: 'Mark the dominant topic-specific visual with data-role="visual" and data-cover-hero.', @@ -1592,11 +1851,35 @@ export async function runContractHtmlQa( prevEnd = Math.max(prevEnd, start + sceneDuration); }); - const htmlSearch = normalizeForSearch(meta.html); + const sceneWindows = scenes + .map((scene, index) => ({ + id: sceneId(scene) || sceneLabel(scene, index), + start: sceneStartSec(scene), + duration: sceneDurationSec(scene), + })) + .filter((scene) => scene.duration > 0); + const absolutePositions = authoredAbsoluteTimelinePositions(meta.html, sceneWindows); + if (absolutePositions.length) { + const replacements = absolutePositions + .slice(0, 12) + .map((p) => `line ${p.line}: tl.${p.method}(..., ${p.seconds}) -> ${p.suggestion}`) + .join('; '); + issues.push({ + code: 'AUTHORED_ABSOLUTE_TIMELINE_SECONDS', + // Advisory: literal positions are only wrong once windows move; they + // become a real defect the moment a retime shifts data-start. + severity: 'warning', + selector: 'index.html', + message: `${absolutePositions.length} timeline position(s) are absolute seconds. Scene windows move when timing is reconciled (e.g. after narration is measured), and a literal then plays against the wrong scene — ${replacements}.`, + fixHint: 'Position tweens from the scaffold\'s S("") / D("") helpers (they read each section\'s data-start/data-duration), or use relative string positions.', + source: 'orkas-native-contract-html', + }); + } + for (const [index, scene] of scenes.slice(0, 16).entries()) { for (const text of flattenSceneText(scene).slice(0, 5)) { const needle = normalizeForSearch(text); - if (needle && !htmlSearch.includes(needle)) { + if (needle && !htmlContainsCopy(needle)) { issues.push({ code: 'HTML_MISSING_SCENE_COPY', severity: 'error', @@ -1636,6 +1919,12 @@ export async function runSourceAlignmentQa(sceneMapLoad: JsonLoad, shotlistLoad: message: `Could not parse shotlist.json: ${shotlistLoad.error}`, source: 'orkas-native-source-alignment', }); + } else if (!isLegacyShotlist(shotlistLoad.value)) { + // Activation needs the artifact's own shape — a bare shot array, or an + // object carrying `shots`. A stray `{scenes:[...]}` scratch file parked + // under the shotlist name must not wake this layer and judge the + // production against a contract nobody signed. + return { ok: true, skipped: true, reason: 'no_legacy_shotlist', issues }; } if (!sceneMapLoad.exists || sceneMapLoad.error || !scenes.length) { issues.push({ @@ -2232,7 +2521,13 @@ export async function writeFrameContactSheet(evidenceDirAbs: string, samples: Fr return out; } -export function summarizeVideoFrameQa(frameEvidence: FrameEvidence | null, durationSec: number): Record { +/** A sampled frame below this contrast is treated as blank. Exported so any + * capture-retry path re-shoots exactly the frames this check would reject — + * two thresholds drift, and the host retries frames QA accepts while + * shipping ones it does not. */ +export const BLANK_FRAME_MAX_CONTRAST = 1.5; + +export function summarizeVideoFrameQa(frameEvidence: FrameEvidence | null, _durationSec: number): Record { const issues: Issue[] = []; const samples = frameEvidence?.samples || []; if (!samples.length) { @@ -2244,7 +2539,7 @@ export function summarizeVideoFrameQa(frameEvidence: FrameEvidence | null, durat }); } for (const sample of samples) { - if (sample.brightness < 4 || sample.brightness > 251 || sample.contrast < 1.5) { + if (sample.brightness < 4 || sample.brightness > 251 || sample.contrast < BLANK_FRAME_MAX_CONTRAST) { issues.push({ code: sample.label === 'first-frame' ? 'EMPTY_HOOK_FRAME' : 'BLANK_SAMPLE_FRAME', severity: 'error', @@ -2253,22 +2548,9 @@ export function summarizeVideoFrameQa(frameEvidence: FrameEvidence | null, durat }); } } - let runStart = 0; - for (let i = 1; i <= samples.length; i += 1) { - const sameAsRun = i < samples.length && samples[i].hash === samples[runStart].hash; - if (sameAsRun) continue; - const runLen = i - runStart; - const span = runLen > 1 ? samples[i - 1].time_seconds - samples[runStart].time_seconds : 0; - if (runLen >= 3 && span >= Math.min(6, Math.max(2, durationSec * 0.35))) { - issues.push({ - code: 'FROZEN_FRAME_RUN', - severity: 'warning', - message: `${runLen} sampled frames are identical across ${round2(span)}s. Review the contact sheet for intentionally static or unsampled local motion.`, - source: 'orkas-native-video-qa', - }); - } - runStart = i; - } + // No frozen-run detection: identical sampled hashes on an intentionally + // static composition are noise, and stillness the user would object to is + // visible on the contact sheet they review at the preview. const errorCount = issues.filter((issue) => issue.severity === 'error').length; return { ok: errorCount === 0, diff --git a/packages/tools/src/render/render.ts b/packages/tools/src/render/render.ts index 5b71d3a..5998854 100644 --- a/packages/tools/src/render/render.ts +++ b/packages/tools/src/render/render.ts @@ -28,6 +28,8 @@ import { isEnvironmentalDraftFailure, recordDraftFailure, recordDraftSuccess, + applyQaFindingWaivers, + qaFindingIsWaivable, runAudioTimingQa, runContractHtmlQa, runSourceAlignmentQa, @@ -88,6 +90,11 @@ export interface DraftParams extends RenderParams { reportPath?: string; findingsPath?: string; frameEvidenceDir?: string; + /** QA finding codes the USER chose to skip after being shown the finding. + * Waivers persist in `/qa/waivers.json`, so later phases report + * the finding as informational and the user is never asked twice. + * Evidence-integrity codes are refused (repaired, not skipped). */ + waive?: string[]; } export type DraftResult = @@ -654,6 +661,44 @@ export async function draft(params: DraftParams): Promise { steps: {}, }; const steps = report.steps as Record; + + // User-granted QA waivers: stored ones persist on the project so the user is + // never asked to skip the same check twice; newly passed codes are recorded. + // Evidence-integrity codes are refused — those are repaired, not skipped. + const waiverPath = join(project, 'qa', 'waivers.json'); + const storedWaivers = await fs.readFile(waiverPath, 'utf8') + .then((text) => JSON.parse(text) as { waived_codes?: unknown }) + .catch(() => null); + const storedCodes = Array.isArray(storedWaivers?.waived_codes) + ? storedWaivers.waived_codes.filter((code): code is string => typeof code === 'string') + : []; + const requestedCodes = (params.waive ?? []).map((code) => code.trim()).filter(Boolean); + const refusedCodes = requestedCodes.filter((code) => !qaFindingIsWaivable(code)); + const waivedCodes = new Set([...storedCodes, ...requestedCodes].filter(qaFindingIsWaivable)); + if (requestedCodes.some((code) => qaFindingIsWaivable(code) && !storedCodes.includes(code))) { + await fs.mkdir(dirname(waiverPath), { recursive: true }); + await fs.writeFile(waiverPath, JSON.stringify({ waived_codes: [...waivedCodes].sort() }, null, 2) + '\n', 'utf8'); + } + if (waivedCodes.size || refusedCodes.length) { + steps.qa_waivers = { + waived_codes: [...waivedCodes].sort(), + ...(refusedCodes.length ? { refused_codes: refusedCodes, refused_reason: 'evidence-integrity findings are repaired, not skipped' } : {}), + }; + } + /** Re-judge one QA step's stored result after user waivers. */ + const waiveQaStep = >(step: T): T => { + const issues = step.issues as Issue[] | undefined; + if (!issues?.length || !waivedCodes.size) return step; + const { issues: next, applied } = applyQaFindingWaivers(issues, waivedCodes); + if (!applied.length) return step; + const errorCount = next.filter((issue) => issue.severity === 'error').length; + const out: Record = { ...step, issues: next, waived_codes: applied }; + if ('error_count' in out) out.error_count = errorCount; + if ('warning_count' in out) out.warning_count = next.filter((issue) => issue.severity === 'warning').length; + if ('ok' in out) out.ok = errorCount === 0; + return out as T; + }; + const repairBudget = await initDraftRepairBudget(project); steps.repair_budget = repairBudget.summary; report.repair_budget = repairBudget.summary; @@ -720,7 +765,10 @@ export async function draft(params: DraftParams): Promise { // A declared contract has to be a usable budget before a render is spent on // it. Repairing the contract is cheap; a draft rendered from a thin one is not. - const designIssues = designContractIssues(contractLoad.value, sceneMapLoad.value, basename(contractLoad.path) || 'composition-manifest.json'); + const designIssues = applyQaFindingWaivers( + designContractIssues(contractLoad.value, sceneMapLoad.value, basename(contractLoad.path) || 'composition-manifest.json'), + waivedCodes, + ).issues; const designErrors = designIssues.filter((issue) => issue.severity === 'error'); steps.design_contract = { ok: designErrors.length === 0, @@ -734,7 +782,7 @@ export async function draft(params: DraftParams): Promise { }, repairBudget); } - const contractHtml = await runContractHtmlQa(loaded.meta, loaded.issues, contractLoad, sceneMapLoad, project); + const contractHtml = waiveQaStep(await runContractHtmlQa(loaded.meta, loaded.issues, contractLoad, sceneMapLoad, project)); steps.contract_html = contractHtml; if (contractHtml.ok === false) { const firstError = ((contractHtml.issues as Issue[] | undefined) || []).find((issue) => issue.severity === 'error'); @@ -744,7 +792,7 @@ export async function draft(params: DraftParams): Promise { }, repairBudget); } - const sourceAlignment = await runSourceAlignmentQa(sceneMapLoad, shotlistLoad); + const sourceAlignment = waiveQaStep(await runSourceAlignmentQa(sceneMapLoad, shotlistLoad)); steps.source_alignment = sourceAlignment; if (sourceAlignment.ok === false) { return failDraft(report, params, 'E_SOURCE_ALIGNMENT_BLOCKED', 'script/shotlist/composition-manifest alignment failed draft QA.', { @@ -753,7 +801,7 @@ export async function draft(params: DraftParams): Promise { }, repairBudget); } - const audioTiming = await runAudioTimingQa(loaded.meta, contractLoad, sceneMapLoad, narrationMapLoad, project); + const audioTiming = waiveQaStep(await runAudioTimingQa(loaded.meta, contractLoad, sceneMapLoad, narrationMapLoad, project)); steps.audio_timing = audioTiming; if (audioTiming.ok === false) { const firstError = ((audioTiming.issues as Issue[] | undefined) || []).find((issue) => issue.severity === 'error'); @@ -819,7 +867,7 @@ export async function draft(params: DraftParams): Promise { steps.media_probe = mediaProbe; if (isRecord(audioNormalize) && 'loudness_after' in audioNormalize) steps.loudness_after = audioNormalize.loudness_after; } - const mediaQa = await buildMediaQa(loaded.meta, mediaProbe); + const mediaQa = waiveQaStep(await buildMediaQa(loaded.meta, mediaProbe)); steps.media_qa = mediaQa; if (mediaQa.ok === false) { return failDraft(report, params, 'E_MEDIA_QA_BLOCKED', 'draft media QA failed.', { @@ -834,7 +882,7 @@ export async function draft(params: DraftParams): Promise { } catch (err) { steps.frame_evidence_error = (err as Error).message; } - const videoQa = summarizeVideoFrameQa(frameEvidence, loaded.meta.durationSec); + const videoQa = waiveQaStep(summarizeVideoFrameQa(frameEvidence, loaded.meta.durationSec)); steps.video_qa = videoQa; if (videoQa.ok === false) { return failDraft(report, params, 'E_VIDEO_QA_BLOCKED', 'video-level QA failed; repair design-contract/scene-map/HTML before Gate D.', { diff --git a/packages/tools/test/cli-smoke.test.ts b/packages/tools/test/cli-smoke.test.ts index 20cc83e..167af35 100644 --- a/packages/tools/test/cli-smoke.test.ts +++ b/packages/tools/test/cli-smoke.test.ts @@ -149,6 +149,23 @@ suite('cli smoke (built ovs + ovs-mcp)', () => { expect(j.total_primary_sec).toBeLessThan(4); }); + it('plan validate prints the summary alongside a passing result', () => { + const plan = join(dir, 'plan-valid.json'); + writeFileSync(plan, JSON.stringify({ + aspect: '16:9', total_target_sec: 6, language: 'en', + delivery_promise: { type: 'compose_led', motion_min_ratio: 0, source_required: false }, + segments: [{ id: 's1', layer: 'primary', source: 'compose', role: 'hook', target_sec: 6, order: 1, spec: { kind: 'title-card' } }], + tracks: {}, + }), 'utf8'); + const r = ovs(['plan', 'validate', plan]); + expect(r.status).toBe(0); + const j = JSON.parse(r.stdout); + expect(j.ok).toBe(true); + // The summary rides the validation result so the model presents the + // host-rendered plan, not a hand-written abstract of it. + expect(String(j.summary)).toContain('Timeline:'); + }); + it('mcp server lists its tools over stdio', async () => { const names = await mcpToolNames(); expect(names.length).toBeGreaterThan(20); diff --git a/packages/tools/test/composition-scaffold.test.ts b/packages/tools/test/composition-scaffold.test.ts index 23a5a8a..f5e8989 100644 --- a/packages/tools/test/composition-scaffold.test.ts +++ b/packages/tools/test/composition-scaffold.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { prepareComposition, reconcileComposition } from '../src/composition/scaffold.js'; import { resolveHyperframesInvocation } from '../src/hyperframes/client.js'; import { + authoredAbsoluteTimelinePositions, loadCompositionMeta, loadDesignContract, loadNarrationMap, @@ -53,8 +54,42 @@ describe('manifest-owned HyperFrames scaffold', () => { expect(next).toContain('Authored visual survives'); expect(next).toContain('class="authored-scene clip"'); expect(next).toContain('data-duration="12"'); - expect(next).toContain('tl.fromTo("#scene-hook .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, 0);'); - expect(next).toContain('tl.fromTo("#scene-payoff .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, 6);'); + // Reveals stay anchored to S(id) — they read the reconciled data-start + // at runtime, so the retime cannot strand them on a literal second. + expect(next).toContain('tl.fromTo("#scene-hook .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, S("hook"));'); + expect(next).toContain('tl.fromTo("#scene-payoff .scene-content", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, S("payoff"));'); + expect(next).toContain('const S = (id)'); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + it('keeps a legacy literal-positioned scaffold literal — S() would reference an undefined helper', async () => { + const project = mkdtempSync(join(tmpdir(), 'ovs-composition-legacy-')); + try { + const manifestPath = join(project, 'composition-manifest.json'); + writeFileSync(manifestPath, JSON.stringify(manifest()), 'utf8'); + // A pre-anchor scaffold: no S()/D() helpers, reveal at a literal second. + writeFileSync(join(project, 'index.html'), [ + '', + '
', + '
', + '
', + '
', + '', + ].join('\n'), 'utf8'); + writeFileSync(manifestPath, JSON.stringify(manifest(12)), 'utf8'); + const reconciled = await reconcileComposition(project); + expect(reconciled).toMatchObject({ ok: true, reconciled: true }); + const next = readFileSync(join(project, 'index.html'), 'utf8'); + expect(next).toContain('data-scene-id="payoff" data-start="6"'); + expect(next).toContain('ease: "power3.out" }, 6);'); + expect(next).not.toContain('S("payoff")'); } finally { rmSync(project, { recursive: true, force: true }); } @@ -75,6 +110,31 @@ describe('manifest-owned HyperFrames scaffold', () => { } }); + it('reports authored absolute timeline seconds with the exact S() replacement', () => { + const scenes = [ + { id: 'hook', start: 0, duration: 5 }, + { id: 'payoff', start: 5, duration: 5 }, + ]; + const html = [ + '', + ].join('\n'); + const found = authoredAbsoluteTimelinePositions(html, scenes); + expect(found).toHaveLength(2); + expect(found[0]).toMatchObject({ method: 'to', seconds: 6.5, scene_id: 'payoff', suggestion: 'S("payoff") + 1.5' }); + // tl.call's position is its THIRD argument, not the second. + expect(found[1]).toMatchObject({ method: 'call', seconds: 5, scene_id: 'payoff', suggestion: 'S("payoff")' }); + }); + + it('has no opinion without scene windows', () => { + expect(authoredAbsoluteTimelinePositions('', [])).toEqual([]); + }); + it('blocks standalone narration until audio is materialized but permits assembler-owned narration', async () => { const project = mkdtempSync(join(tmpdir(), 'ovs-composition-narration-')); try { diff --git a/packages/tools/test/design-contract.test.ts b/packages/tools/test/design-contract.test.ts index f32c542..b9d47d5 100644 --- a/packages/tools/test/design-contract.test.ts +++ b/packages/tools/test/design-contract.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + applyQaFindingWaivers, assertVideoStudioDesignQualityVerdict, compileVideoStudioDesignQualityScorecard, designContractIssues, + designContractReadiness, + htmlCopySearch, + qaFindingIsWaivable, + runSourceAlignmentQa, + type Issue, } from '../src/render/composition-qa'; const FULL = { @@ -157,6 +163,130 @@ describe('designContractIssues', () => { }); }); +describe('designContractIssues — severity calibration and accept-by-meaning', () => { + it('reports generic style language as advisory — authored prose is the user\'s call at the preview', () => { + const issues = designContractIssues({ + ...FULL, + aesthetic: { subject_world: 'sleek modern tech', one_job: 'look premium', aesthetic_risk: 'none really', anti_template_check: 'nothing rejected' }, + }, null); + const generic = issues.find((i) => i.code === 'GENERIC_AESTHETIC_THESIS'); + expect(generic?.severity).toBe('warning'); + }); + + it('names each missing section\'s own required fields, not just the section', () => { + const { aesthetic, visual_direction, ...rest } = FULL; + const issue = designContractIssues(rest, null).find((i) => i.code === 'DESIGN_CONTRACT_BUDGET_INCOMPLETE'); + expect(issue?.message).toContain('aesthetic{subject_world'); + expect(issue?.message).toContain('visual_direction{visual_tradition'); + }); + + it('accepts background/midground/foreground fields — the spelling its own fixHint prescribes', () => { + const issues = designContractIssues({ + ...FULL, + scenes: [{ id: 's1', background: 'lab grid', midground: 'the trace', foreground: 'metadata ticks', motion: 'the trace draws in' }], + }, null); + expect(codes(issues)).not.toContain('SCENE_DEPTH_LAYERS_MISSING'); + expect(codes(issues)).not.toContain('SCENE_MOTION_VERBS_MISSING'); + }); + + it('treats declared-signal count and fidelity-contract completeness as advisory', () => { + const thin = designContractIssues({ ...FULL, cover: { ...FULL.cover, content_signals: ['battery'] } }, null); + expect(thin.find((i) => i.code === 'COVER_CONTENT_SIGNALS_THIN')?.severity).toBe('warning'); + + const fidelity = designContractIssues({ + ...FULL, + references: [{ + id: 'style', media_type: 'image', path: 'assets/references/style.png', intent: 'guide', + roles: ['style'], preserve: ['palette'], may_change: [], target_scene_ids: ['s1'], + }], + reference_fidelity: { mode: 'exact', preserve: ['composition', 'timing'], may_change: ['subject'], layout_anchors: [{ id: 'a' }], verification: { minimum_score: 80 } }, + }, null); + expect(fidelity.find((i) => i.code === 'REFERENCE_EXACT_PRESERVE_THIN')?.severity).toBe('warning'); + expect(fidelity.find((i) => i.code === 'REFERENCE_EXACT_SCORE_FLOOR_LOW')?.severity).toBe('warning'); + }); +}); + +describe('designContractReadiness — the prepare-time hand-off check', () => { + it('is missing with no contract, incomplete with a thin one, ready with a full one', () => { + expect(designContractReadiness(null).status).toBe('missing'); + expect(designContractReadiness({}).status).toBe('missing'); + const { aesthetic, ...thin } = FULL; + expect(designContractReadiness(thin, null).status).toBe('incomplete'); + expect(designContractReadiness(FULL, null).status).toBe('ready'); + }); + + it('catches a thin cover CONTRACT at prepare — that gap is fixable before any HTML exists', () => { + const r = designContractReadiness({ ...FULL, cover: { scene_id: 's1' } }, null); + expect(r.status).toBe('incomplete'); + expect(r.issues.map((i) => i.code)).toContain('COVER_CONTRACT_INCOMPLETE'); + }); +}); + +describe('htmlCopySearch — copy that survives markup', () => { + it('finds a CJK line split across per-word reveal elements', () => { + const contains = htmlCopySearch('
AI 聊过很多次
'); + expect(contains('用AI聊过很多次')).toBe(true); + }); + + it('never claims copy that appears nowhere in the page', () => { + const contains = htmlCopySearch('

team

work

'); + expect(contains('entirely absent line')).toBe(false); + }); + + it('still matches copy carried in attributes and plain text', () => { + const contains = htmlCopySearch('charge curve

Hello world

'); + expect(contains('charge curve')).toBe(true); + expect(contains('hello world')).toBe(true); + }); + + it('does not count copy that only exists in script code', () => { + const contains = htmlCopySearch('

real

'); + expect(contains('ghost copy')).toBe(false); + }); +}); + +describe('runSourceAlignmentQa — legacy shotlist activation', () => { + const load = (value: unknown): { path: string; exists: boolean; value: unknown } => + ({ path: 'shotlist.json', exists: true, value }); + const sceneMap = { path: 'composition-manifest.json', exists: true, value: { scenes: [{ id: 's1', start: 0, duration: 5, source_shots: [] }] } }; + + it('does not wake the retired layer for a {scenes:[...]} file parked under the name', async () => { + const r = await runSourceAlignmentQa(sceneMap, load({ scenes: [{ id: 'x' }] })); + expect(r).toMatchObject({ ok: true, skipped: true, reason: 'no_legacy_shotlist' }); + }); + + it('still activates for a real legacy shotlist shape', async () => { + const r = await runSourceAlignmentQa(sceneMap, load({ shots: [{ id: 'shot-1' }] })); + expect(r.skipped).toBeFalsy(); + }); +}); + +describe('QA waivers — the user may skip a look they accept, never the evidence', () => { + it('refuses evidence-integrity and parse-failure codes', () => { + expect(qaFindingIsWaivable('COVER_HEADLINE_NOT_VISIBLE')).toBe(true); + expect(qaFindingIsWaivable('VIDEO_SAMPLE_FRAMES_MISSING')).toBe(false); + expect(qaFindingIsWaivable('SCENE_MAP_REQUIRED_FOR_SOURCE_ALIGNMENT')).toBe(false); + expect(qaFindingIsWaivable('SHOTLIST_PARSE_FAILED')).toBe(false); + }); + + it('downgrades a waived blocking finding to informational and keeps it in the report', () => { + const issues: Issue[] = [ + { code: 'COVER_HEADLINE_NOT_VISIBLE', severity: 'error', message: 'headline missing' }, + { code: 'HTML_MISSING_SCENE_COPY', severity: 'error', message: 'copy missing' }, + { code: 'VIDEO_SAMPLE_FRAMES_MISSING', severity: 'error', message: 'no frames' }, + { code: 'COVER_HERO_NOT_DECLARED', severity: 'warning', message: 'no hero' }, + ]; + const { issues: next, applied } = applyQaFindingWaivers(issues, ['COVER_HEADLINE_NOT_VISIBLE', 'VIDEO_SAMPLE_FRAMES_MISSING']); + expect(applied).toEqual(['COVER_HEADLINE_NOT_VISIBLE']); + expect(next[0]).toMatchObject({ severity: 'info', waived_by_user: true }); + expect(next[0].message).toContain('[skipped by user decision]'); + // Not waived, not waivable, and non-error findings stay untouched. + expect(next[1].severity).toBe('error'); + expect(next[2].severity).toBe('error'); + expect(next[3].severity).toBe('warning'); + }); +}); + describe('design quality scorecard', () => { const passingScores = { content_alignment: 90, diff --git a/packages/tools/test/edit-coverage.test.ts b/packages/tools/test/edit-coverage.test.ts index 6953b00..98d9a48 100644 --- a/packages/tools/test/edit-coverage.test.ts +++ b/packages/tools/test/edit-coverage.test.ts @@ -15,6 +15,57 @@ describe('edit coverage helpers', () => { expect(timing.voicedEndSec).toBe(3.2); }); + it('reports interior dead air that the reach-only ratio cannot see', () => { + // Narration reaches 58s of a 60s clip (coverageRatio ≈ 0.97) but 30s of + // the middle is silent — the exact half-silent-draft defect. + const r = assessVoiceoverCoverage({ + referenceDurationSec: 60, + voicedStartSec: 0, + voicedEndSec: 58, + audioEndSec: 58, + voicedSpans: [ + { startSec: 0, endSec: 10 }, + { startSec: 40, endSec: 58 }, + ], + }); + expect(r.status).toBe('gapped'); + expect(r.coverageRatio).toBeGreaterThan(0.9); + expect(r.voicedRatio).toBeLessThan(0.5); + expect(r.interiorGaps).toHaveLength(1); + expect(r.maxInteriorGapSec).toBe(30); + expect(r.warnings.join(' ')).toContain('dead air'); + }); + + it('reports colliding line windows as double narration', () => { + const r = assessVoiceoverCoverage({ + referenceDurationSec: 20, + voicedStartSec: 0, + voicedEndSec: 20, + audioEndSec: 20, + voicedSpans: [ + { startSec: 0, endSec: 12 }, + { startSec: 11, endSec: 20 }, + ], + }); + expect(r.status).toBe('overlapped'); + expect(r.overlapCount).toBe(1); + expect(r.maxOverlapSec).toBe(1); + expect(r.warnings.join(' ')).toContain('two lines speak at once'); + }); + + it('keeps head/tail assessment without spans (single-file callers)', () => { + const r = assessVoiceoverCoverage({ + referenceDurationSec: 10, + voicedStartSec: 0, + voicedEndSec: 9.5, + audioEndSec: 9.5, + }); + expect(r.status).toBe('ok'); + expect(r.voicedRatio).toBeCloseTo(0.95, 2); + expect(r.interiorGaps).toEqual([]); + expect(r.overlapCount).toBe(0); + }); + it('flags an uncovered tail and overshoot', () => { const under = assessVoiceoverCoverage({ referenceDurationSec: 10, diff --git a/packages/tools/test/preview-evidence.test.ts b/packages/tools/test/preview-evidence.test.ts index 6f405a4..4d853c7 100644 --- a/packages/tools/test/preview-evidence.test.ts +++ b/packages/tools/test/preview-evidence.test.ts @@ -128,7 +128,7 @@ describe('preview evidence revisions', () => { expect(basename(first).slice(0, 12)).toBe(basename(second).slice(0, 12)); }); - it('treats repeated sampled frames as a review advisory, not proof of failure', () => { + it('does not flag repeated sampled frames — stillness is the user\'s call at the preview', () => { const samples = [0, 4, 8].map((time, index) => ({ label: `frame-${index}`, time_seconds: time, @@ -147,7 +147,10 @@ describe('preview evidence revisions', () => { samples, }, 10); - expect(summary).toMatchObject({ ok: true, error_count: 0, warning_count: 1 }); - expect(JSON.stringify(summary)).toContain('FROZEN_FRAME_RUN'); + // An intentionally static composition produces identical sampled hashes; + // flagging that is noise, and stillness the user would object to is + // visible on the contact sheet they review at the preview. + expect(summary).toMatchObject({ ok: true, error_count: 0, warning_count: 0 }); + expect(JSON.stringify(summary)).not.toContain('FROZEN_FRAME_RUN'); }); }); diff --git a/packages/tools/test/render-draft.test.ts b/packages/tools/test/render-draft.test.ts index bad281d..a9c27e7 100644 --- a/packages/tools/test/render-draft.test.ts +++ b/packages/tools/test/render-draft.test.ts @@ -152,6 +152,57 @@ describe('design contract preflight', () => { }); }); +describe('user QA waivers', () => { + it('moves past a waived blocking finding, refuses integrity codes, and persists the waiver', async () => { + const p = tmpProject('waived-contract'); + try { + writeHtml(p.composition, '
Launch
'); + // Thin contract (design gate blocks) + unmaterialized narration (the + // NEXT local gate) so the run never needs the HyperFrames backend. + writeFileSync(join(p.composition, 'design-contract.json'), JSON.stringify({ + canvas: { width: 1920, height: 1080, duration: 10 }, + scenes: [{ id: 's1', start: 0, duration: 10, headline: 'Launch' }], + }), 'utf8'); + writeSceneMap(p.composition, { + audio: { owner: 'none', tracks: [] }, + scenes: [{ id: 's1', start: 0, duration: 10, headline: 'Launch', narration_text: 'Narrated opening.' }], + }); + + const blocked = await draft({ project: p.composition, output: p.output, reportPath: p.report }); + expect(blocked).toMatchObject({ ok: false, errorCode: 'E_DESIGN_CONTRACT_BLOCKED' }); + + const waived = await draft({ + project: p.composition, + output: p.output, + reportPath: p.report, + waive: [ + 'DESIGN_CONTRACT_BUDGET_INCOMPLETE', + 'COVER_CONTRACT_INCOMPLETE', + 'SCENE_DEPTH_LAYERS_MISSING', + 'SCENE_MOTION_VERBS_MISSING', + 'VIDEO_SAMPLE_FRAMES_MISSING', + ], + }); + // The design gate no longer blocks; the run fails at the NEXT real gate. + expect(waived.errorCode).toBe('E_AUDIO_TIMING_BLOCKED'); + const waiverStep = (waived.report as { steps: Record }).steps.qa_waivers as Record; + expect(waiverStep.refused_codes).toEqual(['VIDEO_SAMPLE_FRAMES_MISSING']); + expect(waiverStep.waived_codes).toContain('DESIGN_CONTRACT_BUDGET_INCOMPLETE'); + + // Persisted: the integrity code was refused, the rest survive the param. + const persisted = JSON.parse(readFileSync(join(p.composition, 'qa', 'waivers.json'), 'utf8')) as { waived_codes: string[] }; + expect(persisted.waived_codes).toContain('SCENE_DEPTH_LAYERS_MISSING'); + expect(persisted.waived_codes).not.toContain('VIDEO_SAMPLE_FRAMES_MISSING'); + + // A later run without the parameter is never re-blocked on a waived code. + const again = await draft({ project: p.composition, output: p.output, reportPath: p.report }); + expect(again.errorCode).toBe('E_AUDIO_TIMING_BLOCKED'); + } finally { + rmSync(p.root, { recursive: true, force: true }); + } + }); +}); + describe('composition draft gate', () => { it('blocks draft from current narration facts before invoking HyperFrames', async () => { const p = tmpProject('draft-narration-facts');