From b4f40758f33de4ee25f661f2cb45dce59bd8f5f0 Mon Sep 17 00:00:00 2001 From: wyt Date: Thu, 10 Sep 2026 11:17:53 +0800 Subject: [PATCH 1/2] Sync VideoStudio contracts and prepare 2026.9.10 Adapt the frozen Orkas 59a8824 source to portable core/tools contracts and thin CLI/MCP entrypoints. Add measured delivery verification, narration receipts, canonical timing and current-user amendment support; preserve target providers and pinned HyperFrames. Validation: all six required commands passed; full suite with real HyperFrames E2E: 290 passed, 1 skipped. Policy check passed after registering all new owning tests. Prompt audit: - **Routing rule, resident in video-router/orchestration:** distinguish modifications of an existing video's timeline from new multiaxis production; a fully specified deterministic edit needs no redundant direction plan. This is required at task entry, before optional line skills load. - **Authority rule, resident in gate-control:** consume current explicit user instructions, never infer approval from turns, retain pending artifact identity and bound exhausted QA. Native quote/turn validation is dropped because OVS has no host conversation store. Line roots point to this single authority. - **Plan contract, orchestration plus conditional compose authoring:** replace mandatory script/shotlist duplication with one manifest. Retain legacy source-alignment checks for existing artifacts. The root owns the handoff; schema details load only for authoring. - **Visual checklist, composition-design-review:** drop numeric pass-score/submission gates; retain concrete frame evidence, one batched localized repair and user-approved intent. Inspect the complete contact index plus full-size cover/risky frames, with no duplicate static review after unchanged rendering. - **Timing contract, scaffold and compose guidance:** S/D helpers derive animation positions from canonical windows; preserve explicit silence and readable opening. HyperFrames visibility remains the sole runtime owner. Pure identity helpers do not grant authorization by themselves. - **Narration execution guidance, conditional audio/transcript references:** exact executable BYO request, immutable line windows and matching receipt reuse. Preserve existing/lip-synced speech and single audio ownership. Drop managed-voice and native-ledger APIs. Language is receipt metadata for the compatible speech endpoint, not an invented provider parameter. - **Delivery contract, assemble/edit guidance and thin entrypoints:** require real final-file verification for plan-backed delivery. Keep caption and audio evidence limitations visible; do not infer actual delivery from planned durations. Failed or unknown billable outcomes never authorize a new charge. - **Version/PR workflow, AGENTS and README:** retain one calendar version and requester-confirmed PR creation. Resident AGENTS placement is necessary before repository mutation; release details live in the changelog. This rule does not introduce production-video approval requirements. - **Conditional loading:** detailed manifest/art-direction/narration/transcript and AUTO-child instructions are referenced from the owning root and shipped by the existing references glob. Installed effective-instruction tests read these references and enforce the public command boundary. --- AGENTS.md | 17 ++ CHANGELOG.md | 13 ++ README.md | 27 +++ package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/index.ts | 12 +- packages/core/package.json | 2 +- packages/core/src/composition/index.ts | 2 + packages/core/src/composition/manifest.ts | 9 +- packages/core/src/composition/timeline.ts | 102 +++++++++ .../core/src/composition/visual-identity.ts | 57 +++++ packages/core/src/delivery/index.ts | 202 ++++++++++++++++++ packages/core/src/gates/transition.ts | 12 ++ packages/core/src/index.ts | 1 + packages/core/src/ir/edl.ts | 106 ++++++++- .../core/test/composition-identity.test.ts | 22 ++ .../core/test/composition-manifest.test.ts | 15 ++ packages/core/test/delivery.test.ts | 24 +++ packages/core/test/edl.test.ts | 24 +++ packages/core/test/gate-transition.test.ts | 6 + packages/mcp/package.json | 2 +- packages/mcp/src/index.ts | 14 +- .../skills/composition-design-review/SKILL.md | 24 +-- packages/skills/frontend-design/SKILL.md | 8 +- packages/skills/gate-control/SKILL.md | 24 ++- .../references/assembled-productions.md | 9 + packages/skills/orchestration/SKILL.md | 10 +- packages/skills/package.json | 2 +- packages/skills/stage-assemble/SKILL.md | 14 +- packages/skills/stage-compose/SKILL.md | 176 +++------------ .../stage-compose/references/art-direction.md | 27 +++ .../references/manifest-and-authoring.md | 90 ++++++++ .../stage-compose/references/narration.md | 22 ++ packages/skills/stage-edit/SKILL.md | 34 +-- .../transcript-and-screen-grounded-editing.md | 29 +++ packages/skills/stage-plan/SKILL.md | 8 +- packages/skills/test/skills-content.test.ts | 14 +- packages/skills/video-router/SKILL.md | 19 +- packages/tools/package.json | 2 +- packages/tools/src/composition/scaffold.ts | 8 +- packages/tools/src/edit/edit.ts | 72 ++++++- packages/tools/src/index.ts | 1 + packages/tools/src/render/composition-qa.ts | 54 ++++- packages/tools/src/render/delivery.ts | 67 ++++++ packages/tools/src/render/render.ts | 9 +- packages/tools/src/speech/speech.ts | 20 +- packages/tools/test/cli-smoke.test.ts | 10 + .../test/composition-qa-regression.test.ts | 29 +++ .../tools/test/composition-scaffold.test.ts | 4 +- packages/tools/test/delivery.test.ts | 36 ++++ packages/tools/test/edit-coverage.test.ts | 11 + packages/tools/test/gen.test.ts | 17 ++ 52 files changed, 1242 insertions(+), 281 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 packages/core/src/composition/timeline.ts create mode 100644 packages/core/src/composition/visual-identity.ts create mode 100644 packages/core/src/delivery/index.ts create mode 100644 packages/core/test/composition-identity.test.ts create mode 100644 packages/core/test/delivery.test.ts create mode 100644 packages/skills/gate-control/references/assembled-productions.md create mode 100644 packages/skills/stage-compose/references/art-direction.md create mode 100644 packages/skills/stage-compose/references/manifest-and-authoring.md create mode 100644 packages/skills/stage-compose/references/narration.md create mode 100644 packages/skills/stage-edit/references/transcript-and-screen-grounded-editing.md create mode 100644 packages/tools/src/render/delivery.ts create mode 100644 packages/tools/test/composition-qa-regression.test.ts create mode 100644 packages/tools/test/delivery.test.ts diff --git a/AGENTS.md b/AGENTS.md index 674a3a8..de72a08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,20 @@ The CLI is the canonical interface; the MCP tools mirror it 1:1. Run `ovs --help full surface (render / edit / transcribe / narration fit / gate transition / speak / speech-capabilities / image / video / plan). Run `ovs doctor` to check that `ffmpeg`, `ffprobe`, and `node` are available. + +## Versioning and synchronization + +Use one independent calendar version `YYYY.M.D` across root and all workspace +package manifests; `package.json` is the source of truth. Use the Asia/Shanghai +release-candidate date without zero padding (for example `2026.9.10`), as in +OrkasOpen. Keep the MCP-reported version consistent. Do not copy the Orkas app +version or its marketplace Agent version; dependency and schema versions are +independent. Ordinary changes preserve a valid version until preparing a release. +Record release changes in `CHANGELOG.md`; release tags use `v` and an +already published date version must never be overwritten. + +Sync from Orkas by reviewed semantic adaptation on a dedicated branch and a PR +to main. Follow the source repository's `OpenSource/SyncVideoStudio` rules. +Prepare the diff and required verification first. Create even a draft PR only +after the requester confirms that concrete result. This does not authorize +merging, tagging, npm publication, or a release. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e88850 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## 2026.9.10 — prepared, unreleased + +- Adopt independent calendar versions across all packages. +- Sync generation-spec and source-preservation contracts, narration window and + voiced-span checks, and route-independent finished-video verification. +- Preserve explicitly silent composition scenes, caption intent, scene-relative + animation timing and actual rendered frame-rate sampling. +- Reuse speech only with matching request and audio receipts; preserve BYO video + provider additions and the pinned HyperFrames renderer. +- Update routing, canonical composition planning, advisory design review and + current-user revision guidance for the public CLI/MCP workflow. diff --git a/README.md b/README.md index 9093b70..923805b 100644 --- a/README.md +++ b/README.md @@ -255,3 +255,30 @@ MIT — see [`LICENSE`](./LICENSE). Rendering uses the Apache-2.0 licensed [HyperFrames](https://github.com/heygen-com/hyperframes) `0.7.60` dependency; editing and media QA use system `ffmpeg`, while transcription is delegated to HyperFrames/whisper.cpp. See [`PLAN.md`](./PLAN.md) for how third-party runtimes are located and the licensing notes. + +### Versions and delivered-video checks + +All OVS packages share an independent `YYYY.M.D` calendar version, using the +Asia/Shanghai release-candidate date without zero padding. See [CHANGELOG.md](CHANGELOG.md) +for prepared/released changes. This version is independent of the Orkas desktop +application and marketplace Agent versions. Tags, when explicitly released, use +`v`; never overwrite a published date version. + +For an assembled plan, verify the finished file as well as its component footage: + +```bash +ovs plan promise-check project/plan.json --probe-produced --video project/render/video.mp4 +``` + +The equivalent MCP `plan_promise_check` accepts `probe_produced` and `video`. +Delivery verification measures duration, canvas, audio, integrated loudness and +per-line voiced spans. Missing narration evidence fails the check. Caption +warnings require visual confirmation when subtitles were burned in. + +`ovs speak` writes a request/audio receipt beside its output and reuses only an +exact matching request with intact bytes. Include the approved `--language`, +voice, model, speed and format; changing those settings invalidates reuse. + +Synchronization uses a dedicated branch and PR. The diff and verification are +reviewed by the requester before creating the PR; merge and release are separate +explicit actions. diff --git a/package.json b/package.json index 3afdbe2..f9a6df1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orkas-video-studio", - "version": "0.0.0", + "version": "2026.9.10", "private": true, "type": "module", "description": "Drive video composition, generation, and editing from your coding agent (Claude Code / Codex).", diff --git a/packages/cli/package.json b/packages/cli/package.json index a69e9cc..941bf6e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@orkas/video-studio", - "version": "0.0.0", + "version": "2026.9.10", "description": "The `ovs` CLI — drive video compose/edit/transcribe/generate from your coding agent.", "license": "MIT", "type": "module", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a13d8e4..02d00dd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -13,7 +13,7 @@ import { resolveGateTransition, } from '@orkas/video-studio-core'; import type { VideoEdl, Take, QualityThresholds, GateTransitionInput } from '@orkas/video-studio-core'; -import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, collectProducedSec, validatePlanWithProvider } from '@orkas/video-studio-tools'; +import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, verifyProductionDelivery, collectProducedSec, validatePlanWithProvider } from '@orkas/video-studio-tools'; import type { EditProgressEvent } from '@orkas/video-studio-tools'; import { listSkills, readSkill, installSkills, type InstallTarget, type InstallScope } from './skills.js'; @@ -419,6 +419,7 @@ const plan = defineCommand({ meta: { name: 'promise-check', description: 'Deterministic delivery guard; exit 1 on a fail verdict.' }, args: { file: { type: 'positional', required: true }, + video: { type: 'string', description: 'Verify a delivered video against plan timing, canvas, narration and captions.' }, 'probe-produced': { type: 'boolean', description: 'probe each primary segment\'s produced_path and assess the real cut, not the planned target_sec', @@ -429,8 +430,9 @@ const plan = defineCommand({ const plan = readPlan(file) as VideoEdl; const producedSec = args['probe-produced'] ? await collectProducedSec(plan, file) : undefined; const a = assessDelivery(plan, producedSec ? { producedSec } : {}); - printJson(producedSec ? { ...a, produced_sec: producedSec } : a); - if (a.verdict === 'fail') process.exitCode = 1; + const delivery = args.video ? await verifyProductionDelivery(plan, file, String(args.video)) : undefined; + printJson({ ...a, ...(producedSec ? { produced_sec: producedSec } : {}), ...(delivery ? { delivery, verdict: delivery.ok ? a.verdict : 'fail' } : {}) }); + if (a.verdict === 'fail' || delivery?.ok === false) process.exitCode = 1; }, }), 'rank-takes': defineCommand({ @@ -500,6 +502,7 @@ const gate = defineCommand({ artifact: { type: 'string', default: 'unknown', description: 'composition | production' }, gate: { type: 'string', default: 'none', description: 'gate_a | gate_b | gate_c | preview | gate_d' }, decision: { type: 'string', default: 'none', description: 'approve | revise | none' }, + origin: { type: 'string', default: 'unknown', description: 'user | model | unknown; authority for a signed plan revision' }, scope: { type: 'string', default: 'unknown', description: 'visual_only | gate_b_payload | none | 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' }, @@ -513,6 +516,7 @@ const gate = defineCommand({ artifact: String(args.artifact) as GateTransitionInput['artifact'], gate: String(args.gate) as GateTransitionInput['gate'], decision: String(args.decision) as GateTransitionInput['decision'], + origin: String(args.origin) as GateTransitionInput['origin'], scope: String(args.scope) as GateTransitionInput['scope'], recovery: String(args.recovery) as GateTransitionInput['recovery'], recoveryDecision: String(args['recovery-decision']) as GateTransitionInput['recoveryDecision'], @@ -561,6 +565,7 @@ const speak = defineCommand({ args: { text: { type: 'string', required: true }, out: { type: 'string', required: true }, + language: { type: 'string', description: 'Approved narration language tag for the reusable receipt' }, voice: { type: 'string' }, model: { type: 'string' }, format: { type: 'string' }, @@ -571,6 +576,7 @@ const speak = defineCommand({ await speech.speak({ text: String(args.text), output: String(args.out), + language: args.language ? String(args.language) : undefined, voice: args.voice ? String(args.voice) : undefined, model: args.model ? String(args.model) : undefined, format: args.format ? String(args.format) : undefined, diff --git a/packages/core/package.json b/packages/core/package.json index 8502c16..be3b2d5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@orkas/video-studio-core", - "version": "0.0.0", + "version": "2026.9.10", "description": "The plan.json video IR (schema + validator + delivery guard), runtime probing, and config for OrkasVideoStudio.", "license": "MIT", "type": "module", diff --git a/packages/core/src/composition/index.ts b/packages/core/src/composition/index.ts index c9ed601..706ec61 100644 --- a/packages/core/src/composition/index.ts +++ b/packages/core/src/composition/index.ts @@ -1,2 +1,4 @@ export * from './manifest.js'; export * from './source-alignment.js'; +export * from './timeline.js'; +export * from './visual-identity.js'; diff --git a/packages/core/src/composition/manifest.ts b/packages/core/src/composition/manifest.ts index c9e42f6..2426b18 100644 --- a/packages/core/src/composition/manifest.ts +++ b/packages/core/src/composition/manifest.ts @@ -46,6 +46,7 @@ export type CompositionManifest = { target_duration?: number; fps: number; language?: string; + caption_mode?: string; }; scenes: CompositionScene[]; audio: { @@ -207,24 +208,26 @@ export function validateCompositionManifest(value: unknown): CompositionManifest height: readPositive(compositionRaw?.height, 'composition-manifest.json#composition.height', issues, true), duration: readPositive(compositionRaw?.duration, 'composition-manifest.json#composition.duration', issues, false, 600), fps: readPositive(compositionRaw?.fps, 'composition-manifest.json#composition.fps', issues, true, 60), + ...(typeof compositionRaw?.caption_mode === 'string' ? { caption_mode: compositionRaw.caption_mode.trim() } : {}), ...(compositionRaw?.target_duration === undefined ? {} : { target_duration: readPositive(compositionRaw.target_duration, 'composition-manifest.json#composition.target_duration', issues, false, 600) }), ...(compositionRaw?.language === undefined ? {} : typeof compositionRaw.language === 'string' && compositionRaw.language.trim() ? { language: compositionRaw.language.trim() } : (() => { issue(issues, 'COMPOSITION_MANIFEST_SCHEMA_INVALID', 'composition-manifest.json#composition.language', 'Expected a non-empty language string.'); return {}; })()), }; + if (compositionRaw?.caption_mode !== undefined && (typeof compositionRaw.caption_mode !== 'string' || !compositionRaw.caption_mode.trim())) issue(issues, 'COMPOSITION_MANIFEST_SCHEMA_INVALID', 'composition-manifest.json#composition.caption_mode', 'Expected a non-empty caption mode.'); const scenesRaw = Array.isArray(root.scenes) ? root.scenes : []; if (!scenesRaw.length) issue(issues, 'COMPOSITION_MANIFEST_SCHEMA_INVALID', 'composition-manifest.json#scenes', 'Expected at least one scene.'); const scenes = scenesRaw.map((raw, index): CompositionScene => { const item = record(raw); if (!item) issue(issues, 'COMPOSITION_MANIFEST_SCHEMA_INVALID', `composition-manifest.json#scenes.${index}`, 'Expected a scene object.'); const narrationText = item?.narration_text; - if (narrationText !== undefined && (typeof narrationText !== 'string' || !narrationText.trim())) issue(issues, 'COMPOSITION_MANIFEST_SCHEMA_INVALID', `composition-manifest.json#scenes.${index}.narration_text`, 'Expected a non-empty string.'); + if (narrationText !== undefined && typeof narrationText !== 'string') issue(issues, 'COMPOSITION_MANIFEST_SCHEMA_INVALID', `composition-manifest.json#scenes.${index}.narration_text`, 'Expected a string; an explicit empty string marks a silent scene.'); return { id: readIdentifier(item?.id, `composition-manifest.json#scenes.${index}.id`, issues), start: readNonnegative(item?.start, `composition-manifest.json#scenes.${index}.start`, issues), duration: readPositive(item?.duration, `composition-manifest.json#scenes.${index}.duration`, issues), approved_copy: readStringList(item?.approved_copy ?? [], `composition-manifest.json#scenes.${index}.approved_copy`, issues), - narration_refs: readStringList(item?.narration_refs ?? [], `composition-manifest.json#scenes.${index}.narration_refs`, issues), - ...(typeof narrationText === 'string' && narrationText.trim() ? { narration_text: narrationText.trim() } : {}), + narration_refs: typeof narrationText === 'string' && !narrationText.trim() ? [] : readStringList(item?.narration_refs ?? [], `composition-manifest.json#scenes.${index}.narration_refs`, issues), + ...(typeof narrationText === 'string' ? { narration_text: narrationText.trim() } : {}), source_shots: readStringList(item?.source_shots ?? [], `composition-manifest.json#scenes.${index}.source_shots`, issues), roles: readStringList(item?.roles ?? [], `composition-manifest.json#scenes.${index}.roles`, issues), }; diff --git a/packages/core/src/composition/timeline.ts b/packages/core/src/composition/timeline.ts new file mode 100644 index 0000000..d93895b --- /dev/null +++ b/packages/core/src/composition/timeline.ts @@ -0,0 +1,102 @@ +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 in inspect. */ +const TIMELINE_POSITION_TOLERANCE_SEC = 0.15; + +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 are recomputed from the measured + * narration audio, and that can happen after the HTML is authored — a TTS + * retry reaches `materialize_narration` on an already-authored file. On + * 2026-08-08 that left 46 literals pointing one scene off; the model spent 11 + * minutes and 13 round trips transcribing new ones by hand and did not finish. + * The host knows every window, so it can hand 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, + }); + } + } + return found; +} diff --git a/packages/core/src/composition/visual-identity.ts b/packages/core/src/composition/visual-identity.ts new file mode 100644 index 0000000..3ad654b --- /dev/null +++ b/packages/core/src/composition/visual-identity.ts @@ -0,0 +1,57 @@ +export function visualProjectionOfCompositionManifest(raw: unknown): string { + const canonical = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonical); + if (!value || typeof value !== 'object') return value; + const out: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + out[key] = canonical((value as Record)[key]); + } + return out; + }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return JSON.stringify(raw ?? null); + const manifest = raw as Record; + const projected: Record = { ...manifest }; + delete projected.audio; + if (manifest.composition && typeof manifest.composition === 'object' && !Array.isArray(manifest.composition)) { + const composition = { ...(manifest.composition as Record) }; + delete composition.target_duration; + projected.composition = composition; + } + if (Array.isArray(manifest.scenes)) { + projected.scenes = manifest.scenes.map((scene) => { + if (!scene || typeof scene !== 'object' || Array.isArray(scene)) return scene; + const visual = { ...(scene as Record) }; + delete visual.narration_text; + delete visual.narration_refs; + return visual; + }); + } + return JSON.stringify(canonical(projected)); +} + +export function normalizeCompositionHtmlForVisualIdentity(html: string): string { + let next = html; + // The protected composition root always starts at zero. Older authored + // files may omit that default and reconciliation materializes it; absence + // versus explicit zero cannot change a frame. + next = next.replace( + /<([a-z][\w:-]*)\b[^>]*\bdata-composition-id=(?:"[^"]*"|'[^']*')[^>]*>/gi, + (tag) => /\sdata-start=(?:"[^"]*"|'[^']*')/i.test(tag) ? tag.replace(/\sdata-start=(?:"[^"]*"|'[^']*')/i, ' data-start="0"') : tag.replace(/>$/, ' data-start="0">'), + ); + // Declarative audio elements are runtime-owned and invisible to the preview, + // so a file that carries one and a file that never did must normalize to the + // same text. An element on its own line takes that whole line — its own + // indentation and its newline — and an inline one is simply removed, leaving + // the surrounding text spacing alone. The earlier rule collapsed the element + // together with ALL adjacent whitespace into one newline, which swallowed the + // FOLLOWING line's indentation: removing an audio track then re-hashed as a + // visual change over two spaces. On 2026-09-01 that dropped the user's + // keyframe go-ahead and asked them to re-approve a byte-identical contact + // sheet. The previous fixture had no indentation at all, so it could not see + // this; identity must not hinge on insertion residue. + const audioElement = ']*\\bdata-start=(?:"[^"]*"|\'[^\']*\')[^>]*>(?:\\s*<\\/audio>)?'; + next = next.replace(new RegExp(`^[ \\t]*${audioElement}[ \\t]*\\r?\\n`, 'gim'), ''); + next = next.replace(new RegExp(audioElement, 'gi'), ''); + next = next.replace(/\n[ \t]*(?:\n[ \t]*)+/g, '\n'); + return next; +} diff --git a/packages/core/src/delivery/index.ts b/packages/core/src/delivery/index.ts new file mode 100644 index 0000000..39d3bc0 --- /dev/null +++ b/packages/core/src/delivery/index.ts @@ -0,0 +1,202 @@ +// Portable delivery assessment. Measurements are supplied by the tools layer. +/** Same targets the assembly tier normalizes to (video-craft §7). */ +export const DELIVERY_LOUDNESS_TARGET_I = -14; +/** Integrated loudness within this many LU of target is delivery-clean. Wider + * than a mastering tolerance on purpose: platforms renormalize, so this is + * meant to catch "nobody normalized at all", not to grade a mix. */ +export const DELIVERY_LOUDNESS_TOLERANCE_LU = 2; +/** Duration agreement between the delivered file and the signed plan. */ +export const DELIVERY_DURATION_TOLERANCE_SEC = 0.5; +/** Two lines closer than this are treated as touching, not overlapping — + * silencedetect boundaries are not frame-exact. */ +export const DELIVERY_OVERLAP_TOLERANCE_SEC = 0.05; + +export type DeliveryVideoSpec = { + durationSec: number | null; + width: number | null; + height: number | null; + fps: number | null; + hasAudio: boolean; + subtitleStreams: number; +}; + +export type DeliveryNarrationLine = { + index: number; + startSec: number; + targetSec: number | null; + /** Absolute seconds on the delivered timeline where this line's speech + * actually starts and stops — NOT the file's byte duration. A provider that + * pads silence would otherwise read as an overlap it does not cause. */ + voicedStartSec: number; + voicedEndSec: number; + textHead: string; +}; + +export type DeliveryIssue = { + code: + | 'DELIVERY_NARRATION_OVERLAP' + | 'DELIVERY_NARRATION_TRUNCATED' + | 'DELIVERY_DURATION_DRIFT' + | 'DELIVERY_ASPECT_MISMATCH' + | 'DELIVERY_NO_AUDIO' + | 'DELIVERY_LOUDNESS_OFF_TARGET' + | 'DELIVERY_CAPTIONS_MISSING' + | 'DELIVERY_NARRATION_UNVERIFIABLE'; + severity: 'error' | 'warning'; + message: string; +}; + +/** Overlapping or truncated speech in the delivered timeline. + * + * Judged on voiced spans, the same way `assessVoiceoverCoverage` judges a mix, + * because a file's duration is not its speech: the 2026-08-10 files happened to + * carry no trailing silence, so duration math agreed by luck, and a provider + * that pads would have produced false overlaps on every line. Interior gaps are + * deliberately NOT reported as issues — a measured survey of 13 plans put a + * usable gap threshold at 6 of 13 firing, so held silence stays the author's + * call. */ +export function assessDeliveredNarration( + lines: readonly DeliveryNarrationLine[], + videoDurationSec: number | null, +): DeliveryIssue[] { + const issues: DeliveryIssue[] = []; + const ordered = [...lines].sort((a, b) => a.voicedStartSec - b.voicedStartSec); + let furthest = ordered[0]; + for (let i = 1; i < ordered.length; i += 1) { + const prev = furthest; + const line = ordered[i]; + const overlap = Math.min(prev.voicedEndSec, line.voicedEndSec) - line.voicedStartSec; + if (line.voicedEndSec > furthest.voicedEndSec) furthest = line; + if (overlap > DELIVERY_OVERLAP_TOLERANCE_SEC) { + issues.push({ + code: 'DELIVERY_NARRATION_OVERLAP', + severity: 'error', + message: `Narration lines ${prev.index} and ${line.index} both speak for ${overlap.toFixed(2)}s` + + ` — line ${prev.index} runs to ${prev.voicedEndSec.toFixed(2)}s and line ${line.index} starts at` + + ` ${line.voicedStartSec.toFixed(2)}s. Shorten line ${prev.index} and re-synthesize it, or move line` + + ` ${line.index} later in the plan.`, + }); + } + } + const last = furthest; + if (last && typeof videoDurationSec === 'number' && videoDurationSec > 0) { + const past = last.voicedEndSec - videoDurationSec; + if (past > DELIVERY_OVERLAP_TOLERANCE_SEC) { + issues.push({ + code: 'DELIVERY_NARRATION_TRUNCATED', + severity: 'error', + message: `Narration line ${last.index} still speaks ${past.toFixed(2)}s after the ${videoDurationSec.toFixed(2)}s` + + ' video ends, so that much of it is missing from the deliverable. Shorten that line and re-synthesize it,' + + ' or extend the video to cover it.', + }); + } + } + return issues; +} + +/** Delivered file against the signed plan: length, canvas, audio presence, + * loudness, and declared captions. */ +export function assessDeliveredSpec(input: { + spec: DeliveryVideoSpec; + planTotalTargetSec: number | null; + planAspect: string | null; + narrationLineCount: number; + captionLineCount: number; + integratedLufs: number | null; + sidecarSubtitleFound: boolean; +}): DeliveryIssue[] { + const issues: DeliveryIssue[] = []; + const { spec } = input; + if (typeof spec.durationSec === 'number' && typeof input.planTotalTargetSec === 'number' + && input.planTotalTargetSec > 0) { + const drift = Math.abs(spec.durationSec - input.planTotalTargetSec); + if (drift > DELIVERY_DURATION_TOLERANCE_SEC) { + issues.push({ + code: 'DELIVERY_DURATION_DRIFT', + severity: 'error', + message: `The delivered video is ${spec.durationSec.toFixed(2)}s but the approved plan is` + + ` ${input.planTotalTargetSec}s (off by ${drift.toFixed(2)}s). Everything timed against the plan —` + + ' narration placement, captions — is judged against the plan length, so this has to agree before delivery.', + }); + } + } + const aspect = parseAspectRatio(input.planAspect); + if (aspect && spec.width && spec.height) { + const delivered = spec.width / spec.height; + if (Math.abs(delivered - aspect) / aspect > 0.02) { + issues.push({ + code: 'DELIVERY_ASPECT_MISMATCH', + severity: 'error', + message: `The delivered video is ${spec.width}x${spec.height} but the approved plan is ${input.planAspect}.`, + }); + } + } + if (input.narrationLineCount > 0 && !spec.hasAudio) { + issues.push({ + code: 'DELIVERY_NO_AUDIO', + severity: 'error', + message: `The plan carries ${input.narrationLineCount} narration line(s) but the delivered file has no audio track.`, + }); + } + if (spec.hasAudio && typeof input.integratedLufs === 'number') { + const off = input.integratedLufs - DELIVERY_LOUDNESS_TARGET_I; + if (Math.abs(off) > DELIVERY_LOUDNESS_TOLERANCE_LU) { + issues.push({ + code: 'DELIVERY_LOUDNESS_OFF_TARGET', + severity: 'warning', + message: `The delivered audio measures ${input.integratedLufs.toFixed(1)} LUFS against a` + + ` ${DELIVERY_LOUDNESS_TARGET_I} LUFS target (${off > 0 ? 'louder' : 'quieter'} by` + + ` ${Math.abs(off).toFixed(1)} LU). Run the assembly loudness step on the final file.`, + }); + } + } + if (input.captionLineCount > 0 && spec.subtitleStreams === 0 && !input.sidecarSubtitleFound) { + issues.push({ + code: 'DELIVERY_CAPTIONS_MISSING', + severity: 'warning', + message: `The plan declares ${input.captionLineCount} caption line(s), and the delivered file has no subtitle` + + ' stream and no sidecar subtitle file beside it. Burned-in captions cannot be detected from the container,' + + ' so if they were burned in say so; otherwise the captions track was not produced.', + }); + } + return issues; +} + +/** `16:9` -> 1.777…; anything unparseable -> null (no aspect claim to check). */ +export function parseAspectRatio(value: string | null | undefined): number | null { + const match = /^\s*(\d+(?:\.\d+)?)\s*[:x/]\s*(\d+(?:\.\d+)?)\s*$/i.exec(String(value ?? '')); + if (!match) return null; + const w = Number(match[1]); + const h = Number(match[2]); + if (!(w > 0) || !(h > 0)) return null; + return w / h; +} + +/** Voiced span of one audio file, in file-relative seconds. + * + * Trailing silence exists only when the final `silence_start` has no matching + * `silence_end`; the pairs in between are ordinary phrase pauses. Reading the + * last `silence_start` as the end of speech reports a 4.26s file as 1.47s of + * audio. */ +export function parseVoicedSpan(stderr: string, durationSec: number): { startSec: number; endSec: number } { + const starts = [...stderr.matchAll(/silence_start:\s*(-?[\d.]+)/g)].map((m) => Number(m[1])); + const ends = [...stderr.matchAll(/silence_end:\s*(-?[\d.]+)/g)].map((m) => Number(m[1])); + const leading = starts.length && ends.length && starts[0] <= 0.001 ? ends[0] : 0; + const trailing = starts.length > ends.length ? Math.max(0, durationSec - starts[starts.length - 1]) : 0; + const startSec = Math.min(Math.max(0, leading), durationSec); + const endSec = Math.max(startSec, durationSec - trailing); + return { startSec, endSec }; +} + +/** Integrated LUFS of a delivered file, or null when it cannot be measured. + * + * Reads the ebur128 SUMMARY, never the running log: that log carries its own + * `I:` and the first line always reads the -70 LUFS gate floor. */ +export function parseIntegratedLufs(stderr: string): number | null { + const at = stderr.toLowerCase().lastIndexOf('summary:'); + if (at < 0) return null; + const match = /\bI:\s*(-?(?:inf|[\d.]+))\s*LUFS/i.exec(stderr.slice(at)); + if (!match || /inf/i.test(match[1])) return null; + const value = Number(match[1]); + return Number.isFinite(value) ? value : null; +} diff --git a/packages/core/src/gates/transition.ts b/packages/core/src/gates/transition.ts index a56dfa2..bedea2d 100644 --- a/packages/core/src/gates/transition.ts +++ b/packages/core/src/gates/transition.ts @@ -14,6 +14,8 @@ export interface GateTransitionInput { gate?: GateName; decision?: GateDecision; scope?: RevisionScope; + /** Authority supplied by the caller; never inferred from plan prose. */ + origin?: 'user' | 'model' | 'unknown'; recovery?: RecoveryState; recoveryDecision?: RecoveryDecision; artifactState?: ArtifactState; @@ -36,6 +38,7 @@ const VALID = { artifact: new Set(['unknown', 'composition', 'production']), gate: new Set(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']), decision: new Set(['none', 'approve', 'revise']), + origin: new Set(['user', 'model', 'unknown']), scope: new Set(['unknown', 'none', 'visual_only', 'gate_b_payload']), recovery: new Set(['unknown', 'available', 'not_available']), recoveryDecision: new Set(['none', 'new_visual_revision', 'pause']), @@ -95,6 +98,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', @@ -105,6 +109,7 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi assertEnum('artifact', input.artifact, VALID.artifact); assertEnum('gate', input.gate, VALID.gate); assertEnum('decision', input.decision, VALID.decision); + assertEnum('origin', input.origin, VALID.origin); assertEnum('scope', input.scope, VALID.scope); assertEnum('recovery', input.recovery, VALID.recovery); assertEnum('recoveryDecision', input.recoveryDecision, VALID.recoveryDecision); @@ -118,6 +123,13 @@ export function resolveGateTransition(raw: GateTransitionInput = {}): GateTransi // 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') { + if (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: ['emit_form', ...NO_VISUAL_RESET], + reason: 'The current user specified the bounded plan change. Apply and validate exactly that instruction; additional model-proposed changes require review.', + }); return result({ nextAction: 'open_gate_b_amendment', authorities: ['edit_current_artifact'], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 83ab070..97f4c04 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,3 +5,4 @@ export * from './decide/index.js'; export * from './narration/index.js'; export * from './gates/index.js'; export * from './composition/index.js'; +export * from './delivery/index.js'; diff --git a/packages/core/src/ir/edl.ts b/packages/core/src/ir/edl.ts index cd79c27..909ea16 100644 --- a/packages/core/src/ir/edl.ts +++ b/packages/core/src/ir/edl.ts @@ -59,6 +59,22 @@ export const VIDEO_EDIT_DECISION_SIGNALS: readonly VideoEditDecisionSignal[] = [ export type VariationType = 'small' | 'medium' | 'large'; export const VARIATION_TYPES: readonly VariationType[] = ['small', 'medium', 'large']; +const COMMON_GENERATE_SPEC_FIELDS = [ + 'prompt', 'media_kind', 'aspect', 'ratio', 'variation_type', 'characters', 'refs', +] as const; +export const GENERATE_SPEC_ALLOWED_FIELDS = { + image: [ + ...COMMON_GENERATE_SPEC_FIELDS, + 'size', 'reference_images', 'reference_image_urls', + ], + video: [ + ...COMMON_GENERATE_SPEC_FIELDS, + 'operation', 'generation_duration_sec', 'resolution', 'quality', 'generate_audio', + 'reference_image_urls', 'reference_image_paths', 'reference_video_urls', 'reference_video_paths', + ], +} as const; +const GENERATE_SPEC_REJECTED_ALIASES = new Set(['duration_sec', 'audio']); + export interface DeliveryPromise { type: DeliveryPromiseType; /** Hard requirement: the deliverable must contain real source footage. */ @@ -115,9 +131,8 @@ export interface VideoEditStrategy { may_change: string[]; } -/** Per-source `spec` is intentionally open (`Record`): the - * validator only enforces the identifying field each source needs to be - * executable, and leaves the rest to the stage skills. */ +/** Generate specs are a closed request contract; other sources keep their + * stage-owned extensions. The public ratio field remains supported. */ export interface EdlSegment { id: string; order: number; @@ -226,6 +241,45 @@ const isObject = (v: unknown): v is Record => const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); const isStr = (v: unknown): v is string => typeof v === 'string' && v.length > 0; +type SourceSegmentLike = { id?: unknown; source?: unknown; spec?: unknown }; +type SourceReferenceLike = { + source?: unknown; + media_type?: unknown; + intent?: unknown; + required?: unknown; + target_segment_ids?: unknown; +}; + +/** A semantic edit is billable and therefore uses `source: generate`, but it + * still preserves source footage when its input is bound by the EDL's signed + * top-level edit reference. Ordinary generation never satisfies this test. */ +function isSourceBackedPrimary( + segment: SourceSegmentLike, + references: SourceReferenceLike[], +): boolean { + if (segment.source === 'edit') return true; + if (segment.source === 'provided' && isObject(segment.spec) && segment.spec.kind === 'video') return true; + if (segment.source !== 'generate' + || !isStr(segment.id) + || !isObject(segment.spec) + || segment.spec.media_kind !== 'video' + || segment.spec.operation !== 'edit') { + return false; + } + + const sources = [ + ...(Array.isArray(segment.spec.reference_video_paths) ? segment.spec.reference_video_paths : []), + ...(Array.isArray(segment.spec.reference_video_urls) ? segment.spec.reference_video_urls : []), + ].filter(isStr); + return sources.some((source) => references.some((reference) => + reference.source === source + && reference.media_type === 'video' + && reference.intent === 'edit' + && reference.required === true + && Array.isArray(reference.target_segment_ids) + && reference.target_segment_ids.includes(segment.id))); +} + /** * Validate a parsed plan.json against the EDL contract. Returns every issue * found (does not short-circuit) so the agent can fix the plan in one pass. @@ -351,6 +405,7 @@ export function validateEdl(obj: unknown): EdlValidation { // --- media references + editing intent ---------------------------------- const referenceIds = new Set(); const referenceSources = new Map>(); + const references: Array> = []; if (obj.references !== undefined) { if (!Array.isArray(obj.references) || obj.references.length === 0) { err('references', 'E_REFERENCES_INVALID', 'references must be a non-empty array when present'); @@ -362,6 +417,7 @@ export function validateEdl(obj: unknown): EdlValidation { err(at, 'E_REFERENCE_INVALID', 'reference must be an object'); continue; } + references.push(reference); if (!isStr(reference.id)) { err(`${at}.id`, 'E_REFERENCE_ID', 'reference id is required'); } else if (referenceIds.has(reference.id)) { @@ -370,7 +426,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 +461,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 +532,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(' | ')}`); } } } @@ -543,13 +599,12 @@ export function validateEdl(obj: unknown): EdlValidation { // --- promise vs. segments consistency ----------------------------------- if (isObject(promise) && segments.length > 0) { const primaries = segments.filter((s) => s.layer === 'primary'); - const hasSource = primaries.some((s) => s.source === 'edit' - || (s.source === 'provided' && isObject(s.spec) && s.spec.kind === 'video')); + const hasSource = primaries.some((segment) => isSourceBackedPrimary(segment, references)); if (promise.source_required === true && !hasSource) { err( 'delivery_promise.source_required', 'E_PROMISE_NO_SOURCE', - 'source_required is true but no primary segment uses real footage (edit or provided kind=video)', + 'source_required is true but no primary segment uses real footage (edit, provided video, or a reference-bound semantic edit)', ); } if (promise.type === 'compose_led' && !segments.some((s) => s.source === 'compose')) { @@ -619,6 +674,23 @@ 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'); } }); + // target_sec is a duration; overlapping windows mix two voices. + 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'); } @@ -728,6 +800,19 @@ function validateSpec( } else if (spec.media_kind === undefined) { warn(`${at}.spec.media_kind`, 'W_SPEC_GENERATE_KIND_DEFAULT', 'missing media_kind defaults to video; declare it explicitly for Gate C'); } + const generateKind = spec.media_kind === 'image' ? 'image' : 'video'; + const allowedFields: readonly string[] = GENERATE_SPEC_ALLOWED_FIELDS[generateKind]; + const knownRejectedFields = generateKind === 'image' ? new Set(['operation']) : new Set(); + for (const field of Object.keys(spec)) { + if (allowedFields.includes(field) + || GENERATE_SPEC_REJECTED_ALIASES.has(field) + || knownRejectedFields.has(field)) continue; + err( + `${at}.spec.${field}`, + 'E_SPEC_GENERATE_UNKNOWN_FIELD', + `unsupported generate spec field "${field}"; write only ${generateKind} generation fields directly on spec: ${allowedFields.join(', ')}`, + ); + } const referenceFields = spec.media_kind === 'image' ? ['reference_images', 'reference_image_urls'] : ['reference_image_urls', 'reference_image_paths', 'reference_video_urls', 'reference_video_paths']; @@ -894,8 +979,7 @@ export function assessDelivery(edl: VideoEdl, opts: { producedSec?: Record s.source === 'edit' - || (s.source === 'provided' && s.spec?.kind === 'video')); + const sourcePresent = primaries.some((segment) => isSourceBackedPrimary(segment, edl.references ?? [])); // HTML composition motion is checked by inspect/snapshot/draft rather than // this real-footage ratio, so compose-led plans use a zero footage floor. const motionMin = promise.type === 'compose_led' diff --git a/packages/core/test/composition-identity.test.ts b/packages/core/test/composition-identity.test.ts new file mode 100644 index 0000000..17de3be --- /dev/null +++ b/packages/core/test/composition-identity.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest'; +import { visualProjectionOfCompositionManifest, normalizeCompositionHtmlForVisualIdentity, authoredAbsoluteTimelinePositions } from '../src/composition/index.js'; +it('keeps visual identity for audio-only changes, but invalidates changed copy and scene windows', () => { + const base = { composition: { duration: 10, target_duration: 10 }, scenes: [{ id: 'one', start: 0, duration: 10, approved_copy: ['Hello'], narration_text: 'Old', narration_refs: ['old'] }], audio: { tracks: [] } }; + const audio = structuredClone(base); audio.audio.tracks = []; + audio.scenes[0].narration_text = 'New voice text'; audio.scenes[0].narration_refs = ['new']; + expect(visualProjectionOfCompositionManifest(audio)).toBe(visualProjectionOfCompositionManifest(base)); + audio.scenes[0].duration = 9; + expect(visualProjectionOfCompositionManifest(audio)).not.toBe(visualProjectionOfCompositionManifest(base)); + audio.scenes[0].duration = 10; audio.scenes[0].approved_copy = ['Goodbye']; + expect(visualProjectionOfCompositionManifest(audio)).not.toBe(visualProjectionOfCompositionManifest(base)); +}); +it('removing a declarative audio line preserves the indentation of adjacent visual markup', () => { + const html = '
\n

Hello

\n
'; + const withAudio = html.replace(' ', ' \n '); + expect(normalizeCompositionHtmlForVisualIdentity(withAudio)).toBe(normalizeCompositionHtmlForVisualIdentity(html)); +}); +it('provides scene-relative fixes for numeric tween positions without changing existing relative expressions', () => { + const found = authoredAbsoluteTimelinePositions('', [{ id: 'one', start: 0, duration: 5 }, { id: 'two', start: 5, duration: 5 }]); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ seconds: 5.2, suggestion: 'S("two") + 0.2', scene_id: 'two' }); +}); diff --git a/packages/core/test/composition-manifest.test.ts b/packages/core/test/composition-manifest.test.ts index 58627db..0ec47cf 100644 --- a/packages/core/test/composition-manifest.test.ts +++ b/packages/core/test/composition-manifest.test.ts @@ -83,3 +83,18 @@ describe('composition manifest v2', () => { expect(canonical.scenes[0].source_shots).toEqual(['hook', 'shared', 'unknown']); }); }); + +it('clears stale narration refs only for explicitly silent scenes and preserves caption intent', () => { + const value = manifest(); + const scenes = value.scenes as Array>; + scenes[0].narration_text = ' '; + scenes[0].narration_refs = ['stale-line']; + scenes[1].narration_refs = ['legacy-line']; + value.audio = { owner: 'assembler', tracks: [] }; + (value.composition as Record).caption_mode = 'burned'; + const parsed = validateCompositionManifest(value); + expect(parsed.ok).toBe(true); + expect(parsed.data?.scenes[0]).toMatchObject({ narration_text: '', narration_refs: [] }); + expect(parsed.data?.scenes[1].narration_refs).toEqual(['legacy-line']); + expect(parsed.data?.composition.caption_mode).toBe('burned'); +}); diff --git a/packages/core/test/delivery.test.ts b/packages/core/test/delivery.test.ts new file mode 100644 index 0000000..2b2be8c --- /dev/null +++ b/packages/core/test/delivery.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest'; +import { assessDeliveredNarration, assessDeliveredSpec, parseIntegratedLufs, parseVoicedSpan, type DeliveryNarrationLine } from '../src/delivery/index.js'; +const line = (index: number, start: number, end: number): DeliveryNarrationLine => ({ index, startSec: start, targetSec: end - start, voicedStartSec: start, voicedEndSec: end, textHead: 'Line' }); +it('checks voiced overlap and truncation while leaving intentional silence alone', () => { + expect(assessDeliveredNarration([line(0, 0, 3), line(1, 7, 10)], 10)).toEqual([]); + expect(assessDeliveredNarration([line(0, 0, 6), line(1, 5.5, 11)], 10).map((x) => x.code)).toEqual(['DELIVERY_NARRATION_OVERLAP', 'DELIVERY_NARRATION_TRUNCATED']); +}); +it('compares real duration, aspect, audio, loudness and caption evidence with the plan', () => { + const args = { spec: { durationSec: 12, width: 1920, height: 1080, fps: 30, hasAudio: false, subtitleStreams: 0 }, planTotalTargetSec: 10, planAspect: '9:16', narrationLineCount: 2, captionLineCount: 2, integratedLufs: -18, sidecarSubtitleFound: false }; + expect(assessDeliveredSpec(args).map((x) => x.code)).toEqual(['DELIVERY_DURATION_DRIFT', 'DELIVERY_ASPECT_MISMATCH', 'DELIVERY_NO_AUDIO', 'DELIVERY_CAPTIONS_MISSING']); + expect(assessDeliveredSpec({ ...args, spec: { ...args.spec, hasAudio: true }, sidecarSubtitleFound: true }).map((x) => x.code)).toContain('DELIVERY_LOUDNESS_OFF_TARGET'); +}); +it('reads only the final integrated summary and preserves speech after interior pauses', () => { + expect(parseIntegratedLufs('t:0 I: -70 LUFS\nSummary:\nI: -19 LUFS\nSummary:\nI: -14.1 LUFS')).toBe(-14.1); + expect(parseIntegratedLufs('I: -70 LUFS')).toBeNull(); + expect(parseVoicedSpan('silence_start: 0\nsilence_end: 0.2\nsilence_start: 1.5\nsilence_end: 2', 4)).toEqual({ startSec: 0.2, endSec: 4 }); + expect(parseVoicedSpan('silence_start: 3', 4)).toEqual({ startSec: 0, endSec: 3 }); +}); + +it('does not lose an overlong voice behind later nested short lines', () => { + const issues = assessDeliveredNarration([line(0, 0, 12), line(1, 1, 2), line(2, 3, 4)], 10); + expect(issues.filter((i) => i.code === 'DELIVERY_NARRATION_OVERLAP')).toHaveLength(2); + expect(issues.find((i) => i.code === 'DELIVERY_NARRATION_TRUNCATED')?.message).toContain('line 0'); +}); diff --git a/packages/core/test/edl.test.ts b/packages/core/test/edl.test.ts index 58d5717..ad48e37 100644 --- a/packages/core/test/edl.test.ts +++ b/packages/core/test/edl.test.ts @@ -301,6 +301,14 @@ describe('validateEdl — promise consistency', () => { }); expect(validateEdl(value).errors).toEqual([]); + value.delivery_promise.source_required = true; + expect(validateEdl(value).errors).toEqual([]); + expect(assessDelivery(value).source_present).toBe(true); + value.references![0].required = false; + expect(codes(validateEdl(value).errors)).toContain('E_PROMISE_NO_SOURCE'); + expect(assessDelivery(value).source_present).toBe(false); + value.references![0].required = true; + delete value.edit_strategy; expect(codes(validateEdl(value).errors)).toContain('E_SEMANTIC_EDIT_STRATEGY_REQUIRED'); @@ -457,3 +465,19 @@ describe('summarizeEdl', () => { expect(summarizeEdl(edl)).toContain('voice=nova (openai-compatible) · language=en-US · speed=1'); }); }); + +it('rejects overlapping signed narration windows but allows touching lines', () => { + const value = plan({ tracks: { narration: { synthesis: { route_ref: 'openai-compatible', voice: 'nova', language: 'en', speed: 1 }, segments: [ + { text: 'First', start_sec: 0, target_sec: 6 }, { text: 'Second', start_sec: 5, target_sec: 4 }, + ] } } }); + expect(codes(validateEdl(value).errors)).toContain('E_NARRATION_WINDOWS_OVERLAP'); + value.tracks.narration!.segments[1].start_sec = 6; + expect(codes(validateEdl(value).errors)).not.toContain('E_NARRATION_WINDOWS_OVERLAP'); +}); +it('closes generate specs without closing compose extension fields or removing public ratio', () => { + const value = plan({ segments: [seg({ id: 'generated', order: 1, target_sec: 30, source: 'generate', spec: { prompt: 'A river', media_kind: 'video', ratio: '16:9', made_up_provider_option: true } })] }); + expect(validateEdl(value).errors.some((e) => e.message.includes('made_up_provider_option'))).toBe(true); + delete value.segments[0].spec.made_up_provider_option; + expect(validateEdl(value).errors.some((e) => e.path.endsWith('.spec.ratio'))).toBe(false); + expect(validateEdl(plan({ segments: [seg({ id: 'composed', order: 1, target_sec: 30, source: 'compose', spec: { kind: 'title', custom_layout: 'ribbon' } })] })).ok).toBe(true); +}); diff --git a/packages/core/test/gate-transition.test.ts b/packages/core/test/gate-transition.test.ts index f78e2ba..ad71374 100644 --- a/packages/core/test/gate-transition.test.ts +++ b/packages/core/test/gate-transition.test.ts @@ -223,3 +223,9 @@ describe('fact-based production admission', () => { }); }); }); + +it('consumes a user-specified signed amendment without asking for the same change twice', () => { + const request = { line: 'compose', artifact: 'composition', gate: 'gate_d', decision: 'revise', scope: 'gate_b_payload', recovery: 'available' } as const; + expect(resolveGateTransition({ ...request, origin: 'user' })).toMatchObject({ next_action: 'apply_user_instruction_then_approve_plan', form: null }); + for (const origin of ['model', 'unknown'] as const) expect(resolveGateTransition({ ...request, origin }).next_action).toBe('open_gate_b_amendment'); +}); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 19de9da..0689483 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@orkas/video-studio-mcp", - "version": "0.0.0", + "version": "2026.9.10", "description": "MCP server for OrkasVideoStudio — mirrors the `ovs` CLI as typed tools.", "license": "MIT", "type": "module", diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 409b76c..fb4d05d 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -15,7 +15,7 @@ import { resolveGateTransition, } from '@orkas/video-studio-core'; import type { VideoEdl } from '@orkas/video-studio-core'; -import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, collectProducedSec, validatePlanWithProvider } from '@orkas/video-studio-tools'; +import { edit, render as renderTool, composition as compositionTool, analyze, speech, image, video, verifyProductionDelivery, collectProducedSec, validatePlanWithProvider } from '@orkas/video-studio-tools'; import type { EditProgressEvent } from '@orkas/video-studio-tools'; import { listSkills, readSkill } from './skills.js'; @@ -43,7 +43,7 @@ const toStderr = (c: string) => process.stderr.write(c); // never write progress // ffmpeg progress → one JSON line per event on stderr (stdout is the MCP channel). const editProgress = { onProgress: (e: EditProgressEvent) => toStderr(JSON.stringify(e) + '\n') }; -const server = new McpServer({ name: 'orkas-video-studio', version: '0.0.0' }); +const server = new McpServer({ name: 'orkas-video-studio', version: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version }); // --- environment ----------------------------------------------------------- server.tool('ovs_doctor', 'Check that Node.js 22+ and ffmpeg/ffprobe are available.', {}, () => format(runDoctor())); @@ -160,14 +160,15 @@ server.tool('plan_summarize', 'Render a human-readable timeline of a plan.json.' server.tool( 'plan_promise_check', 'Deterministic delivery guard (anti-slideshow); reports a pass/warn/fail verdict. Set probe_produced to assess the REAL produced cut (each primary segment\'s produced_path), not the planned target_sec.', - { file: z.string(), probe_produced: z.boolean().optional() }, - ({ file, probe_produced }) => + { file: z.string(), probe_produced: z.boolean().optional(), video: z.string().optional() }, + ({ file, probe_produced, video: deliveredVideo }) => format( (async () => { const plan = readPlan(file) as VideoEdl; const producedSec = probe_produced ? await collectProducedSec(plan, file) : undefined; const a = assessDelivery(plan, producedSec ? { producedSec } : {}); - return producedSec ? { ...a, produced_sec: producedSec } : a; + const delivery = deliveredVideo ? await verifyProductionDelivery(plan, file, deliveredVideo) : undefined; + return { ...a, ...(producedSec ? { produced_sec: producedSec } : {}), ...(delivery ? { delivery, verdict: delivery.ok ? a.verdict : 'fail' } : {}) }; })(), ), ); @@ -206,6 +207,7 @@ server.tool( artifact: z.enum(['unknown', 'composition', 'production']).optional(), gate: z.enum(['none', 'gate_a', 'gate_b', 'gate_c', 'preview', 'gate_d']).optional(), decision: z.enum(['none', 'approve', 'revise']).optional(), + origin: z.enum(['user', 'model', 'unknown']).optional(), scope: z.enum(['unknown', 'none', 'visual_only', 'gate_b_payload']).optional(), recovery: z.enum(['unknown', 'available', 'not_available']).optional(), recoveryDecision: z.enum(['none', 'new_visual_revision', 'pause']).optional(), @@ -226,7 +228,7 @@ server.tool( server.tool( 'speak', 'Synthesize narration to an audio file via the configured BYO TTS provider.', - { text: z.string(), output: z.string(), voice: z.string().optional(), model: z.string().optional(), format: z.string().optional(), speed: z.number().optional() }, + { text: z.string(), output: z.string(), language: z.string().optional(), voice: z.string().optional(), model: z.string().optional(), format: z.string().optional(), speed: z.number().optional() }, (a) => format(speech.speak(a)), ); server.tool( diff --git a/packages/skills/composition-design-review/SKILL.md b/packages/skills/composition-design-review/SKILL.md index 3bb79c1..8365a5d 100644 --- a/packages/skills/composition-design-review/SKILL.md +++ b/packages/skills/composition-design-review/SKILL.md @@ -1,11 +1,11 @@ --- name: composition-design-review -description: Design review layer for OrkasVideoStudio COMPOSE previews and drafts. Review every immutable snapshot frame before showing a visual preview; when preview is skipped, use the draft as the fallback evidence. Return one complete, actionable repair set without opening another user gate. +description: Design review layer for OrkasVideoStudio COMPOSE previews and drafts. Review the complete snapshot index and inspect the cover and risky frames at full size before showing a visual preview. Return one complete, actionable repair set without opening another user gate. --- # composition-design-review -Use this after `stage-compose` has run `ovs snapshot`, before showing the visual preview. The snapshot's `frame_paths` are the review set: inspect every frame individually. The contact sheet is only an index and never substitutes for full-size frame inspection. +Use this after `stage-compose` has run `ovs snapshot`, before showing the visual preview. The snapshot's `frame_paths` are the complete review set. Read every cell of the contact-sheet index, then open frame zero, QA-named frames and cells with doubtful text, overlap or blankness at full size. Never infer a clean unseen frame. If the visual preview is intentionally skipped, run the same review against representative draft frames after an ok `ovs draft` report. This is a design QA layer, not a renderer, line router, or generic video craft checklist. It does not create a new user gate or approval field. @@ -26,7 +26,7 @@ Read only the relevant artifacts: - Every immutable path in the latest successful snapshot's `frame_paths` - `project/composition/qa/check.json` - For the fallback only: `project/render/draft-report.json` and representative draft frames -- The approved script/shotlist only when a finding depends on message intent +- Legacy approved script/shotlist only when a finding depends on message intent Do not review mutable aliases as if they were frozen evidence. Preserve the reviewed `frame_paths` in the review result so the exact revision is auditable. @@ -42,7 +42,7 @@ Blockers: - Text is unreadable, hides the approved promise/CTA, or materially blocks comprehension because of size, safe-zone, overlap, occlusion, or contrast. - The contract/source/audio/media/video QA says approved scene copy, canvas, assets, runtime dependencies, narration mapping, or sampled frames do not match the model-authored HTML/contract. - Visual language contradicts an explicit style source or ignores required brand tokens. -- A reference image or video loses a declared preserve axis, changes something outside `may_change`, violates an anchor, misses the requested edit, or falls below `reference_fidelity.verification.minimum_score`. +- A reference image or video visibly loses a required preserve axis, changes something outside `may_change`, violates an anchor, or misses the requested edit. - The piece reads as a slideshow when the approved promise was motion graphics. - Motion hides the message, distracts from the focal point, or breaks narration timing. - A protected logo/asset/layout was copied without ownership or permission. @@ -76,18 +76,6 @@ Do not solve design problems by only nudging pixels. If the issue is "too generi After the full review, apply at most one localized repair pass containing the complete blocker set. Then run reconcile when needed, `ovs check`, and `ovs snapshot` again. Review every frame in the new `frame_paths`; never show a partially reviewed revision. -## Output Format +## Output -Return a compact review object or bullets: - -- `verdict`: `passed | repair | blocked` -- `review_scope`: why this review was triggered -- `reviewed_frame_paths`: every immutable frame inspected -- `design_direction`: one line -- `quality_scores`: 0-100 `content_alignment`, `cover_communication`, `hierarchy`, `text_legibility`, `motion_readiness`, and `specificity`; add `reference_fidelity` when a concrete reference contract exists -- `blockers`: all concrete locations + evidence + repairs -- `fixes`: concrete location + repair -- `polish`: optional -- `next_action`: rerun check and snapshot, show the visual preview, continue to draft in the fallback path, or surface a blocker - -A passing review requires an overall score of at least 80 and every required dimension at least 70. A manifest may require a higher reference-fidelity floor; `exact` mode requires at least 85. Keep passing findings empty. If a review payload is malformed but its evidence is still current, correct and resubmit that same review result without rerendering or asking the user. +Keep a compact internal record of the complete reviewed frame set and any concrete blockers, fixes applied, and optional polish. This is the author's advisory checklist: no numeric score or separate verdict submission blocks the next operation. Deterministic QA still owns structural and media failures. Hand current readiness and evidence to `gate-control`; never repeat a full static design review after a visually unchanged draft. diff --git a/packages/skills/frontend-design/SKILL.md b/packages/skills/frontend-design/SKILL.md index 9e71fb2..d6b0f39 100644 --- a/packages/skills/frontend-design/SKILL.md +++ b/packages/skills/frontend-design/SKILL.md @@ -9,9 +9,9 @@ Use this as the design-lead layer for COMPOSE work. It shapes HTML/SVG motion gr This skill does not pick the video production line, replace `video-craft`, or relax HyperFrames/OVS renderer constraints. If there is a conflict, renderer determinism, safe zones, legibility, audio ownership, and user-approved creative direction win. -## Required generation references +## Generation references -For every non-trivial COMPOSE deliverable, read these compact references before authoring HTML: +Read the HTML generation playbook before non-trivial authoring. Load visual primitives when selecting scene grammar, and worked compositions only when examples are useful: - `references/html-generation-playbook.md` — the private pre-code art-direction pass, frame-composition rules, and opening/resolved-state authoring pattern. - `references/visual-primitives.md` — reusable CSS/SVG composition primitives and scene-grammar selection guidance. @@ -83,7 +83,7 @@ Example: For each scene, build the fully readable resolved frame first in static HTML/CSS/SVG. This is the frame where the scene's message, hierarchy, and hero visual are clearest. -Then add GSAP entrances and meaningful reveals from that static state. The CSS/SVG resolved layout is the source of truth; the timeline describes how the viewer arrives there. Do not design a scene by placing elements at their animated start state and hoping the tween lands in a good composition. +The exact frame at zero must already show the message and hero. Add GSAP entrances from a visible base state; do not fade the opening title, hero or whole scene from zero opacity. The CSS/SVG resolved layout is the source of truth; the timeline describes how the viewer arrives there. Do not design a scene by placing elements at their animated start state and hoping the tween lands in a good composition. For every non-trivial scene, internally check before writing tweens: @@ -167,7 +167,7 @@ When writing `index.html`: 3. Run the internal pre-code art-direction pass: choose `VisualDirectionV1`, scene grammar, hero visual, three depth layers, motion verbs, typography register, rhythm pattern, opening/resolved states, and cross-scene continuity. Keep it inside the generation turn; no new user confirmation. 4. Write HTML/SVG from the contract using adapted visual primitives and worked examples as references, not fixed templates. 5. Run `ovs draft ... --quality draft`. If structural, contract, source, audio, media, or sampled-frame QA fails, repair the contract or scene structure first; do not only nudge CSS numbers. Missing preview-required art direction is a blocking contract error, not a cosmetic note: `ovs draft` returns `E_DESIGN_CONTRACT_BLOCKED` until the aesthetic thesis, cover, `VisualDirectionV1`, motion budget, scene variation budget, per-scene depth layers, and per-scene motion verbs are complete. Treat visual/readability findings as draft notes unless they make the approved message unreadable. -6. Judge every returned keyframe rather than only frame 0. Score frame-0 cover communication. Compare reference layout anchors and protected axes side-by-side; for video references compare declared source time ranges to target-scene motion/timing. Judge the requested reproduce/edit/guide outcome, never the reference's origin. +6. Judge every returned keyframe rather than only frame 0. Inspect frame-0 cover communication. Compare reference layout anchors and protected axes side-by-side; for video references compare declared source time ranges to target-scene motion/timing. Judge the requested reproduce/edit/guide outcome, never the reference's origin. ## Output Standard diff --git a/packages/skills/gate-control/SKILL.md b/packages/skills/gate-control/SKILL.md index 205ea1d..6d4163d 100644 --- a/packages/skills/gate-control/SKILL.md +++ b/packages/skills/gate-control/SKILL.md @@ -17,10 +17,10 @@ The names below are internal protocol identifiers. In normal user-facing heading | Internal identifier | English | Simplified Chinese | | --- | --- | --- | -| Gate A | Direction confirmation | 方向确认 | -| Gate B | Production plan confirmation | 制作计划确认 | -| Gate C | Paid generation confirmation | 付费生成确认 | -| HTML Preview | Visual preview | 视觉预览 | +| Gate A | Direction confirmation | 制作方向确认 | +| Gate B | Production plan confirmation | 制作方案确认 | +| Gate C | Paid generation confirmation | 付费素材生成确认 | +| HTML Preview | Keyframe preview | 关键帧预览 | | Gate D | Final video confirmation | 成片确认 | Keep `Gate A/B/C/D` and `HTML Preview` for tool calls, stored state, and technical diagnostics only. Choose the production language from an explicit user request first, then the current UI/user language when known, otherwise English. Normalize Chinese to `zh-CN`, English to `en`, Japanese to `ja`, Portuguese to `pt-BR`, and unsupported languages to `en`; once submitted, keep that choice locked unless the user explicitly changes it. @@ -29,7 +29,7 @@ Every gate shows the current artifact, a concise next-action/cost/QA note, one d | 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 | +| Gate B | canonical composition manifest or `plan.json` summary, including narration profile | `gate_b_decision` | production from that exact plan | | Gate C | exact billable segment count and exact provider settings | `gate_c_decision` | those generation calls only | | HTML Preview | current contact sheet | `preview_decision` | `ovs draft` for that preview | | Gate D | draft video plus QA headline | `gate_d_decision` | high-quality finalization of that draft | @@ -75,13 +75,13 @@ 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`. +Pass `--origin user|model|unknown` for signed amendments, from the actual current user reply. Mixed user instructions plus additional model proposals use `model`. This pure resolver trusts caller-supplied facts; it does not verify chat history or record approvals. 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`. ## 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 originating from a model proposal creates exactly one Gate B amendment. A current user-specified change uses `--origin user`: apply and validate exactly that change without asking for the same instruction again. Its approved signature starts a fresh QA cycle, so recovery from the old signature is irrelevant and must not be combined into the form. - 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. @@ -94,3 +94,13 @@ Optional evidence inputs are `--error-code`, `--artifact-state`, and `--approval 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. + +## Concrete review and recovery + +A pending decision is not permission to ask it again. At a stop, show the complete current artifact once, invite the user's changes or go-ahead, then wait. A numbered choice or paraphrase of a displayed option is a decision; mixed approval plus edits means revise. Never use a new turn or a stale reply as approval. + +Before COMPOSE plan confirmation, write only the canonical composition manifest and run free narration fit for its spoken windows. Script/shotlist files are legacy optional evidence, not a second required plan. Direction choice comes before authoring that manifest. For a fully specified one-shot deterministic edit, probe and execute the user's operation directly. + +After two non-converging repair passes, show the current artifact, the visible unresolved problem and concrete directions for the user to choose. Do not silently create another repair cycle, delete QA state, or treat exhaustion as an unlimited retry. A real requested revision authorizes a bounded new attempt. Do not expose internal counters in ordinary user-facing text. + +For AUTO, read [assembled productions](references/assembled-productions.md) before preview or a child revision. For narration uncertainty, preserve the existing output, request identity and provider outcome. A failed or unknown billable request does not authorize another charge; present the concrete retry choice when new authorization is needed. diff --git a/packages/skills/gate-control/references/assembled-productions.md b/packages/skills/gate-control/references/assembled-productions.md new file mode 100644 index 0000000..1cc0423 --- /dev/null +++ b/packages/skills/gate-control/references/assembled-productions.md @@ -0,0 +1,9 @@ +# Assembled productions are one video + +The parent plan authorizes its child compositions. Children use assembler audio ownership and never ask for individual plan or preview approvals. + +Prepare and check each child, capture its complete frame set, and extract a representative frame for each supplied/generated media segment. Present one complete preview message in playback order, with current frame and media paths; label missing evidence and finish capturing it before asking for go-ahead. OVS does not provide the desktop host's automatic parent contact-sheet or state operations. + +A requested change affects only that child and downstream assembly. Reuse unchanged sibling outputs and evidence. Before preview go-ahead, show the updated complete preview and retain the one parent decision. After go-ahead, apply the named edit and continue to the complete parent draft; its final video is the next stop. Never stop at a repaired child alone. + +All assembly routes must run `ovs plan promise-check project/plan.json --probe-produced --video project/render/video.mp4`. This checks actual delivered media, including narration spans, duration, aspect, audio and caption evidence. Do not claim delivery from a planned duration or an intermediate mix report alone. diff --git a/packages/skills/orchestration/SKILL.md b/packages/skills/orchestration/SKILL.md index 472a2c4..ea6ef09 100644 --- a/packages/skills/orchestration/SKILL.md +++ b/packages/skills/orchestration/SKILL.md @@ -21,7 +21,7 @@ Classify and LOCK the line (no silent switching): - **COMPOSE** — explain / teach / animate / motion-graphics / kinetic text, no source footage → `ovs draft` (+ optional `ovs image` / `ovs video` imagery, optional `ovs speak` narration). - **GENERATE** — "footage of / a scene of / cinematic / a presenter or avatar speaking / talking-head" → AI footage via `ovs video` (+ `ovs image` for the subject, `ovs speak` for voice), assembled with `ovs edit`. - **EDIT** — the user supplied real clips to cut / join / subtitle / localize → `ovs edit` (+ `ovs transcribe` for transcript-driven work). -- **AUTO (end-to-end)** — the deliverable spans MORE THAN ONE axis. Run the cross-modal orchestration (read `stage-plan`, then `stage-assemble`); the lock is the plan's `delivery_promise`. +- **AUTO (end-to-end)** — the primary timeline weaves MORE THAN ONE axis; adding audio/captions to an existing video remains EDIT. Run the cross-modal orchestration (read `stage-plan`, then `stage-assemble`); the lock is the plan's `delivery_promise`. ## 2. GATE A — Proposal (all lines) @@ -43,9 +43,9 @@ TALKING-HEAD note: if a GENERATE clip already returned lip-synced built-in speec ## COMPOSE line -3C. Script + storyboard (ONE step) → `project/script.md` + `project/shotlist.json`; include the design thesis inputs that `frontend-design` will put in manifest `art_direction`. -4C. **GATE B** — Script + storyboard sign-off. Show `script.md` + a shotlist summary. Options: approve / revise / change direction. STOP. -5C. (optional) Narration: after the free fit passes, `ovs speak` once → `project/assets/narration.mp3`, probe/measure it, retime the composition within the approved target, and add it as an `