Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,27 @@ 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<version>` 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.
Before any Git commit or PR, run `pnpm verify` on the final candidate. Its real
production E2E must create and decode a three-scene MP4 through the built OVS
CLI, including animation, audio, burned captions and final-delivery checks.
Preserve a sample video and inspect its opening, every scene and ending for
sync/release review; missing runtimes, skipped production cases or failed
checks block submission. A build or container probe alone is insufficient.
After edits, rerun the owning cases and full verification before committing.
Create even a draft PR only
after the requester confirms that concrete result. This does not authorize
merging, tagging, npm publication, or a release.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 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.
- Require a real CLI production before Git commits and PRs: three scenes, animation, audio, burned subtitles, full decoding and delivery checks; retain optional review artifacts.
52 changes: 48 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,19 +231,36 @@ pnpm install
pnpm build # tsc per package (core → tools → cli/mcp)
pnpm test # vitest
pnpm test:video # mock provider round-trip → real playable MP4 → ffprobe
pnpm test:video:e2e # build + real HyperFrames compose→MP4 + built CLI/MCP smoke
pnpm test:video:e2e # build + real CLI production (motion/audio/captions) + CLI/MCP smoke
pnpm benchmark # deterministic core correctness + latency/throughput benchmark
pnpm typecheck
pnpm verify # build + typecheck + unit tests + benchmark + deterministic video test
pnpm verify # all checks, including mandatory real video production
```

`test:video` is deterministic and never spends provider credits: a local fake Seedance endpoint
returns a real H.264 fixture, then OVS downloads it and verifies the result with `ffprobe`.
`test:video:e2e` additionally runs the packaged HyperFrames dependency through `check` and
`render`, validates the resulting 1080p MP4, and exercises the built CLI/MCP surfaces. Both
`test:video:e2e` creates a nine-second, three-scene 1080p production through the
built CLI: composition prepare/reconcile, snapshot, the full draft gate, subtitle
burning and final-file promise checks. It decodes the entire MP4 and checks scene
order, visible motion, audible audio in every scene, burned subtitle pixels and
rejection of a wrong delivery duration. It also exercises the built CLI/MCP surfaces. Both
commands fail with an actionable error when required video runtimes are missing; the ordinary
test suite may skip runtime-heavy cases on machines without ffmpeg or a browser.

Before any Git commit or PR, `pnpm verify` must pass on the final candidate; it
includes the real production case and is also the CI entry point. Test-only
changes follow the same rule. For sync/release review, retain and inspect the
produced video, previews, final frames and reports:

```bash
OVS_VIDEO_EVIDENCE_DIR=../Orkas-VideoStudio-artifacts pnpm test:video:e2e
```

The directory is opt-in and each run uses a unique child directory. Ordinary CI
runs clean up their temporary media. Fixtures use original HTML/SVG and locally
generated instrumental audio; they do not call paid providers or claim TTS/model
quality coverage.

`pnpm benchmark` is zero-key and deterministic. It builds the core package, verifies benchmark
fixtures, then measures gate transitions, EDL validation/delivery summaries, composition-manifest
validation, and narration estimation. Each suite carries a deliberately conservative throughput
Expand All @@ -255,3 +272,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<version>`; 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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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).",
Expand All @@ -17,7 +17,7 @@
"test:video:e2e": "node scripts/test-video.mjs --e2e",
"test:watch": "vitest",
"typecheck": "pnpm -r run typecheck",
"verify": "pnpm build && pnpm typecheck && pnpm test && pnpm benchmark && pnpm test:video"
"verify": "pnpm build && pnpm typecheck && pnpm test && pnpm benchmark && pnpm test:video && pnpm test:video:e2e"
},
"devDependencies": {
"@types/node": "^22.10.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
12 changes: 9 additions & 3 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand All @@ -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({
Expand Down Expand Up @@ -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' },
Expand All @@ -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'],
Expand Down Expand Up @@ -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' },
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/composition/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './manifest.js';
export * from './source-alignment.js';
export * from './timeline.js';
export * from './visual-identity.js';
9 changes: 6 additions & 3 deletions packages/core/src/composition/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type CompositionManifest = {
target_duration?: number;
fps: number;
language?: string;
caption_mode?: string;
};
scenes: CompositionScene[];
audio: {
Expand Down Expand Up @@ -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),
};
Expand Down
Loading
Loading