mirror of
https://github.com/Orkas-AI/Orkas-VideoStudio.git
synced 2026-11-07 06:15:57 -10:00
Require a complete real video production before submission
Upgrade the raw-render smoke case into a nine-second, three-scene production driven through the built OVS CLI. Prepare/reconcile, snapshot, draft, burn captions and verify the delivered file; assert full decoding, scene order, visible motion, audio across all scenes, actual caption pixels and wrong-duration rejection. Preserve optional review artifacts outside the checkout. Wire this case into pnpm verify, already used by CI. It uses local original graphics and instrumental audio without paid services. Video duration is checked independently from AAC tail padding, within the existing delivery tolerance. Validation: the production case and pnpm verify passed before this commit; all six source sync verification commands passed on the same candidate, with executed-case evidence and content-hash stability. Prompt audit: - Keep the pre-commit/PR production requirement in AGENTS as a resident repository workflow rule: a contributor must know it before invoking Git, where an on-demand production skill may never be loaded. The executable requirement belongs to pnpm verify and the sync checker; AGENTS only defines when to run it and inspect evidence. - Keep command details and retained-artifact usage in the on-demand README; no duplicate production approval flow or Agent/Skill instruction is introduced. The user explicitly requested actual test-video production before submission.
This commit is contained in:
1 parent
b4f40758f3
commit
65286ee034
8 files changed
+408
-75
No files matched your search
@@ -42,6 +42,13 @@ 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
|
||||
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.
|
||||
@@ -11,3 +11,4 @@
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
1
|
||||
00:00:00,000 --> 00:00:02,900
|
||||
One plan. A clear story.
|
||||
|
||||
2
|
||||
00:00:03,000 --> 00:00:05,900
|
||||
Three scenes. Real motion.
|
||||
|
||||
3
|
||||
00:00:06,000 --> 00:00:08,900
|
||||
A playable video, verified.
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"composition": {
|
||||
"id": "main",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"duration": 9,
|
||||
"target_duration": 9,
|
||||
"fps": 30,
|
||||
"language": "en",
|
||||
"caption_mode": "burned"
|
||||
},
|
||||
"scenes": [
|
||||
{
|
||||
"id": "plan",
|
||||
"start": 0,
|
||||
"duration": 3,
|
||||
"approved_copy": [
|
||||
"Plan the story"
|
||||
],
|
||||
"narration_text": "",
|
||||
"narration_refs": [],
|
||||
"source_shots": [],
|
||||
"roles": [
|
||||
"hook"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "compose",
|
||||
"start": 3,
|
||||
"duration": 3,
|
||||
"approved_copy": [
|
||||
"Compose the motion"
|
||||
],
|
||||
"narration_text": "",
|
||||
"narration_refs": [],
|
||||
"source_shots": [],
|
||||
"roles": [
|
||||
"body"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deliver",
|
||||
"start": 6,
|
||||
"duration": 3,
|
||||
"approved_copy": [
|
||||
"Deliver the video"
|
||||
],
|
||||
"narration_text": "",
|
||||
"narration_refs": [],
|
||||
"source_shots": [],
|
||||
"roles": [
|
||||
"cta"
|
||||
]
|
||||
}
|
||||
],
|
||||
"audio": {
|
||||
"owner": "composition",
|
||||
"tracks": [
|
||||
{
|
||||
"id": "music",
|
||||
"kind": "music",
|
||||
"src": "assets/music.wav",
|
||||
"start": 0,
|
||||
"duration": 9,
|
||||
"volume": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"art_direction": {
|
||||
"aesthetic": {
|
||||
"subject_world": "video editing timeline and film frames",
|
||||
"one_job": "demonstrate an actual plan-to-video production",
|
||||
"signature_device": "a moving film-frame playhead",
|
||||
"aesthetic_risk": "oversized film geometry instead of UI cards",
|
||||
"anti_template_check": "replace a generic test card with three stages of a film timeline"
|
||||
},
|
||||
"visual_direction": {
|
||||
"visual_tradition": "Swiss editorial film titles",
|
||||
"composition_behavior": "left title and filmstrip, wide motion track, centered final frame",
|
||||
"lazy_defaults_rejected": "no neon dashboard or identical title cards",
|
||||
"video_scale": "1920x1080, 96px titles, 44px supporting labels, 100px safe margin",
|
||||
"depth_layer_rule": "colored field, film geometry, cream title",
|
||||
"motion_verb_rule": "playhead tracks, frames resolve, export frame expands",
|
||||
"rhythm_pattern": "track, assemble, hold"
|
||||
},
|
||||
"cover": {
|
||||
"scene_id": "plan",
|
||||
"headline": "Plan the story",
|
||||
"content_signals": [
|
||||
"Film frames",
|
||||
"Timeline"
|
||||
],
|
||||
"hero_visual": "filmstrip and moving playhead",
|
||||
"composition_strategy": "large filmstrip beneath an editorial title",
|
||||
"frame_time_sec": 0
|
||||
},
|
||||
"layout_boxes": {
|
||||
"title": "x100 y140, 1700x140",
|
||||
"visual": "x120 y380, 1600x450",
|
||||
"caption": "bottom 100px"
|
||||
},
|
||||
"typography_tokens": {
|
||||
"title": "96px bold sans",
|
||||
"body": "44px sans",
|
||||
"caption": "42px"
|
||||
},
|
||||
"color_tokens": {
|
||||
"plan": "#143c4a",
|
||||
"compose": "#4b244a",
|
||||
"deliver": "#244c30",
|
||||
"ink": "#fff4dc",
|
||||
"accent": "#f4ba66"
|
||||
},
|
||||
"motion_budget": {
|
||||
"groups": 3,
|
||||
"verbs": [
|
||||
"track",
|
||||
"resolve",
|
||||
"expand"
|
||||
]
|
||||
},
|
||||
"scene_variation": {
|
||||
"rule": "filmstrip, motion track, delivery frame"
|
||||
},
|
||||
"scenes": [
|
||||
{
|
||||
"id": "plan",
|
||||
"hero_visual": "film timeline",
|
||||
"depth_layers": [
|
||||
"color field",
|
||||
"film geometry",
|
||||
"editorial type"
|
||||
],
|
||||
"motion_verbs": [
|
||||
"track"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "compose",
|
||||
"hero_visual": "film timeline",
|
||||
"depth_layers": [
|
||||
"color field",
|
||||
"film geometry",
|
||||
"editorial type"
|
||||
],
|
||||
"motion_verbs": [
|
||||
"resolve"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "deliver",
|
||||
"hero_visual": "exported film frame",
|
||||
"depth_layers": [
|
||||
"color field",
|
||||
"film geometry",
|
||||
"editorial type"
|
||||
],
|
||||
"motion_verbs": [
|
||||
"expand"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=1920, height=1080">
|
||||
<script src="./assets/vendor/gsap.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body, #root { width: 1920px; height: 1080px; overflow: hidden; }
|
||||
#root { position: relative; color: #fff4dc; font-family: Arial, sans-serif; }
|
||||
.clip { position: absolute; inset: 0; }
|
||||
.field { position: absolute; inset: 0; }
|
||||
#plan .field { background: #143c4a; }
|
||||
#compose .field { background: #4b244a; }
|
||||
#deliver .field { background: #244c30; }
|
||||
h1 { position: absolute; left: 110px; top: 180px; font-size: 96px; line-height: 1.1; }
|
||||
.brand { position: absolute; left: 115px; top: 90px; font-size: 44px; }
|
||||
.label { position: absolute; top: 820px; font-size: 44px; }
|
||||
.visual { position: absolute; left: 120px; top: 375px; width: 1640px; height: 410px; }
|
||||
#deliver h1 { left: 750px; top: 300px; width: 1000px; }
|
||||
#deliver .visual { left: 120px; top: 360px; width: 550px; height: 400px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root" data-composition-id="main" data-start="0" data-duration="9" data-width="1920" data-height="1080" data-fps="30">
|
||||
<section id="plan" class="clip" data-scene-id="plan" data-start="0" data-duration="3" data-track-index="1">
|
||||
<div class="field"></div><div class="brand">Orkas VideoStudio</div><h1 data-role="title">Plan the story</h1>
|
||||
<svg class="visual" data-role="visual" data-cover-hero viewBox="0 0 1640 410" aria-label="filmstrip and timeline">
|
||||
<g fill="none" stroke="#fff4dc" stroke-width="10"><rect x="10" y="20" width="460" height="260" rx="12"/><rect x="590" y="20" width="460" height="260" rx="12"/><rect x="1170" y="20" width="460" height="260" rx="12"/><path d="M0 355 H1640"/></g>
|
||||
<g id="playhead" fill="#f4ba66"><rect x="20" y="310" width="24" height="85"/><path d="M0 305 L64 305 L32 338 Z"/></g>
|
||||
<path fill="#f4ba66" d="M180 80 L180 220 L300 150 Z"/>
|
||||
</svg>
|
||||
<div class="label" style="left:130px" data-cover-signal="Film frames">Film frames</div><div class="label" style="left:1200px" data-cover-signal="Timeline">Timeline</div>
|
||||
</section>
|
||||
<section id="compose" class="clip" data-scene-id="compose" data-start="3" data-duration="3" data-track-index="1">
|
||||
<div class="field"></div><div class="brand">Orkas VideoStudio</div><h1 data-role="title">Compose the motion</h1>
|
||||
<svg class="visual" data-role="visual" viewBox="0 0 1640 410" aria-label="three timed video tracks">
|
||||
<g fill="#fff4dc"><rect x="0" y="10" width="1550" height="8"/><rect x="0" y="390" width="1550" height="8"/></g>
|
||||
<g id="motion-frame" fill="#f4ba66"><rect x="100" y="75" width="330" height="230" rx="12"/></g>
|
||||
<g fill="none" stroke="#fff4dc" stroke-width="8"><rect x="610" y="75" width="330" height="230" rx="12"/><rect x="1120" y="75" width="330" height="230" rx="12"/></g>
|
||||
</svg>
|
||||
<div class="label" style="left:130px">Canonical timing</div><div class="label" style="left:1150px">Visible motion</div>
|
||||
</section>
|
||||
<section id="deliver" class="clip" data-scene-id="deliver" data-start="6" data-duration="3" data-track-index="1">
|
||||
<div class="field"></div><div class="brand">Orkas VideoStudio</div><h1 data-role="title">Deliver the video</h1>
|
||||
<svg class="visual" data-role="visual" viewBox="0 0 550 400" aria-label="completed video frame">
|
||||
<g id="final-frame" fill="none" stroke="#fff4dc" stroke-width="12"><rect x="20" y="30" width="500" height="310" rx="18"/></g>
|
||||
<path fill="#f4ba66" d="M215 105 L215 270 L355 187 Z"/>
|
||||
</svg>
|
||||
<div class="label" style="left:760px;top:510px">Picture. Sound. Captions.</div><div class="label" style="left:760px;top:600px">Made with Orkas VideoStudio</div>
|
||||
</section>
|
||||
<audio id="audio-music" src="./assets/music.wav" data-start="0" data-duration="9" data-track-index="10" data-volume="1"></audio>
|
||||
</main>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const scene = (id) => document.querySelector('[data-scene-id="' + id + '"]');
|
||||
const S = (id) => Number(scene(id).dataset.start);
|
||||
const D = (id) => Number(scene(id).dataset.duration);
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('#playhead', { x: 1510, duration: D('plan') - 0.1, ease: 'none' }, S('plan'));
|
||||
tl.to('#motion-frame', { x: 1020, duration: D('compose') - 0.1, ease: 'power2.inOut' }, S('compose'));
|
||||
tl.fromTo('#final-frame', { scale: 0.82, transformOrigin: '50% 50%' }, { scale: 1, duration: 1.4, ease: 'power2.out' }, S('deliver'));
|
||||
window.__timelines.main = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,80 +1,146 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtempSync, rmSync, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { mkdtempSync, rmSync, existsSync, mkdirSync, statSync, writeFileSync, readFileSync, cpSync, copyFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { resolveBinaries } from '@orkas/video-studio-core';
|
||||
import { check, render } from '../src/render/render';
|
||||
import { probeMedia } from '../src/edit/edit';
|
||||
import { prepareComposition } from '../src/composition/scaffold';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveBinaries, type VideoEdl } from '@orkas/video-studio-core';
|
||||
import { resolveHyperframesInvocation } from '../src/hyperframes/client';
|
||||
|
||||
// Heavy real e2e: scaffolds through OVS and renders with the packaged HyperFrames
|
||||
// dependency. Opt-in (may need a browser download) — set OVS_E2E=1.
|
||||
const bins = resolveBinaries();
|
||||
const requested = process.env.OVS_E2E === '1';
|
||||
const suite = requested ? describe : describe.skip;
|
||||
const suite = process.env.OVS_E2E === '1' ? describe : describe.skip;
|
||||
const cli = fileURLToPath(new URL('../../cli/dist/index.js', import.meta.url));
|
||||
const fixture = fileURLToPath(new URL('./fixtures/production-video/', import.meta.url));
|
||||
|
||||
suite('render e2e (real hyperframes + ffmpeg)', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ovs-render-e2e-'));
|
||||
const proj = join(dir, 'my-video');
|
||||
suite('video production e2e (public CLI + real HyperFrames)', () => {
|
||||
const keep = process.env.OVS_VIDEO_EVIDENCE_DIR;
|
||||
if (keep) mkdirSync(resolve(keep), { recursive: true });
|
||||
const dir = mkdtempSync(join(keep ? resolve(keep) : tmpdir(), 'ovs-production-'));
|
||||
const project = join(dir, 'composition');
|
||||
const draft = join(dir, 'picture-and-sound.mp4');
|
||||
const final = join(dir, 'video.mp4');
|
||||
const planFile = join(dir, 'plan.json');
|
||||
let step = 0;
|
||||
const logReview: Array<{ command: string; stderrLines: number; classification: string }> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const missing = [!bins.node && 'node', !bins.ffmpeg && 'ffmpeg', !bins.ffprobe && 'ffprobe'].filter(Boolean);
|
||||
if (missing.length) throw new Error(`OVS_E2E requires: ${missing.join(', ')}`);
|
||||
function command(executable: string, args: string[], status = 0) {
|
||||
const result = spawnSync(executable, args, { encoding: 'utf8', timeout: 600_000, maxBuffer: 32 * 1024 * 1024 });
|
||||
const prefix = join(dir, `${String(step++).padStart(2, '0')}-${executable === process.execPath ? args[1] : 'media'}`);
|
||||
writeFileSync(`${prefix}-stdout.log`, result.stdout || '');
|
||||
writeFileSync(`${prefix}-stderr.log`, result.stderr || '');
|
||||
expect(result.error, `command launch: ${prefix}`).toBeUndefined();
|
||||
expect(result.status, `command result: ${prefix}\n${result.stdout?.slice(-3000)}\n${result.stderr?.slice(-1000)}`).toBe(status);
|
||||
const lines = (result.stderr || '').split(/\r?\n/).filter(Boolean);
|
||||
let classification = 'empty';
|
||||
if (executable === process.execPath && args[1] === 'draft') {
|
||||
// HyperFrames 0.7.60 emits severity-prefixed renderer logs plus its GPU status.
|
||||
for (const line of lines) expect(line.startsWith('[INFO] ') || line.startsWith('[hyperframes] browserGpuMode '), 'unclassified renderer stderr').toBe(true);
|
||||
classification = 'renderer info and GPU selection';
|
||||
} else if (executable === process.execPath && args[1] === 'edit' && args[2] === 'burnsubs') {
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line);
|
||||
expect(event.type).toBe('progress');
|
||||
expect(['running', 'completed']).toContain(event.status);
|
||||
}
|
||||
expect(JSON.parse(lines.at(-1)!).status).toBe('completed');
|
||||
classification = 'asserted subtitle progress';
|
||||
} else expect(lines, 'unexpected command stderr').toEqual([]);
|
||||
logReview.push({ command: executable === process.execPath ? args.slice(1, 3).filter((arg) => !arg.includes('/')).join(' ') : 'media', stderrLines: lines.length, classification });
|
||||
return result;
|
||||
}
|
||||
function ovs(args: string[], status = 0) {
|
||||
return JSON.parse(command(process.execPath, [cli, ...args], status).stdout);
|
||||
}
|
||||
function pixels(video: string, time: number, filter: string): Buffer {
|
||||
const result = spawnSync(bins.ffmpeg!, ['-v', 'error', '-ss', String(time), '-i', video, '-vf', filter, '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { timeout: 60_000, maxBuffer: 8 * 1024 * 1024 });
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr.toString()).toBe('');
|
||||
return result.stdout;
|
||||
}
|
||||
function difference(a: Buffer, b: Buffer) {
|
||||
expect(a.length).toBeGreaterThan(0);
|
||||
expect(a.length).toBe(b.length);
|
||||
return a.reduce((sum, value, i) => sum + Math.abs(value - b[i]), 0) / a.length;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
expect([bins.node, bins.ffmpeg, bins.ffprobe].every(Boolean), 'Node, ffmpeg and ffprobe are mandatory when OVS_E2E=1').toBe(true);
|
||||
expect(existsSync(cli), 'run pnpm build before production E2E').toBe(true);
|
||||
expect(resolveHyperframesInvocation('doctor').source).toBe('dependency');
|
||||
mkdirSync(proj, { recursive: true });
|
||||
writeFileSync(join(proj, 'composition-manifest.json'), JSON.stringify({
|
||||
schema_version: 2,
|
||||
composition: { id: 'main', width: 1920, height: 1080, duration: 3, target_duration: 3, fps: 30, language: 'en' },
|
||||
scenes: [{ id: 'main-scene', start: 0, duration: 3, approved_copy: ['HyperFrames'], narration_refs: [], source_shots: [], roles: ['title'] }],
|
||||
audio: { owner: 'none', tracks: [] },
|
||||
art_direction: {
|
||||
aesthetic: {
|
||||
subject_world: 'render laboratory', one_job: 'prove the packaged runtime path',
|
||||
signature_device: 'a frame counter', aesthetic_risk: 'single-purpose diagnostic frame',
|
||||
anti_template_check: 'rejected a generic card for a render counter',
|
||||
},
|
||||
visual_direction: {
|
||||
visual_tradition: 'technical test chart', lazy_defaults_rejected: 'no neon dashboard',
|
||||
video_scale: '1920x1080 title at 96px', depth_layer_rule: 'background, counter, label',
|
||||
motion_verb_rule: 'counter resolves', rhythm_pattern: 'reveal-hold',
|
||||
},
|
||||
cover: {
|
||||
scene_id: 'main-scene',
|
||||
headline: 'HyperFrames',
|
||||
content_signals: ['frame counter', 'render laboratory'],
|
||||
hero_visual: 'frame counter',
|
||||
composition_strategy: 'counter centered in a technical test chart',
|
||||
frame_time_sec: 0,
|
||||
},
|
||||
layout_boxes: { title: 'safe center' },
|
||||
typography_tokens: { title: '96px' },
|
||||
color_tokens: { background: '#000000', text: '#ffffff' },
|
||||
motion_budget: { groups: 1, verbs: ['resolve'] },
|
||||
scene_variation: { rule: 'single diagnostic scene' },
|
||||
scenes: [{ id: 'main-scene', depth_layers: ['background', 'counter', 'label'], motion_verbs: ['resolve'] }],
|
||||
},
|
||||
}), 'utf8');
|
||||
const prepared = await prepareComposition(proj);
|
||||
expect(prepared.ok).toBe(true);
|
||||
}, 600_000);
|
||||
cpSync(fixture, project, { recursive: true });
|
||||
mkdirSync(join(project, 'assets'), { recursive: true });
|
||||
// Deterministic instrumental fixture: no paid synthesis, network, voice or model.
|
||||
command(bins.ffmpeg!, ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'aevalsrc=0.10*sin(2*PI*220*t)+0.06*sin(2*PI*330*t)+0.04*sin(2*PI*440*t):s=48000:d=9', '-af', 'afade=t=in:d=0.1,afade=t=out:st=8.5:d=0.5', '-ac', '2', join(project, 'assets/music.wav')]);
|
||||
const plan: VideoEdl = {
|
||||
aspect: '16:9', total_target_sec: 9, language: 'en',
|
||||
delivery_promise: { type: 'compose_led', source_required: false, motion_min_ratio: 0 },
|
||||
segments: [{ id: 'composition', order: 1, role: 'body', layer: 'primary', source: 'compose', target_sec: 9, spec: { project }, produced_path: final }],
|
||||
tracks: { captions: { lines: [
|
||||
{ text: 'One plan. A clear story.', start_sec: 0, target_sec: 2.9 },
|
||||
{ text: 'Three scenes. Real motion.', start_sec: 3, target_sec: 2.9 },
|
||||
{ text: 'A playable video, verified.', start_sec: 6, target_sec: 2.9 },
|
||||
] } },
|
||||
};
|
||||
writeFileSync(planFile, JSON.stringify(plan, null, 2));
|
||||
});
|
||||
afterAll(() => { if (!keep) rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
||||
|
||||
it('renders a composition to a valid 1080p mp4', async () => {
|
||||
const qa = await check(proj) as { ok?: boolean; errorCount?: number; error_count?: number };
|
||||
if (qa.ok === false) throw new Error(JSON.stringify(qa, null, 2));
|
||||
expect(qa.ok).not.toBe(false);
|
||||
expect(Number(qa.errorCount ?? qa.error_count ?? 0)).toBe(0);
|
||||
const r = await render({ project: proj, output: join(dir, 'out.mp4'), quality: 'draft' });
|
||||
expect(existsSync(r.output)).toBe(true);
|
||||
expect(statSync(r.output).size).toBeGreaterThan(1_000);
|
||||
const p = await probeMedia(r.output);
|
||||
expect(p.width).toBe(1920);
|
||||
expect(p.height).toBe(1080);
|
||||
expect(p.v_codec).toBe('h264');
|
||||
expect(p.has_audio).toBe(false);
|
||||
expect(p.duration).toBeGreaterThan(2.5);
|
||||
expect(p.duration).toBeLessThan(3.5);
|
||||
it('produces a complete three-scene video with motion, audio and burned captions before acceptance', () => {
|
||||
const authored = readFileSync(join(project, 'index.html'), 'utf8');
|
||||
expect(ovs(['composition', 'prepare', project]).ok).toBe(true);
|
||||
expect(ovs(['composition', 'reconcile', project]).ok).toBe(true);
|
||||
expect(readFileSync(join(project, 'index.html'), 'utf8')).toContain('id="playhead"');
|
||||
expect(authored).toContain('Plan the story');
|
||||
const preview = ovs(['snapshot', project, '--out', join(dir, 'preview.png')]);
|
||||
expect(preview.frame_paths.length).toBeGreaterThanOrEqual(3);
|
||||
for (const file of preview.frame_paths) expect(statSync(file).size).toBeGreaterThan(1000);
|
||||
writeFileSync(join(dir, 'preview-report.json'), JSON.stringify(preview, null, 2));
|
||||
const rendered = ovs(['draft', project, '--out', draft, '--quality', 'high', '--report', join(dir, 'draft-report.json'), '--evidence-dir', join(dir, 'rendered-frames')]);
|
||||
expect(rendered.ok).toBe(true);
|
||||
expect(rendered.report.steps.media_qa.ok).toBe(true);
|
||||
expect(rendered.report.steps.video_qa.ok).toBe(true);
|
||||
ovs(['edit', 'burnsubs', draft, '--srt', join(project, 'captions.srt'), '--out', final]);
|
||||
copyFileSync(join(project, 'captions.srt'), join(dir, 'video.srt'));
|
||||
expect(statSync(final).size).toBeGreaterThan(10_000);
|
||||
const probe = ovs(['edit', 'probe', final]);
|
||||
expect(probe.width).toBe(1920);
|
||||
expect(probe.height).toBe(1080);
|
||||
expect(probe.v_codec).toBe('h264');
|
||||
expect(probe.has_audio).toBe(true);
|
||||
// The video timeline is exact; AAC normalization may pad the audio tail.
|
||||
// The accepted delivery contract allows 0.5s container/audio tolerance.
|
||||
expect(probe.video_duration).toBeCloseTo(9, 1);
|
||||
expect(Math.abs(probe.duration - 9)).toBeLessThanOrEqual(0.5);
|
||||
expect(Math.abs(probe.audio_duration - 9)).toBeLessThanOrEqual(0.5);
|
||||
// Decode the whole file, not just the container header.
|
||||
expect(command(bins.ffmpeg!, ['-v', 'error', '-i', final, '-f', 'null', '-']).stderr).toBe('');
|
||||
const background = [[20, 60, 74], [75, 36, 74], [36, 76, 48]];
|
||||
for (const [i, time] of [1.5, 4.5, 7.5].entries()) {
|
||||
const sample = pixels(final, time, 'crop=2:2:20:20');
|
||||
for (let channel = 0; channel < 3; channel++) expect(Math.abs(sample[channel] - background[i][channel])).toBeLessThan(8);
|
||||
// Burned captions must change the bottom band in every scene.
|
||||
expect(difference(pixels(draft, time, 'crop=1920:180:0:900,scale=320:30'), pixels(final, time, 'crop=1920:180:0:900,scale=320:30'))).toBeGreaterThan(1);
|
||||
command(bins.ffmpeg!, ['-v', 'error', '-y', '-ss', String(time), '-i', final, '-frames:v', '1', join(dir, `scene-${i + 1}.png`)]);
|
||||
}
|
||||
// The delivered opening is visible and both moving scenes really change pixels.
|
||||
const opening = pixels(final, 0, 'scale=192:108');
|
||||
expect(opening.filter((value) => value > 180).length).toBeGreaterThan(400);
|
||||
for (const time of [0, 3]) expect(difference(pixels(final, time + 0.4, 'crop=1640:410:120:375,scale=164:41'), pixels(final, time + 2.4, 'crop=1640:410:120:375,scale=164:41'))).toBeGreaterThan(1);
|
||||
const pcm = spawnSync(bins.ffmpeg!, ['-v', 'error', '-i', final, '-vn', '-ac', '1', '-ar', '8000', '-f', 'f32le', '-'], { maxBuffer: 1024 * 1024 });
|
||||
expect(pcm.status).toBe(0);
|
||||
expect(pcm.stderr.toString()).toBe('');
|
||||
for (const second of [1, 4, 7]) {
|
||||
let energy = 0;
|
||||
for (let i = second * 8000; i < (second + 1) * 8000; i++) energy += pcm.stdout.readFloatLE(i * 4) ** 2;
|
||||
expect(Math.sqrt(energy / 8000)).toBeGreaterThan(0.01);
|
||||
}
|
||||
const accepted = ovs(['plan', 'promise-check', planFile, '--video', final]);
|
||||
expect(accepted.verdict).not.toBe('fail');
|
||||
expect(accepted.delivery.ok).toBe(true);
|
||||
const wrong = { ...JSON.parse(readFileSync(planFile, 'utf8')), total_target_sec: 12 };
|
||||
writeFileSync(join(dir, 'wrong-plan.json'), JSON.stringify(wrong));
|
||||
const rejected = ovs(['plan', 'promise-check', join(dir, 'wrong-plan.json'), '--video', final], 1);
|
||||
expect(rejected.delivery.issues.some((issue: { code: string }) => issue.code === 'DELIVERY_DURATION_DRIFT')).toBe(true);
|
||||
writeFileSync(join(dir, 'production-evidence.json'), JSON.stringify({ ok: true, final, probe, delivery: accepted.delivery, logReview, checks: ['full-decode', 'scene-order', 'visible-opening', 'real-motion', 'audible-all-scenes', 'burned-captions-all-scenes', 'reject-wrong-duration'] }, null, 2));
|
||||
}, 600_000);
|
||||
});
|
||||
Reference in new issue
Block a user