From 8bd6d01d3430e9d04b281a778d2f24bc745ef321 Mon Sep 17 00:00:00 2001 From: AIGC Director Kit contributors Date: Wed, 12 Aug 2026 14:55:40 +0800 Subject: [PATCH] add Skill workflow runtime handoffs --- .github/workflows/ci.yml | 11 +- CHANGELOG.md | 10 ++ README.md | 48 ++++++ docs/roadmap.md | 19 ++- docs/skill-workflow-integration.md | 124 ++++++++++++++++ examples/prompt_pack_case.json | 37 +++++ examples/qc_report_unverified_case.json | 35 +++++ examples/skill_workflow_case.json | 80 ++++++++++ run_examples.bat | 55 ++++++- schemas/prompt-pack.v1.json | 64 ++++++++ schemas/qc-report.v1.json | 46 ++++++ schemas/runtime-handoff.v1.json | 41 +++++ schemas/skill-workflow.v1.json | 65 ++++++++ src/aigc_director_kit/__init__.py | 12 ++ src/aigc_director_kit/cli.py | 100 +++++++++++++ src/aigc_director_kit/prompt.py | 160 ++++++++++++++++++++ src/aigc_director_kit/qc.py | 127 ++++++++++++++++ src/aigc_director_kit/runtime.py | 92 ++++++++++++ src/aigc_director_kit/workflow.py | 190 ++++++++++++++++++++++++ tests/test_cli.py | 6 + tests/test_prompt.py | 41 +++++ tests/test_qc.py | 68 +++++++++ tests/test_runtime.py | 45 ++++++ tests/test_workflow.py | 90 +++++++++++ 24 files changed, 1554 insertions(+), 12 deletions(-) create mode 100644 docs/skill-workflow-integration.md create mode 100644 examples/prompt_pack_case.json create mode 100644 examples/qc_report_unverified_case.json create mode 100644 examples/skill_workflow_case.json create mode 100644 schemas/prompt-pack.v1.json create mode 100644 schemas/qc-report.v1.json create mode 100644 schemas/runtime-handoff.v1.json create mode 100644 schemas/skill-workflow.v1.json create mode 100644 src/aigc_director_kit/prompt.py create mode 100644 src/aigc_director_kit/qc.py create mode 100644 src/aigc_director_kit/runtime.py create mode 100644 src/aigc_director_kit/workflow.py create mode 100644 tests/test_prompt.py create mode 100644 tests/test_qc.py create mode 100644 tests/test_runtime.py create mode 100644 tests/test_workflow.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86cee74..0a6ece6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,15 @@ jobs: python -m aigc_director_kit validate-plan examples/one_take_previs_case.json --json python -m aigc_director_kit validate-plan examples/film_contract_handoff_case.json --json python -m aigc_director_kit validate-plan examples/multi_actor_prop_case.json --json - python -m aigc_director_kit list-actions --library examples/action_library.json --query 跑 - python -m aigc_director_kit compile-action --library examples/action_library.json --text "快速跑步然后急停,过渡0.2秒,原地" + python -m aigc_director_kit list-actions --library examples/action_library.json --query run + python -m aigc_director_kit compile-action --library examples/action_library.json --text "run quick stop, blend 0.2s, fast, in place" + + - name: Validate workflow, prompt, and QC contracts + run: | + python -m aigc_director_kit validate-workflow examples/skill_workflow_case.json --library examples/action_library.json --json + python -m aigc_director_kit validate-prompt-pack examples/prompt_pack_case.json --plan examples/one_take_previs_case.json --json + python -m aigc_director_kit validate-qc-report examples/qc_report_unverified_case.json --json + python -m aigc_director_kit build-runtime-handoff examples/skill_workflow_case.json --library examples/action_library.json --adapter optional-runtime-adapter --json - name: Check source and JSON files run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index bdfa5be..7b9ae48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Added the `aigc-director-skill-workflow` handoff contract and schema. +- Added `validate-workflow` for structural validation and deterministic action dry-runs. +- Added a sanitized multi-Skill workflow example, documentation, and regression tests. +- Added `aigc-director-prompt-pack` validation for global rules and shot-specific prompt deltas. +- Added `aigc-director-qc-report` validation that separates observed evidence from unverified claims. +- Added `aigc-director-runtime-handoff` and `build-runtime-handoff` for optional adapter consumption. +- Improved the Windows example runner with an explicit Python override and common install-path fallback. + ## [0.1.4] - 2026-08-12 - Added two sanitized cases derived from real local previs forward tests. diff --git a/README.md b/README.md index 6a4b891..e179f43 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,53 @@ # AIGC Director Kit +## Skill workflow handoff + +Local AIGC Skills can hand a sanitized workflow packet to this repository: + +```text +Skill output -> skill-workflow JSON -> validate-workflow -> optional runtime adapter +``` + +The public contract is documented in [`docs/skill-workflow-integration.md`](docs/skill-workflow-integration.md), +with a runnable example at [`examples/skill_workflow_case.json`](examples/skill_workflow_case.json): + +```powershell +python -m aigc_director_kit validate-workflow ` + examples/skill_workflow_case.json ` + --library examples/action_library.json +``` + +This is a deterministic handoff and dry-run boundary. It does not bundle +private Skills, Blender, FFmpeg, model accounts, paid generation, or final +video QC. + +To create one packet for an optional runtime adapter: + +```powershell +python -m aigc_director_kit build-runtime-handoff ` + examples/skill_workflow_case.json ` + --library examples/action_library.json ` + --adapter blender-previs ` + --json +``` + +The packet is always a dry-run handoff: it does not launch Blender or render +media, and it keeps render/QC evidence separate from contract validation. + +Prompt packs and QC evidence use separate contracts: + +```powershell +python -m aigc_director_kit validate-prompt-pack ` + examples/prompt_pack_case.json ` + --plan examples/one_take_previs_case.json + +python -m aigc_director_kit validate-qc-report examples/qc_report_unverified_case.json +``` + +Prompt validation does not generate an image or video. QC validation does not +open or interpret media; a `pass` or `fail` result requires explicitly marked +observed evidence from an available artifact. + [![CI](https://github.com/cyou1314/aigc-director-kit/actions/workflows/ci.yml/badge.svg)](https://github.com/cyou1314/aigc-director-kit/actions/workflows/ci.yml) 面向 AI 动画、AI 漫剧和分镜预演的轻量合同工具包:把镜头任务、相机路径、入/出镜状态和动作选择写成可验证、可复现的 JSON。 diff --git a/docs/roadmap.md b/docs/roadmap.md index c37fa01..6da3cd0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,18 @@ # Roadmap +## v0.2 implementation status + +- [x] Workflow, prompt-pack, and QC evidence contracts are available as + standard-library-only validators. +- [x] A runtime adapter dry-run handoff can combine the validated plan and + compiled action requests without launching Blender. +- [x] The Windows example launcher accepts `AIGC_DIRECTOR_PYTHON` and checks + common Python install locations. +- [ ] Add an optional Blender adapter that consumes the handoff and records + real previs evidence. +- [ ] Add field-level error codes and keep the JSON output stable across + releases. + ## v0.1 — 当前范围 - [x] 镜头计划合同和确定性校验 @@ -8,10 +21,10 @@ - [x] 中文/英文动作请求编译 - [x] 无第三方运行时依赖 -## v0.2 — 需要真实用户反馈后再做 +## v0.2 — 部分完成,等待真实运行时反馈 -- [ ] 可选 Blender 适配器,不把 Blender 加入核心依赖 -- [ ] 计划到运行时的 dry-run 报告 +- [x] 可选运行时 dry-run 交接包,不把 Blender 加入核心依赖 +- [ ] 可选 Blender 适配器,消费交接包并记录真实预演证据 - [ ] 更明确的字段级错误码和 JSON 输出稳定性 ## v0.3 — 暂不承诺 diff --git a/docs/skill-workflow-integration.md b/docs/skill-workflow-integration.md new file mode 100644 index 0000000..c3b1873 --- /dev/null +++ b/docs/skill-workflow-integration.md @@ -0,0 +1,124 @@ +# Skill workflow integration + +This repository is the deterministic public core for a larger AIGC workflow. +It does not bundle Codex skills, private assets, Blender files, model accounts, +or paid generation services. A local workflow can produce a public handoff, +then this package validates the handoff and compiles bounded action requests. + +## Boundary + +```text +local Skill workflow + -> aigc-director-skill-workflow JSON + -> validate-workflow + -> validate-plan + compile-action + -> prompt-pack JSON + qc-report JSON + -> optional Blender / FFmpeg / runtime adapter + -> separately recorded render or QC evidence +``` + +`validate-workflow` does not run an external Skill, render a scene, call a +model, inspect a video, or prove final visual quality. It only checks the +public contract and, when an action catalog is supplied, performs a dry-run +against known action descriptors. + +## Skill-to-contract mapping + +| Workflow responsibility | Public handoff | Boundary | +| --- | --- | --- | +| Story or visual direction | `shot_plan` | The plan is a designed or observed contract, not a rendered result. | +| Audio, pacing, or action intent | `action_requests[]` | Only catalog actions can be selected; unknown actions fail. | +| Project coordination | `stages[]` and `handoff` | Stage names are labels, not runtime dependencies. | +| Prompt writing and revision | `aigc-director-prompt-pack` | Global rules are written once; each shot carries only its delta and contract. | +| Blender previs | Optional runtime adapter | Blender is not a core dependency. | +| FFmpeg preprocessing | External evidence step | Original media remains outside this public repository. | +| Video QC | `aigc-director-qc-report` | Pass/fail requires observed evidence from an available artifact. | + +The example uses `aigc-manga-video-director`, `audio-to-prompt`, and +`aigc-project-prompt-loop` as illustrative stage labels. They are not bundled +with this Python package and can be replaced by another workflow producer. + +## Run the public handoff + +Without an action catalog, the workflow is checked structurally and action +requests receive an explicit not-compiled warning: + +```powershell +python -m aigc_director_kit validate-workflow examples/skill_workflow_case.json +``` + +With the descriptor-only catalog, the action bridge is compiled deterministically: + +```powershell +python -m aigc_director_kit validate-workflow ` + examples/skill_workflow_case.json ` + --library examples/action_library.json +``` + +For machine-readable output, add `--json`. The output is a validation and +dry-run report; a runtime adapter still has to map action ids to real assets. + +## Build a runtime adapter handoff + +When the next tool needs one packet containing the validated shot plan, prompt +pack, compiled action requests, and evidence boundary, build a runtime +handoff: + +```powershell +python -m aigc_director_kit build-runtime-handoff ` + examples/skill_workflow_case.json ` + --library examples/action_library.json ` + --adapter blender-previs ` + --json +``` + +This command creates a `aigc-director-runtime-handoff` packet in `dry-run` +mode. `runtime.executed` stays `false`, `render_status` stays `not_run`, and +the packet does not launch Blender, resolve motion files, render media, or +claim QC. An adapter may consume the packet and must record its own runtime +and media evidence separately. + +## Prompt and QC contracts + +The prompt-pack contract keeps reusable character, scene, style, stability, +and avoid rules in `global_rules`. Each shot must retain a primary task, +action causality, camera job, entry state, exit state, duration, and complete +prompt text. When a source shot plan is supplied, ids are cross-checked and a +duration drift is reported as a warning. + +The QC contract is deliberately stricter than a prompt check. A report may be +`unverified` or `not_run` when no render is available. A `pass` or `fail` +check must use `observed` evidence and an available artifact. This keeps a +contract audit, a dry-run, and actual video QC as separate evidence layers. + +Both layers can also be embedded in the workflow packet as optional +`prompt_pack` and `qc_report` fields. When present, `validate-workflow` checks +them against the embedded shot plan and exposes their status in the summary. + +```powershell +python -m aigc_director_kit validate-prompt-pack ` + examples/prompt_pack_case.json ` + --plan examples/one_take_previs_case.json + +python -m aigc_director_kit validate-qc-report ` + examples/qc_report_unverified_case.json +``` + +## Adding a new workflow producer + +1. Emit a sanitized `aigc-director-skill-workflow` document. +2. Put the producer name, role, output contract, and evidence status in a + `stages[]` entry. +3. Embed a versioned `aigc-director-shot-plan` in `shot_plan`. +4. Put natural-language action intent in `action_requests[]` with a shot and + stage reference. +5. Run `validate-workflow --library ...` before handing the packet to an + optional runtime adapter. +6. Run `build-runtime-handoff ... --adapter ...` when the next runtime needs a + single dry-run packet. +7. Add a public example and a regression test when the contract changes. + +Keep private source paths, credentials, cookies, private character assets, +unlicensed motion data, and generated media out of the packet. Mark inferred +intent and unverified render/QC claims explicitly instead of presenting them +as observed community or production evidence. diff --git a/examples/prompt_pack_case.json b/examples/prompt_pack_case.json new file mode 100644 index 0000000..f6bfc86 --- /dev/null +++ b/examples/prompt_pack_case.json @@ -0,0 +1,37 @@ +{ + "contract": "aigc-director-prompt-pack", + "version": 1, + "project": "sanitized-one-take-previs", + "global_rules": { + "character_anchors": [ + "Keep the hero identity, silhouette, wardrobe, and prop state unchanged." + ], + "scene_anchors": [ + "Preserve the platform axis and the forward-facing spatial relationship." + ], + "style": "Clean low-poly previs with readable staging and restrained detail.", + "stability": [ + "Preserve the first-frame composition and the settled outgoing state." + ], + "avoid": [ + "Do not invent dialogue, extra choreography, or an unverified final-render claim." + ] + }, + "shots": [ + { + "shot_id": "S01", + "source_shot_id": "S01", + "duration_s": 4.0, + "primary_task": "Establish the space and compress attention onto the hero's decision.", + "action_causality": "The hero moves forward from the established space and settles on the platform axis.", + "camera_job": "Dolly from spatial context toward the hero while keeping the endpoint readable.", + "entry_state": {"actors": ["hero"], "movement": "forward"}, + "exit_state": {"actors": ["hero"], "movement": "settled-forward", "screen_position": "near-center"}, + "prompt": "One continuous 4-second previs take. Establish the platform and the hero in space, dolly steadily toward the hero as attention compresses onto the forward decision, then settle with the hero near frame center on the platform axis. Preserve identity, silhouette, wardrobe, prop state, screen direction, and the readable outgoing endpoint. No dialogue, extra choreography, or invented camera movement.", + "evidence": "designed", + "revision_notes": [ + "Keep one primary action and one dominant camera job." + ] + } + ] +} diff --git a/examples/qc_report_unverified_case.json b/examples/qc_report_unverified_case.json new file mode 100644 index 0000000..731f20d --- /dev/null +++ b/examples/qc_report_unverified_case.json @@ -0,0 +1,35 @@ +{ + "contract": "aigc-director-qc-report", + "version": 1, + "project": "sanitized-one-take-previs", + "artifact": { + "label": "No rendered video supplied; contract-only example", + "available": false + }, + "overall_status": "unverified", + "source_shot_ids": ["S01"], + "checks": [ + { + "id": "shot-contract", + "status": "not_run", + "evidence": "designed", + "notes": "The shot contract can be validated separately, but no render was inspected." + }, + { + "id": "continuity", + "status": "unverified", + "evidence": "inferred", + "notes": "Continuity remains unverified until an actual clip and its tail frame are reviewed." + }, + { + "id": "lip-sync", + "status": "not_run", + "evidence": "designed", + "notes": "No dialogue or audio evidence is present in this example." + } + ], + "limitations": [ + "This report does not inspect a video, audio track, or rendered frame.", + "Contract validation is not final visual or audio quality approval." + ] +} diff --git a/examples/skill_workflow_case.json b/examples/skill_workflow_case.json new file mode 100644 index 0000000..ca47915 --- /dev/null +++ b/examples/skill_workflow_case.json @@ -0,0 +1,80 @@ +{ + "contract": "aigc-director-skill-workflow", + "version": 1, + "project": "skill-workflow-demo", + "stages": [ + { + "id": "direction", + "skill": "aigc-manga-video-director", + "role": "turn visual direction into a shot contract", + "output": "aigc-director-shot-plan", + "evidence": "designed", + "status": "validated" + }, + { + "id": "action-bridge", + "skill": "audio-to-prompt", + "role": "turn pacing and action intent into a bounded request", + "output": "aigc-director-action-request", + "evidence": "inferred", + "status": "validated" + }, + { + "id": "coordination", + "skill": "aigc-project-prompt-loop", + "role": "sequence the handoff and preserve evidence boundaries", + "output": "aigc-director-skill-workflow", + "evidence": "designed", + "status": "validated" + } + ], + "shot_plan": { + "contract": "aigc-director-shot-plan", + "version": 1, + "project": "skill-workflow-demo", + "fps": 24, + "resolution": {"width": 640, "height": 360}, + "shots": [ + { + "id": "S01", + "duration_s": 4.0, + "intent": "Establish the subject and end on a readable forward decision.", + "evidence": "designed", + "entry_state": {"actors": ["hero"], "movement": "forward"}, + "exit_state": {"actors": ["hero"], "movement": "settled-forward"}, + "attention_order": ["hero", "platform"], + "beats": ["establish", "decision", "settle"], + "continuous_take": true, + "camera": { + "rig": "dolly", + "job": "Move from spatial context toward the subject decision.", + "focal_length_mm": 35.0, + "path": [ + {"time_s": 0.0, "position": [4.2, -7.5, 3.4], "look_at": [0.0, 1.5, 1.25]}, + {"time_s": 4.0, "position": [2.5, -3.8, 2.15], "look_at": [0.0, 2.25, 1.35]} + ] + }, + "next_handle": "Keep the subject near center and settled on the forward axis." + } + ] + }, + "action_requests": [ + { + "shot_id": "S01", + "stage_id": "action-bridge", + "text": "run quick stop, blend 0.2s, fast, in place", + "evidence": "inferred" + } + ], + "handoff": { + "next_adapter": "optional-runtime-adapter", + "constraints": [ + "Use only action ids returned by compile-action.", + "Keep shot entry and exit states unchanged until a real render is verified." + ], + "unverified": [ + "Final video quality", + "Actual motion asset mapping" + ] + } +} diff --git a/run_examples.bat b/run_examples.bat index 1ec68fb..1beaab3 100644 --- a/run_examples.bat +++ b/run_examples.bat @@ -4,19 +4,39 @@ chcp 65001 >nul cd /d "%~dp0." if defined AIGC_DIRECTOR_PYTHON ( - set "PYTHON_CMD=%AIGC_DIRECTOR_PYTHON%" + set "PYTHON_CMD="%AIGC_DIRECTOR_PYTHON%"" ) else ( where py >nul 2>nul - if not errorlevel 1 ( - set "PYTHON_CMD=py -3" - ) else ( - set "PYTHON_CMD=python" + if not errorlevel 1 set "PYTHON_CMD=py -3" + if not defined PYTHON_CMD ( + where python >nul 2>nul + if not errorlevel 1 set "PYTHON_CMD=python" + ) + if not defined PYTHON_CMD ( + for %%P in ( + "%LocalAppData%\Programs\Python\Python*\python.exe" + "%ProgramFiles%\Python*\python.exe" + "%ProgramFiles(x86)%\Python*\python.exe" + ) do if exist "%%~P" if not defined PYTHON_CMD set "PYTHON_CMD="%%~P"" ) ) +%PYTHON_CMD% --version >nul 2>&1 +if not errorlevel 1 goto :python_ready + +if defined AIGC_DIRECTOR_PYTHON goto :missing_python +set "PYTHON_CMD=" +for %%P in ( + "%LocalAppData%\Programs\Python\Python*\python.exe" + "%ProgramFiles%\Python*\python.exe" + "%ProgramFiles(x86)%\Python*\python.exe" +) do if exist "%%~P" if not defined PYTHON_CMD set "PYTHON_CMD="%%~P"" +if not defined PYTHON_CMD goto :missing_python %PYTHON_CMD% --version >nul 2>&1 if errorlevel 1 goto :missing_python +:python_ready + set "PYTHONPATH=%CD%\src" echo === Validate shot plan === @@ -33,13 +53,34 @@ echo === Compile bounded action request === %PYTHON_CMD% -m aigc_director_kit compile-action --library examples\action_library.json --text "run quick stop, blend 0.2s, fast, in place" if errorlevel 1 goto :failed +echo. +echo === Validate Skill workflow handoff === +%PYTHON_CMD% -m aigc_director_kit validate-workflow examples\skill_workflow_case.json --library examples\action_library.json +if errorlevel 1 goto :failed + +echo. +echo === Validate prompt pack === +%PYTHON_CMD% -m aigc_director_kit validate-prompt-pack examples\prompt_pack_case.json --plan examples\one_take_previs_case.json +if errorlevel 1 goto :failed + +echo. +echo === Validate QC evidence boundary === +%PYTHON_CMD% -m aigc_director_kit validate-qc-report examples\qc_report_unverified_case.json +if errorlevel 1 goto :failed + +echo. +echo === Build runtime adapter handoff === +%PYTHON_CMD% -m aigc_director_kit build-runtime-handoff examples\skill_workflow_case.json --library examples\action_library.json --adapter optional-runtime-adapter +if errorlevel 1 goto :failed + echo. echo Done. The examples ran successfully. goto :finish :missing_python -echo Python 3.10 or newer was not found. -echo Install Python from https://www.python.org/downloads/windows/ and run this file again. +echo Python 3.10 or newer was not found through py, python, or common install locations. +echo Install Python from https://www.python.org/downloads/windows/ and enable "Add Python to PATH". +echo Or set AIGC_DIRECTOR_PYTHON to the full path of python.exe before running this file. goto :failed :failed diff --git a/schemas/prompt-pack.v1.json b/schemas/prompt-pack.v1.json new file mode 100644 index 0000000..4d62edc --- /dev/null +++ b/schemas/prompt-pack.v1.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:aigc-director-kit:prompt-pack:v1", + "title": "AIGC Director Prompt Pack v1", + "type": "object", + "additionalProperties": true, + "required": ["contract", "version", "project", "global_rules", "shots"], + "properties": { + "contract": {"const": "aigc-director-prompt-pack"}, + "version": {"const": 1}, + "project": {"type": "string", "minLength": 1}, + "global_rules": {"$ref": "#/$defs/globalRules"}, + "shots": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": {"$ref": "#/$defs/shotPrompt"} + } + }, + "$defs": { + "state": {"oneOf": [{"type": "string", "minLength": 1}, {"type": "object", "minProperties": 1}]}, + "evidence": {"enum": ["observed", "inferred", "designed"]}, + "globalRules": { + "type": "object", + "additionalProperties": true, + "properties": { + "character_anchors": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "scene_anchors": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "style": {"type": "string", "minLength": 1}, + "stability": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "avoid": {"type": "array", "items": {"type": "string", "minLength": 1}} + } + }, + "shotPrompt": { + "type": "object", + "additionalProperties": true, + "required": [ + "shot_id", + "duration_s", + "primary_task", + "action_causality", + "camera_job", + "entry_state", + "exit_state", + "prompt", + "evidence" + ], + "properties": { + "shot_id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]*$"}, + "source_shot_id": {"type": "string", "minLength": 1}, + "source_panel": {"type": "string", "minLength": 1}, + "duration_s": {"type": "number", "minimum": 0.25, "maximum": 60}, + "primary_task": {"type": "string", "minLength": 1}, + "action_causality": {"type": "string", "minLength": 1}, + "camera_job": {"type": "string", "minLength": 1}, + "entry_state": {"$ref": "#/$defs/state"}, + "exit_state": {"$ref": "#/$defs/state"}, + "prompt": {"type": "string", "minLength": 1, "maxLength": 20000}, + "evidence": {"$ref": "#/$defs/evidence"}, + "revision_notes": {"type": "array", "items": {"type": "string", "minLength": 1}} + } + } + } +} diff --git a/schemas/qc-report.v1.json b/schemas/qc-report.v1.json new file mode 100644 index 0000000..c24a2a8 --- /dev/null +++ b/schemas/qc-report.v1.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:aigc-director-kit:qc-report:v1", + "title": "AIGC Director QC Evidence Report v1", + "type": "object", + "additionalProperties": true, + "required": ["contract", "version", "project", "artifact", "overall_status", "checks"], + "properties": { + "contract": {"const": "aigc-director-qc-report"}, + "version": {"const": 1}, + "project": {"type": "string", "minLength": 1}, + "artifact": {"$ref": "#/$defs/artifact"}, + "overall_status": {"enum": ["pass", "fail", "unverified", "not_run"]}, + "checks": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "items": {"$ref": "#/$defs/check"} + }, + "limitations": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "source_shot_ids": {"type": "array", "items": {"type": "string", "minLength": 1}} + }, + "$defs": { + "evidence": {"enum": ["observed", "inferred", "designed"]}, + "artifact": { + "type": "object", + "additionalProperties": true, + "required": ["label", "available"], + "properties": { + "label": {"type": "string", "minLength": 1}, + "available": {"type": "boolean"} + } + }, + "check": { + "type": "object", + "additionalProperties": true, + "required": ["id", "status", "evidence", "notes"], + "properties": { + "id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]*$"}, + "status": {"enum": ["pass", "fail", "unverified", "not_run"]}, + "evidence": {"$ref": "#/$defs/evidence"}, + "notes": {"type": "string", "minLength": 1} + } + } + } +} diff --git a/schemas/runtime-handoff.v1.json b/schemas/runtime-handoff.v1.json new file mode 100644 index 0000000..d468b7d --- /dev/null +++ b/schemas/runtime-handoff.v1.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:aigc-director-kit:runtime-handoff:v1", + "title": "AIGC Director Runtime Handoff v1", + "type": "object", + "additionalProperties": true, + "required": ["contract", "version", "valid", "project", "validation", "runtime", "evidence"], + "properties": { + "contract": {"const": "aigc-director-runtime-handoff"}, + "version": {"const": 1}, + "valid": {"type": "boolean"}, + "project": {"type": ["string", "null"]}, + "validation": {"type": "object"}, + "shot_plan": {"type": ["object", "null"]}, + "prompt_pack": {"type": ["object", "null"]}, + "compiled_action_requests": {"type": "array"}, + "runtime": {"$ref": "#/$defs/runtime"}, + "evidence": {"$ref": "#/$defs/evidence"} + }, + "$defs": { + "runtime": { + "type": "object", + "required": ["adapter", "mode", "executed", "required_next_steps"], + "properties": { + "adapter": {"type": "string", "minLength": 1}, + "mode": {"const": "dry-run"}, + "executed": {"const": false}, + "required_next_steps": {"type": "array", "items": {"type": "string", "minLength": 1}} + } + }, + "evidence": { + "type": "object", + "required": ["contract_validation", "render_status", "qc_status"], + "properties": { + "contract_validation": {"enum": ["passed", "failed"]}, + "render_status": {"const": "not_run"}, + "qc_status": {"type": "string"} + } + } + } +} diff --git a/schemas/skill-workflow.v1.json b/schemas/skill-workflow.v1.json new file mode 100644 index 0000000..fb722f3 --- /dev/null +++ b/schemas/skill-workflow.v1.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:aigc-director-kit:skill-workflow:v1", + "title": "AIGC Director Skill Workflow Handoff v1", + "type": "object", + "additionalProperties": true, + "required": ["contract", "version", "project", "stages", "shot_plan"], + "properties": { + "contract": {"const": "aigc-director-skill-workflow"}, + "version": {"const": 1}, + "project": {"type": "string", "minLength": 1}, + "stages": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": {"$ref": "#/$defs/stage"} + }, + "shot_plan": {"$ref": "./shot-plan.v1.json"}, + "prompt_pack": {"$ref": "./prompt-pack.v1.json"}, + "qc_report": {"$ref": "./qc-report.v1.json"}, + "action_requests": { + "type": "array", + "maxItems": 100, + "items": {"$ref": "#/$defs/actionRequest"} + }, + "handoff": {"$ref": "#/$defs/handoff"} + }, + "$defs": { + "id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]*$"}, + "evidence": {"enum": ["observed", "inferred", "designed"]}, + "stage": { + "type": "object", + "additionalProperties": true, + "required": ["id", "skill", "role", "output", "evidence"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "skill": {"type": "string", "minLength": 1}, + "role": {"type": "string", "minLength": 1}, + "output": {"type": "string", "minLength": 1}, + "evidence": {"$ref": "#/$defs/evidence"}, + "status": {"enum": ["draft", "validated", "unverified"]} + } + }, + "actionRequest": { + "type": "object", + "additionalProperties": true, + "required": ["shot_id", "stage_id", "text", "evidence"], + "properties": { + "shot_id": {"type": "string", "minLength": 1}, + "stage_id": {"$ref": "#/$defs/id"}, + "text": {"type": "string", "minLength": 1}, + "evidence": {"$ref": "#/$defs/evidence"} + } + }, + "handoff": { + "type": "object", + "additionalProperties": true, + "properties": { + "next_adapter": {"type": "string", "minLength": 1}, + "constraints": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "unverified": {"type": "array", "items": {"type": "string", "minLength": 1}} + } + } + } +} diff --git a/src/aigc_director_kit/__init__.py b/src/aigc_director_kit/__init__.py index 418cd8f..7197bfd 100644 --- a/src/aigc_director_kit/__init__.py +++ b/src/aigc_director_kit/__init__.py @@ -2,6 +2,10 @@ from .actions import compile_action_request, list_actions, load_action_library from .contract import ValidationResult, load_plan, validate_plan, validate_plan_file +from .prompt import validate_prompt_pack, validate_prompt_pack_file +from .qc import validate_qc_report, validate_qc_report_file +from .runtime import build_runtime_handoff, build_runtime_handoff_file +from .workflow import validate_workflow, validate_workflow_file __all__ = [ "ValidationResult", @@ -11,6 +15,14 @@ "load_plan", "validate_plan", "validate_plan_file", + "validate_prompt_pack", + "validate_prompt_pack_file", + "validate_qc_report", + "validate_qc_report_file", + "build_runtime_handoff", + "build_runtime_handoff_file", + "validate_workflow", + "validate_workflow_file", ] __version__ = "0.1.4" diff --git a/src/aigc_director_kit/cli.py b/src/aigc_director_kit/cli.py index 1f756f9..5cb6f9a 100644 --- a/src/aigc_director_kit/cli.py +++ b/src/aigc_director_kit/cli.py @@ -10,6 +10,10 @@ from .actions import compile_action_request, list_actions, load_action_library from .contract import validate_plan_file +from .prompt import validate_prompt_pack_file +from .qc import validate_qc_report_file +from .runtime import build_runtime_handoff_file +from .workflow import validate_workflow_file def _dump(value: Any) -> None: @@ -24,6 +28,39 @@ def _build_parser() -> argparse.ArgumentParser: validate.add_argument("path", type=Path) validate.add_argument("--json", action="store_true", dest="as_json") + workflow = subparsers.add_parser( + "validate-workflow", + help="Validate a skill workflow handoff and optionally compile its action requests.", + ) + workflow.add_argument("path", type=Path) + workflow.add_argument("--library", type=Path) + workflow.add_argument("--json", action="store_true", dest="as_json") + + prompt = subparsers.add_parser( + "validate-prompt-pack", + help="Validate a reusable prompt pack, optionally against a shot plan.", + ) + prompt.add_argument("path", type=Path) + prompt.add_argument("--plan", type=Path) + prompt.add_argument("--json", action="store_true", dest="as_json") + + qc = subparsers.add_parser( + "validate-qc-report", + help="Validate an evidence-bound QC report without opening media.", + ) + qc.add_argument("path", type=Path) + qc.add_argument("--json", action="store_true", dest="as_json") + + handoff = subparsers.add_parser( + "build-runtime-handoff", + help="Build a dry-run packet for an optional runtime adapter.", + ) + handoff.add_argument("path", type=Path) + handoff.add_argument("--library", required=True, type=Path) + handoff.add_argument("--adapter", default="optional-runtime-adapter") + handoff.add_argument("--output", type=Path) + handoff.add_argument("--json", action="store_true", dest="as_json") + search = subparsers.add_parser("list-actions", help="Search an action catalog.") search.add_argument("--library", required=True, type=Path) search.add_argument("--query", default="") @@ -63,6 +100,69 @@ def main(argv: list[str] | None = None) -> int: print(f"warning: {warning}") return 0 if result.valid else 2 + if args.command == "validate-workflow": + result = validate_workflow_file(args.path, args.library) + if args.as_json: + _dump(result.as_dict()) + else: + print(f"valid: {'yes' if result.valid else 'no'}") + print(f"stages: {result.summary.get('stage_count', 0)}") + print(f"shots: {result.summary.get('shot_count', 0)}") + print(f"action_requests: {result.summary.get('action_request_count', 0)}") + print(f"compiled_actions: {result.summary.get('compiled_action_count', 0)}") + for error in result.errors: + print(f"error: {error}") + for warning in result.warnings: + print(f"warning: {warning}") + return 0 if result.valid else 2 + + if args.command == "validate-prompt-pack": + result = validate_prompt_pack_file(args.path, args.plan) + if args.as_json: + _dump(result.as_dict()) + else: + print(f"valid: {'yes' if result.valid else 'no'}") + print(f"prompt_shots: {result.summary.get('prompt_shot_count', 0)}") + print(f"source_shots: {result.summary.get('source_shot_count', 0)}") + for error in result.errors: + print(f"error: {error}") + for warning in result.warnings: + print(f"warning: {warning}") + return 0 if result.valid else 2 + + if args.command == "validate-qc-report": + result = validate_qc_report_file(args.path) + if args.as_json: + _dump(result.as_dict()) + else: + print(f"valid: {'yes' if result.valid else 'no'}") + print(f"checks: {result.summary.get('check_count', 0)}") + print(f"observed_checks: {result.summary.get('observed_check_count', 0)}") + print(f"overall_status: {result.summary.get('overall_status')}") + for error in result.errors: + print(f"error: {error}") + for warning in result.warnings: + print(f"warning: {warning}") + return 0 if result.valid else 2 + + if args.command == "build-runtime-handoff": + packet = build_runtime_handoff_file( + args.path, + args.library, + adapter_name=args.adapter, + ) + if args.output: + _write_new_json(args.output, packet) + if args.as_json or not args.output: + _dump(packet) + else: + print(f"valid: {'yes' if packet['valid'] else 'no'}") + print(f"project: {packet.get('project')}") + print(f"compiled_actions: {len(packet['compiled_action_requests'])}") + print(f"adapter: {packet['runtime']['adapter']}") + print(f"mode: {packet['runtime']['mode']}") + return 0 if packet["valid"] else 2 + if args.command == "list-actions": library = load_action_library(args.library) _dump(list_actions(library, args.query, category=args.category, limit=args.limit)) diff --git a/src/aigc_director_kit/prompt.py b/src/aigc_director_kit/prompt.py new file mode 100644 index 0000000..fd7fa92 --- /dev/null +++ b/src/aigc_director_kit/prompt.py @@ -0,0 +1,160 @@ +"""Validation for reusable, shot-aware AIGC prompt packs.""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any + +from .contract import SHOT_ID_PATTERN, SUPPORTED_EVIDENCE, ValidationResult, load_json, validate_plan + + +PROMPT_CONTRACT = "aigc-director-prompt-pack" +PROMPT_VERSION = 1 + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def _state(value: Any) -> bool: + return _text(value) or (isinstance(value, dict) and bool(value)) + + +def _string_array(value: Any, label: str, errors: list[str]) -> None: + if not isinstance(value, list) or any(not _text(item) for item in value): + errors.append(f"{label} must be a string array.") + + +def validate_prompt_pack( + prompt_pack: Any, + shot_plan: dict[str, Any] | None = None, +) -> ValidationResult: + """Validate global prompt rules and per-shot prompt deltas. + + When a source shot plan is supplied, shot ids and locked durations are + cross-checked. A duration mismatch is a warning so a human can decide + whether the prompt pack intentionally changes the source contract. + """ + + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(prompt_pack, dict): + return ValidationResult(False, ["The prompt pack root must be a JSON object."], [], {}) + + if prompt_pack.get("contract") != PROMPT_CONTRACT: + errors.append(f"contract must be '{PROMPT_CONTRACT}'.") + if prompt_pack.get("version") != PROMPT_VERSION: + errors.append(f"version must be {PROMPT_VERSION}.") + if not _text(prompt_pack.get("project")): + errors.append("project must be a non-empty string.") + + global_rules = prompt_pack.get("global_rules") + if not isinstance(global_rules, dict): + errors.append("global_rules must be an object.") + global_rules = {} + for field_name in ("character_anchors", "scene_anchors", "stability", "avoid"): + if field_name in global_rules: + _string_array(global_rules[field_name], f"global_rules.{field_name}", errors) + if "style" in global_rules and not _text(global_rules["style"]): + errors.append("global_rules.style must be a non-empty string.") + + shots = prompt_pack.get("shots") + shot_ids: set[str] = set() + if not isinstance(shots, list) or not shots: + errors.append("shots must be a non-empty array.") + shots = [] + elif len(shots) > 100: + errors.append("shots cannot contain more than 100 entries.") + + prompt_durations: dict[str, float] = {} + for index, shot in enumerate(shots): + label = f"shots[{index}]" + if not isinstance(shot, dict): + errors.append(f"{label} must be an object.") + continue + shot_id = shot.get("shot_id") + if not isinstance(shot_id, str) or not SHOT_ID_PATTERN.fullmatch(shot_id): + errors.append(f"{label}.shot_id must match {SHOT_ID_PATTERN.pattern!r}.") + elif shot_id in shot_ids: + errors.append(f"{label}.shot_id is duplicated: {shot_id}.") + else: + shot_ids.add(shot_id) + + duration_s = shot.get("duration_s") + if not _number(duration_s) or not 0.25 <= float(duration_s) <= 60: + errors.append(f"{label}.duration_s must be between 0.25 and 60 seconds.") + elif isinstance(shot_id, str): + prompt_durations[shot_id] = float(duration_s) + + for field_name in ( + "primary_task", + "action_causality", + "camera_job", + "prompt", + ): + if not _text(shot.get(field_name)): + errors.append(f"{label}.{field_name} must be a non-empty string.") + if isinstance(shot.get("prompt"), str) and len(shot["prompt"]) > 20000: + errors.append(f"{label}.prompt cannot exceed 20000 characters.") + for field_name in ("entry_state", "exit_state"): + if not _state(shot.get(field_name)): + errors.append(f"{label}.{field_name} must be a non-empty string or object.") + if shot.get("evidence") not in SUPPORTED_EVIDENCE: + errors.append(f"{label}.evidence must be one of {sorted(SUPPORTED_EVIDENCE)}.") + for field_name in ("source_shot_id", "source_panel"): + if field_name in shot and not _text(shot[field_name]): + errors.append(f"{label}.{field_name} must be a non-empty string when provided.") + if "revision_notes" in shot: + _string_array(shot["revision_notes"], f"{label}.revision_notes", errors) + + source_shot_ids: set[str] = set() + source_durations: dict[str, float] = {} + if shot_plan is not None: + plan_result = validate_plan(shot_plan) + errors.extend(f"shot_plan: {error}" for error in plan_result.errors) + warnings.extend(f"shot_plan: {warning}" for warning in plan_result.warnings) + if isinstance(shot_plan, dict) and isinstance(shot_plan.get("shots"), list): + for source_shot in shot_plan["shots"]: + if not isinstance(source_shot, dict) or not isinstance(source_shot.get("id"), str): + continue + source_id = source_shot["id"] + source_shot_ids.add(source_id) + if _number(source_shot.get("duration_s")): + source_durations[source_id] = float(source_shot["duration_s"]) + for index, shot in enumerate(shots): + if not isinstance(shot, dict): + continue + shot_id = shot.get("source_shot_id", shot.get("shot_id")) + if shot_id not in source_shot_ids: + errors.append(f"shots[{index}] must reference a shot in the source shot plan.") + continue + duration_s = prompt_durations.get(shot.get("shot_id")) + source_duration = source_durations.get(shot_id) + if duration_s is not None and source_duration is not None and duration_s != source_duration: + warnings.append( + f"shots[{index}].duration_s differs from source shot {shot_id}; confirm the locked duration." + ) + + summary = { + "prompt_shot_count": len(shots), + "prompt_shot_ids": sorted(shot_ids), + "source_shot_count": len(source_shot_ids), + "global_rule_fields": sorted(global_rules), + } + return ValidationResult(not errors, errors, warnings, summary) + + +def validate_prompt_pack_file( + path: str | Path, + shot_plan_path: str | Path | None = None, +) -> ValidationResult: + """Load and validate a prompt pack, optionally against a shot plan.""" + + prompt_pack = load_json(path) + shot_plan = load_json(shot_plan_path) if shot_plan_path else None + return validate_prompt_pack(prompt_pack, shot_plan) diff --git a/src/aigc_director_kit/qc.py b/src/aigc_director_kit/qc.py new file mode 100644 index 0000000..9e62437 --- /dev/null +++ b/src/aigc_director_kit/qc.py @@ -0,0 +1,127 @@ +"""Evidence-bound validation for optional render and video QC reports.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .contract import SUPPORTED_EVIDENCE, ValidationResult, load_json + + +QC_CONTRACT = "aigc-director-qc-report" +QC_VERSION = 1 +QC_STATUS = frozenset({"pass", "fail", "unverified", "not_run"}) +QC_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _string_array(value: Any, label: str, errors: list[str]) -> None: + if not isinstance(value, list) or any(not _text(item) for item in value): + errors.append(f"{label} must be a string array.") + + +def validate_qc_report(report: Any) -> ValidationResult: + """Validate QC evidence without opening or interpreting a media file.""" + + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(report, dict): + return ValidationResult(False, ["The QC report root must be a JSON object."], [], {}) + + if report.get("contract") != QC_CONTRACT: + errors.append(f"contract must be '{QC_CONTRACT}'.") + if report.get("version") != QC_VERSION: + errors.append(f"version must be {QC_VERSION}.") + if not _text(report.get("project")): + errors.append("project must be a non-empty string.") + + artifact = report.get("artifact") + artifact_available = False + if not isinstance(artifact, dict): + errors.append("artifact must be an object.") + else: + if not _text(artifact.get("label")): + errors.append("artifact.label must be a non-empty string.") + if not isinstance(artifact.get("available"), bool): + errors.append("artifact.available must be boolean.") + else: + artifact_available = artifact["available"] + + overall_status = report.get("overall_status") + if overall_status not in QC_STATUS: + errors.append(f"overall_status must be one of {sorted(QC_STATUS)}.") + + checks = report.get("checks") + check_ids: set[str] = set() + observed_count = 0 + failed_count = 0 + decisive_count = 0 + if not isinstance(checks, list) or not checks: + errors.append("checks must be a non-empty array.") + checks = [] + elif len(checks) > 64: + errors.append("checks cannot contain more than 64 entries.") + + for index, check in enumerate(checks): + label = f"checks[{index}]" + if not isinstance(check, dict): + errors.append(f"{label} must be an object.") + continue + check_id = check.get("id") + if not isinstance(check_id, str) or not QC_ID_PATTERN.fullmatch(check_id): + errors.append(f"{label}.id must match {QC_ID_PATTERN.pattern!r}.") + elif check_id in check_ids: + errors.append(f"{label}.id is duplicated: {check_id}.") + else: + check_ids.add(check_id) + status = check.get("status") + evidence = check.get("evidence") + if status not in QC_STATUS: + errors.append(f"{label}.status must be one of {sorted(QC_STATUS)}.") + if evidence not in SUPPORTED_EVIDENCE: + errors.append(f"{label}.evidence must be one of {sorted(SUPPORTED_EVIDENCE)}.") + if not _text(check.get("notes")): + errors.append(f"{label}.notes must be a non-empty string.") + if status in {"pass", "fail"}: + decisive_count += 1 + if evidence != "observed": + errors.append(f"{label} pass/fail status requires observed evidence.") + if evidence == "observed": + observed_count += 1 + if not artifact_available: + errors.append(f"{label} cannot use observed evidence when artifact.available is false.") + if status == "fail": + failed_count += 1 + + if overall_status in {"pass", "fail"}: + if not artifact_available: + errors.append("overall_status pass/fail requires artifact.available to be true.") + if observed_count == 0 or decisive_count == 0: + errors.append("overall_status pass/fail requires at least one observed pass/fail check.") + if overall_status == "pass" and failed_count: + errors.append("overall_status pass cannot contain a failed check.") + if overall_status == "fail" and not failed_count: + warnings.append("overall_status fail has no failed check; confirm the report summary.") + + for field_name in ("limitations", "source_shot_ids"): + if field_name in report: + _string_array(report[field_name], field_name, errors) + + summary = { + "check_count": len(checks), + "observed_check_count": observed_count, + "failed_check_count": failed_count, + "artifact_available": artifact_available, + "overall_status": overall_status, + } + return ValidationResult(not errors, errors, warnings, summary) + + +def validate_qc_report_file(path: str | Path) -> ValidationResult: + """Load and validate a QC report without inspecting the referenced media.""" + + return validate_qc_report(load_json(path)) diff --git a/src/aigc_director_kit/runtime.py b/src/aigc_director_kit/runtime.py new file mode 100644 index 0000000..61421c3 --- /dev/null +++ b/src/aigc_director_kit/runtime.py @@ -0,0 +1,92 @@ +"""Build a deterministic handoff packet for optional runtime adapters.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from .actions import compile_action_request, load_action_library +from .contract import load_json +from .workflow import validate_workflow + + +RUNTIME_HANDOFF_CONTRACT = "aigc-director-runtime-handoff" +RUNTIME_HANDOFF_VERSION = 1 + + +def build_runtime_handoff( + workflow: Any, + action_library: dict[str, Any], + *, + adapter_name: str = "optional-runtime-adapter", +) -> dict[str, Any]: + """Build a dry-run packet without executing an external runtime. + + The packet contains the validated contracts and compiled action requests, + but it never maps ids to motion files, launches Blender, renders media, or + claims that QC has happened. + """ + + if not isinstance(adapter_name, str) or not adapter_name.strip(): + raise ValueError("adapter_name must be a non-empty string.") + + validation = validate_workflow(workflow, action_library) + workflow_dict = workflow if isinstance(workflow, dict) else {} + compiled_requests: list[dict[str, Any]] = [] + for request in workflow_dict.get("action_requests", []): + if not isinstance(request, dict): + continue + text = request.get("text") + if not isinstance(text, str) or not text.strip(): + continue + compiled = compile_action_request(text, action_library) + if compiled["valid"]: + compiled_requests.append( + { + "shot_id": request.get("shot_id"), + "stage_id": request.get("stage_id"), + "input": text, + "compiled": compiled, + } + ) + + qc_report = workflow_dict.get("qc_report") + qc_status = qc_report.get("overall_status") if isinstance(qc_report, dict) else "not_provided" + return { + "contract": RUNTIME_HANDOFF_CONTRACT, + "version": RUNTIME_HANDOFF_VERSION, + "valid": validation.valid, + "project": workflow_dict.get("project"), + "validation": validation.as_dict(), + "shot_plan": workflow_dict.get("shot_plan"), + "prompt_pack": workflow_dict.get("prompt_pack"), + "compiled_action_requests": compiled_requests, + "runtime": { + "adapter": adapter_name, + "mode": "dry-run", + "executed": False, + "required_next_steps": [ + "Map compiled action ids to runtime-owned motion assets.", + "Honor the shot entry and exit states and next-shot handle.", + "Record actual render and QC evidence separately from this packet.", + ], + }, + "evidence": { + "contract_validation": "passed" if validation.valid else "failed", + "render_status": "not_run", + "qc_status": qc_status, + }, + } + + +def build_runtime_handoff_file( + path: str | Path, + action_library_path: str | Path, + *, + adapter_name: str = "optional-runtime-adapter", +) -> dict[str, Any]: + """Load a workflow and action catalog, then build a dry-run packet.""" + + workflow = load_json(path) + library = load_action_library(action_library_path) + return build_runtime_handoff(workflow, library, adapter_name=adapter_name) diff --git a/src/aigc_director_kit/workflow.py b/src/aigc_director_kit/workflow.py new file mode 100644 index 0000000..4a05733 --- /dev/null +++ b/src/aigc_director_kit/workflow.py @@ -0,0 +1,190 @@ +"""Validation and dry-run compilation for skill workflow handoffs. + +The workflow contract is deliberately an orchestration boundary. It records +which skill produced each handoff, embeds a public shot plan, and keeps action +requests as text until the supplied action catalog compiles them. It does not +run external skills, Blender, FFmpeg, model APIs, or video QC. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from .actions import compile_action_request, load_action_library +from .contract import SUPPORTED_EVIDENCE, ValidationResult, load_json, validate_plan +from .prompt import validate_prompt_pack +from .qc import validate_qc_report + + +WORKFLOW_CONTRACT = "aigc-director-skill-workflow" +WORKFLOW_VERSION = 1 +WORKFLOW_ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") +SUPPORTED_STAGE_STATUS = frozenset({"draft", "validated", "unverified"}) + + +def _text(value: Any) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _string_array(value: Any, label: str, errors: list[str]) -> None: + if not isinstance(value, list) or any(not _text(item) for item in value): + errors.append(f"{label} must be a string array.") + + +def validate_workflow( + workflow: Any, + action_library: dict[str, Any] | None = None, +) -> ValidationResult: + """Validate a skill handoff and optionally compile its action requests. + + Without an action library, action requests remain valid but receive a + warning because they have not been matched to runtime-safe action ids. + With a library, an unmatched request is an error and no motion is invented. + """ + + errors: list[str] = [] + warnings: list[str] = [] + if not isinstance(workflow, dict): + return ValidationResult(False, ["The workflow root must be a JSON object."], [], {}) + + if workflow.get("contract") != WORKFLOW_CONTRACT: + errors.append(f"contract must be '{WORKFLOW_CONTRACT}'.") + if workflow.get("version") != WORKFLOW_VERSION: + errors.append(f"version must be {WORKFLOW_VERSION}.") + if not _text(workflow.get("project")): + errors.append("project must be a non-empty string.") + + stages = workflow.get("stages") + stage_ids: set[str] = set() + if not isinstance(stages, list) or not stages: + errors.append("stages must be a non-empty array.") + stages = [] + elif len(stages) > 32: + errors.append("stages cannot contain more than 32 entries.") + + for index, stage in enumerate(stages): + label = f"stages[{index}]" + if not isinstance(stage, dict): + errors.append(f"{label} must be an object.") + continue + stage_id = stage.get("id") + if not isinstance(stage_id, str) or not WORKFLOW_ID_PATTERN.fullmatch(stage_id): + errors.append(f"{label}.id must match {WORKFLOW_ID_PATTERN.pattern!r}.") + elif stage_id in stage_ids: + errors.append(f"{label}.id is duplicated: {stage_id}.") + else: + stage_ids.add(stage_id) + for field_name in ("skill", "role", "output"): + if not _text(stage.get(field_name)): + errors.append(f"{label}.{field_name} must be a non-empty string.") + evidence = stage.get("evidence") + if evidence not in SUPPORTED_EVIDENCE: + errors.append(f"{label}.evidence must be one of {sorted(SUPPORTED_EVIDENCE)}.") + status = stage.get("status", "draft") + if status not in SUPPORTED_STAGE_STATUS: + errors.append(f"{label}.status must be one of {sorted(SUPPORTED_STAGE_STATUS)}.") + + shot_plan = workflow.get("shot_plan") + plan_result: ValidationResult | None = None + shot_ids: set[str] = set() + if shot_plan is None: + errors.append("shot_plan must be an embedded shot-plan object.") + else: + plan_result = validate_plan(shot_plan) + errors.extend(f"shot_plan: {error}" for error in plan_result.errors) + warnings.extend(f"shot_plan: {warning}" for warning in plan_result.warnings) + if isinstance(shot_plan, dict) and isinstance(shot_plan.get("shots"), list): + shot_ids = { + shot.get("id") + for shot in shot_plan["shots"] + if isinstance(shot, dict) and isinstance(shot.get("id"), str) + } + + action_requests = workflow.get("action_requests", []) + compiled_action_count = 0 + matched_action_ids: list[str] = [] + if not isinstance(action_requests, list): + errors.append("action_requests must be an array when provided.") + action_requests = [] + elif len(action_requests) > 100: + errors.append("action_requests cannot contain more than 100 entries.") + + for index, request in enumerate(action_requests): + label = f"action_requests[{index}]" + if not isinstance(request, dict): + errors.append(f"{label} must be an object.") + continue + shot_id = request.get("shot_id") + if shot_id not in shot_ids: + errors.append(f"{label}.shot_id must reference a shot in shot_plan.") + stage_id = request.get("stage_id") + if stage_id not in stage_ids: + errors.append(f"{label}.stage_id must reference a workflow stage.") + if not _text(request.get("text")): + errors.append(f"{label}.text must be a non-empty string.") + if request.get("evidence") not in SUPPORTED_EVIDENCE: + errors.append(f"{label}.evidence must be one of {sorted(SUPPORTED_EVIDENCE)}.") + if action_library is None: + warnings.append(f"{label} was not compiled; provide --library for deterministic matching.") + continue + if not _text(request.get("text")): + continue + compiled = compile_action_request(request["text"], action_library) + if not compiled["valid"]: + errors.append(f"{label} could not be compiled: {compiled['error']}") + continue + compiled_action_count += 1 + matched_action_ids.extend(item["id"] for item in compiled["matched_actions"]) + + handoff = workflow.get("handoff", {}) + if not isinstance(handoff, dict): + errors.append("handoff must be an object when provided.") + handoff = {} + elif "next_adapter" in handoff and not _text(handoff["next_adapter"]): + errors.append("handoff.next_adapter must be a non-empty string.") + for field_name in ("constraints", "unverified"): + if field_name in handoff: + _string_array(handoff[field_name], f"handoff.{field_name}", errors) + + prompt_pack = workflow.get("prompt_pack") + prompt_pack_present = prompt_pack is not None + if prompt_pack_present: + prompt_result = validate_prompt_pack(prompt_pack, shot_plan if isinstance(shot_plan, dict) else None) + errors.extend(f"prompt_pack: {error}" for error in prompt_result.errors) + warnings.extend(f"prompt_pack: {warning}" for warning in prompt_result.warnings) + + qc_report = workflow.get("qc_report") + qc_report_status = None + if qc_report is not None: + qc_result = validate_qc_report(qc_report) + errors.extend(f"qc_report: {error}" for error in qc_result.errors) + warnings.extend(f"qc_report: {warning}" for warning in qc_result.warnings) + if isinstance(qc_report, dict): + qc_report_status = qc_report.get("overall_status") + + plan_summary = plan_result.summary if plan_result else {} + summary = { + "stage_count": len(stages), + "shot_count": plan_summary.get("shot_count", 0), + "shot_ids": plan_summary.get("shot_ids", []), + "action_request_count": len(action_requests), + "compiled_action_count": compiled_action_count, + "matched_action_ids": matched_action_ids, + "next_adapter": handoff.get("next_adapter"), + "prompt_pack_present": prompt_pack_present, + "qc_report_status": qc_report_status, + } + return ValidationResult(not errors, errors, warnings, summary) + + +def validate_workflow_file( + path: str | Path, + action_library_path: str | Path | None = None, +) -> ValidationResult: + """Load and validate a workflow, optionally compiling action requests.""" + + workflow = load_json(path) + library = load_action_library(action_library_path) if action_library_path else None + return validate_workflow(workflow, library) diff --git a/tests/test_cli.py b/tests/test_cli.py index 32a3cfd..0ee7758 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,12 @@ class CliTests(unittest.TestCase): + def test_windows_example_runner_has_portable_python_fallbacks(self) -> None: + runner = (ROOT / "run_examples.bat").read_text(encoding="utf-8") + self.assertIn("AIGC_DIRECTOR_PYTHON", runner) + self.assertIn("%LocalAppData%\\Programs\\Python\\Python*\\python.exe", runner) + self.assertIn("Python 3.10 or newer", runner) + def test_validate_plan_json_mode(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: output = Path(temp_dir) / "result.json" diff --git a/tests/test_prompt.py b/tests/test_prompt.py new file mode 100644 index 0000000..89d4ce8 --- /dev/null +++ b/tests/test_prompt.py @@ -0,0 +1,41 @@ +import json +import unittest +from pathlib import Path + +from aigc_director_kit.contract import load_json +from aigc_director_kit.prompt import validate_prompt_pack, validate_prompt_pack_file + + +ROOT = Path(__file__).resolve().parents[1] + + +class PromptPackTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.prompt_path = ROOT / "examples" / "prompt_pack_case.json" + cls.plan_path = ROOT / "examples" / "one_take_previs_case.json" + + def test_example_matches_source_plan(self) -> None: + result = validate_prompt_pack_file(self.prompt_path, self.plan_path) + self.assertTrue(result.valid, result.errors) + self.assertEqual(result.summary["prompt_shot_count"], 1) + self.assertEqual(result.summary["source_shot_count"], 1) + self.assertEqual(result.warnings, []) + + def test_duration_drift_is_a_warning(self) -> None: + prompt_pack = json.loads(self.prompt_path.read_text(encoding="utf-8")) + prompt_pack["shots"][0]["duration_s"] = 3.5 + result = validate_prompt_pack(prompt_pack, load_json(self.plan_path)) + self.assertTrue(result.valid, result.errors) + self.assertTrue(any("duration_s differs" in warning for warning in result.warnings)) + + def test_action_causality_is_required(self) -> None: + prompt_pack = json.loads(self.prompt_path.read_text(encoding="utf-8")) + del prompt_pack["shots"][0]["action_causality"] + result = validate_prompt_pack(prompt_pack) + self.assertFalse(result.valid) + self.assertTrue(any("action_causality" in error for error in result.errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qc.py b/tests/test_qc.py new file mode 100644 index 0000000..428fe79 --- /dev/null +++ b/tests/test_qc.py @@ -0,0 +1,68 @@ +import json +import unittest +from pathlib import Path + +from aigc_director_kit.qc import validate_qc_report, validate_qc_report_file + + +ROOT = Path(__file__).resolve().parents[1] + + +class QcReportTests(unittest.TestCase): + def test_unverified_example_is_valid(self) -> None: + result = validate_qc_report_file(ROOT / "examples" / "qc_report_unverified_case.json") + self.assertTrue(result.valid, result.errors) + self.assertEqual(result.summary["observed_check_count"], 0) + self.assertEqual(result.summary["overall_status"], "unverified") + + def test_pass_requires_observed_artifact_evidence(self) -> None: + report = { + "contract": "aigc-director-qc-report", + "version": 1, + "project": "qc-test", + "artifact": {"label": "clip", "available": True}, + "overall_status": "pass", + "checks": [ + { + "id": "continuity", + "status": "pass", + "evidence": "inferred", + "notes": "Not actually inspected.", + } + ], + } + result = validate_qc_report(report) + self.assertFalse(result.valid) + self.assertTrue(any("observed evidence" in error for error in result.errors)) + + def test_observed_pass_is_valid_when_artifact_is_available(self) -> None: + report = { + "contract": "aigc-director-qc-report", + "version": 1, + "project": "qc-test", + "artifact": {"label": "clip", "available": True}, + "overall_status": "pass", + "checks": [ + { + "id": "continuity", + "status": "pass", + "evidence": "observed", + "notes": "Tail frame matches the next-shot entry contract.", + } + ], + } + result = validate_qc_report(report) + self.assertTrue(result.valid, result.errors) + + def test_unavailable_artifact_cannot_have_observed_check(self) -> None: + report = json.loads( + (ROOT / "examples" / "qc_report_unverified_case.json").read_text(encoding="utf-8") + ) + report["checks"][0]["evidence"] = "observed" + result = validate_qc_report(report) + self.assertFalse(result.valid) + self.assertTrue(any("artifact.available" in error for error in result.errors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..42fd9a7 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,45 @@ +import json +import unittest +from pathlib import Path + +from aigc_director_kit.actions import load_action_library +from aigc_director_kit.runtime import build_runtime_handoff, build_runtime_handoff_file + + +ROOT = Path(__file__).resolve().parents[1] + + +class RuntimeHandoffTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow_path = ROOT / "examples" / "skill_workflow_case.json" + cls.library_path = ROOT / "examples" / "action_library.json" + cls.library = load_action_library(cls.library_path) + + def test_builds_dry_run_packet(self) -> None: + packet = build_runtime_handoff_file(self.workflow_path, self.library_path, adapter_name="blender-previs") + self.assertTrue(packet["valid"]) + self.assertEqual(packet["contract"], "aigc-director-runtime-handoff") + self.assertEqual(packet["runtime"]["adapter"], "blender-previs") + self.assertFalse(packet["runtime"]["executed"]) + self.assertEqual( + packet["compiled_action_requests"][0]["compiled"]["matched_actions"][0]["id"], + "run_quick_stop", + ) + self.assertEqual(packet["evidence"]["render_status"], "not_run") + + def test_invalid_workflow_remains_invalid_in_packet(self) -> None: + workflow = json.loads(self.workflow_path.read_text(encoding="utf-8")) + workflow["action_requests"][0]["text"] = "invent an unsupported action" + packet = build_runtime_handoff(workflow, self.library) + self.assertFalse(packet["valid"]) + self.assertEqual(packet["evidence"]["contract_validation"], "failed") + self.assertEqual(packet["compiled_action_requests"], []) + + def test_adapter_name_is_required(self) -> None: + with self.assertRaises(ValueError): + build_runtime_handoff({}, self.library, adapter_name=" ") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..bfb20a3 --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,90 @@ +import json +import unittest +from pathlib import Path + +from aigc_director_kit.actions import load_action_library +from aigc_director_kit.workflow import validate_workflow, validate_workflow_file + + +ROOT = Path(__file__).resolve().parents[1] + + +class WorkflowTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow_path = ROOT / "examples" / "skill_workflow_case.json" + cls.library = load_action_library(ROOT / "examples" / "action_library.json") + + def test_example_is_structurally_valid_without_library(self) -> None: + result = validate_workflow_file(self.workflow_path) + self.assertTrue(result.valid, result.errors) + self.assertEqual(result.summary["stage_count"], 3) + self.assertEqual(result.summary["shot_count"], 1) + self.assertEqual(result.summary["compiled_action_count"], 0) + self.assertTrue(result.warnings) + + def test_example_compiles_action_handoff(self) -> None: + result = validate_workflow_file( + self.workflow_path, + ROOT / "examples" / "action_library.json", + ) + self.assertTrue(result.valid, result.errors) + self.assertEqual(result.summary["compiled_action_count"], 1) + self.assertEqual(result.summary["matched_action_ids"], ["run_quick_stop"]) + self.assertEqual(result.warnings, []) + + def test_unknown_action_is_rejected(self) -> None: + workflow = json.loads(self.workflow_path.read_text(encoding="utf-8")) + workflow["action_requests"][0]["text"] = "invent a flying sword choreography" + result = validate_workflow(workflow, self.library) + self.assertFalse(result.valid) + self.assertTrue(any("could not be compiled" in error for error in result.errors)) + + def test_action_request_must_reference_a_known_shot(self) -> None: + workflow = json.loads(self.workflow_path.read_text(encoding="utf-8")) + workflow["action_requests"][0]["shot_id"] = "S99" + result = validate_workflow(workflow, self.library) + self.assertFalse(result.valid) + self.assertTrue(any("shot_id" in error for error in result.errors)) + + def test_optional_prompt_and_qc_layers_are_validated(self) -> None: + workflow = json.loads(self.workflow_path.read_text(encoding="utf-8")) + workflow["prompt_pack"] = json.loads( + (ROOT / "examples" / "prompt_pack_case.json").read_text(encoding="utf-8") + ) + workflow["prompt_pack"]["project"] = workflow["project"] + workflow["prompt_pack"]["shots"][0]["source_shot_id"] = "S01" + workflow["prompt_pack"]["shots"][0]["duration_s"] = 4.0 + workflow["qc_report"] = json.loads( + (ROOT / "examples" / "qc_report_unverified_case.json").read_text(encoding="utf-8") + ) + workflow["qc_report"]["project"] = workflow["project"] + result = validate_workflow(workflow, self.library) + self.assertTrue(result.valid, result.errors) + self.assertTrue(result.summary["prompt_pack_present"]) + self.assertEqual(result.summary["qc_report_status"], "unverified") + + def test_invalid_embedded_qc_layer_rejects_workflow(self) -> None: + workflow = json.loads(self.workflow_path.read_text(encoding="utf-8")) + workflow["qc_report"] = { + "contract": "aigc-director-qc-report", + "version": 1, + "project": workflow["project"], + "artifact": {"label": "missing", "available": False}, + "overall_status": "pass", + "checks": [ + { + "id": "continuity", + "status": "pass", + "evidence": "inferred", + "notes": "Not inspected.", + } + ], + } + result = validate_workflow(workflow, self.library) + self.assertFalse(result.valid) + self.assertTrue(any("qc_report" in error for error in result.errors)) + + +if __name__ == "__main__": + unittest.main()