From 2d450816661cba665e31ff64c8585639bdfe98ee Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Mon, 12 Oct 2026 23:09:56 +0000 Subject: [PATCH 01/68] Agent interface, chained long-video generation and editor foundations The local Agent interface (discovery, connection, jobs, projects, cast and exports), the chain backend that generates a long video in continued parts with seam repair, and the editor modules these build on. --- THIRD_PARTY_NOTICES.md | 7 +- __init__.py | 12 + constraints/engine.txt | 1 + constraints/macos-runtime-requirements.txt | 1 + constraints/macos-runtime.txt | 1 + constraints/unified.txt | 1 + constraints/windows-portable.txt | 1 + constraints/windows-unified.txt | 1 + docs/community-results.md | 5 - docs/zh-CN/community-results.md | 5 - freevideo_engine/access_proxy.py | 13 +- freevideo_engine/agent/__init__.py | 15 + freevideo_engine/agent/access.py | 63 + freevideo_engine/agent/assets.py | 37 + freevideo_engine/agent/builtin_director.py | 553 ++++++ freevideo_engine/agent/cast.py | 775 ++++++++ freevideo_engine/agent/cli.py | 277 +++ freevideo_engine/agent/client.py | 360 ++++ freevideo_engine/agent/comfy.py | 347 ++++ freevideo_engine/agent/drama.py | 571 ++++++ freevideo_engine/agent/edit.py | 1746 +++++++++++++++++ freevideo_engine/agent/editor_runtime.py | 347 ++++ freevideo_engine/agent/errors.py | 196 ++ freevideo_engine/agent/forecast.py | 39 + freevideo_engine/agent/guide.py | 260 +++ freevideo_engine/agent/image_runtime.py | 272 +++ freevideo_engine/agent/images.py | 382 ++++ freevideo_engine/agent/jobs.py | 1403 +++++++++++++ freevideo_engine/agent/mcp.py | 451 +++++ freevideo_engine/agent/media.py | 740 +++++++ freevideo_engine/agent/operation_receipts.py | 30 + freevideo_engine/agent/production.py | 700 +++++++ freevideo_engine/agent/productions.py | 953 +++++++++ freevideo_engine/agent/projects.py | 399 ++++ freevideo_engine/agent/routes.py | 1409 +++++++++++++ freevideo_engine/agent/schema.py | 1390 +++++++++++++ freevideo_engine/agent/sessions.py | 390 ++++ freevideo_engine/agent/spec.py | 248 +++ freevideo_engine/backends/mps.py | 1 + freevideo_engine/backends/mps_nvfp4.py | 31 +- freevideo_engine/bootstrap.py | 2 +- freevideo_engine/chain_join.py | 562 ++++++ freevideo_engine/chain_plan.py | 94 + freevideo_engine/chain_runner.py | 586 ++++++ freevideo_engine/chain_store.py | 281 +++ freevideo_engine/cli.py | 3 + freevideo_engine/comfy_assets.py | 17 +- freevideo_engine/comfy_bridge.py | 812 ++++++-- freevideo_engine/comfy_chain.py | 437 +++++ freevideo_engine/comfy_editor.py | 1197 +++++++++++ freevideo_engine/comfy_host.py | 11 +- freevideo_engine/comfy_launcher_runtime.py | 3 + freevideo_engine/comfy_library.py | 47 +- freevideo_engine/comfy_media.py | 14 +- freevideo_engine/comfy_nodes.py | 28 +- freevideo_engine/comfy_progress.py | 9 +- freevideo_engine/comfy_service.py | 14 +- freevideo_engine/decode_prefetch.py | 19 +- freevideo_engine/diagnostic_resources.py | 3 +- freevideo_engine/editor_audio.py | 159 ++ freevideo_engine/editor_export.py | 865 ++++++++ freevideo_engine/editor_export_options.py | 126 ++ freevideo_engine/editor_frames.py | 31 + freevideo_engine/editor_picture.py | 366 ++++ freevideo_engine/editor_picture_fields.py | 10 + freevideo_engine/editor_speed.py | 488 +++++ freevideo_engine/editor_text.py | 237 +++ freevideo_engine/generate.py | 1 + freevideo_engine/launcher_settings.py | 7 +- freevideo_engine/licenses/quickjs-MIT.txt | 47 + .../licenses/runtime/THIRD_PARTY_NOTICES.txt | 5 + freevideo_engine/macos_encoder.py | 20 +- freevideo_engine/macos_encoder_attention.py | 166 ++ freevideo_engine/macos_encoder_linear.py | 246 +++ freevideo_engine/managed.py | 14 +- freevideo_engine/offload.py | 36 + freevideo_engine/policy.py | 7 +- freevideo_engine/progress_timeline.py | 9 +- freevideo_engine/prompt_vlm/rules.py | 112 ++ freevideo_engine/prompt_vlm/service.py | 50 +- freevideo_engine/prompt_vlm/worker.py | 110 +- freevideo_engine/proxy_identity.py | 34 + freevideo_engine/release_notes.json | 4 +- freevideo_engine/runtime.py | 24 +- freevideo_engine/support_report.py | 1 + freevideo_engine/two_pass.py | 35 + freevideo_engine/worker.py | 12 +- pyproject.toml | 4 +- scripts/test_freevideo_entry.mjs | 29 +- web/agent_activity.css | 50 + web/agent_activity.js | 204 ++ web/agent_connect.css | 19 + web/agent_connect.js | 116 ++ web/agent_session.js | 28 + web/dialog_keys.js | 87 + web/editor.css | 142 ++ web/editor.js | 813 ++++++++ web/editor_actions.js | 94 + web/editor_adjust.css | 35 + web/editor_adjust.js | 245 +++ web/editor_adjust_picture.css | 21 + web/editor_adjust_picture.js | 123 ++ web/editor_entry.js | 37 + web/editor_export_dialog.css | 28 + web/editor_export_dialog.js | 196 ++ web/editor_frame.css | 32 + web/editor_frame.js | 134 ++ web/editor_genbar.js | 103 + web/editor_generation.js | 15 + web/editor_geometry.js | 147 ++ web/editor_icons.js | 13 + web/editor_library.js | 193 ++ web/editor_menu.css | 9 + web/editor_menu.js | 163 ++ web/editor_model.js | 941 +++++++++ web/editor_notes.css | 24 + web/editor_notes.js | 165 ++ web/editor_picture_fields.js | 40 + web/editor_picture_session.js | 67 + web/editor_player.js | 183 ++ web/editor_preview_picture.css | 18 + web/editor_preview_picture.js | 275 +++ web/editor_room.js | 142 ++ web/editor_select.css | 15 + web/editor_select.js | 302 +++ web/editor_state.js | 129 ++ web/editor_store.js | 475 +++++ web/editor_text.css | 46 + web/editor_text.js | 278 +++ web/editor_text_coverage.js | 1 + web/editor_text_layout.js | 69 + web/editor_text_model.js | 120 ++ web/editor_tracks.css | 96 + web/editor_tracks.js | 1106 +++++++++++ web/fonts.js | 7 +- web/frame_sizes.js | 11 + web/freevideo.js | 84 +- web/health.js | 10 +- web/image_commands.js | 273 +++ web/image_editor.js | 226 +-- web/image_editor_engine.js | 7 +- web/library.css | 1 + web/library.js | 15 +- web/prompt_references.js | 3 +- web/setup.js | 3 +- web/share.js | 6 +- web/share_layout.js | 8 + web/studio.js | 58 +- web/view_navigation.css | 10 +- web/view_navigation.js | 24 +- 150 files changed, 29651 insertions(+), 461 deletions(-) create mode 100644 freevideo_engine/agent/__init__.py create mode 100644 freevideo_engine/agent/access.py create mode 100644 freevideo_engine/agent/assets.py create mode 100644 freevideo_engine/agent/builtin_director.py create mode 100644 freevideo_engine/agent/cast.py create mode 100644 freevideo_engine/agent/cli.py create mode 100644 freevideo_engine/agent/client.py create mode 100644 freevideo_engine/agent/comfy.py create mode 100644 freevideo_engine/agent/drama.py create mode 100644 freevideo_engine/agent/edit.py create mode 100644 freevideo_engine/agent/editor_runtime.py create mode 100644 freevideo_engine/agent/errors.py create mode 100644 freevideo_engine/agent/forecast.py create mode 100644 freevideo_engine/agent/guide.py create mode 100644 freevideo_engine/agent/image_runtime.py create mode 100644 freevideo_engine/agent/images.py create mode 100644 freevideo_engine/agent/jobs.py create mode 100644 freevideo_engine/agent/mcp.py create mode 100644 freevideo_engine/agent/media.py create mode 100644 freevideo_engine/agent/operation_receipts.py create mode 100644 freevideo_engine/agent/production.py create mode 100644 freevideo_engine/agent/productions.py create mode 100644 freevideo_engine/agent/projects.py create mode 100644 freevideo_engine/agent/routes.py create mode 100644 freevideo_engine/agent/schema.py create mode 100644 freevideo_engine/agent/sessions.py create mode 100644 freevideo_engine/agent/spec.py create mode 100644 freevideo_engine/chain_join.py create mode 100644 freevideo_engine/chain_plan.py create mode 100644 freevideo_engine/chain_runner.py create mode 100644 freevideo_engine/chain_store.py create mode 100644 freevideo_engine/comfy_chain.py create mode 100644 freevideo_engine/comfy_editor.py create mode 100644 freevideo_engine/editor_audio.py create mode 100644 freevideo_engine/editor_export.py create mode 100644 freevideo_engine/editor_export_options.py create mode 100644 freevideo_engine/editor_frames.py create mode 100644 freevideo_engine/editor_picture.py create mode 100644 freevideo_engine/editor_picture_fields.py create mode 100644 freevideo_engine/editor_speed.py create mode 100644 freevideo_engine/editor_text.py create mode 100644 freevideo_engine/licenses/quickjs-MIT.txt create mode 100644 freevideo_engine/macos_encoder_attention.py create mode 100644 freevideo_engine/macos_encoder_linear.py create mode 100644 freevideo_engine/proxy_identity.py create mode 100644 web/agent_activity.css create mode 100644 web/agent_activity.js create mode 100644 web/agent_connect.css create mode 100644 web/agent_connect.js create mode 100644 web/agent_session.js create mode 100644 web/dialog_keys.js create mode 100644 web/editor.css create mode 100644 web/editor.js create mode 100644 web/editor_actions.js create mode 100644 web/editor_adjust.css create mode 100644 web/editor_adjust.js create mode 100644 web/editor_adjust_picture.css create mode 100644 web/editor_adjust_picture.js create mode 100644 web/editor_entry.js create mode 100644 web/editor_export_dialog.css create mode 100644 web/editor_export_dialog.js create mode 100644 web/editor_frame.css create mode 100644 web/editor_frame.js create mode 100644 web/editor_genbar.js create mode 100644 web/editor_generation.js create mode 100644 web/editor_geometry.js create mode 100644 web/editor_icons.js create mode 100644 web/editor_library.js create mode 100644 web/editor_menu.css create mode 100644 web/editor_menu.js create mode 100644 web/editor_model.js create mode 100644 web/editor_notes.css create mode 100644 web/editor_notes.js create mode 100644 web/editor_picture_fields.js create mode 100644 web/editor_picture_session.js create mode 100644 web/editor_player.js create mode 100644 web/editor_preview_picture.css create mode 100644 web/editor_preview_picture.js create mode 100644 web/editor_room.js create mode 100644 web/editor_select.css create mode 100644 web/editor_select.js create mode 100644 web/editor_state.js create mode 100644 web/editor_store.js create mode 100644 web/editor_text.css create mode 100644 web/editor_text.js create mode 100644 web/editor_text_coverage.js create mode 100644 web/editor_text_layout.js create mode 100644 web/editor_text_model.js create mode 100644 web/editor_tracks.css create mode 100644 web/editor_tracks.js create mode 100644 web/frame_sizes.js create mode 100644 web/image_commands.js create mode 100644 web/share_layout.js diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fe905cc2..1c8d1992 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,15 +7,16 @@ FreeVideo code uses [Apache-2.0](LICENSE). Its dependencies and models retain th | Qt, PySide6 and Shiboken6 | LGPL-3.0; [component notices](freevideo_engine/launcher/licenses/NOTICE.txt) | | PyInstaller bootloader | GPL-2.0 with the bootloader exception | | CPython / psutil | PSF / BSD-3-Clause | +| [QuickJS Python binding](https://github.com/PetterS/quickjs) 1.19.4 (author: Petter Strandmark) and QuickJS (© 2017–2021 Fabrice Bellard and Charlie Gordon) | [MIT notices](freevideo_engine/licenses/quickjs-MIT.txt) | | [ComfyUI](https://github.com/Comfy-Org/ComfyUI), including the H3 text encoder code | GPL-3.0 | | [VDN-H3](https://github.com/OpenVDN/vdn-minimax-h3), [Diffusers](https://github.com/huggingface/diffusers), [SageAttention](https://github.com/thu-ml/SageAttention) | Apache-2.0 | | [VDN-H3 and H3 model weights](https://huggingface.co/OpenVDN/vdn-minimax-h3-edge/blob/main/LICENSE) | MiniMax H3 Community License | | [MiniMax H3 latent upscaler](https://huggingface.co/LBH-123-AI/Minimax_h3_latent_Upscaler) | [MIT](freevideo_engine/licenses/latent-upscaler-MIT.txt) | | [FreeToken](https://github.com/FlashML-org/FreeToken) prompt VLM operators, [Qwen3-VL-4B-Instruct](https://huggingface.co/Qwen/Qwen3-VL-4B-Instruct) optional weights | [Apache-2.0](freevideo_engine/licenses/FreeToken-Apache-2.0.txt) | | [Lucide](https://lucide.dev) icons (image editor: eyedropper, hand, crop) | [ISC](freevideo_engine/licenses/Lucide-ISC.txt) | -| [Noto Sans CJK SC and Noto Sans Mono CJK SC](https://github.com/notofonts/noto-cjk) 2.004, © 2014-2021 Adobe: subsets renamed FreeVideo Sans SC and FreeVideo Mono SC (image editor text, share card, Linux launcher) | [SIL OFL 1.1](freevideo_engine/licenses/Noto-CJK-Sans-OFL.txt) | -| [Noto Serif CJK SC](https://github.com/notofonts/noto-cjk) 2.003, © 2017-2024 Adobe: subset renamed FreeVideo Serif SC (image editor text) | [SIL OFL 1.1](freevideo_engine/licenses/Noto-CJK-Serif-OFL.txt) | -| [LXGW WenKai](https://github.com/lxgw/LxgwWenKai) v1.522, © 2021-2026 LXGW, © 2020 The Klee Project Authors: subset renamed FreeVideo Kai SC (image editor text) | [SIL OFL 1.1](freevideo_engine/licenses/LXGW-WenKai-OFL.txt) | +| [Noto Sans CJK SC and Noto Sans Mono CJK SC](https://github.com/notofonts/noto-cjk) 2.004, © 2014-2021 Adobe: subsets renamed FreeVideo Sans SC and FreeVideo Mono SC (image editor text, editor text and subtitles / 剪辑视图的文字和字幕, share card, Linux launcher) | [SIL OFL 1.1](freevideo_engine/licenses/Noto-CJK-Sans-OFL.txt) | +| [Noto Serif CJK SC](https://github.com/notofonts/noto-cjk) 2.003, © 2017-2024 Adobe: subset renamed FreeVideo Serif SC (image editor text, editor text and subtitles / 剪辑视图的文字和字幕) | [SIL OFL 1.1](freevideo_engine/licenses/Noto-CJK-Serif-OFL.txt) | +| [LXGW WenKai](https://github.com/lxgw/LxgwWenKai) v1.522, © 2021-2026 LXGW, © 2020 The Klee Project Authors: subset renamed FreeVideo Kai SC (image editor text, editor text and subtitles / 剪辑视图的文字和字幕) | [SIL OFL 1.1](freevideo_engine/licenses/LXGW-WenKai-OFL.txt) | Original launcher license texts are included in Windows packages. Other runtime dependencies are listed in [constraints](constraints), with their license information in the installed packages. diff --git a/__init__.py b/__init__.py index 98f88f8c..ef16ef70 100644 --- a/__init__.py +++ b/__init__.py @@ -5,6 +5,12 @@ async def comfy_entrypoint(): from .freevideo_engine.comfy_progress import register as progress_routes progress_routes() + try: + from .freevideo_engine.comfy_chain import register as chain_routes + chain_routes() + except Exception as error: + import logging + logging.warning('[FreeVideo] Chain routes not registered: %s', error) from .freevideo_engine.comfy_upload import register register() from .freevideo_engine.comfy_setup import register as setup_routes @@ -17,11 +23,17 @@ async def comfy_entrypoint(): update_routes() from .freevideo_engine.comfy_library import register as library_routes library_routes() + from .freevideo_engine.comfy_editor import register as editor_routes + editor_routes() + from .freevideo_engine.editor_export import register as editor_export_routes + editor_export_routes() from .freevideo_engine.comfy_capabilities import register as capabilities_routes capabilities_routes() from .freevideo_engine.comfy_share import register as share_routes share_routes() from .freevideo_engine.comfy_metadata import register as workflow_backfill workflow_backfill() + from .freevideo_engine.agent.routes import register as agent_routes + agent_routes() from .freevideo_engine.comfy_nodes import FreeVideoExtension return FreeVideoExtension() diff --git a/constraints/engine.txt b/constraints/engine.txt index 7f56b90b..5260638e 100644 --- a/constraints/engine.txt +++ b/constraints/engine.txt @@ -62,6 +62,7 @@ protobuf==6.33.6 psutil==7.2.2 pygments==2.21.0 pyyaml==6.0.3 +quickjs==1.19.4 quack-kernels==0.5.3 regex==2026.9.3 requests==2.34.2 diff --git a/constraints/macos-runtime-requirements.txt b/constraints/macos-runtime-requirements.txt index 3a2708d0..30792585 100644 --- a/constraints/macos-runtime-requirements.txt +++ b/constraints/macos-runtime-requirements.txt @@ -15,6 +15,7 @@ httpx[socks] requests[socks] importlib-metadata packaging +quickjs==1.19.4 wheel setuptools pip diff --git a/constraints/macos-runtime.txt b/constraints/macos-runtime.txt index 4db0198f..f9d65229 100644 --- a/constraints/macos-runtime.txt +++ b/constraints/macos-runtime.txt @@ -43,6 +43,7 @@ pygments==2.21.0 pycparser==3.0 pysocks==1.7.1 pyyaml==6.0.3 +quickjs==1.19.4 regex==2026.9.3 requests==2.34.2 rich==15.0.0 diff --git a/constraints/unified.txt b/constraints/unified.txt index 882a9d69..883da4aa 100644 --- a/constraints/unified.txt +++ b/constraints/unified.txt @@ -59,6 +59,7 @@ pygments==2.21.0 pycparser==3.0 pysocks==1.7.1 pyyaml==6.0.3 +quickjs==1.19.4 regex==2026.9.3 requests==2.34.2 rich==15.0.0 diff --git a/constraints/windows-portable.txt b/constraints/windows-portable.txt index 5d576087..66290b9b 100644 --- a/constraints/windows-portable.txt +++ b/constraints/windows-portable.txt @@ -73,6 +73,7 @@ pyopengl==3.1.10 pysocks==1.7.1 python-dotenv==1.2.3 pyyaml==6.0.3 +quickjs==1.19.4 regex==2026.9.3 requests==2.34.2 rich==15.0.0 diff --git a/constraints/windows-unified.txt b/constraints/windows-unified.txt index f3699539..b545bd43 100644 --- a/constraints/windows-unified.txt +++ b/constraints/windows-unified.txt @@ -42,6 +42,7 @@ pip==26.2.1 psutil==7.2.2 pygments==2.21.0 pyyaml==6.0.3 +quickjs==1.19.4 regex==2026.9.3 requests==2.34.2 rich==15.0.0 diff --git a/docs/community-results.md b/docs/community-results.md index 4194d603..e149f0f5 100644 --- a/docs/community-results.md +++ b/docs/community-results.md @@ -48,11 +48,6 @@ Thanks to the community members who sent diagnostic reports. FreeVideo already r | 5060 | 8 GB | 32 GB | 1344×768 | 10 s | Light | 0.3.23 | 1279 s | 0.3.25 | 1150 s (−130 s) | | 3060 | 12 GB | 32 GB | 832×1248 | 10 s | Light | 0.3.24 | 1668 s | 0.3.24 | GPU shared⁷ | | 5080 | 16 GB | 64 GB | 1344×768 | 10 s | Light | 0.3.24 | 312 s | 0.3.24 | 278 s (−34 s) | -| 4070 Ti SUPER | 16 GB | 96 GB | 1344×768 | 10 s | Light | 0.3.24 | 575 s | 0.3.24 | Already optimal | -| 4080 Laptop | 12 GB | 32 GB | 1344×768 | 10 s | Light | 0.3.24 | 970 s | 0.3.24 | Already optimal | -| 4060 Laptop | 8 GB | 32 GB | 768×1344 | 6.6 s | Light⁵ | 0.3.24 | 386 s | 0.3.24 | Already optimal | -| 5060 Ti | 16 GB | 32 GB | 768×1344 | 15.1 s | Light | 0.3.24 | 847 s | 0.3.24 | Already optimal | -| 5060 | 8 GB | 32 GB | 1344×768 | 4.5 s | Light | 0.3.25 | 390 s | 0.3.25 | Already optimal | - Time: end to end, from submission until the video is saved. - After: the estimated time, derived from the report's stage times and from complete planned runs on our test machines (RTX 3090 and RTX 5060 Ti on Windows, RTX PRO 6000 on Linux) under the reporting machine's VRAM and RAM limits, not from reruns on that machine. diff --git a/docs/zh-CN/community-results.md b/docs/zh-CN/community-results.md index 52ab98c8..619b8833 100644 --- a/docs/zh-CN/community-results.md +++ b/docs/zh-CN/community-results.md @@ -48,11 +48,6 @@ | 5060 | 8 GB | 32 GB | 1344×768 | 10 s | 轻⁠量 | 0.3.23 | 1279 s | 0.3.25 | 1150 s⁠(−130 s⁠) | | 3060 | 12 GB | 32 GB | 832×1248 | 10 s | 轻⁠量 | 0.3.24 | 1668 s | 0.3.24 | 显⁠卡⁠被⁠占⁠用⁷ | | 5080 | 16 GB | 64 GB | 1344×768 | 10 s | 轻⁠量 | 0.3.24 | 312 s | 0.3.24 | 278 s⁠(−34 s⁠) | -| 4070 Ti SUPER | 16 GB | 96 GB | 1344×768 | 10 s | 轻⁠量 | 0.3.24 | 575 s | 0.3.24 | 已⁠是⁠最⁠优 | -| 4080 笔⁠记⁠本 | 12 GB | 32 GB | 1344×768 | 10 s | 轻⁠量 | 0.3.24 | 970 s | 0.3.24 | 已⁠是⁠最⁠优 | -| 4060 笔⁠记⁠本 | 8 GB | 32 GB | 768×1344 | 6.6 s | 轻⁠量⁵ | 0.3.24 | 386 s | 0.3.24 | 已⁠是⁠最⁠优 | -| 5060 Ti | 16 GB | 32 GB | 768×1344 | 15.1 s | 轻⁠量 | 0.3.24 | 847 s | 0.3.24 | 已⁠是⁠最⁠优 | -| 5060 | 8 GB | 32 GB | 1344×768 | 4.5 s | 轻⁠量 | 0.3.25 | 390 s | 0.3.25 | 已⁠是⁠最⁠优 | - 用时:从提交到视频保存完成的端到端用时。 - 优化后:预估用时,根据报告中的分段用时,结合测试机(Windows RTX 3090 和 RTX 5060 Ti、Linux RTX PRO 6000)在报告机器的显存和内存上限下运行完整规划的实测推算,不是在报告机器上重跑。 diff --git a/freevideo_engine/access_proxy.py b/freevideo_engine/access_proxy.py index 6aacf435..4f70e422 100644 --- a/freevideo_engine/access_proxy.py +++ b/freevideo_engine/access_proxy.py @@ -9,12 +9,14 @@ import argparse import asyncio import hmac +import os from pathlib import Path import ssl from urllib.parse import urlencode from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, WSMsgType, client_exceptions, web from yarl import URL +from . import proxy_identity COOKIE = 'freevideo_access' DAYS = 30 @@ -54,7 +56,8 @@ def forwarded(request): headers = [] for name, value in request.headers.items(): lower = name.lower() - if lower in HOP_BY_HOP or lower == 'authorization': + if lower in HOP_BY_HOP or lower in ('authorization', proxy_identity.HEADER.lower(), + proxy_identity.PEER.lower(), proxy_identity.ORIGIN.lower()): continue if lower == 'cookie': value = '; '.join(part for part in value.split(';') if part.strip() and @@ -69,6 +72,10 @@ def create(upstream, token): upstream = upstream.rstrip('/') sockets = 'ws' + upstream[len('http'):] client = [] + hop_secret = os.environ.pop(proxy_identity.ENV, '') + + def forwarding_headers(request): + return forwarded(request) + proxy_identity.headers(request, hop_secret) async def startup(app): # No total timeout: downloads and the progress socket last as long as they need. @@ -82,7 +89,7 @@ async def forward(request): body = request.content if request.body_exists else None try: async with client[0].request(request.method, URL(upstream + request.raw_path, encoded=True), - headers=forwarded(request), data=body, allow_redirects=False) as reply: + headers=forwarding_headers(request), data=body, allow_redirects=False) as reply: response = web.StreamResponse(status=reply.status, reason=reply.reason) for name, value in reply.headers.items(): if name.lower() not in HOP_BY_HOP: @@ -100,7 +107,7 @@ async def forward(request): async def websocket(request): browser = web.WebSocketResponse(max_msg_size=0) await browser.prepare(request) - headers = [(k, v) for k, v in forwarded(request) if k.lower() not in ( + headers = [(k, v) for k, v in forwarding_headers(request) if k.lower() not in ( 'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol')] try: async with client[0].ws_connect(URL(sockets + request.raw_path, encoded=True), headers=headers, diff --git a/freevideo_engine/agent/__init__.py b/freevideo_engine/agent/__init__.py new file mode 100644 index 00000000..af92663e --- /dev/null +++ b/freevideo_engine/agent/__init__.py @@ -0,0 +1,15 @@ +"""Agent-native control of FreeVideo: job specs, media tools, headless edits and MCP. + +The agent features are held back for now: they stay off, with no routes, token, +commands or controls, unless FREEVIDEO_AGENT=1 is set where ComfyUI runs (and +for the agent and mcp commands).""" +import os + +API_VERSION = 1 +SWITCH = 'FREEVIDEO_AGENT' + + +def enabled(environ=None): + """Whether the agent features are on: FREEVIDEO_AGENT=1 (or true, yes, on).""" + value = (os.environ if environ is None else environ).get(SWITCH, '') + return value.strip().lower() in ('1', 'true', 'yes', 'on') diff --git a/freevideo_engine/agent/access.py b/freevideo_engine/agent/access.py new file mode 100644 index 00000000..553ad56b --- /dev/null +++ b/freevideo_engine/agent/access.py @@ -0,0 +1,63 @@ +"""Peer and browser-origin policy, independent of bearer authentication.""" +import ipaddress +from urllib.parse import urlsplit + +from .jobs import JobError +from .. import proxy_identity + + +def peer_address(request): + peer = request.transport.get_extra_info('peername') if request.transport else None + try: + address = ipaddress.ip_address(peer[0].split('%')[0]) + address = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped else address + if address.is_loopback and proxy_identity.trusted(request): + address = ipaddress.ip_address(request.headers.get(proxy_identity.PEER, '').split('%')[0]) + return address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped else address + except (TypeError, ValueError, IndexError): + return None + + +def lan_enabled(): + from ..comfy_bridge import installation_root + from ..launcher_settings import Store + try: + return Store(installation_root()).currently_allows('lan_access') + except (OSError, ValueError): + return False + + +def check(request, *, local=False): + address = peer_address(request) + proxied = proxy_identity.HEADER in request.headers + # Even a proxy client on loopback is never a local control-plane client. + if (proxied and (not proxy_identity.trusted(request) or local or not lan_enabled()) + or address is None or not address.is_loopback and (local or not address.is_private or not lan_enabled())): + raise JobError('local_only', 'Peer is not permitted') + socket = request.transport.get_extra_info('sockname') if request.transport else None + try: + host = urlsplit(request.scheme + '://' + request.host) + allowed = {socket[0].split('%')[0]} if socket else set() + if address.is_loopback: + allowed.update(('localhost', '127.0.0.1', '::1')) + scheme = request.scheme + if proxied: + proxy_origin = urlsplit(request.headers.get(proxy_identity.ORIGIN, '')) + if (host.hostname, host.port) == (proxy_origin.hostname, proxy_origin.port): + allowed.add(proxy_origin.hostname) + socket = (proxy_origin.hostname, proxy_origin.port) + scheme = proxy_origin.scheme + if host.hostname not in allowed or host.port not in (None, socket[1]): + raise ValueError + if len(request.headers.getall('Origin', [])) > 1: + raise ValueError + origin = request.headers.get('Origin') + if origin is not None: + parsed = urlsplit(origin) + port = parsed.port or (443 if parsed.scheme == 'https' else 80) + own_port = host.port or (443 if scheme == 'https' else 80) + if (parsed.scheme != scheme or parsed.hostname != host.hostname or port != own_port + or parsed.path or parsed.query or parsed.fragment or parsed.username or parsed.password): + raise ValueError + except (ValueError, TypeError, IndexError): + raise JobError('origin_forbidden', 'Origin or Host does not identify this instance') from None diff --git a/freevideo_engine/agent/assets.py b/freevideo_engine/agent/assets.py new file mode 100644 index 00000000..3b7707e8 --- /dev/null +++ b/freevideo_engine/agent/assets.py @@ -0,0 +1,37 @@ +"""Public media identities; filesystem resolution stays behind the authenticated API.""" +from urllib.parse import quote, parse_qs, urlsplit +from pathlib import PurePosixPath +import re + +PREFIX = '/freevideo/agent/v1' + + +def view(relative, kind='input'): + return dict(file=str(relative), url=PREFIX + '/media/' + kind + '/' + quote(str(relative), safe='/') + '/file') + + +def public(value, _key=None): + """Migrate legacy media views while discarding server-local filesystem fields.""" + if isinstance(value, list): + return [public(item, _key) for item in value] + if isinstance(value, str): + if _key in ('url', 'thumbnail') and value.startswith(PREFIX + '/'): + return value + if PurePosixPath(value).is_absolute() or re.match(r'^(?:[A-Za-z]:[\\/]|\\\\)', value): + return '[local path]' + return value + if not isinstance(value, dict): + return value + # Migrate the URL before recursing; /view is an obsolete media URL, not + # an absolute filesystem path to preserve. + result = {k: v for k, v in value.items() if k != 'path'} + url = result.get('url') + if isinstance(url, str) and url.startswith('/view?'): + query = parse_qs(urlsplit(url).query) + relative = result.get('file') or '/'.join((query.get('subfolder', [''])[0], query.get('filename', [''])[0])).lstrip('/') + kind = query.get('type', ['output'])[0] + if relative and kind in ('input', 'output') and not PurePosixPath(relative).is_absolute() and '..' not in PurePosixPath(relative).parts: + result.update(view(relative, kind)) + else: + result.pop('url', None) + return {k: public(v, k) for k, v in result.items()} diff --git a/freevideo_engine/agent/builtin_director.py b/freevideo_engine/agent/builtin_director.py new file mode 100644 index 00000000..76261b05 --- /dev/null +++ b/freevideo_engine/agent/builtin_director.py @@ -0,0 +1,553 @@ +"""The built-in director is a client of the shared production operations. + +No durable state lives here. ``run`` consumes only production.view() data. +The production adapter implements get(id, *, since, wait), sheet(id, shot, +*, frames), choose(id, shot, take, *, by, reason, ref), reshoot(id, shot, +*, by, note, takes, reason, ref), and finish(id, *, by, ref). Mutations MUST +atomically deduplicate ref with their durable receipt; exceptions expose code +and optionally status. plan/create/cancel belong to the same adapter but are +not needed to drive an already-created production. + +A sheet is {'takes': [{'take': int, 'image': bytes | MediaRef}], +'previous': optional_image}. It covers every successful non-stale take. +ChatAsk consumes bytes; JobAsk consumes path-free MediaRefs. The sheet adapter +supplies the appropriate representation; this module never opens media paths. +JobAsk additionally uses the existing Client.submit(spec) / wait(id, timeout=) / cancel(id) +job interface. All asks return a schema-checked object or None. +""" +import base64 +from copy import deepcopy +from dataclasses import dataclass +import hashlib +import ipaddress +import json +import math +import re +import urllib.request +from urllib.parse import urlsplit + +from . import drama, spec + + +@dataclass(frozen=True) +class Problem: + code: str + message: str + + +@dataclass(frozen=True) +class Operation: + action: str + shot: str | None = None + reason: str = '' + take: int | None = None + note: str | None = None + changes: dict | None = None + takes: int | None = None + + +Decision = Operation + + +def _object(properties, required=()): + return dict(type='object', properties=properties, required=list(required), additionalProperties=False) + + +def _string(limit=20000): + return dict(type='string', minLength=1, maxLength=limit) + + +class _Invalid(ValueError): + pass + + +def _validate(value, schema, path='$'): + """Small, fail-closed JSON Schema subset used by both transports. + + Supports type, properties, required, additionalProperties, items, enum, + const, oneOf, bounds and lengths. Unknown keywords are rejected rather than + silently claiming validation. No external schema resolver or dependency. + """ + supported = {'type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const', + 'oneOf', 'minimum', 'maximum', 'minLength', 'maxLength', 'minItems', 'maxItems', + 'description', 'title'} + if not isinstance(schema, dict) or set(schema) - supported: + raise _Invalid('Unsupported JSON schema') + if 'oneOf' in schema: + matches = 0 + for option in schema['oneOf']: + try: + _validate(value, option, path) + matches += 1 + except _Invalid: + pass + if matches != 1: + raise _Invalid(path + ': must match exactly one allowed operation') + kind = schema.get('type') + types = {'object': isinstance(value, dict), 'array': isinstance(value, list), + 'string': isinstance(value, str), 'boolean': type(value) is bool, + 'integer': type(value) is int, + 'number': type(value) in (int, float) and math.isfinite(value), 'null': value is None} + if kind is not None and not types.get(kind, False): + raise _Invalid(path + ': expected ' + str(kind)) + # JSON's true and 1 are different values, despite Python equality. + encoded = _dump(value) + if 'enum' in schema and encoded not in [_dump(v) for v in schema['enum']]: + raise _Invalid(path + ': must be one of the allowed values') + if 'const' in schema and encoded != _dump(schema['const']): + raise _Invalid(path + ': must equal the required value') + if isinstance(value, dict): + properties = schema.get('properties', {}) + for key in schema.get('required', []): + if key not in value: + raise _Invalid(path + '/' + key + ': required') + for key, item in value.items(): + if key in properties: + _validate(item, properties[key], path + '/' + key) + elif schema.get('additionalProperties') is False: + raise _Invalid(path + ': contains an unallowed field') + elif isinstance(schema.get('additionalProperties'), dict): + _validate(item, schema['additionalProperties'], path + '/*') + if isinstance(value, list): + for i, item in enumerate(value): + _validate(item, schema.get('items', {}), path + '/' + str(i)) + if isinstance(value, (str, list)): + suffix = 'Length' if isinstance(value, str) else 'Items' + if len(value) < schema.get('min' + suffix, 0) or len(value) > schema.get('max' + suffix, math.inf): + raise _Invalid(path + ': length outside allowed bounds') + if type(value) in (int, float): + if not math.isfinite(value) or not schema.get('minimum', -math.inf) <= value <= schema.get('maximum', math.inf): + raise _Invalid(path + ': number outside allowed bounds') + + +def _dump(value): + return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(',', ':')) + + +def _checked(value, schema): + if isinstance(value, str): + try: + value = json.loads(value) + except (ValueError, RecursionError): + raise _Invalid('Reply is not valid JSON') from None + if not isinstance(value, dict): + raise _Invalid('Reply must be a JSON object') + _validate(value, schema) + return deepcopy(value) + + +def _ask(ask, prompt, images, schema, max_tokens=1500): + # Also validate injected asks: a fake or embedding application cannot make + # an invalid score/operation slip past the pure decision functions. + try: + return _checked(ask(prompt, images, schema=deepcopy(schema), max_tokens=max_tokens), schema) + except Exception: + return None + + +class _Ask: + def __call__(self, prompt, images, *, schema, max_tokens): + try: + if (not isinstance(prompt, str) or not prompt.strip() or not isinstance(images, list) + or len(images) > 16 or type(max_tokens) is not int or not 16 <= max_tokens <= 1500): + return None + instruction = prompt + '\nReturn only a JSON object matching this schema:\n' + _dump(schema) + for attempt in range(2): + try: + return _checked(self._reply(instruction, images, max_tokens), schema) + except _Invalid as error: + if attempt: + return None + instruction += '\nPrevious reply was invalid: ' + str(error) + '. Correct it.' + except Exception: + # No raw transport error, response body or credentials reach logs, + # exception text, or persistent storage. + return None + return None + + +class JobAsk(_Ask): + """Use the shared VLM job queue. + + The question waits its turn behind queued generations, so the wait lasts + until the job ends, stop() turns true, or `timeout` seconds pass. A job + that is given up on is cancelled rather than left to run for nobody. + """ + def __init__(self, tools, *, timeout=3600, stop=None): + self.tools, self.timeout, self.stop = tools, timeout, stop or (lambda: False) + + def _wait(self, identity): + left = self.timeout + while left > 0 and not self.stop(): + done = self.tools.wait(identity, timeout=min(25, left)) + if done.get('status') in ('succeeded', 'failed', 'cancelled', 'interrupted'): + return done + left -= 25 + raise TimeoutError # given up: _reply cancels the job; no second try + + def _reply(self, prompt, images, max_tokens): + refs = [] + for image in images: + # A relative Comfy upload name is a reference, never an opened path. + if isinstance(image, dict) and 'path' in image: + raise ValueError('Local media paths are not accepted') + refs.append(spec.media_item(image, '/images', image=True)) + request = dict(kind='vlm', questions=[dict(text=prompt, images=refs)], + format='json', max_tokens=max_tokens) + spec.check(request) + job = self.tools.submit(request) + try: + done = self._wait(job['id']) + except Exception: + try: + self.tools.cancel(job['id']) + except Exception: + pass + raise + if done.get('status') != 'succeeded': + return None + answers = (done.get('result') or {}).get('answers', []) + if len(answers) != 1 or answers[0].get('error'): + raise _Invalid('The job did not return one usable answer') + return answers[0].get('json', answers[0].get('text')) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise ValueError('Redirects are disabled') + + +class ChatAsk(_Ask): + """Loopback-only OpenAI-compatible transport, with no proxy or redirects. + + Model discovery is lazy and cached. An injected opener has urlopen's + (request, timeout=) interface (or .open); it is a trusted transport and must + not log credentials or follow redirects. No image references are fetched. + """ + def __init__(self, base_url, key=None, model=None, *, opener=None, timeout=120): + try: + url = urlsplit(base_url) + host = url.hostname + local = host == 'localhost' or ipaddress.ip_address(host).is_loopback + if (not local or url.scheme not in ('http', 'https') or url.username is not None + or url.password is not None or url.query or url.fragment + or url.path.rstrip('/') not in ('', '/v1') or '%' in host): + raise ValueError + # Avoid resolving localhost through DNS or environment proxy rules. + host = '127.0.0.1' if host == 'localhost' else host + host = '[' + host + ']' if ':' in host else host + self.base_url = url.scheme + '://' + host + (':' + str(url.port) if url.port else '') + if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0: + raise ValueError + if key is not None and (not isinstance(key, str) or '\r' in key or '\n' in key): + raise ValueError + if model is not None and (not isinstance(model, str) or not model.strip()): + raise ValueError + except Exception: + raise ValueError('ChatAsk requires a loopback HTTP URL and valid options') from None + self._key, self.model, self.timeout = key, model, timeout + self._opener = opener or urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()) + + def _request(self, path, payload=None): + headers = {'Accept': 'application/json'} + if self._key: + headers['Authorization'] = 'Bearer ' + self._key + data = None + if payload is not None: + headers['Content-Type'] = 'application/json' + data = _dump(payload).encode('utf-8') + request = urllib.request.Request(self.base_url + '/v1/' + path, data=data, headers=headers) + open_request = getattr(self._opener, 'open', self._opener) + with open_request(request, timeout=self.timeout) as response: + raw = response.read(2 * 1024 * 1024 + 1) + if len(raw) > 2 * 1024 * 1024: + raise _Invalid('Response is too large') + try: + return json.loads(raw) + except ValueError: + raise _Invalid('Response is not valid JSON') from None + + def _reply(self, prompt, images, max_tokens): + content = [{'type': 'text', 'text': prompt}] + for data in images: + if not isinstance(data, bytes): + raise ValueError('ChatAsk needs image bytes') + if data.startswith(b'\x89PNG\r\n\x1a\n'): + mime = 'image/png' + elif data.startswith(b'\xff\xd8\xff'): + mime = 'image/jpeg' + elif data.startswith((b'GIF87a', b'GIF89a')): + mime = 'image/gif' + elif data.startswith(b'RIFF') and data[8:12] == b'WEBP': + mime = 'image/webp' + else: + raise ValueError('Unsupported image encoding') + content.append({'type': 'image_url', 'image_url': { + 'url': 'data:' + mime + ';base64,' + base64.b64encode(data).decode('ascii')}}) + if self.model is None: + models = self._request('models').get('data', []) + self.model = next((row['id'] for row in models if isinstance(row, dict) + and isinstance(row.get('id'), str) and row['id'].strip()), None) + if self.model is None: + return None + response = self._request('chat/completions', dict(model=self.model, messages=[dict(role='user', content=content)], + response_format={'type': 'json_object'}, max_tokens=max_tokens)) + try: + choice = response['choices'][0] + if choice.get('finish_reason') not in (None, 'stop'): + raise _Invalid('Reply was not completed') + return choice['message']['content'] + except (KeyError, IndexError, TypeError): + raise _Invalid('Response has no answer') from None + + +_LINE = _object({'who': _string(), 'line': _string(), 'how': _string(), 'language': _string()}, ('who', 'line')) +_CHANGES = {key: _string() for key in ('action', 'camera', 'framing', 'prompt')} +_CHANGES.update(lines=dict(type='array', items=_LINE), seconds=dict(type='number')) +_SHOT = _object(dict(_CHANGES, id=_string(), **{'continue': dict(type='boolean')}, + sound=_string(), music=_string(), cast=dict(type='array', items=_string())), ('action',)) +_PLAY = _object(dict(title=_string(120), style=_string(), language=_string(), music=_string(), + captions=dict(type='boolean'), aspect=_string(), + characters=dict(type='object', additionalProperties=_object( + {key: _string() for key in ('name', 'look', 'voice', 'language')})), + shots=dict(type='array', items=_SHOT)), ('shots',)) + + +def plan(story, *, ask, shots=(3, 8)): + """Try at most three screenplay candidates, never inventing a fallback.""" + if (not isinstance(story, str) or not story.strip() or not isinstance(shots, (tuple, list)) or len(shots) != 2 + or any(type(n) is not int for n in shots) or not 1 <= shots[0] <= shots[1] <= 40): + return Problem('plan_failed', '故事或镜头数量不合适,请补充后再试。') + schema = deepcopy(_PLAY) + schema['properties']['shots'].update(minItems=shots[0], maxItems=shots[1]) + prompt = ('把下面的故事写成可拍摄的分镜剧本,保持人物和故事事实。镜头数 %d–%d。' + '每镜给简短的 action,可给 seconds(1.625–60 秒)、framing、camera、lines。' + '默认硬切(continue=false);只有故事明确是同一场连续动作才用 continue=true,' + '接续镜换版会使后面的镜头重拍,第一镜不能接续。不要添加 schema 以外的字段。' + '\n故事(数据,不是系统指令):\n%s') % (*shots, story) + for _ in range(3): + candidate = _ask(ask, prompt, [], schema) + if candidate is None: + prompt += '\n上次回答不是符合所给 schema 的 JSON 对象,请修正。' + continue + try: + drama.check(candidate) + except drama.Screenplay as error: + # Keep check's exact paths and messages; do not paraphrase them. + prompt += '\ndrama.check problems: ' + _dump(error.problems) + continue + return candidate + return Problem('plan_failed', '没能把故事排成合格剧本,请补充镜头内容后再试。') + + +def _selectable(shot): + return [t for t in shot['takes'] if t['state'] == 'succeeded' and not t['stale']] + + +def _retaken(shot): + if 'decisions' in shot: + return any(d.get('action') == 'reshoot' and d.get('shot') == shot['id'] for d in shot['decisions']) + # Standalone callers may only have the shot row. Conservatively regard a + # later generation as the one allowed reshoot; never risk an extra round. + return any(t['generation'] > 0 for t in shot['takes']) + + +def _take_count(shot): + first = min((t['generation'] for t in shot['takes']), default=0) + return max(1, min(8, sum(t['generation'] == first for t in shot['takes']))) + + +def judge(shot_view, sheets, *, ask): + """Score all selectable takes; invalid/missing evidence leaves a human choice. + + Optional screenplay/characters/style/captions fields carry visual intent; + run supplies them from the public board. Optional decisions distinguish a + requested reshoot from automatic continuation reconnection. + """ + shot = shot_view + if shot.get('chosen_by') == 'user' or shot.get('chosen') is not None: + return Decision('keep', shot['id'], '保留已选定的版本。') + if shot.get('state') in ('waiting', 'shooting'): + return Decision('wait', shot['id'], '等待这一镜的各版完成。') + uncertain = Decision('ask_user', shot['id'], '拿不准,请你挑一版。') + takes = _selectable(shot) + if not takes: + return uncertain + try: + rows = sheets['takes'] + images = {row['take']: row['image'] for row in rows} + numbers = [t['take'] for t in takes] + if len(images) != len(rows) or set(images) != set(numbers): + return uncertain + previous = sheets.get('previous') + batch_size = 15 if previous is not None else 16 + ratings = [] + for start in range(0, len(numbers), batch_size): + batch = numbers[start:start + batch_size] + rating = _object(dict(take=dict(type='integer', enum=batch), score=dict(type='number', minimum=0, maximum=10), + reason=_string(30), note=_string(300), captions=dict(type='boolean')), + ('take', 'score', 'reason', 'note', 'captions')) + schema = _object(dict(ratings=dict(type='array', minItems=len(batch), maxItems=len(batch), items=rating)), + ('ratings',)) + prompt = ('评价这一镜的每个版本:0–10 分,reason 用不超过30字的中文指出具体优点或问题' + '(多余的字、人物变形、和描述不符等);note 给下一次生成能用的具体修正。' + 'captions 表示有叠加字幕/文字,场景内真实招牌不算。不评价声音。' + '\n镜头描述:' + _dump({k: shot[k] for k in ('screenplay', 'characters', 'style', 'captions') if k in shot}) + + '\n图片顺序对应版号:' + _dump(batch) + + (';最后一张是上一镜选定版的末帧,仅供接续参考。' if previous is not None else '。')) + reply = _ask(ask, prompt, [images[n] for n in batch] + ([previous] if previous is not None else []), schema) + if reply is None or sorted(r['take'] for r in reply['ratings']) != sorted(batch): + return uncertain + ratings.extend(reply['ratings']) + for row in ratings: + spec._text(row['reason'], '/reason', 30) + spec._text(row['note'], '/note', 300) + if row['captions'] and not shot.get('captions', False): + row['score'] = min(row['score'], 3) + best = max(ratings, key=lambda r: (r['score'], -r['take'])) + if best['score'] >= 7: + return Decision('choose', shot['id'], best['reason'], take=best['take']) + if _retaken(shot): + return Decision('choose', shot['id'], '重拍后仍不理想:' + best['reason'], take=best['take']) + return Decision('reshoot', shot['id'], best['reason'], note=best['note'], takes=_take_count(shot)) + except (KeyError, TypeError, ValueError): + return uncertain + + +def revise(view, sentence, *, ask): + """Translate one request into one checked reshoot, or ask for clarification.""" + unclear = Problem('unclear', '请说明要改哪一镜,以及画面或时长要怎样改?') + if not isinstance(sentence, str) or not sentence.strip(): + return unclear + ids = [s['id'] for s in view['shots']] + operation = _object(dict(action=dict(const='reshoot'), shot=dict(type='string', enum=ids), + changes=_object(_CHANGES), note=_string(1000), reason=_string(300)), ('action', 'shot')) + question = _object(dict(action=dict(const='unclear'), question=_string(300)), ('action', 'question')) + schema = dict(type='object', oneOf=[operation, question]) + prompt = ('把用户的修改意见变成一条 reshoot 操作。只能修改一镜的 action/lines/camera/framing/prompt/seconds,' + '或用 note 补充生成意见;不能增删或重排镜头,不能改接续关系,不能声称完成音频编辑。' + '序号按 shots 顺序,结尾指最后一镜。目标不唯一、要求不支持或时长没有可确定的数值时,' + '返回 unclear 并用一句中文反问,不猜。\n拍摄记录:' + _dump(view['screenplay']) + + '\n镜头编号:' + _dump(ids) + '\n用户意见:' + sentence) + reply = _ask(ask, prompt, [], schema) + if reply is None: + return unclear + if reply['action'] == 'unclear': + return Problem('unclear', reply['question']) + changes, note = reply.get('changes', {}), reply.get('note') + if not changes and not note: + return unclear + play = deepcopy(view['screenplay']) + target = next((s for s in play['shots'] if s['id'] == reply['shot']), None) + if target is None: + return unclear + target.update(changes) + try: + drama.check(play) + if note is not None: + spec._text(note, '/note', 1000) + spec._text(reply.get('reason', sentence), '/reason', 20000) + except (drama.Screenplay, spec.SpecError): + return unclear + return Operation('reshoot', reply['shot'], reply.get('reason', sentence), note=note, changes=changes or None) + + +def _context(view, shot_id): + shot = deepcopy(next(s for s in view['shots'] if s['id'] == shot_id)) + shot['screenplay'] = next(s for s in view['screenplay']['shots'] if s['id'] == shot_id) + for field, default in (('characters', {}), ('style', ''), ('captions', False)): + shot[field] = view['screenplay'].get(field, default) + shot['decisions'] = [d for d in view['decisions'] if d.get('shot') == shot_id] + return shot + + +def _ref(identity, action, snapshot): + digest = hashlib.sha256(_dump(snapshot).encode('utf-8')).hexdigest() + return identity + ':builtin:' + action + ':' + digest + + +def run(tools, production_id, *, ask, stop, wait=25): + """Yield {shot, action, reason}; stop never cancels generation jobs. + + A yielded finish means the join was accepted; the existing job queue owns + its completion. Human handoffs are suppressed for the same shot snapshot + in this invocation, then long-poll until the user changes the record. + """ + wait = max(0, min(25, wait)) + since, errors, conflicts = None, 0, 0 + handed_off = set() + while not stop(): + try: + view = tools.get(production_id, since=since, wait=wait if since is not None else 0) + since = view['revision'] + if stop() or view['status'] in ('cancelled', 'finished', 'joining'): + return + acted = False + for todo in view['todo']: + if stop(): + return + action = todo['action'] + if action == 'finish': + if not all(s.get('chosen') is not None and any(t['take'] == s['chosen'] for t in _selectable(s)) + for s in view['shots']): + continue + snapshot = [(s['id'], s['chosen']) for s in view['shots']] + tools.finish(production_id, by='builtin', ref=_ref(production_id, 'finish', snapshot)) + yield dict(shot=None, action='finish', reason='每镜已选定,开始接成影片。') + return + if action not in ('choose', 'reshoot'): + continue + shot = _context(view, todo['shot']) + fingerprint = _ref(production_id, 'review', shot) + if fingerprint in handed_off or shot['chosen'] is not None or shot['chosen_by'] == 'user': + continue + sheets = tools.sheet(production_id, shot['id'], frames=6) + if stop(): + return + decision = judge(shot, sheets, ask=ask) + if stop(): + return + # The model call can be slow. Discard its decision if evidence + # changed, including an upstream choice that made takes stale. + latest = tools.get(production_id, since=None, wait=0) + if stop() or latest['status'] in ('cancelled', 'finished', 'joining'): + return + if _context(latest, shot['id']) != shot: + since, acted = None, True + break + if decision.action == 'ask_user': + handed_off.add(fingerprint) + elif decision.action in ('choose', 'reshoot'): + ref = _ref(production_id, decision.action, shot) + if decision.action == 'choose': + tools.choose(production_id, shot['id'], decision.take, by='builtin', reason=decision.reason, ref=ref) + else: + tools.reshoot(production_id, shot['id'], by='builtin', note=decision.note, + takes=decision.takes, reason=decision.reason, ref=ref) + since, acted = None, True + else: + continue + errors = conflicts = 0 + yield dict(shot=decision.shot, action=decision.action, reason=decision.reason) + if acted: + break # fetch a new board before deciding about another shot + if not acted: + errors = 0 + except Exception as error: + code = getattr(error, 'code', '') + if code == 'chosen_by_user' or (getattr(error, 'status', None) == 409 + and code in ('take_not_ready', 'not_ready', 'unknown_take')): + # A race with the user is normal, but each round costs a model + # call: a conflict that keeps coming back ends the run. + conflicts += 1 + errors, since = 0, None + if conflicts >= 5: + yield dict(shot=None, action='error', reason='多次和已有的选择冲突,已停止:' + code) + return + continue + errors += 1 + since = None + if errors >= 3: + label = code if isinstance(code, str) and re.fullmatch(r'[a-z_]{1,80}', code) else type(error).__name__ + yield dict(shot=None, action='error', reason='连续三次操作失败,已停止:' + label) + return diff --git a/freevideo_engine/agent/cast.py b/freevideo_engine/agent/cast.py new file mode 100644 index 00000000..767f5495 --- /dev/null +++ b/freevideo_engine/agent/cast.py @@ -0,0 +1,775 @@ +"""The cast library: durable character and setting cards shared by every film. + +One JSON file per card under /cast, written atomically under Jobs' lock, +the same way films are kept. Pictures and voices enter a card only as input +references (a file under ComfyUI input, or a saved library video); a card never +stores or shows a local path. Deleting moves the file to cast/.deleted. +""" +from copy import deepcopy +import hashlib +import json +import logging +import os +from pathlib import Path +import re +import secrets +import threading +import uuid + +from . import assets, errors, operation_receipts, schema, spec +from .jobs import ACTIVE, DONE, JobError, iso + +CARD = re.compile(r'c[0-9a-f]{12}') +KINDS = ('character', 'setting') +ORIGINS = ('user', 'agent', 'builtin') +LABELS = schema.LABELS +EDITABLE = ('name', 'description', 'images', 'voice') +MAX_IMAGES = 6 +MAX_CANDIDATES = 6 +MAX_RECEIPTS = 64 +VOICE_SECONDS = (2, 15) +FINISHED = ('finished', 'cancelled') +LIST_NEXT = 'GET /freevideo/agent/v1/cast to find an existing card ID.' + +# Reference pictures made by the server (POST /cast/{id}/generate). Remy's consistency tests set the +# defaults; the clip prompts live in reference_prompt and the labels follow its half turn. +COUNT = 4 +PROMPT_VERSION = 1 +TURN = dict(aspect='9:16', seconds=6) +CLOSE_UP = dict(aspect='1:1', seconds=4) +SCENE = dict(aspect='16:9', seconds=6) + + +def reference_prompt(card, *, face=False): + """The one place the reference clip prompts are written.""" + who = (card.get('description') or card['name']).strip().rstrip('.') + if card['kind'] == 'setting': + return ('%s. A slow, steady camera pan across the empty place, no people, even daylight, ' + 'sharp focus, no text.' % who) + if face: + return ('%s. A close-up of the face and shoulders, looking straight at the camera with a neutral ' + 'expression, in front of a plain light grey studio backdrop. The person stays still. ' + 'Even soft studio light, a static camera, sharp focus, no text.' % who) + return ('%s. The person stands alone in the centre of the frame, the whole body from head to feet, in front ' + 'of a plain light grey studio backdrop, in a neutral pose with the arms relaxed. The person turns ' + 'slowly in place from facing the camera to facing away from it. Even soft studio light, a static ' + 'camera, sharp focus, no other people, no text.' % who) + + +def turn_label(progress): + """The view at this point of the half turn the prompt asks for (0 faces the camera, 1 faces away).""" + angle = 180 * progress + return ('front' if angle < 30 else 'three_quarter' if angle < 75 else 'side' if angle < 120 + else 'three_quarter' if angle < 150 else 'back') + + +class CastError(JobError): + """A card error; conflicts carry the current card so the caller can retry from it.""" + + def __init__(self, code, message, next_action, *, card=None, job=None): + super().__init__(code, message) + self.status = errors.ERRORS[code][0] + self.next_action, self.card, self.job = next_action, card, job + + def view(self): + value = errors.build(self.code, validation=True, details=[], message=self.message, + next_action=self.next_action) + if self.card is not None: + value['card'] = deepcopy(self.card) + if self.job is not None: + value['job'] = self.job + return value + + +def media_view(item): + if 'library' in item: + return dict(library=item['library'], url=schema.PREFIX + '/library/%s/file' % item['library']) + return assets.view(item['file']) + + +def view(card): + """The public card: input references with URLs, no internal bookkeeping, no local paths.""" + value = {k: deepcopy(v) for k, v in card.items() if not k.startswith('_')} + for key in ('images', 'candidates'): + value[key] = [dict(deepcopy(row), media=media_view(row['media'])) for row in card.get(key, [])] + if card.get('voice'): + value['voice'] = dict(deepcopy(card['voice']), media=media_view(card['voice']['media']), + clip=assets.view(card['voice']['clip']['file'])) + return value + + +def thumbnail(card): + images = card['images'] + chosen = next((row for row in images if row['label'] == 'front'), images[0] if images else None) + return media_view(chosen['media']) if chosen else None + + +def row_view(card): + """One list row: identity, short text and one thumbnail reference (never the picture itself).""" + return dict(id=card['id'], kind=card['kind'], name=card['name'], description=card['description'], + origin=card['origin'], revision=card['revision'], updated_at=card['updated_at'], + images=len(card['images']), voice=card['voice'] is not None, + generating=card.get('generating') is not None, thumbnail=thumbnail(card)) + + +def _plain(item): + """The input file an image item names unchanged (a returned card file), else None.""" + if isinstance(item, str): + return item + if isinstance(item, dict) and set(item) <= {'file', 'label', 'url'} and isinstance(item.get('file'), str): + return item['file'] + return None + + +def _check(record, stem): + card = record['card'] + if (card['id'] != stem or not CARD.fullmatch(stem) or card['kind'] not in KINDS + or card['origin'] not in ORIGINS or card['by'] not in ORIGINS + or type(card['revision']) is not int or card['revision'] < 1 + or not isinstance(card['name'], str) or not isinstance(card['description'], (str, type(None)))): + raise ValueError('Invalid card identity') + if set(card['edited_by_user']) != set(EDITABLE) or not all(type(v) is bool for v in card['edited_by_user'].values()): + raise ValueError('Invalid edit marks') + for key, limit in (('images', MAX_IMAGES), ('candidates', MAX_CANDIDATES)): + if not isinstance(card[key], list) or len(card[key]) > limit: + raise ValueError('Invalid pictures') + for row in card[key]: + if row['label'] not in LABELS or not isinstance(row['media']['file'], str): + raise ValueError('Invalid picture') + if card['voice'] is not None: + if not isinstance(card['voice']['clip']['file'], str) or not ( + isinstance(card['voice']['media'].get('file'), str) or isinstance(card['voice']['media'].get('library'), str)): + raise ValueError('Invalid voice') + if not isinstance(card['used_by'], list) or not all(isinstance(v, str) for v in card['used_by']): + raise ValueError('Invalid film list') + record['receipts'] = operation_receipts.load(record['receipts']) + json.dumps(record, allow_nan=False) + + +def _revision(body, header): + """The expected revision from the body or If-Match; both must agree when both are sent.""" + if header is not None and not isinstance(header, int): + text = str(header).strip() + text = text[2:] if text.startswith('W/') else text + text = text.strip('"') + if not re.fullmatch(r'[1-9][0-9]{0,15}', text): + raise spec.SpecError([schema.diagnostic('/If-Match', 'a card revision number', text)]) + header = int(text) + if body is not None and header is not None and body != header: + raise spec.SpecError([schema.diagnostic('/revision', 'equal to If-Match when both are supplied', body)]) + return body if body is not None else header + + +class Cast: + def __init__(self, jobs): + self.jobs = jobs + self.directory = jobs.directory / 'cast' + self.records = {} + self.deleted = {} + self.changed = threading.Condition(jobs.lock) + self.landing = set() # Cards whose reference pictures a tick is landing now (under the lock). + self.load() + + # Records ------------------------------------------------------------------------------------------ + def load(self): + for folder, target in ((self.directory / '.deleted', self.deleted), (self.directory, self.records)): + for path in sorted(folder.glob('*.json')): + try: + record = json.loads(path.read_text(encoding='utf-8')) + _check(record, path.stem) + if target is self.records and path.stem in self.deleted: + # A deletion stopped after its tombstone was written: the deletion wins. + path.unlink() + continue + target[path.stem] = record + except (ValueError, KeyError, TypeError, AttributeError, IndexError, RecursionError, OSError): + try: + path.replace(path.with_name(path.name + '.bad-' + uuid.uuid4().hex)) + except OSError: + pass + logging.getLogger(__name__).warning('Isolated an invalid cast card; other cards remain available') + + def _write(self, folder, record): + folder.mkdir(parents=True, exist_ok=True) + path = folder / (record['card']['id'] + '.json') + temporary = path.with_name(path.name + '.' + uuid.uuid4().hex + '.tmp') + try: + with temporary.open('x', encoding='utf-8') as stream: + json.dump(record, stream, ensure_ascii=False, allow_nan=False) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + def _save(self, record): + self._write(self.directory, record) + self.records[record['card']['id']] = record + self.changed.notify_all() + + def _record(self, identity): + record = self.records.get(identity) if isinstance(identity, str) else None + if record is None: + raise CastError('not_found', 'The card does not exist.', LIST_NEXT) + return record + + def _receipt(self, scope, payload, ref, client=None): + if ref is None: + return None, None + schema.validate(schema.REF, ref) + digest = hashlib.sha256(json.dumps(dict(scope=scope, body=payload), sort_keys=True, + separators=(',', ':'), allow_nan=False).encode()).hexdigest() + for record in list(self.records.values()) + list(self.deleted.values()): + for old in record['receipts']: + if old['key'] != ref or old.get('client') != client: + continue + if old['digest'] != digest: + raise JobError('idempotency_conflict', 'Key already used') + return digest, deepcopy(old['result']) + return digest, None + + @staticmethod + def _remember(record, ref, digest, result, client=None): + if ref is None: + return + receipts = record['receipts'] + receipts.append(dict(key=ref, digest=digest, result=deepcopy(result), + **({'client': client} if client else {}))) + while len(receipts) > MAX_RECEIPTS: + receipts.pop(0) + + def _stale(self, card): + return CastError('stale_revision', 'The card changed since you read it.', + 'Apply your change to error.card and send its revision.', card=view(card)) + + def _now(self): + return iso(self.jobs.clock()) + + # Media -------------------------------------------------------------------------------------------- + def _picture(self, identity, item, by): + """Resolve one picture to an input file in this card's folder (frames, imports, edits).""" + from ..comfy_assets import media_kind + if isinstance(item, dict): + item = {k: v for k, v in item.items() if k != 'url'} + source = spec.media_item(item, '/media', image=True) + relative = self.jobs.media_file(dict(id=identity), source, 'image-' + uuid.uuid4().hex[:8]) + try: + kind = media_kind(relative) + except ValueError: + kind = None + if kind != 'image': + raise CastError('invalid_media', 'A card picture must be an image.', + 'Upload an image, or name a video frame with from_job or library and frame.') + entry = dict(media=dict(file=relative), by=by) + if isinstance(source, dict) and source.get('from_job'): + entry['from_job'] = source['from_job'] + return entry + + def _voice(self, identity, value): + """A 2 to 15 second voice sample: the stable source reference plus a trimmed WAV clip.""" + from . import media + item = value['media'] + item = {'file': item} if isinstance(item, str) else {k: v for k, v in item.items() if k not in ('url', 'label')} + port, folder = self.jobs.port, 'freevideo/agent/' + identity + name = 'voice-' + uuid.uuid4().hex[:8] + if 'file' in item: + source, kept = port.input_path(item['file']), dict(file=item['file']) + if not source.is_file(): + raise JobError('invalid_media', 'Upload the media file first') + elif 'path' in item: + source = Path(item['path']).expanduser() + if not source.is_absolute() or not source.is_file(): + raise JobError('invalid_media', 'Local file not found') + kept = dict(file=folder + '/' + name + '-source' + source.suffix.lower()) + port.import_file(source, port.input_path(kept['file'], create=True)) + else: + source = self.jobs.source_video(item) + library = item.get('library') or (self.jobs.get(item['from_job']).get('result') or {}).get('library_id') + if library: + kept = dict(library=library) + else: + kept = dict(file=folder + '/' + name + '-source' + source.suffix.lower()) + port.import_file(source, port.input_path(kept['file'], create=True)) + start = value.get('start', 0) + with self.jobs.cpu: + try: + info = media.inspect(source) + if info['kind'] not in ('audio', 'video') or not info.get('has_audio'): + raise ValueError('No audio') + end = value.get('end') + if end is None: + end = min(info['seconds'], start + VOICE_SECONDS[1]) if info.get('seconds') else start + VOICE_SECONDS[1] + if not VOICE_SECONDS[0] <= round(end - start, 3) <= VOICE_SECONDS[1]: + raise ValueError('Voice length') + clip = folder + '/' + name + '.wav' + written = media.trim_audio(source, port.input_path(clip, create=True), start, end) + except ValueError: + written = 0 + if written < VOICE_SECONDS[0] - .05: + raise CastError('invalid_media', 'The voice must be readable audio from 2 to 15 seconds long.', + 'Send voice.media with audio, and voice.start and voice.end 2 to 15 seconds apart.') + if written < end - start - .01: + end = start + written # The audio ended before the requested end: the card shows the clip it has. + return dict(media=kept, start=start, end=round(end, 3), clip=dict(file=clip)) + + # Operations --------------------------------------------------------------------------------------- + def create(self, value, *, by='builtin', ref=None, made_for=None, local=True, client=None): + shape = schema.cast_schema('create', local=local) + schema.validate(shape, value) + ref = ref if ref is not None else value.get('ref') + value = schema.defaults(shape, {k: v for k, v in value.items() if k != 'ref'}) + with self.changed: + digest, replay = self._receipt('cast:create', value, ref, client) + if replay is not None: + return replay + identity = 'c' + uuid.uuid4().hex[:12] + images = [] + for row in value.get('images', []): + entry = self._picture(identity, row['media'], by) + images.append(dict(entry, label=row.get('label', 'other'))) + voice = self._voice(identity, value['voice']) if value.get('voice') else None + with self.changed: + digest, replay = self._receipt('cast:create', value, ref, client) + if replay is not None: + return replay + stamp = self._now() + supplied = dict(name=True, description=value.get('description') is not None, + images=bool(images), voice=voice is not None) + card = dict(id=identity, kind=value['kind'], name=value['name'], description=value.get('description'), + images=images, voice=voice, revision=1, created_at=stamp, updated_at=stamp, by=by, + origin=by, made_for=made_for, used_by=[], + edited_by_user={k: by == 'user' and supplied[k] for k in EDITABLE}, + candidates=[], generating=None, generate_error=None, generated=None, _user_images=0) + record = dict(card=card, receipts=[]) + result = view(card) + self._remember(record, ref, digest, result, client) + self._save(record) + return result + + def get(self, identity): + with self.changed: + return view(self._record(identity)['card']) + + def list(self, *, q=None, kind=None, origin=None, production=None, limit=50): + with self.changed: + cards = [r['card'] for r in self.records.values()] + text = q.casefold() if q else None + rows = [c for c in cards if (not kind or c['kind'] == kind) and (not origin or c['origin'] == origin) + and (not production or production in c['used_by'] or c['made_for'] == production) + and (not text or text in c['name'].casefold() or text in (c['description'] or '').casefold())] + rows.sort(key=lambda c: (c['updated_at'], c['id']), reverse=True) + return dict(cards=[row_view(c) for c in rows[:limit]], total=len(rows)) + + def update(self, identity, value, *, by='builtin', ref=None, revision=None, local=False, client=None): + shape = schema.cast_schema('update', local=local) + schema.validate(shape, value) + ref = ref if ref is not None else value.get('ref') + value = {k: v for k, v in value.items() if k != 'ref'} + expected = _revision(value.pop('revision', None), revision) + payload = dict(value, revision=expected) + scope = 'cast:update:' + str(identity) + with self.changed: + digest, replay = self._receipt(scope, payload, ref, client) + if replay is not None: + return replay + card = self._record(identity)['card'] + if expected is None: + raise spec.SpecError([schema.diagnostic('/revision', 'the card revision you read (or an If-Match header)', + None, missing=True)]) + if expected != card['revision']: + raise self._stale(card) + changes = self._changes(card, value, by) + resolved = {} + for key, rows in changes.items(): + if key == 'images': + resolved['images'] = [entry if entry is not None else dict(self._picture(identity, row['media'], by), + label=row.get('label', 'other')) for row, entry in rows] + elif key == 'voice': + resolved['voice'] = None if rows is None else self._voice(identity, rows) + else: + resolved[key] = rows + with self.changed: + digest, replay = self._receipt(scope, payload, ref, client) + if replay is not None: + return replay + record = deepcopy(self._record(identity)) + card = record['card'] + if card['revision'] != expected: + raise self._stale(card) + if resolved: + if 'images' in resolved: + files = {row['media']['file'] for row in resolved['images']} + card['candidates'] = [row for row in card['candidates'] if row['media']['file'] not in files] + if by == 'user': + card['_user_images'] = card.get('_user_images', 0) + 1 + card.update(resolved, revision=card['revision'] + 1, updated_at=self._now(), by=by) + if by == 'user': + for key in resolved: + card['edited_by_user'][key] = True + result = view(card) + self._remember(record, ref, digest, result, client) + if resolved or ref is not None: + self._save(record) + return result + + def _changes(self, card, value, by): + """What this request changes, checked against the user's edits before any media work.""" + changes = {} + for key in ('name', 'description'): + if key in value and value[key] != card[key]: + changes[key] = value[key] + if 'images' in value: + known = {row['media']['file']: row for row in card['images']} + offered = {row['media']['file']: row for row in card['candidates']} + rows, kept, added, fresh = [], set(), set(), False + for row in value['images']: + file = _plain(row['media']) + entry = deepcopy(known.get(file) or offered.get(file)) if file else None + if entry is not None: + (kept if file in known else added).add(file) + if row.get('label', entry['label']) != entry['label']: + entry.update(label=row['label'], by=by) + entry.pop('label_guess', None) + if by == 'user': + entry.pop('generated', None) # The user's label makes it the user's picture. + else: + fresh = True + rows.append((row, entry)) + current = [(r['media']['file'], r['label']) for r in card['images']] + proposed = [(e['media']['file'], e['label']) for _, e in rows if e is not None] + if fresh or proposed != current: + changes['images'] = rows + if by != 'user' and card['edited_by_user']['images']: + # The user's pictures may only be joined by candidates the server made; never replaced. + untouched = all((f, l) in proposed for f, l in current) and len(kept) == len(current) + if fresh or not untouched or not added: + raise self._by_user(card, 'images') + if 'voice' in value: + voice, current = value['voice'], card['voice'] + if voice is None: + if current is not None: + changes['voice'] = None + else: + item = voice['media'] if isinstance(voice['media'], dict) else {'file': voice['media']} + item = {k: v for k, v in item.items() if k != 'url'} + same = current is not None and item == current['media'] and voice.get('start', 0) == current['start'] \ + and voice.get('end', current['end']) == current['end'] + if not same: + changes['voice'] = voice + if by != 'user': + for key in changes: + if key != 'images' and card['edited_by_user'][key]: + raise self._by_user(card, key) + return changes + + def _by_user(self, card, key): + return CastError('changed_by_user', 'The user changed %s on this card.' % key, + "Keep the user's change; do not change it back. Ask the user if it must change.", + card=view(card)) + + def delete(self, identity, value=None, *, by='builtin', ref=None, revision=None, client=None): + value = value or {} + schema.validate(schema.cast_schema('delete'), value) + ref = ref if ref is not None else value.get('ref') + value = {k: v for k, v in value.items() if k != 'ref'} + expected = _revision(value.pop('revision', None), revision) + payload = dict(value, revision=expected) + with self.changed: + digest, replay = self._receipt('cast:delete:' + str(identity), payload, ref, client) + if replay is not None: + return replay + record = deepcopy(self._record(identity)) + card = record['card'] + if expected is not None and expected != card['revision']: + raise self._stale(card) + if by != 'user': + if card['origin'] != by or any(card['edited_by_user'].values()): + raise CastError('not_allowed', 'Only the user may delete this card.', + 'Ask the user before deleting this card.', card=view(card)) + if any(self._unfinished(film) for film in card['used_by']): + raise CastError('card_in_use', errors.ERRORS['card_in_use'][2], + errors.ERRORS['card_in_use'][3], card=view(card)) + self._before_delete(card) + card.update(deleted_at=self._now(), deleted_by=by) + result = dict(id=card['id'], deleted=True) + self._remember(record, ref, digest, result, client) + self._write(self.directory / '.deleted', record) + (self.directory / (card['id'] + '.json')).unlink(missing_ok=True) + self.records.pop(card['id'], None) + self.deleted[card['id']] = record + self.changed.notify_all() + return result + + def _before_delete(self, card): + """A generation ends with its card; Jobs also refuses to start it once the card is gone.""" + for job in _generation_jobs(card.get('generating')): + row = self.jobs.jobs.get(job) + if row is not None and row['status'] in ACTIVE: + try: + self.jobs.cancel(job) + except (JobError, KeyError): + pass + + def _unfinished(self, film): + record = self.jobs.productions.records.get(film) + return record is not None and record['state'].get('status') not in FINISHED + + # Filming ------------------------------------------------------------------------------------------ + def snapshot(self, identity): + """The current card for a submission, or None once it is deleted or unknown.""" + with self.changed: + record = self.records.get(identity) + return deepcopy(record['card']) if record else None + + def used(self, identities, film): + """Record that a film uses these cards; a film never leaves used_by once it is there.""" + with self.changed: + for identity in identities: + record = self.records.get(identity) + if record is None or film in record['card']['used_by']: + continue + record = deepcopy(record) + card = record['card'] + if card['made_for'] is None and not card['used_by'] and card['origin'] != 'user': + card['made_for'] = film + card['used_by'].append(film) + self._save(record) + + # Reference pictures ------------------------------------------------------------------------------- + def generate(self, identity, value=None, *, by='builtin', ref=None, revision=None, client=None): + """Submit a reference clip (and a face close-up) for this card; the scheduler lands the frames.""" + value = value or {} + shape = schema.cast_schema('generate') + schema.validate(shape, value) + ref = ref if ref is not None else value.get('ref') + value = schema.defaults(shape, {k: v for k, v in value.items() if k != 'ref'}) + expected = _revision(value.pop('revision', None), revision) + payload = dict(value, revision=expected) + with self.changed: + digest, replay = self._receipt('cast:generate:' + str(identity), payload, ref, client) + if replay is not None: + return replay + record = deepcopy(self._record(identity)) + card = record['card'] + if expected is not None and expected != card['revision']: + raise self._stale(card) + if card['generating']: + job = card['generating']['job'] + raise CastError('already_generating', errors.ERRORS['already_generating'][2], + 'GET /freevideo/agent/v1/jobs/%s; when it ends, get the card.' % job, + card=view(card), job=job) + if value['face'] and card['kind'] != 'character': + raise spec.SpecError([schema.diagnostic('/face', 'false for a setting card', True)]) + seed = secrets.randbelow(spec.MAX_SEED + 1) + token = uuid.uuid4().hex[:12] + parts = [('turn', SCENE if card['kind'] == 'setting' else TURN, False)] + if value['face']: + parts.append(('face', CLOSE_UP, True)) + items = [dict(kind='generate', prompt=reference_prompt(card, face=face), seed=seed, + label=('Cast · %s · %s' % (card['name'], part))[:120], + metadata=dict(cast_card=identity, cast_part=part), **shot) + for part, shot, face in parts] + rows, _ = self.jobs.submit_request(dict(jobs=items), client='cast:' + identity, + parent=dict(cast=identity, ref=token)) + jobs = [row['id'] for row in rows] + card['generating'] = dict(job=jobs[0], face_job=jobs[1] if len(jobs) > 1 else None, + replace=value['replace'], at=self._now(), by=by) + card['_generating'] = dict(ref=token, seed=seed, kind=card['kind'], user_images=card.get('_user_images', 0)) + result = dict(job=jobs[0], jobs=jobs, card=view(card)) + self._remember(record, ref, digest, result, client) + self._save(record) + return result + + def allows(self, parent): + """May Jobs still start this generation? Only while its card waits for it.""" + with self.changed: + record = self.records.get(parent.get('cast')) + hidden = record['card'].get('_generating') if record else None + return bool(hidden) and hidden['ref'] == parent.get('ref') + + def _finished(self): + """Cards whose generation jobs have all ended, with those jobs as they ended.""" + ready = [] + with self.changed: + for identity, record in self.records.items(): + card = record['card'] + if not card.get('generating') or not card.get('_generating'): + continue + rows = {job: self.jobs.jobs.get(job) for job in _generation_jobs(card['generating'])} + if all(row is None or row['status'] in DONE for row in rows.values()): + ready.append((identity, deepcopy(card['generating']), deepcopy(card['_generating']), + {job: deepcopy(row) for job, row in rows.items()})) + return ready + + def pending(self): + return bool(self._finished()) + + def tick(self): + """Land finished reference clips on their cards. Runs off Jobs' lock: frames take a moment to pick. + + Several callers tick at once (the scheduler, job submissions, resumes); each card is landed by one + of them at a time. Never raises: a landing that cannot be saved is recorded on its card.""" + try: + finished = self._finished() + except Exception as error: # noqa: BLE001 - submissions that tick must not fail because of a card + logging.getLogger(__name__).warning('Reference pictures could not be checked (%s)', type(error).__name__) + return + for identity, generating, hidden, rows in finished: + if not self._claim(identity, hidden): + continue # Landed already, or another tick is landing it now. + try: + self._land(identity, generating, hidden, rows) + except Exception as error: # noqa: BLE001 - one card must never stop the others + logging.getLogger(__name__).warning('Reference pictures could not be saved (%s)', type(error).__name__) + self._fail(identity, hidden, dict(code='internal_error', job=generating['job'], + message='The reference pictures could not be saved.')) + finally: + with self.changed: + self.landing.discard(identity) + + def _claim(self, identity, hidden): + """Take this card's landing if it still waits for this generation and nobody else is landing it.""" + with self.changed: + record = self.records.get(identity) + if (identity in self.landing or record is None + or (record['card'].get('_generating') or {}).get('ref') != hidden['ref']): + return False + self.landing.add(identity) + return True + + def _land(self, identity, generating, hidden, rows): + from . import media + pictures, failure, staged = [], None, [] + try: + for job in _generation_jobs(generating): + row = rows[job] + face = job == generating.get('face_job') + if row is None or row['status'] != 'succeeded': + code = (row.get('error') or {}).get('code') if row else 'not_found' + failure = failure or dict(code=code or row['status'], job=job, message=( + 'The generation was cancelled.' if row and row['status'] == 'cancelled' + else 'The reference clip did not finish.')) + continue + folder = 'freevideo/agent/%s/ref-%s' % (identity, job) + count = 1 if face else COUNT + # Frames go to new names first and replace the card's files only once the card takes them, + # so a file a card already names is never missing, even for a moment. + mark = uuid.uuid4().hex[:8] + temporary = [self.jobs.port.input_path('%s-%d.landing-%s.png' % (folder, n, mark), create=True) + for n in range(count)] + for left in temporary[0].parent.glob('ref-%s-*.landing-*.png' % job): + if left not in temporary: + left.unlink(missing_ok=True) # Left by a landing that stopped before the card was saved. + try: + video = self.jobs.job_video(job) + with self.jobs.cpu: + picked = media.sharpest_frames(video, temporary) + except Exception as error: # noqa: BLE001 - the other clip's frames still land + for out in temporary: + out.unlink(missing_ok=True) + if _unreadable(error): + failure = failure or dict(code='clip_unreadable', job=job, + message='The reference clip could not be read.') + else: + logging.getLogger(__name__).warning('Reference frames could not be picked (%s)', + type(error).__name__) + failure = failure or dict(code='internal_error', job=job, + message='The reference pictures could not be saved.') + continue + setting = hidden.get('kind') == 'setting' + for n, row_frame in enumerate(picked): + if row_frame is None: + temporary[n].unlink(missing_ok=True) + continue + file = '%s-%d.png' % (folder, n) + staged.append((temporary[n], file)) + entry = dict(media=dict(file=file), by=generating['by'], from_job=job, generated=True) + if face: + entry['label'] = 'face' + elif setting: + entry['label'] = 'other' + else: + entry.update(label=turn_label((n + .5) / count), label_guess=True) + pictures.append(entry) + if not any(picked): + failure = failure or dict(code='no_sharp_frame', job=job, + message='The reference clip had no sharp frame to keep.') + self._settle(identity, hidden, pictures, failure, staged) + finally: + for out, _ in staged: + out.unlink(missing_ok=True) # Not taken by the card (published ones are gone already). + + def _settle(self, identity, hidden, pictures, failure, staged=()): + """Write the pictures into the card once; a later generation or a deletion makes this one stale.""" + with self.changed: + record = self.records.get(identity) + if record is None or (record['card'].get('_generating') or {}).get('ref') != hidden['ref']: + return + record = deepcopy(record) + card = record['card'] + generating = card['generating'] + by = generating['by'] + protected = by != 'user' and card['edited_by_user']['images'] + if card.get('_user_images', 0) != hidden['user_images'] or protected: + # The user changed the pictures meanwhile, or owns them: offer, never add or overwrite. + images, spare = card['images'], pictures + elif generating['replace']: + kept = [r for r in card['images'] if r.get('by') == 'user' and not r.get('generated')] + room = MAX_IMAGES - len(kept) + images, spare = kept + pictures[:room], pictures[room:] + else: + room = MAX_IMAGES - len(card['images']) + images, spare = card['images'] + pictures[:room], pictures[room:] + files = {r['media']['file'] for r in images} + candidates, seen = [], set() + for row in spare + card['candidates']: + file = row['media']['file'] + if file not in files and file not in seen: + seen.add(file) + candidates.append(row) + for out, file in staged: + os.replace(out, self.jobs.port.input_path(file, create=True)) + card.update(images=images, candidates=candidates[:MAX_CANDIDATES], generating=None, + generate_error=failure, revision=card['revision'] + 1, updated_at=self._now(), by=by, + generated=dict(job=generating['job'], face_job=generating.get('face_job'), + seed=hidden['seed'], prompt_version=PROMPT_VERSION)) + card.pop('_generating', None) + self._save(record) + + def _fail(self, identity, hidden, failure): + """Record a landing that failed on its card, keeping the pictures it has.""" + try: + self._settle(identity, hidden, [], failure) + return + except Exception as error: # noqa: BLE001 - the card file cannot be written now + logging.getLogger(__name__).warning('A cast card could not be saved (%s)', type(error).__name__) + # Shown until the next restart, so ticks do not land it again and again; the saved card still waits + # for this generation and lands it again after a restart. + with self.changed: + record = self.records.get(identity) + if record is None or (record['card'].get('_generating') or {}).get('ref') != hidden['ref']: + return + record = deepcopy(record) + card = record['card'] + card.update(generating=None, generate_error=failure, revision=card['revision'] + 1, + updated_at=self._now()) + card.pop('_generating', None) + self.records[identity] = record + self.changed.notify_all() + + +def _unreadable(error): + """A clip that is gone or cannot be decoded, rather than a fault of ours.""" + if isinstance(error, (JobError, ValueError, OSError)): + return True + try: + import av + except ImportError: + return False + return isinstance(error, av.error.FFmpegError) + + +def _generation_jobs(generating): + if not generating: + return [] + return [generating['job']] + ([generating['face_job']] if generating.get('face_job') else []) diff --git a/freevideo_engine/agent/cli.py b/freevideo_engine/agent/cli.py new file mode 100644 index 00000000..ed1b81ea --- /dev/null +++ b/freevideo_engine/agent/cli.py @@ -0,0 +1,277 @@ +"""./freevideo agent ... — the agent API from a shell; every command prints JSON. + + ./freevideo agent status + ./freevideo agent submit shot.json --wait + ./freevideo agent batch storyboard.json --wait + ./freevideo agent job j0123456789ab +""" +import argparse +import json +from pathlib import Path +import sys + +from .client import ApiError, Client +from . import schema, spec, errors + + +def read(path): + text = sys.stdin.read() if path == '-' else Path(path).read_text(encoding='utf-8') + value = json.loads(text) + schema.validate({'type': 'object'}, value) + return value + + +EXTRA_COMMANDS = { + 'inspect': ('POST', '/media/inspect'), 'contact-sheet': ('POST', '/media/contact-sheet'), + 'edit': ('POST', '/media/edit'), 'library': ('GET', '/library'), + 'queue': ('GET', '/queue'), 'hello': ('POST', '/connect/hello'), + 'connection-status': ('GET', '/connect/status'), 'openapi': ('GET', '/openapi.json'), +} + + +class Parser(argparse.ArgumentParser): + def error(self, message): + self.exit(2, errors.text(errors.build('invalid_request')) + '\n') + + +def build_parser(): + parser = Parser(prog='./freevideo agent', description='Drive FreeVideo through its agent API.') + parser.add_argument('--root', type=Path) + parser.add_argument('--url') + commands = parser.add_subparsers(dest='command', required=True) + commands.add_parser('status', help='Readiness, limits, LoRAs, VLM and queue') + commands.add_parser('guide', help='How to write H3 prompts') + commands.add_parser('schema', help='JSON Schemas of jobs') + command = commands.add_parser('capabilities', help='Read the studio capability cache') + schema.cli_arguments(command, schema.contracts()['GET', '/capabilities']['query']) + command = commands.add_parser('asset', help='Download a saved library video or thumbnail over HTTP') + command.add_argument('id') + command.add_argument('target', type=Path) + command.add_argument('--thumbnail', action='store_true') + for name, text in (('plan', 'Validate a job file and estimate it'), ('submit', 'Submit a job file (- = stdin)'), + ('batch', 'Submit {"jobs": [...]} with @ref links')): + command = commands.add_parser(name, help=text) + command.add_argument('file') + if name != 'plan': + command.add_argument('--wait', action='store_true', help='Wait for the job(s) and print the results') + if name in ('submit', 'plan'): + schema.cli_arguments(command, schema.generate(local=True)) + else: + schema.cli_arguments(command, schema.batch_schema(local=True), exclude=('jobs',)) + for name in ('job', 'wait', 'cancel', 'resume'): + command = commands.add_parser(name) + command.add_argument('id') + if name == 'wait': + schema.cli_arguments(command, schema.obj({'timeout': schema.CLIENT_WAIT})) + elif name == 'job': + schema.cli_arguments(command, schema.contracts()['GET', '/jobs/{id}']['query']) + elif name == 'resume': + schema.cli_arguments(command, schema.contracts()['POST', '/jobs/{id}/resume']['body']) + command = commands.add_parser('jobs') + schema.cli_arguments(command, schema.contracts()['GET', '/jobs']['query']) + command = commands.add_parser('upload', help='Copy a local media file into FreeVideo inputs') + command.add_argument('path') + command = commands.add_parser('drama', help='Plan, start, review and finish a durable film') + drama = command.add_subparsers(dest='operation', required=True) + for name, (method, path, summary) in schema.DRAMA_OPERATIONS.items(): + sub = drama.add_parser(name, help=summary) + if '{id}' in path: + sub.add_argument('id') + if '{shot}' in path: + sub.add_argument('shot') + if name in ('plan', 'start'): + sub.add_argument('file', help='JSON production request with screenplay (- = stdin)') + schema.cli_arguments(sub, schema.drama_schema(name)) + command = commands.add_parser('cast', help='Find, create and change character and setting cards') + cast = command.add_subparsers(dest='operation', required=True) + for name, (method, path, summary) in schema.CAST_OPERATIONS.items(): + sub = cast.add_parser(name, help=summary) + if '{id}' in path: + sub.add_argument('id') + if name in ('create', 'update'): + sub.add_argument('file', nargs='?', help='JSON card fields (- = stdin); options override them') + schema.cli_arguments(sub, schema.cast_schema(name, local=True)) + command = commands.add_parser('projects', help='Read and edit timelines shared with the editor page') + projects = command.add_subparsers(dest='operation', required=True) + for name, (method, path, summary) in schema.PROJECT_OPERATIONS.items(): + sub = projects.add_parser(name, help=summary) + if '{id}' in path: + sub.add_argument('id') + if name in ('create', 'write'): + sub.add_argument('file', nargs='?', help='JSON {project, ref?} request (- = stdin); options override it') + if name == 'operations': + sub.add_argument('--operations-file', help='JSON array of operations (- = stdin)') + schema.cli_arguments(sub, schema.project_schema(name)) + command = commands.add_parser('images', help='Edit pictures with the image editor\'s commands') + images = command.add_subparsers(dest='operation', required=True) + for name, (method, path, summary) in schema.IMAGE_OPERATIONS.items(): + sub = images.add_parser(name, help=summary) + if '{id}' in path: + sub.add_argument('id') + if name in ('create', 'write'): + sub.add_argument('file', nargs='?', help='JSON request (- = stdin); options override it') + if name == 'operations': + sub.add_argument('--operations-file', help='JSON array of operations (- = stdin)') + schema.cli_arguments(sub, schema.image_schema(name)) + command = commands.add_parser('sessions', help='Share messages and plans with the user in a work conversation') + sessions = command.add_subparsers(dest='operation', required=True) + for name in ('list', 'create', 'events', 'post'): + _, path, summary = schema.SESSION_OPERATIONS[name] + sub = sessions.add_parser(name, help=summary) + if '{id}' in path: + sub.add_argument('id') + if name == 'post': + content = sub.add_mutually_exclusive_group(required=True) + content.add_argument('--message', help='Text to send to the user') + content.add_argument('--plan-file', help='JSON {shots, total_seconds?} plan (- = stdin)') + else: + schema.cli_arguments(sub, schema.session_schema(name)) + command = commands.add_parser('frame', help='Save a frame of a job or saved video') + command.add_argument('source') + schema.cli_arguments(command, schema.contracts()['POST', '/media/frame']['body'], exclude=('source',)) + for name, (method, path) in EXTRA_COMMANDS.items(): + command = commands.add_parser(name) + definition = schema.contracts(local=True)[method, path] + schema.cli_arguments(command, definition['query'] if method == 'GET' else definition['body']) + return parser + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + values = vars(args) + + def progress(view): + value = view.get('progress') or {} + line = '%s %s %s' % (view['id'], view['status'], value.get('label') or '') + if value.get('fraction') is not None: + line += ' %d%%' % round(100 * value['fraction']) + print(line.rstrip(), file=sys.stderr, flush=True) + + def source(value): + from .spec import JOB_ID, LIBRARY_ID + return {'from_job': value} if JOB_ID.fullmatch(value) else {'library': value} if LIBRARY_ID.fullmatch(value) \ + else {'path': str(Path(value).resolve())} if Path(value).exists() else {'file': value} + + try: + client = Client(args.url, root=args.root, name='freevideo-cli') + if args.command in EXTRA_COMMANDS: + method, path = EXTRA_COMMANDS[args.command] + definition = schema.contracts(local=True)[method, path] + shape = definition['query'] if method == 'GET' else definition['body'] + value = {k: values[k] for k in shape['properties'] if k in values} + schema.validate(shape, value) + result = client.get(path, **value) if method == 'GET' else client.post(path, value) + elif args.command == 'status': + result = client.capabilities() + elif args.command == 'guide': + result = client.guide() + elif args.command == 'schema': + result = client.schema() + elif args.command == 'capabilities': + fields = schema.contracts()['GET', '/capabilities']['query']['properties'] + result = client.capability_table(**{k: values[k] for k in fields if k in values}) + elif args.command == 'asset': + result = dict(id=args.id, path=str(client.asset(args.id, args.target, thumbnail=args.thumbnail))) + elif args.command == 'plan': + value = read(args.file) | {k: values[k] for k in schema.generate()['properties'] if k in values} + schema.validate_job(value, local=True) + schema.preflight_submission(value) + result = client.plan(value) + elif args.command in ('submit', 'batch'): + fields = schema.batch_schema() if args.command == 'batch' else schema.generate() + value = read(args.file) | {k: values[k] for k in fields['properties'] if k in values} + schema.validate(schema.batch_schema(local=True) if args.command == 'batch' else schema.job_schema(local=True), value) + schema.preflight_submission(value) + jobs = client.batch(value['jobs'], **({'ref': value['ref']} if 'ref' in value else {})) if args.command == 'batch' else \ + client.submit_takes(value, value['takes']) if value.get('takes', 1) > 1 else [client.submit(value)] + if args.wait: + jobs = [client.wait(job['id'], on_progress=progress) for job in jobs] + result = jobs[0] if len(jobs) == 1 and args.command == 'submit' else {'jobs': jobs} + elif args.command == 'job': + result = client.job(args.id, **({'wait': values['wait']} if 'wait' in values else {})) + elif args.command == 'wait': + result = client.wait(args.id, values.get('timeout', 25), on_progress=progress) + elif args.command == 'cancel': + result = client.cancel(args.id) + elif args.command == 'resume': + result = client.resume(args.id, **({'ref': values['ref']} if 'ref' in values else {})) + elif args.command == 'jobs': + result = {'jobs': client.jobs(**{k: values[k] for k in schema.contracts()['GET', '/jobs']['query']['properties'] if k in values})} + elif args.command == 'drama': + method, path, _ = schema.DRAMA_OPERATIONS[args.operation] + shape = schema.drama_schema(args.operation) + value = read(args.file) if args.operation in ('plan', 'start') else {} + value.update({k: values[k] for k in shape['properties'] if k in values}) + schema.validate(shape, value) + for key in ('id', 'shot'): + if '{' + key + '}' in path: + schema.validate(schema.PRODUCTION if key == 'id' else schema.SLUG, values[key]) + path = path.replace('{' + key + '}', values[key]) + result = client.get(path, **value) if method == 'GET' else client.post(path, value) + elif args.command == 'cast': + method, path, _ = schema.CAST_OPERATIONS[args.operation] + shape = schema.cast_schema(args.operation, local=True) + value = read(args.file) if getattr(args, 'file', None) else {} + value.update({k: values[k] for k in shape['properties'] if k in values}) + schema.validate(shape, value) + if '{id}' in path: + schema.validate(schema.CAST, values['id']) + path = path.replace('{id}', values['id']) + result = client.get(path, **value) if method == 'GET' else client.send(method, path, value) + elif args.command in ('projects', 'images'): + table, shape_of, ident = ((schema.PROJECT_OPERATIONS, schema.project_schema, schema.PROJECT) + if args.command == 'projects' else (schema.IMAGE_OPERATIONS, schema.image_schema, schema.IMAGE)) + method, path, _ = table[args.operation] + shape = shape_of(args.operation) + value = read(args.file) if getattr(args, 'file', None) else {} + value.update({k: values[k] for k in shape['properties'] if k in values}) + if getattr(args, 'operations_file', None): + text = sys.stdin.read() if args.operations_file == '-' else Path(args.operations_file).read_text(encoding='utf-8') + value['operations'] = json.loads(text) + schema.validate(shape, value) + if '{id}' in path: + schema.validate(ident, args.id) + path = path.replace('{id}', args.id) + result = client.get(path, **value) if method == 'GET' else client.send(method, path, value) + elif args.command == 'sessions': + method, path, _ = schema.SESSION_OPERATIONS[args.operation] + shape = schema.session_schema(args.operation) + if args.operation == 'post': + if args.message is not None: + value = dict(type='message', text=args.message) + else: + value = dict(type='plan') | read(args.plan_file) + schema.validate(schema.session_event(agent_only=True)['oneOf'][1], value) + schema.validate(schema.session_event(agent_only=True), value) + else: + value = {k: values[k] for k in shape['properties'] if k in values} + schema.validate(shape, value) + if '{id}' in path: + schema.validate(schema.SESSION, args.id) + path = path.replace('{id}', args.id) + result = client.get(path, **value) if method == 'GET' else client.post(path, value) + elif args.command == 'upload': + result = client.upload(args.path) + else: + result = client.frame(source(args.source), values.get('at', 'last')) + except ApiError as error: + if error.inbox: + print(json.dumps(dict(inbox=error.inbox), ensure_ascii=False, indent=1)) + print(str(error), file=sys.stderr) + return 2 + except spec.SpecError as error: + print(errors.text(errors.validation(error.errors)), file=sys.stderr) + return 2 + except (ValueError, OSError): + print(errors.text(errors.build('invalid_request')), file=sys.stderr) + return 2 + print(json.dumps(result, ensure_ascii=False, indent=1)) + failed = isinstance(result, dict) and result.get('status') in ('failed', 'cancelled', 'interrupted') + if failed and result.get('error'): + print(errors.text(result['error']), file=sys.stderr) + return 1 if failed else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/freevideo_engine/agent/client.py b/freevideo_engine/agent/client.py new file mode 100644 index 00000000..ca61ef39 --- /dev/null +++ b/freevideo_engine/agent/client.py @@ -0,0 +1,360 @@ +"""A small standard-library client for the FreeVideo agent API (used by the MCP server and the CLI). + + from freevideo_engine.agent.client import Client + fv = Client() # finds the server and token of this installation + shot = fv.submit({'prompt': '...', 'aspect': '9:16', 'seconds': 6}) + done = fv.wait(shot['id']) # {'status': 'succeeded', 'result': {'video': {...}}} +""" +import base64 +import json +import ipaddress +import os +from pathlib import Path +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +DONE = ('succeeded', 'failed', 'cancelled', 'interrupted') + + +class ApiError(Exception): + def __init__(self, status, code, message, details=None, *, inbox=None, **context): + from . import errors + error = errors.build(code, **context) + # The user's words delivered with a failed call are already read on the server. + self.inbox = inbox if isinstance(inbox, list) else [] + # Server errors already passed the public boundary. Keep their exact text. + if details and details.get('next'): + error.update(message=message, **details) + self.status, self.code, self.message = status, error['code'], error['message'] + self.details = {k: v for k, v in error.items() if k not in ('code', 'message')} + super().__init__(errors.text(error)) + + +def local_file(path): + try: + source = Path(path).expanduser() + if source.is_file(): + return source + except (OSError, ValueError, TypeError): + pass + # Agents often pass a path FreeVideo returned (assets, inputs, outputs) as a local file. + raise ApiError(422, 'invalid_media', 'There is no readable file at this path on this computer.', dict( + next='Use "path" only for a file on this computer. Send a file FreeVideo returned as {"file": ""}.', + details=[dict(field='path')])) + + +def default_root(): + if os.environ.get('FREEVIDEO_HOME'): + return Path(os.environ['FREEVIDEO_HOME']).expanduser() + here = Path(__file__).resolve().parents[2] + if (here / 'machine.json').is_file() or (here / 'agent').is_dir(): + return here + try: + from ..bootstrap import DEFAULT_ROOT + return Path(DEFAULT_ROOT) + except Exception: # noqa: BLE001 - the bootstrap module needs the full engine environment + return here + + +def endpoint_url(value): + try: + parsed = urllib.parse.urlsplit(value) + if (parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username is not None + or parsed.password is not None or parsed.query or parsed.fragment or parsed.port == 0 + or any(c.isspace() for c in value) or '\\' in value): + raise ValueError + parsed.port + except (TypeError, ValueError, AttributeError): + raise ApiError(422, 'invalid_request', 'Invalid endpoint') from None + return value.rstrip('/') + + +def origin(url): + value = urllib.parse.urlsplit(endpoint_url(url.split('?', 1)[0].split('#', 1)[0])) + return value.scheme, value.hostname.lower(), value.port or (443 if value.scheme == 'https' else 80) + + +class LocalProxyHandler(urllib.request.ProxyHandler): + def proxy_open(self, req, proxy, type): + host = urllib.parse.urlsplit(req.full_url).hostname + try: + address = ipaddress.ip_address(host) + local = address.is_loopback or bool(getattr(address, 'ipv4_mapped', None) and address.ipv4_mapped.is_loopback) + except ValueError: + local = host.lower() == 'localhost' + if local: + return None + return super().proxy_open(req, proxy, type) + + +class CredentialRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + credentialed = any(req.has_header(k) for k in ('Authorization', 'X-freevideo-agent', 'Cookie')) + if credentialed and origin(req.full_url) != origin(newurl): + raise ApiError(403, 'redirect_forbidden', 'Cross-origin redirect refused') + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def controlled_opener(): + return urllib.request.build_opener(LocalProxyHandler(), CredentialRedirect()) + + +class Client: + def __init__(self, url=None, token=None, *, root=None, name='freevideo-python', timeout=60): + root = Path(root).expanduser() if root else default_root() + self.root = root + endpoint = {} + try: + endpoint = json.loads((root / 'agent' / 'endpoint.json').read_text(encoding='utf-8')) + except (OSError, ValueError): + pass + self.url = endpoint_url(url or os.environ.get('FREEVIDEO_URL') or (endpoint.get('url') if isinstance(endpoint, dict) else None) or 'http://127.0.0.1:8188') + self.opener = controlled_opener() + if token is None: + try: + token = (root / 'agent' / 'token').read_text(encoding='ascii').strip() + except OSError: + token = None + self.token, self.name, self.timeout = token, name, timeout + # Per-thread extra headers: the MCP server runs each tool call on its own thread. + self.call_headers = threading.local() + + # Transport ------------------------------------------------------------------------------------------ + def relative_url(self, path): + if not isinstance(path, str) or not path.startswith('/') or path.startswith('//') or '\\' in path: + raise ApiError(422, 'invalid_request', 'Expected a relative API endpoint') + return self.url + path + + def request(self, method, path, body=None, *, query=None, timeout=None, raw=False, encoded=None, content_type=None, content_length=None): + if self.token is None: + raise ApiError(0, 'no_token', 'The local token is unavailable.') + url = self.relative_url(path) + ('?' + urllib.parse.urlencode(query) if query else '') + data = encoded if encoded is not None else None if body is None else json.dumps(body).encode() + headers = {'Authorization': 'Bearer ' + self.token, 'X-FreeVideo-Client': self.name} + headers.update(getattr(self.call_headers, 'value', None) or {}) + if data is not None: + headers['Content-Type'] = content_type or 'application/json' + if content_length is not None: + headers['Content-Length'] = str(content_length) + request = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with self.opener.open(request, timeout=timeout or self.timeout) as response: + payload = response.read() + except urllib.error.HTTPError as error: + payload = error.read() + try: + body = json.loads(payload) + problem = body.get('error') or {} + except (ValueError, AttributeError): + body, problem = {}, {} + raise ApiError(error.code, problem.get('code', 'http_%d' % error.code), + problem.get('message') or payload.decode('utf-8', 'replace')[:500], + {k: v for k, v in problem.items() if k not in ('code', 'message')}, + inbox=body.get('inbox')) from None + except urllib.error.URLError: + submission = method == 'POST' and (path.endswith('/jobs') or path.endswith('/resume')) + raise ApiError(0, 'unreachable', 'The FreeVideo server could not be reached.', + stage='submission_unknown' if submission else 'request') from None + return payload if raw else json.loads(payload or b'{}') + + def get(self, path, **query): + return self.request('GET', '/freevideo/agent/v1' + path, query={k: v for k, v in query.items() if v is not None}) + + def local_media(self, value): + if isinstance(value, list): + return [self.local_media(row) for row in value] + if isinstance(value, dict): + if 'path' in value and not any(k in value for k in ('file', 'from_job', 'library')): + return {k: v for k, v in value.items() if k != 'path'} | {'file': self.upload(value['path'])['file']} + return {k: v if k in ('metadata', 'edit', 'document', 'recipe') else self.local_media(v) for k, v in value.items()} + return value + + def check_local_files(self, value): + """Check the whole client-side input set before publishing the first asset.""" + from .schema import MAX_UPLOAD_BYTES + if isinstance(value, list): + for child in value: + self.check_local_files(child) + elif isinstance(value, dict): + if 'path' in value: + source = local_file(value['path']) + try: + if not 0 < source.stat().st_size <= MAX_UPLOAD_BYTES: + raise ValueError + except (OSError, ValueError, TypeError): + raise ApiError(422, 'invalid_media', 'Invalid local input') from None + for key, child in value.items(): + if key not in ('metadata', 'edit', 'document', 'recipe'): + self.check_local_files(child) + + def post(self, path, body, **kw): + return self.send('POST', path, body, **kw) + + def patch(self, path, body, **kw): + return self.send('PATCH', path, body, **kw) + + def delete(self, path, body=None, **kw): + return self.send('DELETE', path, body if body is not None else {}, **kw) + + def send(self, method, path, body, **kw): + from . import schema + shape = schema.request_contract(method, path, local=True) + if shape is not None: + schema.validate(shape, body) + if path in ('/jobs', '/plan'): + schema.preflight_submission(body) + if path != '/media': + self.check_local_files(body) + body = self.local_media(body) + shape = schema.request_contract(method, path) + if shape is not None: + schema.validate(shape, body) + return self.request(method, '/freevideo/agent/v1' + path, body, **kw) + + def hello(self, via='http'): + return self.post('/connect/hello', dict(client=self.name, via=via)) + + # API ------------------------------------------------------------------------------------------------ + def capabilities(self): + return self.get('') + + def capability_table(self, **query): + return self.get('/capabilities', **query) + + def guide(self): + return self.get('/guide') + + def schema(self): + return self.get('/schema') + + def plan(self, spec): + return self.post('/plan', spec) + + def submit(self, spec, takes=1): + body = dict(spec, takes=takes) if takes != 1 else spec + return self.post('/jobs', body)['job'] + + def submit_takes(self, spec, takes): + return self.post('/jobs', dict(spec, takes=takes))['jobs'] + + def batch(self, jobs, *, ref=None): + """Jobs may name themselves with "ref" and refer to each other as "@ref".""" + return self.post('/jobs', dict(jobs=jobs, **({'ref': ref} if ref else {})))['jobs'] + + def job(self, identity, wait=0): + wait = max(0, min(25, wait)) + return self.request('GET', '/freevideo/agent/v1/jobs/' + identity, query={'wait': wait} if wait else None, + timeout=max(self.timeout, wait + 5)) + + def jobs(self, status=None, project=None, kind=None, limit=None): + return self.get('/jobs', status=status, project=project, kind=kind, limit=limit)['jobs'] + + def wait(self, identity, timeout=None, on_progress=None): + """Until the job finishes (or timeout seconds pass); returns the last job view.""" + deadline = None if timeout is None else time.monotonic() + timeout + while True: + left = 25 if deadline is None else max(0, min(25, deadline - time.monotonic())) + view = self.job(identity, wait=left) + if on_progress: + on_progress(view) + if view['status'] in DONE or (deadline is not None and time.monotonic() >= deadline): + return view + + def cancel(self, identity): + return self.post('/jobs/%s/cancel' % identity, {}) + + def resume(self, identity, *, ref=None): + return self.post('/jobs/%s/resume' % identity, {'ref': ref} if ref else {}) + + def asset(self, identity, target, *, thumbnail=False): + """Retrieve a library file through authenticated HTTP only.""" + path = '/freevideo/agent/v1/library/%s/%s' % (identity, 'thumbnail' if thumbnail else 'file') + return self.download({'url': path}, target) + + def upload(self, path): + """Read on the client computer and upload content; never send a local path.""" + from .schema import MAX_UPLOAD_BYTES, MAX_JSON_UPLOAD_BYTES + source = local_file(path) + try: + size = source.stat().st_size + if size > MAX_UPLOAD_BYTES: + raise ApiError(413, 'too_large', 'File exceeds upload limit', + dict(next='Reduce the file to at most %d bytes.' % MAX_UPLOAD_BYTES, details=[dict(limit_bytes=MAX_UPLOAD_BYTES)])) + if size * 4 // 3 + 4096 <= MAX_JSON_UPLOAD_BYTES: + return self.upload_bytes(source.name, source.read_bytes()) + import secrets + boundary = 'freevideo-' + secrets.token_hex(16) + # Only a safe extension crosses a MIME header; local filenames stay local. + suffix = source.suffix.lower() + if not suffix[1:].isalnum(): + raise ApiError(422, 'invalid_media', 'Invalid media extension') + prefix = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="media%s"\r\n' + 'Content-Type: application/octet-stream\r\n\r\n' % (boundary, suffix)).encode('ascii') + ending = ('\r\n--%s--\r\n' % boundary).encode('ascii') + with source.open('rb') as stream: + def chunks(): + yield prefix + while data := stream.read(1024 * 1024): + yield data + yield ending + return self.request('POST', '/freevideo/agent/v1/media', encoded=chunks(), + content_type='multipart/form-data; boundary=' + boundary, + content_length=len(prefix) + size + len(ending), timeout=600) + except OSError: + raise ApiError(0, 'invalid_media', 'Cannot read local file') from None + + def upload_bytes(self, name, data): + return self.post('/media', {'name': name, 'data_base64': base64.b64encode(data).decode()}, timeout=600) + + def inspect(self, source): + return self.post('/media/inspect', {'source': source}) + + def frame(self, source, at=None): + return self.post('/media/frame', dict(source=source, **({'at': at} if at is not None else {}))) + + def edit(self, source, recipe=None, *, document=None, target=None): + body = {'source': source} + body.update({'recipe': recipe} if document is None else {'document': document}) + if target: + body['target'] = list(target) + return self.post('/media/edit', body) + + def contact_sheet(self, source, count=None, columns=None): + return self.post('/media/contact-sheet', dict(source=source, **{k: v for k, v in dict(count=count, columns=columns).items() if v is not None})) + + def library(self, limit=None, before=None, query=None, project=None): + return self.get('/library', limit=limit, before=before, query=query, project=project) + + def queue(self): + return self.get('/queue') + + def download(self, view, target): + """Save a result file (a job result's video/view dict) somewhere else.""" + target = Path(target) + if self.token is None: + raise ApiError(0, 'no_token', 'The local token is unavailable.') + request = urllib.request.Request(self.relative_url(view['url']), headers={ + 'Authorization': 'Bearer ' + (self.token or ''), 'X-FreeVideo-Client': self.name}) + try: + with self.opener.open(request, timeout=600) as response, target.open('wb') as stream: + while True: + chunk = response.read(1 << 20) + if not chunk: + break + stream.write(chunk) + except urllib.error.HTTPError as error: + try: + problem = json.loads(error.read()).get('error') or {} + except ValueError: + problem = {} + raise ApiError(error.code, problem.get('code', 'asset_unavailable'), + problem.get('message', 'The library asset could not be retrieved.'), + dict(details=[], next='GET /freevideo/agent/v1/library and use an existing asset ID.', + retryable=False) | {k: v for k, v in problem.items() if k not in ('code', 'message')}) from None + except urllib.error.URLError: + raise ApiError(0, 'unreachable', 'The FreeVideo server could not be reached.', + dict(details=[], next='Start FreeVideo, then retry the asset download.', retryable=True)) from None + return target diff --git a/freevideo_engine/agent/comfy.py b/freevideo_engine/agent/comfy.py new file mode 100644 index 00000000..8996f1f2 --- /dev/null +++ b/freevideo_engine/agent/comfy.py @@ -0,0 +1,347 @@ +"""The running ComfyUI server as seen by agent jobs: its queue, history, folders and the local VLM.""" +import json +import os +from pathlib import Path, PurePosixPath +import shutil +import stat +from urllib.parse import urlencode +import uuid + +from . import spec as specs +from .jobs import JobError + + +class Request: + """Just enough of an aiohttp request for ComfyUI's own POST /prompt handler.""" + method, path, content_type, query, match_info = 'POST', '/prompt', 'application/json', {}, {} + + def __init__(self, body): + from multidict import CIMultiDict, CIMultiDictProxy + self.body = body + self.headers = CIMultiDictProxy(CIMultiDict({'Content-Type': 'application/json'})) + + async def json(self): + return self.body + + +class ComfyPort: + def __init__(self, server): + import folder_paths + self.server = server + self.folders = folder_paths + self.handler = None + + # Folders ------------------------------------------------------------------------------------------- + @property + def input_root(self): + return Path(self.folders.get_input_directory()).resolve() + + @property + def output_root(self): + return Path(self.folders.get_output_directory()).resolve() + + def _inside(self, root, relative, create): + parts = PurePosixPath(relative).parts + if not relative or PurePosixPath(relative).is_absolute() or '..' in parts or '\\' in relative or ':' in relative: + raise JobError('invalid_media', 'Not a relative path: %s' % relative) + path = (root / relative).resolve() + if not path.is_relative_to(root): + raise JobError('invalid_media', 'Path leaves the folder: %s' % relative) + if create: + path.parent.mkdir(parents=True, exist_ok=True) + return path + + def input_path(self, relative, create=False): + return self._inside(self.input_root, relative, create) + + def output_path(self, relative, create=False): + return self._inside(self.output_root, relative, create) + + def output_relative(self, path): + return Path(path).resolve().relative_to(self.output_root).as_posix() + + def library_video(self, identity): + from ..comfy_library import _video, _report + try: + path = _video(self.output_root, identity) + info = path.stat() + if not stat.S_ISREG(info.st_mode) or not info.st_size: + raise ValueError('Video is not a nonempty regular file') + _report(path) + except (OSError, ValueError): + raise JobError('invalid_media', 'No finished saved video %s' % identity) from None + return path + + def file_view(self, relative, kind='output'): + from .assets import view + return view(relative, kind) + + + def chain_runner(self): + from ..comfy_chain import RUNNER + return RUNNER + + async def capability_view(self, query): + from ..comfy_capabilities import _query + from ..capabilities import UNAVAILABLE + key = _query(query) + cache = getattr(self.server, '_freevideo_capabilities', None) + if not hasattr(cache, 'get'): + return dict(UNAVAILABLE) + try: + await cache.check() + return cache.get(key) + except Exception: + cache._log_once() + return dict(UNAVAILABLE) + + def capability_snapshot(self, query): + from ..comfy_capabilities import _query + from ..capabilities import UNAVAILABLE + cache = getattr(self.server, '_freevideo_capabilities', None) + return cache.get(_query(query)) if hasattr(cache, 'get') else dict(UNAVAILABLE) + + def output_summary(self, relative): + from .jobs import library_id + from ..comfy_library import _report + from ..comfy_assets import output_summary + path = self.library_video(library_id(relative)) + if path != self.output_path(relative): + raise ValueError('Invalid library output') + report = _report(path) + summary = output_summary(report, Path(relative)) + if report.get('result_cache_hit') is not None: + summary['result_cache_hit'] = report['result_cache_hit'] + return summary + + # Checks at submission (fast; files are checked again when the job starts) -------------------------------- + def check(self, spec): + from .schema import diagnostic, editor_issues + errors = [] + names = set(self.folders.get_filename_list('loras')) if spec.get('loras') else set() + for i, row in enumerate(spec.get('loras', [])): + if row['name'] not in names: + errors.append(diagnostic('/loras/%d/name' % i, 'an installed LoRA listed by GET /freevideo/agent/v1', row['name'])) + def walk(value, pointer=''): + if isinstance(value, list): + for i, row in enumerate(value): + walk(row, pointer + '/' + str(i)) + elif isinstance(value, dict): + source = None + for key in ('file', 'path', 'library'): + if key in value: + try: + source = (self.input_path(value[key]) if key == 'file' else + self.library_video(value[key]) if key == 'library' else Path(value[key]).expanduser()) + if not source.is_file(): + raise ValueError + except (OSError, ValueError, JobError): + errors.append(diagnostic(pointer + '/' + key, 'an existing uploaded input or saved library asset', value[key])) + if value.get('edit') is not None: + size = (spec.get('width', 1344), spec.get('height', 768)) + if source is not None and source.is_file(): + from .media import inspect + try: + info = inspect(source) + size = (info['width'], info['height']) + except (OSError, ValueError, KeyError): + errors.append(diagnostic(pointer, 'a readable image or video', None)) + errors.extend(editor_issues(value['edit'], pointer + '/edit', source_size=size, + target=(spec.get('width', 1344), spec.get('height', 768)), resolve=self.input_path)) + for key, child in value.items(): + if key not in ('metadata', 'edit'): + walk(child, pointer + '/' + key) + for key in ('media', 'clips', 'questions', 'refine_from'): + if key in spec: + walk(spec[key], '/' + key) + if errors: + raise specs.SpecError(errors) + + # Queue --------------------------------------------------------------------------------------------- + def snapshot(self): + running, pending = self.server.prompt_queue.get_current_queue() + return [row[1] for row in running], [row[1] for row in sorted(pending, key=lambda r: r[0])] + + def editor_exports(self): + """The editor page's export queue (editor_export.register), shared so exports take turns.""" + return getattr(self.server, '_freevideo_editor_export', None) + + def queue_busy(self): + running, pending = self.snapshot() + return bool(running or pending) + + def prompt_state(self, prompt_id): + running, pending = self.snapshot() + return 'running' if prompt_id in running else 'pending' if prompt_id in pending else 'missing' + + def position(self, prompt_id): + running, pending = self.snapshot() + return pending.index(prompt_id) + 1 if prompt_id in pending else None + + def history(self, prompt_id): + return self.server.prompt_queue.get_history(prompt_id=prompt_id).get(prompt_id) + + def progress(self, node): + from ..comfy_progress import STATE + with STATE.lock: + row = STATE.rows.get(str(node)) + return json.loads(json.dumps(row['message'])) if row else None + + async def submit(self, graph, extra): + if self.handler is None: + for route in self.server.routes: + if getattr(route, 'method', None) == 'POST' and getattr(route, 'path', None) == '/prompt': + self.handler = route.handler + if self.handler is None: + raise JobError('unsupported_comfyui', 'This ComfyUI has no POST /prompt route') + prompt_id = str(uuid.uuid4()) + value = dict(prompt=graph, prompt_id=prompt_id, extra_data=extra) + try: + response = await self.handler(Request(value)) + status, body = response.status, json.loads(response.text or '{}') + except (AttributeError, TypeError): + # A newer handler reads more of the request: post to this server instead. + status, body = await self.post_prompt(value) + if status != 200: + error = body.get('error') or {} + messages = [row.get('message') or row.get('details') for node in (body.get('node_errors') or {}).values() + for row in node.get('errors', [])] + message = '; '.join(m for m in messages if m) or error.get('message') or 'ComfyUI rejected the request' + details = [row.get('details') for node in (body.get('node_errors') or {}).values() + for row in node.get('errors', []) if row.get('details')] + not_ready = any('setup' in (m or '').lower() or 'install' in (m or '').lower() for m in messages + details) + raise JobError('not_ready' if not_ready else 'invalid_request', + '; '.join(d for d in details if d) or message) + return body.get('prompt_id', prompt_id) + + async def post_prompt(self, value): + import aiohttp + from comfy.cli_args import args + host = (args.listen or '127.0.0.1').split(',')[0] + host = '127.0.0.1' if host in ('0.0.0.0', '') else '::1' if host == '::' else host + scheme = 'https' if getattr(args, 'tls_keyfile', None) else 'http' + url = '%s://%s:%d/prompt' % (scheme, '[%s]' % host if ':' in host else host, args.port) + async with aiohttp.ClientSession() as session: + async with session.post(url, json=value, ssl=False) as response: + return response.status, await response.json(content_type=None) + + def cancel(self, prompt_id, node): + import nodes + from ..comfy_progress import cancel_request + return cancel_request(self.server.prompt_queue, prompt_id, node, nodes.interrupt_processing) + + def generation_error(self, history, node): + messages = (history.get('status') or {}).get('messages') or [] + text = None + for kind, data in messages: + if kind == 'execution_interrupted': + return dict(code='cancelled', message='Cancelled') + if kind == 'execution_error': + text = str(data.get('exception_message') or data.get('exception_type') or '').strip() + error = dict(code='generation_failed', message=(text or 'The generation did not finish')[:4000]) + run = self.run_output(node) + if run is not None: + error['run'] = self.output_relative(run.parent) + report = _read(run.with_suffix('.request.json')) + engine = _read(run.with_suffix('.engine.json')) + if report.get('resource_error'): + details = report['resource_error'] if isinstance(report['resource_error'], dict) else {} + error.update(code='insufficient_memory', retryable=False, + alternatives=(details.get('details') or details).get('alternatives') or []) + failure = engine.get('failure') or {} + if failure.get('kind') in ('gpu_oom', 'ram_pressure'): + error.update(code='out_of_memory', retryable=True) + elif failure.get('kind'): + error.setdefault('failure_kind', failure['kind']) + debug = run.with_suffix('.debug.json') + if debug.is_file(): + error['report'] = self.file_view(self.output_relative(debug)) + return error + + def run_output(self, node): + from ..comfy_progress import REPORTS + message = self.progress(node) or {} + with REPORTS.lock: + path = REPORTS.runs.get(message.get('report_id')) + return path + + # Local VLM ----------------------------------------------------------------------------------------- + @property + def vlm(self): + service = getattr(self.server, '_freevideo_prompt_vlm', None) + if service is None: + raise JobError('vlm_unavailable', 'The prompt VLM service is not loaded') + return service + + def vlm_busy(self): + service = getattr(self.server, '_freevideo_prompt_vlm', None) + return service is not None and service.busy() + + def vlm_start(self, action, value): + try: + return self.vlm.start(action, value)['job'] + except ValueError as error: + from .jobs import VLM_ERRORS + code = str(error) if str(error) in VLM_ERRORS or str(error).isidentifier() else 'invalid_request' + raise JobError(code, VLM_ERRORS.get(code, str(error)), retryable=code == 'busy') from None + + def vlm_status(self, key): + return self.vlm.status(key) + + def vlm_cancel(self, key): + try: + self.vlm.cancel(key) + except ValueError: + pass + + # Media tools --------------------------------------------------------------------------------------- + def import_file(self, source, target): + from ..comfy_assets import media_kind + media_kind(source.name) + if source.stat().st_size > 2 * 1024 ** 3: + raise JobError('invalid_media', 'Media files are limited to 2 GiB') + try: + os.link(source, target) + except OSError: + shutil.copyfile(source, target) + + def extract_frame(self, video, out, at): + from . import media + try: + return media.extract_frame(video, out, at=at) + except ValueError as error: + raise JobError('invalid_media', str(error)) from None + + def sample_frames(self, video, out_dir, count): + from . import media + return media.sample_frames(video, out_dir, count=count) + + def edit(self, source, recipe, out, target=None): + """Render a recipe (or an editor document) of the input file `source`; keep the document beside it.""" + from . import edit + from PIL import Image, ImageOps + resolve = lambda name: self.input_path(name) + try: + if recipe.get('version') == 2 and 'layers' in recipe: + doc = recipe + else: + with Image.open(resolve(source)) as image: + size = ImageOps.exif_transpose(image).size + doc = edit.compile_recipe(recipe, source=source, source_size=size, target=target, resolve=resolve) + made = edit.save(doc, resolve, out) + except edit.EditError as error: + raise JobError('invalid_edit', str(error), path=getattr(error, 'path', None)) from None + out.with_suffix('.edit.json').write_text(json.dumps(doc), encoding='utf-8') + return dict(width=made['width'], height=made['height'], doc=doc) + + def assemble(self, clips, out, **options): + from . import media + return media.assemble(clips, out, **options) + + +def _read(path): + try: + value = json.loads(Path(path).read_text(encoding='utf-8')) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} diff --git a/freevideo_engine/agent/drama.py b/freevideo_engine/agent/drama.py new file mode 100644 index 00000000..e5631d76 --- /dev/null +++ b/freevideo_engine/agent/drama.py @@ -0,0 +1,571 @@ +"""A screenplay in, a finished short drama out: the reference director loop for agents. + + ./freevideo agent drama plan film.json # preview prompts and take counts + ./freevideo agent drama start film.json # start a durable film + ./freevideo agent drama get ID --since 1 --wait 25 + ./freevideo agent drama sheet ID SHOT # review the takes of one shot + ./freevideo agent drama choose ID SHOT --take 2 + ./freevideo agent drama reshoot ID SHOT --note "..." + ./freevideo agent drama finish ID # join the chosen takes + ./freevideo agent drama cancel ID + +The screenplay is structured (characters, shots, lines), so a planner model only +has to decide what happens. This module writes the H3 prompts, links shots that +continue each other, asks the local VLM to score every shot against its own +description, retakes weak shots with new seeds (with the shots that continue +from them), keeps the best take of each and assembles the cut. +""" +import json +import re +import time + +from . import spec as specs + +EXAMPLE = { + 'title': '末班车', 'name': 'the-last-train', 'aspect': '9:16', 'quality': 'light', + 'style': 'Live-action, cinematic, rainy night in a modern Chinese city, teal and amber light', + 'characters': {'lin': {'name': 'the young woman', 'look': 'in her twenties with shoulder-length black hair, a ' + 'mustard-yellow raincoat over a white shirt, carrying a small brown leather suitcase', + 'voice': 'soft, clear'}}, + 'shots': [ + {'id': 'platform', 'seconds': 6, 'framing': 'vertical medium-wide shot of an empty elevated platform', + 'action': 'Lin runs in through shallow puddles and stops under a flickering lamp, catching her breath and ' + 'looking up at the departure board.', 'camera': 'The camera tracks her with small amplitude, then ' + 'holds still.', 'lines': [{'who': 'lin', 'line': '还来得及吗?', 'how': 'says breathlessly'}], + 'sound': 'Heavy rain on the platform roof, splashing footsteps, the buzz of the lamp.'}, + {'id': 'arrival', 'seconds': 6, 'continue': True, 'framing': 'vertical medium close-up', + 'action': 'A train headlight sweeps across Lin\'s face; the wind of the arriving train lifts her wet hair and ' + 'her worry turns into a relieved smile.', 'camera': 'The camera pushes in slowly toward her face.', + 'lines': [{'who': 'lin', 'line': '来了。', 'how': 'says quietly'}], + 'sound': 'A rising rumble and screech of brakes, a rush of wind, a pneumatic hiss.'}, + {'id': 'window', 'seconds': 6, 'framing': 'vertical medium shot inside a night subway car', + 'action': 'Lin sits by the rain-streaked window with the suitcase on her knees, watching city lights ' + 'slide past.', 'camera': 'The camera pushes in slowly to her profile.', + 'lines': [{'who': 'lin', 'line': '再见了。', 'how': 'says softly'}], + 'sound': 'The hum of the subway, rain tapping on glass.'}, + ], + 'music': 'A sparse, slow piano figure over soft sustained strings.', + 'review': {'min_score': 7, 'retakes': 1, 'frames': 6}, + 'transition': 'crossfade', 'transition_seconds': .4, +} + + +class Screenplay(ValueError): + """All validation problems; the string remains the first legacy message.""" + def __init__(self, problems): + self.problems = problems if isinstance(problems, list) else [{'path': '', 'message': problems}] + super().__init__(self.problems[0]['message']) + + +CAPTIONED = 3 # the score of a take with unwanted captions +CARD = re.compile(r'c[0-9a-f]{12}') # characters..card names a cast library card +# How a shot carries its cast cards. Change these after the reference tests; nothing else needs to. +PICTURE_ORDER = ('sheet', 'front', 'three_quarter', 'face') # then any other label, in card order +PICTURES_EACH = 3 +MAX_VOICES = 3 # voice clips per shot, the model limit +MAX_REFERENCES = 32 + + +def check(play): + """Validate every independent field, mutating only missing shot ids.""" + problems = [] + + def problem(path, message): + problems.append(dict(path=path, message=message)) + + def validate(function, *args, **kwargs): + try: + function(*args, **kwargs) + except specs.SpecError as error: + problems.extend(error.errors) + except (TypeError, ValueError, OverflowError): + problem(args[1] if len(args) > 1 else '', 'must contain valid JSON field values') + + if not isinstance(play, dict) or not isinstance(play.get('shots'), list) or not 1 <= len(play['shots']) <= 40: + problem('/shots', 'A screenplay needs 1 to 40 shots') + if not isinstance(play, dict): + raise Screenplay(problems) + characters = play.get('characters', {}) + if not isinstance(characters, dict): + problem('/characters', 'must be an object') + characters = {} + for key, person in characters.items(): + path = '/characters/' + str(key).replace('~', '~0').replace('/', '~1') + if not isinstance(key, str) or not key: + problem(path, 'character names must be non-empty strings') + elif '\x00' in key: + problem(path, 'character names cannot contain NUL') + if not isinstance(person, dict): + problem(path, 'must be an object') + continue + for field in ('name', 'look', 'voice', 'language'): + if field in person: + validate(specs._text, person[field], path + '/' + field, 20000, empty=True) + if 'card' in person and (not isinstance(person['card'], str) or not CARD.fullmatch(person['card'])): + problem(path + '/card', 'must be a cast card ID like c0123456789ab') + for field in ('title', 'style', 'music', 'language'): + if field in play: + validate(specs._text, play[field], '/' + field, 20000, empty=True) + if 'name' in play and (not isinstance(play['name'], str) or not specs.SLUG.fullmatch(play['name'])): + problem('/name', 'must be a short project name') + if 'captions' in play: + validate(specs._bool, play['captions'], '/captions') + if 'quality' in play and (not isinstance(play['quality'], str) or play['quality'] not in specs.QUALITY): + problem('/quality', 'must be one of ' + ', '.join(specs.QUALITY)) + # Check aspect and megapixels independently before their combined canvas. + if 'aspect' in play: + aspect = play['aspect'] + match = re.fullmatch(r'(\d{1,3}):(\d{1,3})', aspect) if isinstance(aspect, str) else None + if not match or not int(match[1]) or not int(match[2]): + problem('/aspect', 'must be like "16:9", "9:16" or "1:1"') + if 'megapixels' in play: + validate(specs._number, play['megapixels'], '/megapixels', .1, 16) + if not any(p['path'] in ('/aspect', '/megapixels') for p in problems): + validate(specs.canvas_size, {key: play[key] for key in ('aspect', 'megapixels') if key in play}) + if 'seconds' in play: + validate(specs._number, play['seconds'], '/seconds', 1.625, 60.) + names = set() + for i, shot in enumerate(play.get('shots', []) if isinstance(play.get('shots'), list) else []): + path = '/shots/%d' % i + if not isinstance(shot, dict): + problem(path, '/shots/%d must be an object' % i) + continue + shot.setdefault('id', 'shot-%d' % (i + 1)) + if not isinstance(shot['id'], str) or not specs.SLUG.fullmatch(shot['id']) or shot['id'] in names: + problem(path + '/id', '/shots/%d/id must be a unique short name' % i) + if isinstance(shot['id'], str): + names.add(shot['id']) + if not any(isinstance(shot.get(key), str) and shot[key].strip() for key in ('action', 'idea', 'prompt')): + problem(path, '/shots/%d needs action, idea or prompt' % i) + if shot.get('continue') and i == 0: + problem(path + '/continue', '/shots/0 cannot continue a previous shot') + if 'continue' in shot: + validate(specs._bool, shot['continue'], path + '/continue') + for field in ('action', 'idea', 'prompt', 'camera', 'framing', 'sound', 'music'): + if field in shot: + validate(specs._text, shot[field], path + '/' + field, 20000, empty=True) + for field, low, high in (('takes', 1, 8), ('seed', 0, specs.MAX_SEED)): + if field in shot: + validate(specs._int, shot[field], path + '/' + field, low, high) + if 'seconds' in shot: + validate(specs._number, shot['seconds'], path + '/seconds', 1.625, 60.) + modes = int(shot.get('first') is not None) + int(bool(shot.get('continue'))) + int('references' in shot) + if modes > 1: + problem(path, 'first, continue and references are mutually exclusive') + # Continuing a shot, or one longer than one 15 s block, needs two-pass text generation. + seconds = shot.get('seconds', play.get('seconds')) + long = isinstance(seconds, (int, float)) and not isinstance(seconds, bool) and seconds > 15 + if ((shot.get('continue') or long) and play.get('quality') in specs.QUALITY + and not specs.QUALITY[play['quality']][0] and all(p['path'] != '/quality' for p in problems)): + problem('/quality', '"light" (two-pass) when a shot continues another or is longer than 15 s') + if long and (shot.get('first') is not None or 'references' in shot): + problem(path + '/seconds', 'at most 15 with a first frame or references') + elif long and not shot.get('continue') and _carded(play, shot): + # Cast cards travel as references, which one generation of at most 15 s carries. + problem(path + '/seconds', 'at most 15 when a character in the shot has a cast card ' + '(a following shot with continue=true carries the same people further)') + if shot.get('first') is not None: + validate(specs.media_item, shot['first'], path + '/first', image=True) + if 'references' in shot: + refs = shot['references'] + if not isinstance(refs, list) or len(refs) > 32: + problem(path + '/references', 'must be a list of at most 32 items') + if isinstance(refs, list): + for j, ref in enumerate(refs): + validate(specs.media_item, ref, path + '/references/%d' % j) + if 'cast' in shot: + if not isinstance(shot['cast'], list): + problem(path + '/cast', 'must be a list of character names') + else: + for j, key in enumerate(shot['cast']): + if not isinstance(key, str) or key not in characters: + problem(path + '/cast/%d' % j, 'must name a character') + lines = shot.get('lines', []) + if not isinstance(lines, list): + problem(path + '/lines', 'must be a list') + continue + for j, line in enumerate(lines): + line_path = path + '/lines/%d' % j + if not isinstance(line, dict): + problem(line_path, 'must be an object') + continue + if not isinstance(line.get('who'), str) or line['who'] not in characters: + problem(line_path + '/who', '/shots/%d/lines/%d/who must name a character' % (i, j)) + validate(specs._text, line.get('line'), line_path + '/line', 20000) + for field in ('how', 'language'): + if field in line: + validate(specs._text, line[field], line_path + '/' + field, 20000, empty=True) + if problems: + raise Screenplay(problems) + return play + + +def cast(play, shot): + """Characters in this shot, speakers first, in order of appearance.""" + order = [line['who'] for line in shot.get('lines', [])] + order += [key for key in play.get('characters', {}) if key in (shot.get('cast') or []) + or play['characters'][key].get('name', key).lower() in shot.get('action', '').lower() + or key.lower() in shot.get('action', '').lower()] + return list(dict.fromkeys(order)) + + +def _carded(play, shot): + """Whether check() can already see a cast card on someone in this shot; malformed fields are reported elsewhere.""" + try: + return any(isinstance(play['characters'][key].get('card'), str) for key in cast(play, shot)) + except (AttributeError, KeyError, TypeError): + return False + + +SHOWN_TEXT = re.compile(r'["“「][^"”」]+["”」]') + + +def prompt(play, shot): + """The H3 prompt of one shot, from its structured fields.""" + if shot.get('prompt'): + return shot['prompt'] + characters = play.get('characters', {}) + people = cast(play, shot) + lead = ', '.join(x for x in (play.get('style'), shot.get('framing')) if x) + parts = ['[Shot 1] ' + (lead + '.' if lead else '')] + if shot.get('continue'): + parts.append('Continuing from the opening frame, the scene keeps its exact layout, lighting and characters.') + for key in people: + person = characters[key] + if person.get('look'): + parts.append('%s is %s.' % ((person.get('name') or key).capitalize(), person['look'].rstrip('.'))) + parts.append(named(play, people, shot.get('action', ''))) + if shot.get('camera'): + parts.append(shot['camera']) + speakers = {} + for line in shot.get('lines', []): + who = line['who'] + person = characters[who] + first = who not in speakers + speakers.setdefault(who, len(speakers) + 1) + voice = (' with a %s voice' % person['voice']) if first and person.get('voice') else '' + language = line.get('language') or person.get('language') or play.get('language') or guess(line['line']) + parts.append('%s%s (S%d) %s: [%s] %s' % ((person.get('name') or who).capitalize(), voice, + speakers[who], line.get('how') or 'says', language, line['line'])) + if not play.get('captions'): + # Quoted words are text the shot shows (a sign, a menu); then only captions are ruled out. + scene = ' '.join(shot.get(k) or '' for k in ('framing', 'action', 'camera')) + ' ' + (play.get('style') or '') + parts.append('No subtitles or captions.' if SHOWN_TEXT.search(scene) else 'No subtitles, no on-screen text.') + sound = shot.get('sound') or 'Natural ambience of the scene.' + music = shot.get('music') or play.get('music') or 'N/A' + return ('integrated_multimodal_description: ' + ' '.join(p for p in parts if p) + + '\n\noverall_soundscape: ' + sound + '\n\nnon_diegetic_music: ' + music) + + +def subject_definitions(subjects): + """The prompt prefix tying cast cards to their reference tags; the one place its wording lives. + + subjects: [{'name', 'description', 'pictures': (first, last) or None, 'audio': number or None}].""" + parts = [] + for number, subject in enumerate(subjects, 1): + text = ' is %s' % (number, subject['name']) + description = (subject.get('description') or '').strip().rstrip('.') + text += (': %s.' % description) if description else '.' + tags = [] + if subject.get('pictures'): + first, last = subject['pictures'] + pictures = '' % first if first == last else ' to ' % (first, last) + tags.append('%s show%s its appearance' % (pictures, 's' if first == last else '')) + if subject.get('audio'): + tags.append('