Prepare DiffusionGemma Director 0.2.0
This commit is contained in:
@@ -7,6 +7,8 @@ __pycache__/
|
||||
.git/
|
||||
.github/
|
||||
docs/
|
||||
tests/
|
||||
GITHUB_RELEASE_CHECKLIST.md
|
||||
temp/
|
||||
tmp/
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: Python unit tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install unit-test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --index-url https://download.pytorch.org/whl/cpu torch
|
||||
python -m pip install "jsonschema>=4.26.0"
|
||||
|
||||
- name: Run unit tests
|
||||
run: python -m unittest discover -s tests -p "test_*.py" -v
|
||||
|
||||
schema-readiness:
|
||||
name: Schema readiness
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
DG_PROGRESS: "0"
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install schema-test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install "jsonschema>=4.26.0"
|
||||
|
||||
- name: Run readiness and schema smoke
|
||||
run: python scripts/smoke_prompt_builder.py
|
||||
|
||||
javascript:
|
||||
name: JavaScript tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Check shipped JavaScript syntax
|
||||
run: node --check web/js/model_loader.js
|
||||
|
||||
- name: Run JavaScript tests
|
||||
run: node --test tests/*.mjs
|
||||
@@ -3,13 +3,16 @@ name: Publish to Comfy Registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish custom node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Publish custom node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
|
||||
+62
-31
@@ -1,38 +1,69 @@
|
||||
# GitHub Release Checklist
|
||||
# GitHub and Comfy Registry Release Checklist
|
||||
|
||||
Use this checklist before pushing the node pack to a standalone GitHub repo.
|
||||
Use this checklist from the standalone `ComfyUI-DiffusionGemmaPromptBuilder` repository before pushing or publishing a release.
|
||||
|
||||
## Package Scope
|
||||
## Product Boundary
|
||||
|
||||
- Exported DiffusionGemma nodes are limited to the nodes used by `examples/07_ltx23_character_motion_transfer.json`.
|
||||
- The example assets under `examples/assets/character_motion_transfer` are intended to ship with the repo.
|
||||
- The NVFP4 bridge is source code inside `nodes.py`; do not bundle model weights or generated tensor dumps.
|
||||
- Keep the stable Director Assistant surface at seven exports:
|
||||
- five-node core: Model Loader, Context Hub, Target Profile, CoT Generator, JSON Splitter
|
||||
- two companions: H3 Reference Context and Generation Gate
|
||||
- Keep the two SplatStage planners visibly experimental. Their mapping ids stay compatible, their display names end in `(Experimental)`, and their category is `prompt/diffusiongemma/experimental-motion-planning`.
|
||||
- Confirm `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS` contain the same nine ids.
|
||||
- Treat `ltx_reframe.py` and the reframe workflows as unexported prototypes. Do not describe them as installed nodes unless the package entry point and frontend are deliberately restored together.
|
||||
- Do not bundle model weights or generated tensor dumps. The NVFP4 bridge remains source code in `nodes.py`.
|
||||
|
||||
## Before Upload
|
||||
## Repository Preflight
|
||||
|
||||
1. Confirm the GitHub repo URL in `pyproject.toml`.
|
||||
2. Confirm the Comfy Registry publisher id in `pyproject.toml` if you plan to publish through the Registry.
|
||||
3. Confirm `LICENSE` and the Python copyright headers still match the intended copyright holder and release policy.
|
||||
4. Run `C:\ComfyUI\.venv\Scripts\python.exe scripts\smoke_prompt_builder.py`.
|
||||
5. Run `C:\ComfyUI\.venv\Scripts\python.exe proof_gates.py --model-path C:\ComfyUI\models\LLM\diffusiongemma-26B-A4B-it-NVFP4`.
|
||||
6. Fresh-install the repo in a clean ComfyUI custom-nodes folder and confirm the five nodes appear.
|
||||
7. Copy `examples/assets/character_motion_transfer` to `ComfyUI/input/character_motion_transfer` and load the example workflow.
|
||||
8. Confirm `.comfyignore` excludes `*.mp4` and `*.gif` before publishing to the Comfy Registry.
|
||||
1. Run `git rev-parse --show-toplevel` and confirm it resolves to this package directory, not the parent ComfyUI checkout.
|
||||
2. Confirm `origin` is `https://github.com/exportAnything/ComfyUI-DiffusionGemmaPromptBuilder` and that the branch is based on the public repository history.
|
||||
3. Confirm the worktree is clean and the intended release commit is pushed.
|
||||
4. Confirm the repository URLs in `pyproject.toml` are current.
|
||||
5. Keep `[tool.comfy].PublisherId` exactly `exportanything`; this is the immutable Registry id, while `exportAnything` is its display name.
|
||||
6. Confirm `project.version` is newer than the latest Registry version. Version `0.1.0` is already published, so this release begins at `0.2.0`.
|
||||
7. Confirm `requires-comfyui = ">=0.30.1"` and record ComfyUI `0.30.1` as the tested baseline. The Registry field is a compatibility range, not a test-report field.
|
||||
8. Confirm `LICENSE` and the Python copyright headers match the intended release policy.
|
||||
|
||||
## Files To Include
|
||||
## Automated Gates
|
||||
|
||||
- `__init__.py`
|
||||
- `nodes.py`
|
||||
- `proof_gates.py`
|
||||
- `README.md`
|
||||
- `requirements.txt`
|
||||
- `pyproject.toml`
|
||||
- `.gitignore`
|
||||
- `.comfyignore`
|
||||
- `.github/workflows/publish_action.yml`
|
||||
- `web/`
|
||||
- `schemas/`
|
||||
- `scripts/`
|
||||
- `examples/07_ltx23_character_motion_transfer.json`
|
||||
- `examples/README.md`
|
||||
- `examples/assets/character_motion_transfer/`
|
||||
Run the same three gates enforced by `.github/workflows/ci.yml`:
|
||||
|
||||
```text
|
||||
python -m unittest discover -s tests -p "test_*.py" -v
|
||||
python scripts/smoke_prompt_builder.py
|
||||
node --check web/js/model_loader.js
|
||||
node --test tests/*.mjs
|
||||
```
|
||||
|
||||
- The unit suite must validate the exact nine-node surface, the Director/experimental category split, both bundled SplatStage schemas, and retained reframe helpers.
|
||||
- The readiness/schema smoke must finish with `Prompt Builder readiness/schema smoke passed`.
|
||||
- The JavaScript suite must exercise the shipped `model_loader.js`. No test may import the retired `ltx_reframe_layout.js`.
|
||||
- Require all three CI jobs to pass on the release commit.
|
||||
|
||||
## Manual Runtime Gates
|
||||
|
||||
1. Run the NVFP4 proof gate on a CUDA workstation with the real model:
|
||||
|
||||
```text
|
||||
python proof_gates.py --model-path C:\ComfyUI\models\LLM\diffusiongemma-26B-A4B-it-NVFP4
|
||||
```
|
||||
|
||||
2. Fresh-install the repository in a clean ComfyUI `0.30.1` custom-nodes folder and confirm all nine nodes appear: seven Director nodes at the stable category and two SplatStage planners only under the experimental motion-planning category.
|
||||
3. Copy `examples/assets/character_motion_transfer` to `ComfyUI/input/character_motion_transfer` and load `examples/07_ltx23_character_motion_transfer.json`.
|
||||
4. Load the MiniMax H3 T2VA and Ref2VA examples and confirm the Generation Gate blocks invalid or empty prompt packets.
|
||||
5. Treat longer experimental SplatStage and reframe workflows as separate opt-in validation, not as evidence for the stable Director compatibility promise.
|
||||
|
||||
## Files Tracked on GitHub
|
||||
|
||||
- Package entry point and source: `__init__.py`, `nodes.py`, `ltx_reframe.py`, `proof_gates.py`
|
||||
- Metadata and policy: `README.md`, `LICENSE`, `requirements.txt`, `pyproject.toml`, `.gitignore`, `.comfyignore`, this checklist
|
||||
- Runtime contracts: `schemas/`, `web/`, `scripts/`
|
||||
- Verification: `tests/`, `.github/workflows/ci.yml`
|
||||
- Publishing: `.github/workflows/publish_action.yml`
|
||||
- Documentation, current examples, and GitHub demo assets: `docs/`, `examples/`
|
||||
|
||||
## Registry Archive
|
||||
|
||||
- Keep `.github/`, `docs/`, `tests/`, caches, temporary files, and large demo video/GIF files out of the Registry archive through `.comfyignore`.
|
||||
- Confirm both SplatStage schemas remain in the Registry archive because the experimental planners load them at runtime.
|
||||
- Inspect the archive before publishing and confirm it contains no model weights, local paths, logs, caches, or generated outputs.
|
||||
- Publish only after the GitHub release commit and version metadata agree.
|
||||
|
||||
@@ -4,21 +4,39 @@
|
||||
|
||||
# ComfyUI DiffusionGemma Prompt Builder
|
||||
|
||||
Five DiffusionGemma Director Assistant nodes for ComfyUI workflows that use DiffusionGemma as a multimodal prompt writer, not as the image or video sampler.
|
||||
DiffusionGemma Director provides a stable five-node prompt-authoring core for ComfyUI, with two stable Director companion nodes. The package also exports two experimental SplatStage motion-planning nodes. DiffusionGemma writes and validates prompts or blueprints; it is not the downstream image or video sampler.
|
||||
|
||||
This release is packaged around the working LTX 2.3 character motion-transfer workflow in `examples/07_ltx23_character_motion_transfer.json`.
|
||||
The primary stable workflow is the working LTX 2.3 character motion-transfer graph in `examples/07_ltx23_character_motion_transfer.json`. Additional Director examples cover native MiniMax H3 T2VA and Ref2VA paths.
|
||||
|
||||
## Exported Nodes
|
||||
|
||||
Only the nodes used by the packaged workflow are registered in ComfyUI:
|
||||
Nine nodes are registered in ComfyUI. Their stable and experimental surfaces are intentionally separate.
|
||||
|
||||
### Stable Director Assistant
|
||||
|
||||
The five-node core path used by the primary LTX workflow is:
|
||||
|
||||
- `DiffusionGemmaModelLoader` - loads the local Transformers/NVFP4 DiffusionGemma runtime.
|
||||
- `DiffusionGemmaContextHub` - combines user text, optional image/video inputs, and optional `visual_description` into one `DG_CONTEXT`.
|
||||
- `DiffusionGemmaTargetProfile` - stores target generation settings such as LTX profile, audio mode, duration, aspect ratio, and negative prompt policy.
|
||||
- `DiffusionGemmaTargetProfile` - stores the LTX, Ideogram4, or MiniMax H3 target plus audio, duration, aspect ratio, and negative prompt settings.
|
||||
- `DiffusionGemmaCoTGenerator` - asks DiffusionGemma for the final prompt packet and returns final JSON plus debug outputs.
|
||||
- `DiffusionGemmaJSONSplitter` - turns the final JSON into LTX prompt, negative prompt, metadata, resolution, segment, and readiness outputs.
|
||||
- `DiffusionGemmaJSONSplitter` - turns the final JSON into LTX, Ideogram4, or MiniMax H3 prompt output plus metadata, resolution, segment, and readiness outputs.
|
||||
|
||||
Older compatibility and utility classes remain in source where the final nodes still reuse helper behavior internally, but they are not exported as ComfyUI nodes in this package.
|
||||
Two stable companions extend that core without changing it:
|
||||
|
||||
- `DiffusionGemmaH3ReferenceContext` - binds an ordered H3 asset-role manifest and optional picture/video evidence into a Ref2VA-aware `DG_CONTEXT`.
|
||||
- `DiffusionGemmaGenerationGate` - fails closed when validation says a prompt is not ready, preventing an invalid prompt from reaching a downstream generator.
|
||||
|
||||
### Experimental Motion Planning
|
||||
|
||||
These larger planners are exported for opt-in SplatStage work, but they are not part of the stable Director surface or the primary LTX workflow:
|
||||
|
||||
- `DiffusionGemmaSplatStagePlanner` - writes and validates the strict SplatStage show blueprint.
|
||||
- `DiffusionGemmaSplatStageEfficientPlanner` - writes and validates the higher-variation efficient SplatStage blueprint.
|
||||
|
||||
Both are labeled `(Experimental)` and appear under `prompt/diffusiongemma/experimental-motion-planning` in ComfyUI. Their schemas ship with this repository, while executing a resulting show still requires the appropriate downstream SplatStage workflow and nodes.
|
||||
|
||||
Older compatibility and utility classes remain in source where exported nodes reuse their behavior internally, but they are not registered. The `ltx_reframe.py` module and LTX reframe workflows are retained as unexported prototypes; the retired `ltx_reframe_layout.js` frontend is not part of this release.
|
||||
|
||||
## Packaged Workflow
|
||||
|
||||
@@ -74,6 +92,89 @@ DiffusionGemma JSON Splitter.ready_for_generation -> generation gate or manual c
|
||||
|
||||
Use `image_identity_video_control` on the Context Hub when the reference image should define the output subject while the video provides motion, pose, camera, depth, Canny/edge layout, blocking, and timing.
|
||||
|
||||
For MiniMax H3, select `minimax_h3` on the same Target Profile node and wire that same `target_profile_config` to both the CoT Generator and JSON Splitter:
|
||||
|
||||
```text
|
||||
DiffusionGemma Target Profile.target_profile_config -> DiffusionGemma CoT Generator.target_profile_config
|
||||
DiffusionGemma Target Profile.target_profile_config -> DiffusionGemma JSON Splitter.target_profile_config
|
||||
DiffusionGemma CoT Generator.final_json -> DiffusionGemma JSON Splitter.final_json
|
||||
DiffusionGemma JSON Splitter.minimax_h3_prompt -> MiniMax H3 text encoder prompt
|
||||
```
|
||||
|
||||
The splitter appends `minimax_h3_prompt` after its existing outputs so saved LTX and Ideogram4 workflow output indices remain unchanged.
|
||||
|
||||
## MiniMax H3 T2VA Prompt Profile
|
||||
|
||||
With `minimax_h3_mode=t2va`, the MiniMax H3 profile turns a normal creative brief into H3's canonical text-to-video/audio representation. T2VA does not invent keyframe or full-reference labels when Director has not been given explicit reference roles. Its output preserves section breaks and uses exactly these three fields in order:
|
||||
|
||||
```text
|
||||
integrated_multimodal_description: [Shot 1] ...
|
||||
|
||||
overall_soundscape: ...
|
||||
|
||||
non_diegetic_music: ...
|
||||
```
|
||||
|
||||
The first shot has no timestamp. Later cuts use increasing instants such as `[Shot 2] At 00:03.500, the camera cuts to...`, all inside the selected duration. Hard cuts are the default; cross-dissolves, fades, and wipes are used only when the brief requests them. Synchronized visible action, speech, and scene sounds stay in `integrated_multimodal_description`; ambient sound and Foley belong in `overall_soundscape`; audience-only score belongs in `non_diegetic_music`, where instrumentation, tempo/rhythm, and dynamic development are described concretely instead of as an abstract mood.
|
||||
|
||||
`target_duration_seconds` remains user controlled and has no H3-specific upper clamp. MiniMax documents 4-15 seconds as the standard H3 generation range, so that range is useful quality guidance, but Director does not clamp longer requests. A value such as 20 seconds is retained in the prompt contract; the connected H3 workflow's frame count, sampler settings, VRAM, and generation time determine whether it can be rendered successfully. `0` uses source-media duration when available, then an explicit duration written in the creative brief, and otherwise a 5-second H3 planning default.
|
||||
|
||||
H3 does not expose a separate negative-conditioning prompt through its local text encoder. When MiniMax H3 is selected, Director keeps `negative_prompt` empty and expresses necessary exclusions concisely inside `integrated_multimodal_description` for T2VA or `detailed_description` for Ref2VA instead of inventing an unsupported negative channel.
|
||||
|
||||
See [`examples/09_minimax_h3_t2va_director.json`](examples/09_minimax_h3_t2va_director.json) for a native MiniMax-H3 T2VA workflow. It shares one user-editable duration between Director and H3, routes the dedicated H3 prompt plus computed resolution into the generator, previews both the generated prompt and any validation block reasons, unloads DiffusionGemma before sampling, and is preset to the proven 20-second, 25-step configuration.
|
||||
|
||||
## MiniMax H3 Ref2VA Prompt Profile
|
||||
|
||||
Set `target_profile=minimax_h3`, `minimax_h3_mode=ref2va`, and build the context with `DiffusionGemmaH3ReferenceContext`. Its manifest is the asset-role contract for the native reference sockets, for example:
|
||||
|
||||
```text
|
||||
<Picture 1>: protagonist identity, face, hair, body, wardrobe, and distinguishing traits; preserve fully.
|
||||
<Picture 2>: environment, palette, lighting, and 2D animation style; do not transfer pictured subject identity.
|
||||
<Audio 1>: synchronized soundtrack timing and impact-texture reference for <Video 1>; do not copy speech.
|
||||
<Video 1>: running motion, camera path, hard-cut rhythm, and landing mechanics; do not transfer performer identity.
|
||||
```
|
||||
|
||||
Tags are one-based and numbered independently by type. Keep the manifest aligned with native H3: all pictures are presented first; a paired video soundtrack receives an Audio label immediately before its Video label; standalone audio follows the videos. Director supports the native local limits of nine pictures, three videos, three paired soundtracks, and three standalone audio inputs. It deliberately blocks an audio-only manifest because its Ref2VA compiler requires at least one visible reference source.
|
||||
|
||||
Ref2VA output follows MiniMax's six-section Context-IR contract exactly:
|
||||
|
||||
```text
|
||||
subject_definitions:
|
||||
...
|
||||
|
||||
summary:
|
||||
...
|
||||
|
||||
retention_analysis:
|
||||
...
|
||||
|
||||
detailed_description:
|
||||
...
|
||||
|
||||
overall_soundscape:
|
||||
...
|
||||
|
||||
non_diegetic_music:
|
||||
...
|
||||
```
|
||||
|
||||
`<Subject N>` means reusable visible content abstracted from one or more assets; it is not an attachment number. Pictures used only for identity, environment, costume, or style are cited inside Subject definitions. A standalone Picture definition is reserved for a concrete composition/storyboard anchor. In the local native Ref2VA path, an identity/style picture is not a guaranteed first- or last-frame pixel lock; use the separate FL2VA path when that is the intended control.
|
||||
|
||||
The compiler requires official summary task types and retention markers, exact declared asset tags, consecutive Subject labels, a medium/style opening before `[Shot 1]`, a detailed chronological timeline, camera direction, reference use at the relevant moments, audio-layer fidelity, and a resolved final state. Invalid, missing, duplicate, or undeclared references block the splitter output instead of reaching H3.
|
||||
|
||||
See [`examples/10_minimax_h3_ref2va_director.json`](examples/10_minimax_h3_ref2va_director.json) for the native two-picture sample. It wires both original pictures to `MiniMaxH3ReferenceToVideo`, sends an ordered analysis batch to Director, uses a full 2048-token prompt budget, and retains the user-selected 20-second, 481-frame, 25-step configuration without running a render automatically.
|
||||
|
||||
Official references:
|
||||
|
||||
- [MiniMax H3 repository and model overview](https://github.com/MiniMax-AI/MiniMax-H3)
|
||||
- [MiniMax H3 prompt-writing skill](https://github.com/MiniMax-AI/MiniMax-H3/blob/main/skills/h3-prompt-writing/SKILL.md)
|
||||
- [MiniMax H3 Base prompt guide](https://github.com/MiniMax-AI/MiniMax-H3/blob/main/skills/h3-prompt-writing/references/base-en.txt)
|
||||
- [MiniMax H3 full-reference prompt guide](https://github.com/MiniMax-AI/MiniMax-H3/blob/main/skills/h3-prompt-writing/references/ref-en.txt)
|
||||
|
||||
## Compatibility
|
||||
|
||||
The verified baseline is ComfyUI `0.30.1` with Python 3.11. Registry metadata declares `requires-comfyui = ">=0.30.1"`: this is the Registry's minimum-compatible-version constraint, while `0.30.1` remains the version exercised by the compatibility and release checks. Raise that floor when an exported node begins to require a newer ComfyUI API.
|
||||
|
||||
## Runtime Dependencies
|
||||
|
||||
This pack assumes a working ComfyUI install with CUDA-capable PyTorch. The Python dependencies declared in `requirements.txt` are:
|
||||
@@ -186,7 +287,7 @@ A good NVFP4 result reports:
|
||||
|
||||
## Packaging Notes
|
||||
|
||||
`pyproject.toml` is included for GitHub and Comfy Registry readiness. The Comfy Registry requires a real publisher id; if `exportAnything` is not the exact Registry publisher id, update `[tool.comfy].PublisherId` before publishing.
|
||||
`pyproject.toml` is included for GitHub and Comfy Registry publishing. The immutable Registry publisher id is lowercase `exportanything` (display name `exportAnything`); keep that id and casing unchanged. Increment `project.version` for every Registry release.
|
||||
|
||||
The current GitHub repository URL in metadata is:
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# LTX Reframe Layout
|
||||
|
||||
`LTX Reframe Layout` is a preparation node for LTX video outpainting. It does not load a model or sample a new video.
|
||||
|
||||
## What the node does
|
||||
|
||||
1. Select or upload a source video.
|
||||
2. Choose the final canvas aspect ratio and exact LTX-compatible dimensions.
|
||||
3. Drag the red source rectangle to reposition the video.
|
||||
4. Drag any red corner to resize it without changing the source aspect ratio. Scale below `1.0` zooms out; scale above `1.0` zooms in and crops the source at the target boundary.
|
||||
5. Execute the node to decode the video and produce padded frames, an outpaint mask, a source mask, the original frames, audio, FPS, and placement metadata.
|
||||
|
||||
Target dimensions are snapped to multiples of 64. The source is never stretched. `source_scale` ranges from `0.1` to `4.0`: the fitted source is `1.0`, smaller values create outpaint space, and larger values crop/zoom into the source. In the preview, video beyond the target boundary is dimmed while the red resize handles remain visible. `position_x`, `position_y`, and `source_scale` are ordinary serialized ComfyUI widgets, so the visual placement is reproducible in saved workflows and API prompts.
|
||||
|
||||
## LTX 2.3 connection
|
||||
|
||||
- Connect `padded_frames` to `LTXVInpaintPreprocess.images`.
|
||||
- Connect `outpaint_mask` to `LTXVInpaintPreprocess.mask`.
|
||||
- Feed the preprocessed image into the official LTX 2.3 IC-LoRA outpaint guide path.
|
||||
- Use `source_mask` with `padded_frames` for a final source-preserving composite if the generated result changes the original region.
|
||||
- Connect `audio` and `fps` to the final video assembly nodes.
|
||||
|
||||
The official local reference workflow is:
|
||||
|
||||
`ComfyUI-LTXVideo/example_workflows/2.3/LTX-2.3_ICLoRA_Outpaint_Two_Stage_Distilled.json`
|
||||
|
||||
## Current boundary
|
||||
|
||||
The first version supports one static placement for the entire clip. Animated reframing/keyframes, rotation, perspective warping, per-shot layouts, and automatic subject-aware positioning are intentionally left for later nodes or workflow stages.
|
||||
@@ -0,0 +1,484 @@
|
||||
# DiffusionGemma Plate Repair Workbench Prototype Plan
|
||||
|
||||
Status: research-backed prototype plan, not implementation proof.
|
||||
|
||||
Implementation target: `C:\ComfyUI\app\custom_nodes\ComfyUI-DiffusionGemma-LTX-Motion-Planner`.
|
||||
|
||||
Prompt Builder role: keep the stable Director Assistant surface. The Prompt Builder should supply the local DiffusionGemma NVFP4 runtime and structured decision packets, but the video repair workbench belongs in the sibling Motion Planner pack.
|
||||
|
||||
Research method: this plan was informed by three focused subagent probes:
|
||||
|
||||
- LTX/Lightricks lane: confirmed LTX-2.3 IC-LoRA In/Outpainting and mask-aware LTXVideo nodes are the best LTX-native repair primitives; Motion Brush is timeline/control support, not the raw plate-mask bridge.
|
||||
- Wan/VACE lane: confirmed `WanVaceToVideo` and official VACE semantics fit masked repair plates; local VACE model availability still needs verification.
|
||||
- ComfyUI glue lane: confirmed local IO, SAM3, crop/uncrop, batch composite, alpha, and pyramid-blend primitives already cover most non-model work; missing pieces are thin deterministic adapters and QA.
|
||||
|
||||
## Thesis
|
||||
|
||||
The repair loop should not behave like a serial full-video retake agent. It should behave like a small VFX plate system:
|
||||
|
||||
```text
|
||||
Immutable source plate
|
||||
-> DiffusionGemma issue inventory
|
||||
-> plate plan
|
||||
-> per-issue temporal masks
|
||||
-> per-issue repair plates from LTX or Wan
|
||||
-> deterministic masked composite over source
|
||||
-> critic verifies the composite and updates issue status
|
||||
```
|
||||
|
||||
This is scientifically plausible because it converts an underconstrained generative loop into a sequence of explicit hypotheses:
|
||||
|
||||
1. A defect exists at a named time window and layer.
|
||||
2. The defect has a maskable target.
|
||||
3. A mask covers the intended damaged pixels and not stable pixels.
|
||||
4. A backend can generate a candidate for only that plate.
|
||||
5. The compositor preserves unmasked source pixels.
|
||||
6. The critic evaluates the composite, not a full-video regeneration chain.
|
||||
|
||||
The architecture should prefer visible schema-bound nodes over hidden autonomous loops. DiffusionGemma is the orchestrator; ComfyUI nodes are the subagents.
|
||||
|
||||
## Evidence Summary
|
||||
|
||||
### LTX/Lightricks
|
||||
|
||||
LTX provides relevant primitives for this architecture, especially through IC-LoRA in/outpainting and ComfyUI-LTXVideo utility nodes.
|
||||
|
||||
External evidence:
|
||||
|
||||
- `https://huggingface.co/Lightricks/LTX-2.3-22b-IC-LoRA-In-Outpainting`
|
||||
- The model card describes inpainting and outpainting on top of LTX-2.3-22B.
|
||||
- It names the training type as video-to-video, mask-conditioned IC-LoRA.
|
||||
- It uses a reference video plus binary mask, generating the masked region while leaving the unmasked area intact.
|
||||
- It describes a two-stage pipeline with boundary refinement and Laplacian blending.
|
||||
- It warns to describe only the masked/outpainted region, not the full scene.
|
||||
- `https://docs.ltx.video/open-source-model/usage-guides/ic-lo-ra`
|
||||
- IC-LoRAs are task-specific, reference-driven adapters for structural control, VFX, restoration, and creative transforms.
|
||||
- The guide names `LTX Add Video IC-LoRA Guide` and `LTX Add Video IC-LoRA Guide Advanced`; the advanced path includes mask-aware conditioning.
|
||||
- `https://docs.ltx.video/open-source-model/integration-tools/ltx-comfy-ui-nodes`
|
||||
- The official LTX ComfyUI node documentation names the same inpainting and blend primitives used by the local node pack.
|
||||
- `https://github.com/Lightricks/ComfyUI-LTXVideo`
|
||||
- The repository hosts LTX-2 ComfyUI custom nodes and workflows, including IC-LoRA workflows and LTX-2.3 adapter assets.
|
||||
|
||||
Local evidence:
|
||||
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\vanish_nodes.py:8` registers `LTXVDilateVideoMask`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\vanish_nodes.py:96` registers `LTXVInpaintPreprocess`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\masks.py:11` registers `LTXVPreprocessMasks`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\latents.py:209` registers `LTXVSetVideoLatentNoiseMasks`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\pyramid_blending.py:178` registers `LTXVLaplacianPyramidBlend`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\guide.py:258` registers `LTXVAddGuideAdvancedAttention`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\iclora.py:261` registers `LTXAddVideoICLoRAGuideAdvanced`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\example_workflows\2.3\LTX-2.3_ICLoRA_Inpaint_Two_Stage_Distilled.json` exists locally.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\example_workflows\2.3\LTX-2.3_ICLoRA_Outpaint_Two_Stage_Distilled.json` exists locally.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\example_workflows\2.3\LTX-2.3_ICLoRA_Motion_Track_Distilled.json` exists locally.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-LTXVideo\example_workflows\2.3\LTX-2.3_ICLoRA_Motion_Brush_Distilled.json` exists locally.
|
||||
|
||||
Important limitation:
|
||||
|
||||
`ComfyUI-LTX-Director-Motion-Brush` is not the raw mask compositor. Motion Brush is useful for timeline and guide payloads, but the direct mask seam is in LTXVideo guide, IC-LoRA, mask, latent-mask, and blend nodes.
|
||||
|
||||
Keep three mask concepts separate:
|
||||
|
||||
- `attention_mask`: controls guide/conditioning influence.
|
||||
- latent or denoise/noise mask: controls where generation may rewrite latent content.
|
||||
- composite or blend mask: controls final pixel replacement over the source plate.
|
||||
|
||||
### Wan/VACE
|
||||
|
||||
Wan VACE is also a strong backend candidate for repair plates. It may be better than LTX for some masked object replacement and region-regeneration tasks.
|
||||
|
||||
External evidence:
|
||||
|
||||
- `https://github.com/ali-vilab/VACE`
|
||||
- VACE describes itself as all-in-one video creation and editing.
|
||||
- It explicitly includes reference-to-video generation, video-to-video editing, and masked video-to-video editing.
|
||||
- Its preprocessing path produces `src_video`, `src_mask`, and `src_ref_images`, which maps cleanly onto a plate manifest.
|
||||
- `https://docs.comfy.org/built-in-nodes/conditioning/video-models/wan-vace-to-video`
|
||||
- The ComfyUI built-in `WanVaceToVideo` node accepts `control_video`, `control_masks`, and `reference_image`.
|
||||
- `https://docs.comfy.org/tutorials/video/wan/vace`
|
||||
- ComfyUI's Wan VACE page describes VACE as supporting text, images, video, masks, and control signals; it names local replacement through masks.
|
||||
- `https://github.com/stuttlepress/ComfyUI-Wan-VACE-Prep`
|
||||
- Its `Wan VACE Inpaint` node prepares a control video and mask for `WanVaceToVideo.control_video` and `control_masks`, replacing masked pixels with neutral gray and preserving the mask polarity.
|
||||
|
||||
Local evidence:
|
||||
|
||||
- `C:\ComfyUI\app\comfy_extras\nodes_wan.py:285` defines `WanVaceToVideo`.
|
||||
- `C:\ComfyUI\app\comfy_extras\nodes_wan.py:301` exposes `control_video`.
|
||||
- `C:\ComfyUI\app\comfy_extras\nodes_wan.py:302` exposes `control_masks`.
|
||||
- `C:\ComfyUI\app\comfy_extras\nodes_wan.py:303` exposes `reference_image`.
|
||||
- `C:\ComfyUI\app\comfy_extras\nodes_wan.py:365` and `:366` attach `vace_frames`, `vace_mask`, and `vace_strength` into conditioning.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui_fill-nodes\nodes\wip\FL_WanVaceToVideoMultiRef.py:17` provides a local multi-reference adapter.
|
||||
|
||||
Important limitation:
|
||||
|
||||
Wan VACE should be treated as a repair backend, not as the overall autonomous planner. The plate contract should be independent of the backend so LTX and Wan can be compared on the same issue packet.
|
||||
|
||||
Local model status:
|
||||
|
||||
- Current local scan found Wan diffusion models in `C:\ComfyUI\app\models\diffusion_models`, specifically `wan2.2_bernini_r_low_noise_mxfp8.safetensors` and `wan2.2_bernini_r_high_noise_mxfp8.safetensors`.
|
||||
- The same scan did not find an obvious VACE diffusion model. Wan/VACE should therefore be designed into the prototype, but LTX is the likely first runnable backend unless VACE weights are added.
|
||||
- `ComfyUI-Wan-VACE-Prep` is useful prior art, but it is not installed locally. A tiny Wan prep node may be justified if we want to avoid an external custom-node dependency.
|
||||
|
||||
### ComfyUI Glue
|
||||
|
||||
The non-model pieces also mostly exist.
|
||||
|
||||
Local evidence:
|
||||
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui-videohelpersuite\videohelpersuite\load_video_nodes.py:476` provides local video loading into frame batches.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui-videohelpersuite\videohelpersuite\nodes.py:235` provides `VHS_VideoCombine`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui-kjnodes\nodes\image_nodes.py:1962` provides `GetImageRangeFromBatch`.
|
||||
- `C:\ComfyUI\app\comfy_extras\nodes_sam3.py:88` provides native SAM3 node definitions.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-DiffusionGemma-LTX-Motion-Planner\nodes.py:3471` defines `DiffusionGemmaLTXQualityCritic`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-DiffusionGemma-LTX-Motion-Planner\nodes.py:3666` defines `DiffusionGemmaLTXIssueScheduler`.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-DiffusionGemma-LTX-Motion-Planner\nodes.py:3771` rejects scheduled issues unless they are both time-localized and maskable.
|
||||
- `C:\ComfyUI\app\custom_nodes\ComfyUI-DiffusionGemma-LTX-Motion-Planner\nodes.py:3933` defines `DiffusionGemmaLTXTimelineMaskPad`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui_essentials\image.py:206` defines `ImageCompositeFromMaskBatch`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui_fill-nodes\nodes\utility\FL_PasteByMask.py:65` defines `FL_PasteByMask`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui_fill-nodes\nodes\utility\FL_VideoCropNStitch.py:6` defines `FL_VideoCropMask`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui_fill-nodes\nodes\utility\FL_VideoCropNStitch.py:135` defines `FL_VideoRecompose`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui-kjnodes\nodes\batchcrop_nodes.py:26` defines `BatchCropFromMask`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui-kjnodes\nodes\batchcrop_nodes.py:156` defines `BatchUncrop`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui-kjnodes\nodes\batchcrop_nodes.py:253` defines `BatchCropFromMaskAdvanced`.
|
||||
- `C:\ComfyUI\app\custom_nodes\comfyui-kjnodes\nodes\batchcrop_nodes.py:535` defines `BatchUncropAdvanced`.
|
||||
|
||||
Composite caution:
|
||||
|
||||
`FL_VideoRecompose` is useful, but it replaces the whole crop rectangle. It should not be the scientific preservation proof. The final proof should still use an explicit mask composite over the immutable source plate.
|
||||
|
||||
## Proposed Node Pack Shape
|
||||
|
||||
### 1. DiffusionGemma Plate Critic
|
||||
|
||||
Can start as an extension of `DiffusionGemmaLTXQualityCritic`.
|
||||
|
||||
Inputs:
|
||||
|
||||
- original source evidence
|
||||
- generated/composited candidate video
|
||||
- user intent
|
||||
- optional current plate manifest
|
||||
- optional previous issue statuses
|
||||
|
||||
Outputs:
|
||||
|
||||
- `issue_inventory_json`
|
||||
- `plate_plan_json`
|
||||
- `blocked_or_advisory_reasons`
|
||||
- `verification_level`
|
||||
|
||||
Each issue should include:
|
||||
|
||||
- `issue_id`
|
||||
- `category`
|
||||
- `severity`
|
||||
- `confidence`
|
||||
- `time_window`
|
||||
- `target_layer`
|
||||
- `z_order`
|
||||
- `maskable_target`
|
||||
- `sam_prompt`
|
||||
- `bbox_hint`
|
||||
- `repair_instruction`
|
||||
- `preserve_prompt`
|
||||
- `backend_preference`
|
||||
- `evidence_notes`
|
||||
- `status`
|
||||
|
||||
### 2. Plate Plan Compiler
|
||||
|
||||
New thin deterministic node.
|
||||
|
||||
Purpose:
|
||||
|
||||
Convert issue inventory into executable plate jobs. This is the place to enforce scientific constraints before any generator runs.
|
||||
|
||||
Required invariants:
|
||||
|
||||
- every executable job has `time_localized=true`
|
||||
- every executable job has `maskable_target=true`
|
||||
- every executable job references the immutable `base_plate_id`
|
||||
- every executable job declares mask polarity
|
||||
- every executable job declares backend and prompt scope
|
||||
- no job may use a previous generated/composited video as `base_plate`
|
||||
|
||||
Outputs:
|
||||
|
||||
- `plate_manifest_json`
|
||||
- `next_plate_job_json`
|
||||
- `remaining_plate_jobs_json`
|
||||
- `source_plate_file`
|
||||
- `repair_prompt`
|
||||
- `preserve_prompt`
|
||||
- `backend_kind`
|
||||
- `z_order`
|
||||
- `composite_mode`
|
||||
|
||||
### 3. Mask Acquisition Lane
|
||||
|
||||
Reuse existing nodes:
|
||||
|
||||
```text
|
||||
Plate job
|
||||
-> Issue Scheduler or Plate Plan Compiler lane output
|
||||
-> SAM3 Detect / SAM3 Video Track / SAM3 Track to Mask
|
||||
-> DiffusionGemma LTX Timeline Mask Pad
|
||||
-> mask processors or LTXVDilateVideoMask
|
||||
```
|
||||
|
||||
This lane should produce:
|
||||
|
||||
- `window_mask`
|
||||
- `timeline_mask`
|
||||
- `mask_debug_json`
|
||||
- `nonzero_frame_count`
|
||||
- `mask_quality_flags`
|
||||
|
||||
### 4. Repair Backend Adapter
|
||||
|
||||
New thin routing node or two separate backend-specific adapter nodes.
|
||||
|
||||
Backend A: LTX IC-LoRA in/outpainting.
|
||||
|
||||
Likely node chain:
|
||||
|
||||
```text
|
||||
base source frames + timeline mask
|
||||
-> LTXVInpaintPreprocess
|
||||
-> LTX IC-LoRA loader / Add Video IC-LoRA Guide Advanced
|
||||
-> sampler/decode
|
||||
-> repair_plate_frames
|
||||
```
|
||||
|
||||
Alternative LTX guide path:
|
||||
|
||||
```text
|
||||
timeline_mask
|
||||
-> LTXVAddGuideAdvancedAttention.attention_mask
|
||||
or LTXAddVideoICLoRAGuideAdvanced.attention_mask
|
||||
```
|
||||
|
||||
Backend B: Wan VACE.
|
||||
|
||||
Likely node chain:
|
||||
|
||||
```text
|
||||
base source frames + timeline mask
|
||||
-> VACE control video prep
|
||||
-> WanVaceToVideo.control_video
|
||||
-> WanVaceToVideo.control_masks
|
||||
-> Wan sampler/decode
|
||||
-> repair_plate_frames
|
||||
```
|
||||
|
||||
If `ComfyUI-Wan-VACE-Prep` is not installed, the first Wan adapter can be tiny:
|
||||
|
||||
- input `IMAGE` source frames and `MASK`
|
||||
- output `control_video` where masked pixels become neutral 0.5 gray
|
||||
- output `control_masks` with white indicating regenerate
|
||||
- output width, height, length
|
||||
|
||||
Backend C: no-generator deterministic proof.
|
||||
|
||||
Before either LTX or Wan generation, the router should support a synthetic or passthrough repair plate. This lets tests prove mask polarity, frame alignment, and unmasked preservation without confusing generator errors with orchestration errors.
|
||||
|
||||
### 5. Plate Composite
|
||||
|
||||
Start with existing nodes:
|
||||
|
||||
- `ImageCompositeFromMaskBatch`
|
||||
- `LTXVLaplacianPyramidBlend`
|
||||
- `FL_PasteByMask`
|
||||
- `FL_VideoRecompose`
|
||||
|
||||
Add a custom node only if existing nodes fail frame-count, z-order, or holdout semantics.
|
||||
|
||||
Potential new node:
|
||||
|
||||
`DiffusionGemmaPlateComposite`
|
||||
|
||||
Inputs:
|
||||
|
||||
- `base_frames`
|
||||
- `repair_frames`
|
||||
- `timeline_mask`
|
||||
- `plate_manifest_json`
|
||||
- `z_order`
|
||||
- `feather_or_blend_mode`
|
||||
|
||||
Outputs:
|
||||
|
||||
- `composited_frames`
|
||||
- `composite_debug_json`
|
||||
- `unmasked_pixel_delta`
|
||||
- `mask_coverage_stats`
|
||||
|
||||
Non-negotiable test:
|
||||
|
||||
Pixels outside the active mask must match the immutable source plate exactly, or within a declared tolerance if a blend operation intentionally softens boundaries.
|
||||
|
||||
Recommended convention:
|
||||
|
||||
- project mask polarity: `1 = repair/generate`, `0 = preserve source`
|
||||
- exact composite: `ImageCompositeFromMaskBatch(image_from=source, image_to=repair, mask=timeline_mask)`
|
||||
- LTX Laplacian blend: `image_a=repair`, `image_b=source`, `mask=timeline_mask`
|
||||
- feathered/pyramid modes may allow tolerance only inside a declared dilated boundary band
|
||||
|
||||
### 6. Plate Verifier
|
||||
|
||||
Can start as a mode of `DiffusionGemmaLTXQualityCritic`.
|
||||
|
||||
It should evaluate:
|
||||
|
||||
- issue fixed or not fixed
|
||||
- new artifacts introduced or not
|
||||
- unmasked preservation respected or not
|
||||
- mask leakage at boundary
|
||||
- temporal consistency inside the repaired window
|
||||
- whether remaining issues should proceed
|
||||
|
||||
Outputs:
|
||||
|
||||
- `issue_status_json`
|
||||
- `fixed_issue_ids`
|
||||
- `failed_issue_ids`
|
||||
- `regression_issue_ids`
|
||||
- `continue_next_plate`
|
||||
- `verification_level`
|
||||
|
||||
## Minimal Prototype Workflow
|
||||
|
||||
### Phase 0: Deterministic Composite Proof
|
||||
|
||||
No generator.
|
||||
|
||||
Use synthetic tensors or a short loaded clip:
|
||||
|
||||
1. Load base video frames.
|
||||
2. Create a simple moving mask or use SAM3 output.
|
||||
3. Create a synthetic repair plate, for example solid color or blurred patch.
|
||||
4. Composite repair over source.
|
||||
5. Assert unmasked pixels are preserved.
|
||||
6. Assert frame counts and mask windows match.
|
||||
|
||||
This proves the plate invariant before any model gets blamed.
|
||||
|
||||
### Phase 1: One-Issue LTX Plate
|
||||
|
||||
1. Source evidence from original video.
|
||||
2. DiffusionGemma emits one issue with time window, layer, mask prompt, and repair instruction.
|
||||
3. SAM3 generates the window mask.
|
||||
4. Timeline Mask Pad expands it to full frame count.
|
||||
5. LTX in/outpainting or IC-LoRA guide generates one repair plate.
|
||||
6. Composite repair over immutable source.
|
||||
7. Critic verifies only that issue.
|
||||
|
||||
Success condition:
|
||||
|
||||
The final output is source pixels plus masked repair, not generation two of generation one.
|
||||
|
||||
### Phase 2: Three Manual Sequential Lanes
|
||||
|
||||
Expose three lanes for `issue_index=0`, `1`, and `2`.
|
||||
|
||||
This keeps debugging visible and avoids pretending the automatic loop is solved.
|
||||
|
||||
Each lane writes:
|
||||
|
||||
- selected issue
|
||||
- mask preview
|
||||
- repair plate preview
|
||||
- composite preview
|
||||
- issue status
|
||||
|
||||
### Phase 3: LTX vs Wan Backend Comparison
|
||||
|
||||
For the same issue packet and same mask:
|
||||
|
||||
1. Generate repair plate with LTX.
|
||||
2. Generate repair plate with Wan VACE.
|
||||
3. Composite both over the same immutable source.
|
||||
4. Critic compares composites using the same criteria.
|
||||
|
||||
This creates a scientific backend-selection loop. The orchestrator can later learn that LTX is better for some classes and Wan is better for others.
|
||||
|
||||
## Backend Selection Hypotheses
|
||||
|
||||
Start with these priors and let evidence update them:
|
||||
|
||||
- LTX IC-LoRA In-Outpainting: best first choice for masked fill, boundary-aware repair, canvas extension, and repairs that should preserve LTX timing/style.
|
||||
- LTX attention-mask guide path: useful when the repair should bias conditioning but not fully replace pixels.
|
||||
- Wan VACE: strong alternate for local replacement, background replacement, and larger masked video-to-video edits.
|
||||
- Motion Brush: useful for timeline, retake, and motion-control payloads, but not the raw mask-to-pixel compositor.
|
||||
- Generic image/composite nodes: should own final pixel preservation and plate merge whenever possible.
|
||||
|
||||
## Scientific Acceptance Criteria
|
||||
|
||||
The prototype is not accepted until these are true:
|
||||
|
||||
1. Source plate immutability is explicit in every plate manifest.
|
||||
2. Every executable repair job has a time window and maskable target.
|
||||
3. Timeline masks fail fast on frame-count mismatch.
|
||||
4. Unmasked source pixels are preserved by deterministic composite.
|
||||
5. Prompts for repair backends describe only the repaired region.
|
||||
6. The critic updates per-issue status rather than one overall retake score.
|
||||
7. A generated or composited output is never reused as the next base plate.
|
||||
8. LTX and Wan backends can be tested against the same plate job.
|
||||
9. Fallback or metadata-only critic reports cannot green-light execution.
|
||||
10. The graph exposes intermediate plate, mask, and composite previews.
|
||||
|
||||
## What Not To Build
|
||||
|
||||
Do not build:
|
||||
|
||||
- a hidden all-in-one retake agent
|
||||
- a new SAM implementation
|
||||
- a custom compositor before testing `ImageCompositeFromMaskBatch`, `LTXVLaplacianPyramidBlend`, and `FL_PasteByMask`
|
||||
- a custom Wan VACE inpaint implementation if a small control-video prep adapter is enough
|
||||
- a prompt-only multi-issue repair chain that collapses layers into one dense retake prompt
|
||||
- a loop where pass two treats pass one's defects as new source truth
|
||||
|
||||
## Required New Artifacts
|
||||
|
||||
Likely needed:
|
||||
|
||||
- `schemas/plate_plan.schema.json`
|
||||
- `schemas/plate_job.schema.json`
|
||||
- `schemas/plate_result.schema.json`
|
||||
- `schemas/issue_status.schema.json`
|
||||
- smoke test for synthetic composite preservation
|
||||
- smoke test for scheduler sequential lanes
|
||||
- smoke test for vague issue refusal
|
||||
- smoke test for strict mask/window mismatch
|
||||
- optional example workflow: `examples/layered_repair_workbench_poc.json`
|
||||
|
||||
Likely new nodes:
|
||||
|
||||
- `DiffusionGemma Source Plate Manifest`
|
||||
- `DiffusionGemma LTX Plate Plan Compiler`
|
||||
- `DiffusionGemma LTX Plate Job Router`
|
||||
- `DiffusionGemma Plate Composite QA`
|
||||
- `DiffusionGemma Plate Composite` only if existing composites do not satisfy z-order/holdout/testing needs
|
||||
- `DiffusionGemma Wan VACE Prep` only if installing or relying on external Wan prep nodes is undesirable
|
||||
|
||||
## Open Verification Tasks
|
||||
|
||||
Before implementation:
|
||||
|
||||
1. Load the official LTX in/outpainting workflow locally and record exact node sequence.
|
||||
2. Confirm local `ComfyUI-LTXVideo` in/outpainting workflows run with the installed model files, not only that the JSON exists.
|
||||
3. Confirm whether local ComfyUI has native SAM3 model files available, not just node classes.
|
||||
4. Confirm whether Wan VACE model files are installed and runnable in this ComfyUI environment.
|
||||
5. Decide whether the first prototype should target LTX only, or LTX plus a Wan A/B backend.
|
||||
6. Build a no-generator composite smoke before touching generation.
|
||||
|
||||
## Handoff Summary
|
||||
|
||||
The architecture is not vibes:
|
||||
|
||||
- LTX/Lightricks provides mask-conditioned in/outpainting and blend primitives.
|
||||
- Wan VACE provides masked video-to-video/control-mask primitives.
|
||||
- ComfyUI already provides SAM3 masks, crop/recompose, batch compositing, and LTX/Wan sockets.
|
||||
- DiffusionGemma's unique role is not to invent pixels. Its role is to produce verified issue inventory, plate plans, backend routing, and critic status updates.
|
||||
|
||||
The next implementation should be a visible plate workbench, not a closed-loop full-video retake chain.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+55
-2
@@ -1,6 +1,6 @@
|
||||
# DiffusionGemma Example Workflow
|
||||
# DiffusionGemma Example Workflows
|
||||
|
||||
This package ships one release workflow:
|
||||
The established character-motion release workflow is:
|
||||
|
||||
```text
|
||||
07_ltx23_character_motion_transfer.json
|
||||
@@ -47,3 +47,56 @@ examples/assets/character_motion_transfer/diffusiongemma_ltx_character_demo.mp4
|
||||
```
|
||||
|
||||
Wire downstream generation nodes from the JSON Splitter outputs only when `ready_for_generation=true`.
|
||||
|
||||
## MiniMax H3 Director T2VA
|
||||
|
||||
The native text-to-video/audio example is:
|
||||
|
||||
```text
|
||||
09_minimax_h3_t2va_director.json
|
||||
```
|
||||
|
||||
It converts a normal creative brief into Director's dedicated `minimax_h3_prompt`, wires the splitter's width and height outputs into native MiniMax-H3, and shares one editable duration between the Director profile and H3 frame calculation. Parallel previews show the generated H3 prompt and the splitter metadata/blocked reasons without interrupting the direct prompt connection. The included preset is 20 seconds, 481 frames at 24 fps, 0.4 MP at multiple-of-32 resolution, and 25 sampling steps.
|
||||
|
||||
The workflow uses the native `MiniMaxH3ImageToVideo` path plus `RHMiniMaxH3SageAttentionPatch`; install `ComfyUI_RH_MinMaxH3` and place the four model files selected in the embedded H3 group in their normal ComfyUI model folders. DiffusionGemma is set to unload after prompt generation so H3 can reclaim VRAM before sampling.
|
||||
|
||||
## MiniMax H3 Director Ref2VA
|
||||
|
||||
The ordered-reference example is:
|
||||
|
||||
```text
|
||||
10_minimax_h3_ref2va_director.json
|
||||
```
|
||||
|
||||
It uses two images in explicit `<Picture 1>` / `<Picture 2>` order, analyzes that same ordered batch with `DiffusionGemmaH3ReferenceContext`, and sends the dedicated Ref2VA prompt directly to native `MiniMaxH3ReferenceToVideo`. The sample creative brief explicitly requests 2D hand-drawn animation and assigns protagonist identity to Picture 1 and environment/style to Picture 2, preventing vague “inspired” wording from drifting into generic CGI subjects.
|
||||
|
||||
One editable duration is shared by Director planning and native frame calculation; the included setting is 20 seconds, 481 frames at 24 fps, with 25 sampling steps. The Director uses its full 2048-token output budget because the official Ref2VA representation includes subject definitions, retention analysis, and a detailed timeline in addition to audio sections. Replace both `example.png` placeholders before running and keep their socket order aligned with the reference manifest. The Director loader unloads before H3 sampling, and both original images feed the native Ref2VA node separately.
|
||||
|
||||
## LTX 2.3 Reframe with selectable audio
|
||||
|
||||
The provisional, unexported repository prototype is:
|
||||
|
||||
```text
|
||||
gemmaREFRAME_Experimental_audio_on-off.json
|
||||
```
|
||||
|
||||
The current package entry point does not register the `LTXReframe*` nodes, and the retired layout frontend is not shipped. Keep these reframe artifacts separate from the stable Director examples when preparing a Registry release.
|
||||
|
||||
Its **Use Custom Vocal Audio?** switch defaults to off:
|
||||
|
||||
- **Off:** the source video's audio is used for both LTX audio conditioning and the final video. Videos without an audio track use duration-matched silence.
|
||||
- **On:** upload a custom audio track, choose its start time in **Custom Track Window**, and use the MelBandRoFormer vocal stem. Long tracks are trimmed and short tracks are padded with silence to match the effective video range.
|
||||
|
||||
Required custom-node dependencies:
|
||||
|
||||
- `ComfyUI-MelBandRoFormer`
|
||||
- `comfyui-kjnodes`
|
||||
- `comfy-mtb`
|
||||
|
||||
Required model:
|
||||
|
||||
```text
|
||||
ComfyUI/models/diffusion_models/MelBandRoformer_fp16.safetensors
|
||||
```
|
||||
|
||||
No custom music is bundled. Source-audio mode can remain selected without choosing a custom file; custom-audio mode reports a clear error until an audio file is uploaded or selected.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1049
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "diffusiongemma-prompt-builder"
|
||||
version = "0.1.0"
|
||||
description = "Five-node DiffusionGemma Director Assistant bridge for ComfyUI LTX workflows."
|
||||
version = "0.2.0"
|
||||
description = "Stable DiffusionGemma Director prompt authoring for LTX, Ideogram4, and MiniMax H3 in ComfyUI, plus experimental SplatStage planners."
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -25,4 +25,4 @@ Repository = "https://github.com/exportAnything/ComfyUI-DiffusionGemmaPromptBuil
|
||||
[tool.comfy]
|
||||
PublisherId = "exportanything"
|
||||
DisplayName = "DiffusionGemma Prompt Builder"
|
||||
requires-comfyui = ">=1.0.0"
|
||||
requires-comfyui = ">=0.30.1"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{"type": "object"}
|
||||
]
|
||||
},
|
||||
"minimax_h3_prompt": {"type": "string"},
|
||||
"negative_prompt": {"type": "string"},
|
||||
"scene_segments": {
|
||||
"type": "array",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "splatstage.efficient_show_blueprint@1",
|
||||
"title": "SplatStage Efficient Show Blueprint",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"version",
|
||||
"project_title",
|
||||
"creative_summary",
|
||||
"music",
|
||||
"performer",
|
||||
"background",
|
||||
"fx",
|
||||
"editing",
|
||||
"finish"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {"const": "splatstage.efficient_show_blueprint"},
|
||||
"version": {"const": 1},
|
||||
"project_title": {"type": "string", "minLength": 1, "maxLength": 120},
|
||||
"creative_summary": {"type": "string", "minLength": 1, "maxLength": 800},
|
||||
"music": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ace_tags", "vocal_mode", "lyrics", "bpm", "key", "time_signature", "language"],
|
||||
"properties": {
|
||||
"ace_tags": {"type": "string", "minLength": 1, "maxLength": 800},
|
||||
"vocal_mode": {"enum": ["vocal_hook", "instrumental"]},
|
||||
"lyrics": {"type": "string", "minLength": 1, "maxLength": 5000},
|
||||
"bpm": {"type": "integer", "minimum": 60, "maximum": 220},
|
||||
"key": {
|
||||
"enum": [
|
||||
"C major", "C minor", "C# major", "C# minor", "D major", "D minor",
|
||||
"D# major", "D# minor", "E major", "E minor", "F major", "F minor",
|
||||
"F# major", "F# minor", "G major", "G minor", "G# major", "G# minor",
|
||||
"A major", "A minor", "A# major", "A# minor", "B major", "B minor"
|
||||
]
|
||||
},
|
||||
"time_signature": {"enum": ["2", "3", "4", "6"]},
|
||||
"language": {
|
||||
"enum": ["en", "es", "fr", "de", "it", "pt", "ja", "ko", "zh", "hi", "ar"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"performer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["identity_description", "positive_prompt", "negative_prompt", "segmentation_prompt", "variations"],
|
||||
"properties": {
|
||||
"identity_description": {"type": "string", "minLength": 1, "maxLength": 800},
|
||||
"positive_prompt": {"type": "string", "minLength": 1, "maxLength": 2200},
|
||||
"negative_prompt": {"type": "string", "minLength": 1, "maxLength": 1600},
|
||||
"segmentation_prompt": {"type": "string", "minLength": 1, "maxLength": 500},
|
||||
"variations": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 700}
|
||||
}
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["positive_prompt", "negative_prompt", "variations"],
|
||||
"properties": {
|
||||
"positive_prompt": {"type": "string", "minLength": 1, "maxLength": 2200},
|
||||
"negative_prompt": {"type": "string", "minLength": 1, "maxLength": 1600},
|
||||
"variations": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 700}
|
||||
}
|
||||
}
|
||||
},
|
||||
"fx": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["positive_prompt", "negative_prompt", "blend_mode", "key_mode", "opacity"],
|
||||
"properties": {
|
||||
"positive_prompt": {"type": "string", "minLength": 1, "maxLength": 1800},
|
||||
"negative_prompt": {"type": "string", "minLength": 1, "maxLength": 1600},
|
||||
"blend_mode": {"enum": ["screen", "add", "lighten"]},
|
||||
"key_mode": {"enum": ["luminance", "black"]},
|
||||
"opacity": {"type": "number", "minimum": 0.2, "maximum": 0.65}
|
||||
}
|
||||
},
|
||||
"editing": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["density", "music_reactivity"],
|
||||
"properties": {
|
||||
"density": {"enum": ["sparse", "musical", "energetic"]},
|
||||
"music_reactivity": {"enum": ["low", "medium", "high"]}
|
||||
}
|
||||
},
|
||||
"finish": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["style_preset"],
|
||||
"properties": {
|
||||
"style_preset": {"enum": ["broadcast_clean", "cable_access", "late_night_vhs", "damaged_tape"]}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "splatstage.show_blueprint@1",
|
||||
"title": "SplatStage Show Blueprint",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"version",
|
||||
"project_title",
|
||||
"creative_summary",
|
||||
"music",
|
||||
"performer",
|
||||
"background",
|
||||
"fx",
|
||||
"editing",
|
||||
"finish"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "splatstage.show_blueprint"
|
||||
},
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"project_title": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"creative_summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1000
|
||||
},
|
||||
"music": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"ace_tags",
|
||||
"vocal_mode",
|
||||
"lyrics",
|
||||
"bpm",
|
||||
"key",
|
||||
"time_signature",
|
||||
"language"
|
||||
],
|
||||
"properties": {
|
||||
"ace_tags": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 1200
|
||||
},
|
||||
"vocal_mode": {
|
||||
"enum": [
|
||||
"vocal_hook",
|
||||
"instrumental"
|
||||
]
|
||||
},
|
||||
"lyrics": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 2000
|
||||
},
|
||||
"bpm": {
|
||||
"type": "integer",
|
||||
"minimum": 10,
|
||||
"maximum": 300
|
||||
},
|
||||
"key": {
|
||||
"enum": [
|
||||
"C major",
|
||||
"C# major",
|
||||
"Db major",
|
||||
"D major",
|
||||
"D# major",
|
||||
"Eb major",
|
||||
"E major",
|
||||
"F major",
|
||||
"F# major",
|
||||
"Gb major",
|
||||
"G major",
|
||||
"G# major",
|
||||
"Ab major",
|
||||
"A major",
|
||||
"A# major",
|
||||
"Bb major",
|
||||
"B major",
|
||||
"C minor",
|
||||
"C# minor",
|
||||
"Db minor",
|
||||
"D minor",
|
||||
"D# minor",
|
||||
"Eb minor",
|
||||
"E minor",
|
||||
"F minor",
|
||||
"F# minor",
|
||||
"Gb minor",
|
||||
"G minor",
|
||||
"G# minor",
|
||||
"Ab minor",
|
||||
"A minor",
|
||||
"A# minor",
|
||||
"Bb minor",
|
||||
"B minor"
|
||||
]
|
||||
},
|
||||
"time_signature": {
|
||||
"enum": [
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"6"
|
||||
]
|
||||
},
|
||||
"language": {
|
||||
"enum": [
|
||||
"ar",
|
||||
"az",
|
||||
"bg",
|
||||
"bn",
|
||||
"ca",
|
||||
"cs",
|
||||
"da",
|
||||
"de",
|
||||
"el",
|
||||
"en",
|
||||
"es",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr",
|
||||
"he",
|
||||
"hi",
|
||||
"hr",
|
||||
"ht",
|
||||
"hu",
|
||||
"id",
|
||||
"is",
|
||||
"it",
|
||||
"ja",
|
||||
"ko",
|
||||
"la",
|
||||
"lt",
|
||||
"ms",
|
||||
"ne",
|
||||
"nl",
|
||||
"no",
|
||||
"pa",
|
||||
"pl",
|
||||
"pt",
|
||||
"ro",
|
||||
"ru",
|
||||
"sa",
|
||||
"sk",
|
||||
"sr",
|
||||
"sv",
|
||||
"sw",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tl",
|
||||
"tr",
|
||||
"uk",
|
||||
"ur",
|
||||
"vi",
|
||||
"yue",
|
||||
"zh",
|
||||
"unknown"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"performer": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"label",
|
||||
"positive_prompt",
|
||||
"negative_prompt",
|
||||
"segmentation_prompt"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"positive_prompt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"negative_prompt": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
},
|
||||
"segmentation_prompt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 320
|
||||
}
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"positive_prompt",
|
||||
"negative_prompt"
|
||||
],
|
||||
"properties": {
|
||||
"positive_prompt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"negative_prompt": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
}
|
||||
}
|
||||
},
|
||||
"fx": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"positive_prompt",
|
||||
"negative_prompt",
|
||||
"blend_mode",
|
||||
"key_mode",
|
||||
"opacity"
|
||||
],
|
||||
"properties": {
|
||||
"positive_prompt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"negative_prompt": {
|
||||
"type": "string",
|
||||
"maxLength": 2000
|
||||
},
|
||||
"blend_mode": {
|
||||
"enum": [
|
||||
"screen",
|
||||
"add",
|
||||
"overlay"
|
||||
]
|
||||
},
|
||||
"key_mode": {
|
||||
"enum": [
|
||||
"luminance",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"opacity": {
|
||||
"type": "number",
|
||||
"minimum": 0.05,
|
||||
"maximum": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"editing": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"background_density",
|
||||
"fx_density",
|
||||
"music_reactivity"
|
||||
],
|
||||
"properties": {
|
||||
"background_density": {
|
||||
"enum": [
|
||||
"calm",
|
||||
"musical",
|
||||
"energetic"
|
||||
]
|
||||
},
|
||||
"fx_density": {
|
||||
"enum": [
|
||||
"calm",
|
||||
"musical",
|
||||
"energetic"
|
||||
]
|
||||
},
|
||||
"music_reactivity": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"finish": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"style_preset"
|
||||
],
|
||||
"properties": {
|
||||
"style_preset": {
|
||||
"enum": [
|
||||
"broadcast_clean",
|
||||
"cable_access",
|
||||
"late_night_vhs",
|
||||
"damaged_tape"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = (
|
||||
ROOT.parent
|
||||
/ "ComfyUI-LTXVideo"
|
||||
/ "example_workflows"
|
||||
/ "2.3"
|
||||
/ "LTX-2.3_ICLoRA_Outpaint_Two_Stage_Distilled.json"
|
||||
)
|
||||
OUTPUT = ROOT / "examples" / "08_ltx23_reframe_iclora_outpaint_two_stage.json"
|
||||
INSTALLED_OUTPUT = (
|
||||
ROOT.parents[1]
|
||||
/ "user"
|
||||
/ "default"
|
||||
/ "workflows"
|
||||
/ "LTX 2.3 Reframe IC-LoRA Outpaint - Verified.json"
|
||||
)
|
||||
|
||||
REMOVED_NODE_IDS = {5168, 5363, 5371, 5393}
|
||||
REMOVED_LINK_IDS = {14383, 14395, 14445, 14446}
|
||||
|
||||
|
||||
def node_by_id(workflow: dict[str, Any], node_id: int) -> dict[str, Any]:
|
||||
return next(node for node in workflow["nodes"] if node["id"] == node_id)
|
||||
|
||||
|
||||
def build_reframe_node() -> dict[str, Any]:
|
||||
return {
|
||||
"id": 5368,
|
||||
"type": "LTXReframeLayout",
|
||||
"pos": [-4225.854240441995, 3547.938320763212],
|
||||
"size": [600, 760],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{"name": "padded_frames", "type": "IMAGE", "links": [14364]},
|
||||
{"name": "outpaint_mask", "type": "MASK", "links": [14369]},
|
||||
{"name": "source_mask", "type": "MASK", "links": []},
|
||||
{"name": "source_frames", "type": "IMAGE", "links": []},
|
||||
{"name": "audio", "type": "AUDIO", "links": [14422]},
|
||||
{"name": "fps", "type": "FLOAT", "links": [13889, 13968]},
|
||||
{"name": "source_video", "type": "VIDEO", "links": []},
|
||||
{"name": "target_width", "type": "INT", "links": []},
|
||||
{"name": "target_height", "type": "INT", "links": []},
|
||||
{"name": "placement_json", "type": "STRING", "links": []},
|
||||
],
|
||||
"title": "1. LTX Reframe Layout · Source & Framing",
|
||||
"properties": {"Node name for S&R": "LTXReframeLayout"},
|
||||
"widgets_values": [
|
||||
"LTX-2_00180_.mp4",
|
||||
"9:16",
|
||||
576,
|
||||
1024,
|
||||
0.5,
|
||||
0.5,
|
||||
0.7,
|
||||
32,
|
||||
],
|
||||
"color": "#1f4d52",
|
||||
"bgcolor": "#16383c",
|
||||
}
|
||||
|
||||
|
||||
def build_mask_batch_node(
|
||||
node_id: int,
|
||||
*,
|
||||
pos: list[float],
|
||||
mask_link: int,
|
||||
size_link: int,
|
||||
output_link: int,
|
||||
title: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": node_id,
|
||||
"type": "MaskExpandBatch+",
|
||||
"pos": pos,
|
||||
"size": [270, 106],
|
||||
"flags": {},
|
||||
"order": 63 if node_id == 5400 else 64,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "mask", "type": "MASK", "link": mask_link},
|
||||
{
|
||||
"name": "size",
|
||||
"type": "INT",
|
||||
"widget": {"name": "size"},
|
||||
"link": size_link,
|
||||
},
|
||||
],
|
||||
"outputs": [{"name": "MASK", "type": "MASK", "links": [output_link]}],
|
||||
"title": title,
|
||||
"properties": {
|
||||
"cnr_id": "comfyui_essentials",
|
||||
"Node name for S&R": "MaskExpandBatch+",
|
||||
},
|
||||
"widgets_values": [121, "expand"],
|
||||
"color": "#5b4636",
|
||||
"bgcolor": "#3c3027",
|
||||
}
|
||||
|
||||
|
||||
def clean_graph_references(workflow: dict[str, Any]) -> None:
|
||||
valid_node_ids = {node["id"] for node in workflow["nodes"]}
|
||||
workflow["links"] = [
|
||||
link
|
||||
for link in workflow["links"]
|
||||
if link[0] not in REMOVED_LINK_IDS
|
||||
and link[1] in valid_node_ids
|
||||
and link[3] in valid_node_ids
|
||||
]
|
||||
valid_link_ids = {link[0] for link in workflow["links"]}
|
||||
|
||||
for node in workflow["nodes"]:
|
||||
for item in node.get("inputs", []):
|
||||
if item.get("link") not in valid_link_ids:
|
||||
item["link"] = None
|
||||
for item in node.get("outputs", []):
|
||||
item["links"] = [
|
||||
link_id for link_id in item.get("links") or [] if link_id in valid_link_ids
|
||||
]
|
||||
|
||||
extra = workflow.get("extra", {})
|
||||
reroutes = extra.get("reroutes", [])
|
||||
for reroute in reroutes:
|
||||
reroute["linkIds"] = [
|
||||
link_id for link_id in reroute.get("linkIds", []) if link_id in valid_link_ids
|
||||
]
|
||||
extra["reroutes"] = [reroute for reroute in reroutes if reroute.get("linkIds")]
|
||||
extra["linkExtensions"] = [
|
||||
extension
|
||||
for extension in extra.get("linkExtensions", [])
|
||||
if extension.get("id") in valid_link_ids
|
||||
]
|
||||
|
||||
for group in workflow.get("groups", []):
|
||||
group["nodes"] = [node_id for node_id in group.get("nodes", []) if node_id in valid_node_ids]
|
||||
|
||||
workflow["last_node_id"] = max(valid_node_ids)
|
||||
workflow["last_link_id"] = max(valid_link_ids)
|
||||
|
||||
|
||||
def validate_graph(workflow: dict[str, Any]) -> None:
|
||||
nodes = {node["id"]: node for node in workflow["nodes"]}
|
||||
links = {link[0]: link for link in workflow["links"]}
|
||||
if len(nodes) != len(workflow["nodes"]):
|
||||
raise ValueError("Duplicate node IDs remain in the generated workflow.")
|
||||
if len(links) != len(workflow["links"]):
|
||||
raise ValueError("Duplicate link IDs remain in the generated workflow.")
|
||||
|
||||
for link_id, link in links.items():
|
||||
if link[1] not in nodes or link[3] not in nodes:
|
||||
raise ValueError(f"Link {link_id} has a missing endpoint: {link}")
|
||||
source_output = nodes[link[1]]["outputs"][link[2]]
|
||||
destination_input = nodes[link[3]]["inputs"][link[4]]
|
||||
if link_id not in (source_output.get("links") or []):
|
||||
raise ValueError(f"Link {link_id} is absent from its source output socket.")
|
||||
if destination_input.get("link") != link_id:
|
||||
raise ValueError(f"Link {link_id} is absent from its destination input socket.")
|
||||
|
||||
reframe = nodes.get(5368)
|
||||
if reframe is None or reframe["type"] != "LTXReframeLayout":
|
||||
raise ValueError("The generated workflow is missing LTXReframeLayout node 5368.")
|
||||
|
||||
expected = {
|
||||
14364: (5368, 0, 5360, 0, "IMAGE"),
|
||||
14369: (5368, 1, 5365, 0, "MASK"),
|
||||
13889: (5368, 5, 1241, 2, "FLOAT"),
|
||||
13968: (5368, 5, 5227, 2, "FLOAT"),
|
||||
14422: (5368, 4, 5382, 0, "AUDIO"),
|
||||
14376: (5365, 0, 5400, 0, "MASK"),
|
||||
14377: (5364, 0, 5401, 0, "MASK"),
|
||||
14447: (5054, 2, 5400, 1, "INT"),
|
||||
14448: (5054, 2, 5401, 1, "INT"),
|
||||
14449: (5400, 0, 5226, 2, "MASK"),
|
||||
14450: (5401, 0, 5266, 2, "MASK"),
|
||||
}
|
||||
for link_id, contract in expected.items():
|
||||
actual = tuple(links[link_id][1:])
|
||||
if actual != contract:
|
||||
raise ValueError(f"Link {link_id} is {actual}, expected {contract}.")
|
||||
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
workflow = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
workflow["id"] = str(
|
||||
uuid.uuid5(uuid.NAMESPACE_URL, "ltx-2.3-reframe-iclora-outpaint-two-stage")
|
||||
)
|
||||
workflow["nodes"] = [
|
||||
node for node in workflow["nodes"] if node["id"] not in REMOVED_NODE_IDS | {5368}
|
||||
]
|
||||
workflow["nodes"].append(build_reframe_node())
|
||||
workflow["nodes"].extend(
|
||||
[
|
||||
build_mask_batch_node(
|
||||
5400,
|
||||
pos=[1050.0, 3261.0],
|
||||
mask_link=14376,
|
||||
size_link=14447,
|
||||
output_link=14449,
|
||||
title="Full-Res Mask · Match Video Frame Count",
|
||||
),
|
||||
build_mask_batch_node(
|
||||
5401,
|
||||
pos=[-660.0, 3432.0],
|
||||
mask_link=14377,
|
||||
size_link=14448,
|
||||
output_link=14450,
|
||||
title="Half-Res Mask · Match Video Frame Count",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
links = {link[0]: link for link in workflow["links"]}
|
||||
links[14364] = [14364, 5368, 0, 5360, 0, "IMAGE"]
|
||||
links[14369] = [14369, 5368, 1, 5365, 0, "MASK"]
|
||||
links[13889] = [13889, 5368, 5, 1241, 2, "FLOAT"]
|
||||
links[13968] = [13968, 5368, 5, 5227, 2, "FLOAT"]
|
||||
links[14422] = [14422, 5368, 4, 5382, 0, "AUDIO"]
|
||||
links[14376] = [14376, 5365, 0, 5400, 0, "MASK"]
|
||||
links[14377] = [14377, 5364, 0, 5401, 0, "MASK"]
|
||||
links[14447] = [14447, 5054, 2, 5400, 1, "INT"]
|
||||
links[14448] = [14448, 5054, 2, 5401, 1, "INT"]
|
||||
links[14449] = [14449, 5400, 0, 5226, 2, "MASK"]
|
||||
links[14450] = [14450, 5401, 0, 5266, 2, "MASK"]
|
||||
workflow["links"] = list(links.values())
|
||||
|
||||
node_by_id(workflow, 5226)["inputs"][2]["link"] = 14449
|
||||
node_by_id(workflow, 5266)["inputs"][2]["link"] = 14450
|
||||
node_by_id(workflow, 5054)["outputs"][2]["links"].extend([14447, 14448])
|
||||
|
||||
node_by_id(workflow, 5023)["widgets_values"][0] = (
|
||||
"gemma_3_12B_it_fp4_mixed.safetensors"
|
||||
)
|
||||
node_by_id(workflow, 4922)["widgets_values"][0] = (
|
||||
"ltx-2.3-22b-distilled-lora-384-1.1.safetensors"
|
||||
)
|
||||
node_by_id(workflow, 5011)["widgets_values"][0] = (
|
||||
"ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors"
|
||||
)
|
||||
node_by_id(workflow, 2483)["widgets_values"][0] = (
|
||||
"A cinematic live-action scene continuing naturally beyond the source frame, "
|
||||
"with a coherent environment, realistic lighting, consistent subjects and camera "
|
||||
"motion, seamless edges, and stable fine detail."
|
||||
)
|
||||
node_by_id(workflow, 5228)["widgets_values"][0] = "video/LTX_Reframe_Outpaint"
|
||||
node_by_id(workflow, 5356)["widgets_values"][0] = (
|
||||
"### Reframe Setup\n\nUse **LTX Reframe Layout** to choose the final canvas and place "
|
||||
"the source. `source_scale < 1` zooms out for outpainting; `source_scale > 1` "
|
||||
"zooms in and crops. The example starts at **576 × 1024**, 121 frames, with "
|
||||
"audio, so it is suitable for a first end-to-end test."
|
||||
)
|
||||
|
||||
for group in workflow.get("groups", []):
|
||||
if group["id"] == 164:
|
||||
group["title"] = "1. Reframe Source Video"
|
||||
group["bounding"] = [-4235.854240441995, 3507.938320763212, 620, 800]
|
||||
elif group["id"] == 168:
|
||||
group["title"] = "2. Prepare Two-Stage Outpaint Conditions"
|
||||
elif group["id"] == 178:
|
||||
group["title"] = "Stage 2 Full-Resolution Mask"
|
||||
elif group["id"] == 179:
|
||||
group["title"] = "Stage 1 Half-Resolution Mask"
|
||||
|
||||
workflow.setdefault("extra", {})["ltx_reframe_workflow"] = {
|
||||
"schema": "ltx_reframe_iclora_outpaint/v1",
|
||||
"based_on": SOURCE.name,
|
||||
"source_node": "LTXReframeLayout",
|
||||
"default_source": "LTX-2_00180_.mp4",
|
||||
"default_target": [576, 1024],
|
||||
"required_lora": "ltx-2.3-22b-ic-lora-in-outpainting-0.9.safetensors",
|
||||
"mask_batch_safety": "MaskExpandBatch+ before both Laplacian blends",
|
||||
}
|
||||
|
||||
clean_graph_references(workflow)
|
||||
validate_graph(workflow)
|
||||
return workflow
|
||||
|
||||
|
||||
def main() -> None:
|
||||
workflow = build()
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_text(json.dumps(workflow, indent=2) + "\n", encoding="utf-8")
|
||||
INSTALLED_OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(OUTPUT, INSTALLED_OUTPUT)
|
||||
print(f"Wrote {OUTPUT}")
|
||||
print(f"Installed {INSTALLED_OUTPUT}")
|
||||
print(f"Nodes: {len(workflow['nodes'])}; links: {len(workflow['links'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -9,9 +9,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_WORKFLOW = Path(r"C:\Users\danrh\Desktop\diffusion_gemma_workflows\LTX-2.3-INPAINTING-EDIT-ANY-VIDEO.json")
|
||||
DEFAULT_OUTPUT_WORKFLOW = Path(r"C:\Users\danrh\Desktop\diffusion_gemma_workflows\LTX-2.3-INPAINTING-EDIT-ANY-VIDEO_DG_PROMPT_INJECTED_v0.json")
|
||||
DEFAULT_MANIFEST = Path(r"C:\Users\danrh\Desktop\diffusion_gemma_workflows\LTX-2.3-INPAINTING-EDIT-ANY-VIDEO_DG_PROMPT_INJECTED_v0.manifest.json")
|
||||
DEFAULT_WORKFLOW = Path("LTX-2.3-INPAINTING-EDIT-ANY-VIDEO.json")
|
||||
DEFAULT_OUTPUT_WORKFLOW = Path("LTX-2.3-INPAINTING-EDIT-ANY-VIDEO_DG_PROMPT_INJECTED_v0.json")
|
||||
DEFAULT_MANIFEST = Path("LTX-2.3-INPAINTING-EDIT-ANY-VIDEO_DG_PROMPT_INJECTED_v0.manifest.json")
|
||||
REPAIR_SCOPE = "localized_masked_inpaint"
|
||||
|
||||
|
||||
|
||||
+1392
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import wave
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as torch_functional
|
||||
|
||||
|
||||
def install_comfy_stubs() -> None:
|
||||
"""Install the narrow ComfyUI seams exercised by the reframe unit tests."""
|
||||
|
||||
folder_paths = ModuleType("folder_paths")
|
||||
folder_paths.get_input_directory = lambda: str(Path.cwd())
|
||||
folder_paths.annotated_filepath = lambda name: (name, None)
|
||||
folder_paths.filter_files_content_types = lambda files, _types: list(files)
|
||||
|
||||
def load_audio(path: str):
|
||||
with wave.open(str(path), "rb") as handle:
|
||||
if handle.getsampwidth() != 2:
|
||||
raise ValueError("test audio stub supports 16-bit PCM only")
|
||||
channels = handle.getnchannels()
|
||||
frame_count = handle.getnframes()
|
||||
sample_rate = handle.getframerate()
|
||||
payload = bytearray(handle.readframes(frame_count))
|
||||
samples = torch.frombuffer(payload, dtype=torch.int16).reshape(-1, channels)
|
||||
waveform = samples.transpose(0, 1).to(torch.float32).div_(32768.0).contiguous()
|
||||
return waveform, sample_rate
|
||||
|
||||
comfy_extras = ModuleType("comfy_extras")
|
||||
comfy_extras.__path__ = []
|
||||
comfy_audio = ModuleType("comfy_extras.nodes_audio")
|
||||
comfy_audio.load = load_audio
|
||||
comfy_extras.nodes_audio = comfy_audio
|
||||
|
||||
comfy = ModuleType("comfy")
|
||||
comfy.__path__ = []
|
||||
comfy_utils = ModuleType("comfy.utils")
|
||||
comfy_utils.common_upscale = lambda images, width, height, _method, _crop: (
|
||||
torch_functional.interpolate(
|
||||
images,
|
||||
size=(int(height), int(width)),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
)
|
||||
comfy.utils = comfy_utils
|
||||
|
||||
class UnsupportedVideo:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
raise RuntimeError("video decoding is outside this unit-test seam")
|
||||
|
||||
comfy_latest = ModuleType("comfy_api.latest")
|
||||
comfy_latest.InputImpl = SimpleNamespace(
|
||||
VideoFromFile=UnsupportedVideo,
|
||||
VideoFromComponents=UnsupportedVideo,
|
||||
)
|
||||
comfy_latest.Types = SimpleNamespace(
|
||||
VideoComponents=lambda **values: SimpleNamespace(**values)
|
||||
)
|
||||
comfy_api = ModuleType("comfy_api")
|
||||
comfy_api.__path__ = []
|
||||
comfy_api.latest = comfy_latest
|
||||
|
||||
aiohttp_web = ModuleType("aiohttp.web")
|
||||
aiohttp_web.json_response = lambda data, status=200: SimpleNamespace(
|
||||
data=data,
|
||||
status=status,
|
||||
)
|
||||
aiohttp = ModuleType("aiohttp")
|
||||
aiohttp.__path__ = []
|
||||
aiohttp.web = aiohttp_web
|
||||
|
||||
sys.modules.update(
|
||||
{
|
||||
"aiohttp": aiohttp,
|
||||
"aiohttp.web": aiohttp_web,
|
||||
"comfy": comfy,
|
||||
"comfy.utils": comfy_utils,
|
||||
"comfy_api": comfy_api,
|
||||
"comfy_api.latest": comfy_latest,
|
||||
"comfy_extras": comfy_extras,
|
||||
"comfy_extras.nodes_audio": comfy_audio,
|
||||
"folder_paths": folder_paths,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import wave
|
||||
|
||||
import torch
|
||||
|
||||
from comfy_test_stubs import install_comfy_stubs
|
||||
|
||||
install_comfy_stubs()
|
||||
|
||||
from ltx_reframe import (
|
||||
LTXReframeCustomAudioLoader,
|
||||
LTXReframeRectilinearCorrection,
|
||||
LTXReframeTargetFit,
|
||||
NODE_CLASS_MAPPINGS,
|
||||
build_reframe_masks,
|
||||
compute_reframe_placement,
|
||||
ensure_audio_track,
|
||||
fit_audio_track,
|
||||
prepare_reframe_canvas,
|
||||
rectilinear_correct_images,
|
||||
resolve_frame_range,
|
||||
snap_dimension,
|
||||
)
|
||||
|
||||
|
||||
class LTXReframeTests(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _write_test_wav(path: Path, *, sample_rate: int = 8000, samples: int = 80):
|
||||
with wave.open(str(path), "wb") as handle:
|
||||
handle.setnchannels(1)
|
||||
handle.setsampwidth(2)
|
||||
handle.setframerate(sample_rate)
|
||||
handle.writeframes(b"\x00\x00" * samples)
|
||||
|
||||
def test_custom_audio_loader_is_registered_and_accepts_an_empty_lazy_branch(self):
|
||||
self.assertIs(
|
||||
NODE_CLASS_MAPPINGS["LTXReframeCustomAudioLoader"],
|
||||
LTXReframeCustomAudioLoader,
|
||||
)
|
||||
self.assertTrue(LTXReframeCustomAudioLoader.VALIDATE_INPUTS(""))
|
||||
with self.assertRaisesRegex(ValueError, "no custom audio file is selected"):
|
||||
LTXReframeCustomAudioLoader().load_audio("")
|
||||
|
||||
def test_custom_audio_loader_lists_audio_uploads_with_a_blank_default(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.filter_files_content_types",
|
||||
return_value=["song.wav"],
|
||||
),
|
||||
):
|
||||
spec = LTXReframeCustomAudioLoader.INPUT_TYPES()
|
||||
|
||||
options, config = spec["required"]["audio_file"]
|
||||
self.assertEqual(options, ["", "song.wav"])
|
||||
self.assertTrue(config["audio_upload"])
|
||||
|
||||
def test_custom_audio_loader_decodes_and_fingerprints_an_input_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
audio_path = Path(directory) / "clip.wav"
|
||||
self._write_test_wav(audio_path)
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.annotated_filepath",
|
||||
side_effect=lambda name: (name, None),
|
||||
),
|
||||
):
|
||||
audio, = LTXReframeCustomAudioLoader().load_audio("clip.wav")
|
||||
first_fingerprint = LTXReframeCustomAudioLoader.IS_CHANGED("clip.wav")
|
||||
second_fingerprint = LTXReframeCustomAudioLoader.IS_CHANGED("clip.wav")
|
||||
with audio_path.open("ab") as handle:
|
||||
handle.write(b"changed")
|
||||
changed_fingerprint = LTXReframeCustomAudioLoader.IS_CHANGED("clip.wav")
|
||||
|
||||
self.assertEqual(audio["sample_rate"], 8000)
|
||||
self.assertEqual(tuple(audio["waveform"].shape), (1, 1, 80))
|
||||
self.assertEqual(first_fingerprint, second_fingerprint)
|
||||
self.assertNotEqual(first_fingerprint, changed_fingerprint)
|
||||
self.assertEqual(len(first_fingerprint), 64)
|
||||
|
||||
def test_custom_audio_loader_reports_a_missing_input_file(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.annotated_filepath",
|
||||
side_effect=lambda name: (name, None),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "file was not found"):
|
||||
LTXReframeCustomAudioLoader().load_audio("missing.wav")
|
||||
|
||||
def test_custom_audio_loader_rejects_paths_outside_comfyui_input(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with (
|
||||
patch("ltx_reframe.folder_paths.get_input_directory", return_value=directory),
|
||||
patch(
|
||||
"ltx_reframe.folder_paths.annotated_filepath",
|
||||
return_value=("../outside.wav", None),
|
||||
),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "input directory"):
|
||||
LTXReframeCustomAudioLoader().load_audio("../outside.wav")
|
||||
|
||||
def test_custom_audio_example_workflow_has_consistent_links_and_safe_defaults(self):
|
||||
workflow_path = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "examples"
|
||||
/ "gemmaREFRAME_Experimental_audio_on-off.json"
|
||||
)
|
||||
workflow = json.loads(workflow_path.read_text(encoding="utf-8"))
|
||||
nodes = {node["id"]: node for node in workflow["nodes"]}
|
||||
links = {link[0]: link for link in workflow["links"]}
|
||||
|
||||
self.assertEqual(len(nodes), len(workflow["nodes"]))
|
||||
self.assertEqual(len(links), len(workflow["links"]))
|
||||
for link_id, (_, source_id, source_slot, target_id, target_slot, _type) in links.items():
|
||||
self.assertIn(source_id, nodes)
|
||||
self.assertIn(target_id, nodes)
|
||||
self.assertIn(link_id, nodes[source_id]["outputs"][source_slot].get("links") or [])
|
||||
self.assertEqual(nodes[target_id]["inputs"][target_slot].get("link"), link_id)
|
||||
|
||||
custom_loader = nodes[9086]
|
||||
selector = nodes[9112]
|
||||
reframe = nodes[5553]
|
||||
stage_one = nodes[9055]
|
||||
create_video = nodes[5539]
|
||||
preview = nodes[9088]
|
||||
|
||||
self.assertEqual(custom_loader["type"], "LTXReframeCustomAudioLoader")
|
||||
self.assertEqual(custom_loader["widgets_values"], [""])
|
||||
self.assertEqual(selector["type"], "LazySwitchKJ")
|
||||
self.assertFalse(selector["widgets_values"][0])
|
||||
self.assertEqual(set(selector["outputs"][0]["links"]), {14666, 14818, 14918})
|
||||
self.assertEqual(reframe["outputs"][4]["links"], [14990, 14991])
|
||||
self.assertEqual(stage_one["inputs"][10]["link"], 14818)
|
||||
self.assertEqual(preview["inputs"][0]["link"], 14918)
|
||||
self.assertEqual(create_video["inputs"][1]["link"], 14999)
|
||||
self.assertEqual(create_video["inputs"][2]["link"], 14666)
|
||||
|
||||
def test_existing_audio_is_preserved(self):
|
||||
audio = {
|
||||
"waveform": torch.ones((1, 1, 100), dtype=torch.float32),
|
||||
"sample_rate": 16000,
|
||||
}
|
||||
|
||||
result, synthesized = ensure_audio_track(audio, frame_count=24, fps=24.0)
|
||||
|
||||
self.assertIs(result, audio)
|
||||
self.assertFalse(synthesized)
|
||||
|
||||
def test_audio_less_video_gets_duration_matched_stereo_silence(self):
|
||||
audio, synthesized = ensure_audio_track(None, frame_count=121, fps=24.0)
|
||||
|
||||
self.assertTrue(synthesized)
|
||||
self.assertEqual(audio["sample_rate"], 44100)
|
||||
self.assertEqual(tuple(audio["waveform"].shape), (1, 2, 222338))
|
||||
self.assertEqual(torch.count_nonzero(audio["waveform"]).item(), 0)
|
||||
|
||||
def test_silent_audio_fallback_rejects_invalid_fps(self):
|
||||
with self.assertRaisesRegex(ValueError, "valid video frame rate"):
|
||||
ensure_audio_track(None, frame_count=121, fps=0.0)
|
||||
|
||||
def test_audio_is_cropped_and_padded_to_selected_duration(self):
|
||||
cropped, synthesized = fit_audio_track(
|
||||
{"waveform": torch.ones((1, 2, 2000)), "sample_rate": 1000},
|
||||
frame_count=25,
|
||||
fps=25.0,
|
||||
)
|
||||
padded, _ = fit_audio_track(
|
||||
{"waveform": torch.ones((1, 2, 500)), "sample_rate": 1000},
|
||||
frame_count=25,
|
||||
fps=25.0,
|
||||
)
|
||||
|
||||
self.assertFalse(synthesized)
|
||||
self.assertEqual(cropped["waveform"].shape[-1], 1000)
|
||||
self.assertEqual(padded["waveform"].shape[-1], 1000)
|
||||
self.assertEqual(torch.count_nonzero(padded["waveform"][..., 500:]).item(), 0)
|
||||
|
||||
def test_full_video_range_is_not_aligned_or_trimmed(self):
|
||||
selected = resolve_frame_range(70, 25.0, False, 8, 16)
|
||||
|
||||
self.assertFalse(selected.range_enabled)
|
||||
self.assertEqual(selected.effective_first_frame, 0)
|
||||
self.assertEqual(selected.effective_last_frame, 69)
|
||||
self.assertEqual(selected.selected_frame_count, 70)
|
||||
|
||||
def test_valid_sixty_five_frame_range_is_preserved(self):
|
||||
selected = resolve_frame_range(241, 25.0, True, 0, 64)
|
||||
|
||||
self.assertEqual(selected.effective_first_frame, 0)
|
||||
self.assertEqual(selected.effective_last_frame, 64)
|
||||
self.assertEqual(selected.selected_frame_count, 65)
|
||||
|
||||
def test_range_alignment_preserves_first_frame_and_trims_end(self):
|
||||
zero_based = resolve_frame_range(241, 25.0, True, 0, 70)
|
||||
offset = resolve_frame_range(241, 25.0, True, 100, 199)
|
||||
|
||||
self.assertEqual((zero_based.effective_first_frame, zero_based.effective_last_frame), (0, 64))
|
||||
self.assertEqual(zero_based.selected_frame_count, 65)
|
||||
self.assertEqual((offset.effective_first_frame, offset.effective_last_frame), (100, 196))
|
||||
self.assertEqual(offset.selected_frame_count, 97)
|
||||
|
||||
def test_last_frame_minus_one_resolves_to_source_end_then_aligns(self):
|
||||
selected = resolve_frame_range(100, 25.0, True, 10, -1)
|
||||
|
||||
self.assertEqual(selected.requested_last_frame, 99)
|
||||
self.assertEqual(selected.effective_last_frame, 98)
|
||||
self.assertEqual(selected.selected_frame_count, 89)
|
||||
|
||||
def test_invalid_frame_ranges_are_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "greater than or equal"):
|
||||
resolve_frame_range(100, 25.0, True, 30, 20)
|
||||
with self.assertRaisesRegex(ValueError, "outside"):
|
||||
resolve_frame_range(100, 25.0, True, 0, 100)
|
||||
with self.assertRaisesRegex(ValueError, "at least nine"):
|
||||
resolve_frame_range(100, 25.0, True, 0, 7)
|
||||
|
||||
def test_single_frame_range_is_allowed(self):
|
||||
selected = resolve_frame_range(100, 25.0, True, 42, 42)
|
||||
|
||||
self.assertEqual(selected.selected_frame_count, 1)
|
||||
self.assertEqual(selected.effective_last_frame, 42)
|
||||
|
||||
def test_dimensions_snap_to_ltx_multiple(self):
|
||||
self.assertEqual(snap_dimension(1919), 1920)
|
||||
self.assertEqual(snap_dimension(1050), 1024)
|
||||
self.assertEqual(snap_dimension(1), 256)
|
||||
|
||||
def test_portrait_canvas_centers_aspect_locked_landscape_source(self):
|
||||
placement = compute_reframe_placement(1920, 1080, 1088, 1920, 0.5, 0.5, 0.7)
|
||||
self.assertEqual(placement.target_width, 1088)
|
||||
self.assertEqual(placement.target_height, 1920)
|
||||
self.assertLess(abs(placement.box_width / placement.box_height - 16 / 9), 0.01)
|
||||
self.assertLessEqual(abs(placement.box_x * 2 + placement.box_width - placement.target_width), 1)
|
||||
self.assertLessEqual(abs(placement.box_y * 2 + placement.box_height - placement.target_height), 1)
|
||||
|
||||
def test_canvas_and_masks_preserve_source_region(self):
|
||||
frames = torch.ones((2, 8, 16, 3), dtype=torch.float32)
|
||||
placement = compute_reframe_placement(16, 8, 256, 256, 0.5, 0.5, 0.5)
|
||||
canvas = prepare_reframe_canvas(frames, placement)
|
||||
outpaint, source = build_reframe_masks(placement, feather=0)
|
||||
|
||||
self.assertEqual(tuple(canvas.shape), (2, 256, 256, 3))
|
||||
self.assertEqual(tuple(outpaint.shape), (1, 256, 256))
|
||||
self.assertEqual(tuple(source.shape), (1, 256, 256))
|
||||
self.assertTrue(torch.allclose(outpaint + source, torch.ones_like(source)))
|
||||
self.assertEqual(canvas[:, placement.box_y, placement.box_x].mean().item(), 1.0)
|
||||
self.assertEqual(canvas[:, 0, 0].sum().item(), 0.0)
|
||||
|
||||
def test_feather_ramps_only_outside_source_box(self):
|
||||
placement = compute_reframe_placement(16, 9, 256, 256, 0.5, 0.5, 0.5)
|
||||
outpaint, source = build_reframe_masks(placement, feather=16)
|
||||
inside_y = placement.box_y + placement.box_height // 2
|
||||
inside_x = placement.box_x + placement.box_width // 2
|
||||
self.assertEqual(outpaint[0, inside_y, inside_x].item(), 0.0)
|
||||
self.assertEqual(source[0, inside_y, inside_x].item(), 1.0)
|
||||
self.assertEqual(outpaint[0, 0, 0].item(), 1.0)
|
||||
|
||||
def test_zoom_in_can_extend_source_beyond_target(self):
|
||||
placement = compute_reframe_placement(1920, 1080, 1088, 1920, 0.5, 0.5, 2.0)
|
||||
|
||||
self.assertEqual(placement.box_width, 2176)
|
||||
self.assertEqual(placement.box_height, 1224)
|
||||
self.assertEqual(placement.box_x, -544)
|
||||
self.assertEqual(placement.box_y, 348)
|
||||
self.assertEqual(placement.source_scale, 2.0)
|
||||
|
||||
def test_zoomed_source_is_cropped_safely_to_target_canvas(self):
|
||||
frames = torch.ones((2, 8, 16, 3), dtype=torch.float32)
|
||||
placement = compute_reframe_placement(16, 8, 256, 256, 0.5, 0.5, 4.0)
|
||||
canvas = prepare_reframe_canvas(frames, placement)
|
||||
outpaint, source = build_reframe_masks(placement, feather=32)
|
||||
|
||||
self.assertEqual(tuple(canvas.shape), (2, 256, 256, 3))
|
||||
self.assertTrue(torch.allclose(canvas, torch.ones_like(canvas)))
|
||||
self.assertTrue(torch.allclose(source, torch.ones_like(source)))
|
||||
self.assertTrue(torch.allclose(outpaint, torch.zeros_like(outpaint)))
|
||||
|
||||
def test_target_fit_returns_exact_dimensions(self):
|
||||
images = torch.rand((2, 96, 160, 3), dtype=torch.float32)
|
||||
|
||||
fitted, = LTXReframeTargetFit().fit(images, target_width=128, target_height=128)
|
||||
|
||||
self.assertEqual(tuple(fitted.shape), (2, 128, 128, 3))
|
||||
self.assertGreaterEqual(fitted.min().item(), 0.0)
|
||||
self.assertLessEqual(fitted.max().item(), 1.0)
|
||||
|
||||
def test_rectilinear_correction_is_temporally_deterministic(self):
|
||||
horizontal = torch.linspace(0.0, 1.0, 65).view(1, 1, 65, 1)
|
||||
frame = horizontal.expand(1, 33, 65, 3)
|
||||
images = frame.expand(2, -1, -1, -1).clone()
|
||||
|
||||
corrected, correction_mask = rectilinear_correct_images(
|
||||
images,
|
||||
correction_strength=0.1,
|
||||
center_x=0.5,
|
||||
center_y=0.5,
|
||||
protect_source=0.0,
|
||||
protection_feather=0,
|
||||
interpolation="bilinear",
|
||||
chunk_size=1,
|
||||
)
|
||||
|
||||
self.assertEqual(tuple(corrected.shape), tuple(images.shape))
|
||||
self.assertEqual(tuple(correction_mask.shape), (1, 33, 65))
|
||||
self.assertTrue(torch.allclose(corrected[0], corrected[1]))
|
||||
self.assertGreater(corrected[0, 16, 0, 0].item(), images[0, 16, 0, 0].item())
|
||||
self.assertLess(corrected[0, 16, -1, 0].item(), images[0, 16, -1, 0].item())
|
||||
self.assertAlmostEqual(
|
||||
corrected[0, 16, 32, 0].item(),
|
||||
images[0, 16, 32, 0].item(),
|
||||
places=5,
|
||||
)
|
||||
|
||||
def test_rectilinear_source_mask_can_fully_protect_original_pixels(self):
|
||||
images = torch.rand((2, 24, 40, 3), dtype=torch.float32)
|
||||
source_mask = torch.ones((1, 24, 40), dtype=torch.float32)
|
||||
|
||||
corrected, correction_mask = rectilinear_correct_images(
|
||||
images,
|
||||
correction_strength=0.15,
|
||||
center_x=0.5,
|
||||
center_y=0.5,
|
||||
protect_source=1.0,
|
||||
protection_feather=0,
|
||||
interpolation="bicubic",
|
||||
chunk_size=2,
|
||||
source_mask=source_mask,
|
||||
)
|
||||
|
||||
self.assertTrue(torch.allclose(corrected, images))
|
||||
self.assertEqual(torch.count_nonzero(correction_mask).item(), 0)
|
||||
|
||||
def test_rectilinear_node_bypass_is_pixel_exact(self):
|
||||
images = torch.rand((3, 16, 20, 3), dtype=torch.float32)
|
||||
|
||||
bypassed, correction_mask = LTXReframeRectilinearCorrection().correct(
|
||||
images,
|
||||
enabled=False,
|
||||
correction_strength=0.05,
|
||||
center_x=0.5,
|
||||
center_y=0.5,
|
||||
protect_source=1.0,
|
||||
protection_feather=96,
|
||||
interpolation="bicubic",
|
||||
chunk_size=8,
|
||||
)
|
||||
|
||||
self.assertIs(bypassed, images)
|
||||
self.assertEqual(tuple(correction_mask.shape), (1, 16, 20))
|
||||
self.assertEqual(torch.count_nonzero(correction_mask).item(), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
let registeredExtension = null;
|
||||
globalThis.window = {
|
||||
comfyAPI: {
|
||||
app: {
|
||||
app: {
|
||||
registerExtension(extension) {
|
||||
registeredExtension = extension;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await import(`../web/js/model_loader.js?test=${Date.now()}`);
|
||||
assert.ok(registeredExtension, "the model-loader frontend extension should register");
|
||||
|
||||
class UnrelatedNode {}
|
||||
await registeredExtension.beforeRegisterNodeDef(UnrelatedNode, { name: "OtherNode" });
|
||||
assert.equal(
|
||||
UnrelatedNode.prototype.onNodeCreated,
|
||||
undefined,
|
||||
"unrelated node types should not be patched",
|
||||
);
|
||||
|
||||
let createdCalls = 0;
|
||||
let configuredCalls = 0;
|
||||
|
||||
class FakeLoaderNode {}
|
||||
FakeLoaderNode.prototype.onNodeCreated = function () {
|
||||
createdCalls += 1;
|
||||
};
|
||||
FakeLoaderNode.prototype.onConfigure = function () {
|
||||
configuredCalls += 1;
|
||||
};
|
||||
|
||||
await registeredExtension.beforeRegisterNodeDef(FakeLoaderNode, {
|
||||
name: "DiffusionGemmaModelLoader",
|
||||
});
|
||||
|
||||
const hiddenWidgetNames = new Set([
|
||||
"backend",
|
||||
"dtype",
|
||||
"quantization",
|
||||
"local_files_only",
|
||||
"unload_policy",
|
||||
"max_memory_gb",
|
||||
]);
|
||||
const node = new FakeLoaderNode();
|
||||
node.widgets = [
|
||||
{ name: "model_path", value: "models/diffusiongemma" },
|
||||
{ name: "temperature", value: "invalid" },
|
||||
...[...hiddenWidgetNames].map((name) => ({ name, value: "advanced" })),
|
||||
];
|
||||
node.size = [520, 640];
|
||||
node.computeSize = () => [360, 240];
|
||||
node.setSize = (size) => {
|
||||
node.size = [...size];
|
||||
};
|
||||
|
||||
node.onNodeCreated();
|
||||
assert.equal(createdCalls, 1, "the original creation hook should still run");
|
||||
assert.equal(
|
||||
node.widgets.find((entry) => entry.name === "temperature").value,
|
||||
0.45,
|
||||
"invalid saved temperatures should migrate to the stable default",
|
||||
);
|
||||
assert.deepEqual(node.size, [520, 240], "hiding widgets should preserve the user's width");
|
||||
|
||||
for (const entry of node.widgets) {
|
||||
if (hiddenWidgetNames.has(entry.name)) {
|
||||
assert.equal(entry.hidden, true, `${entry.name} should be hidden`);
|
||||
assert.equal(entry.__dgHidden, true, `${entry.name} should only be hidden once`);
|
||||
assert.deepEqual(entry.computeSize(), [0, -4]);
|
||||
} else {
|
||||
assert.notEqual(entry.hidden, true, `${entry.name} should remain visible`);
|
||||
}
|
||||
}
|
||||
|
||||
node.widgets.find((entry) => entry.name === "temperature").value = 0.7;
|
||||
node.size = [610, 700];
|
||||
node.computeSize = () => [380, 260];
|
||||
node.onConfigure({});
|
||||
assert.equal(configuredCalls, 1, "the original configuration hook should still run");
|
||||
assert.equal(node.widgets.find((entry) => entry.name === "temperature").value, 0.7);
|
||||
assert.deepEqual(node.size, [610, 260], "configuration should keep the saved width");
|
||||
|
||||
console.log("DiffusionGemma model-loader UI checks passed");
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class PackageMetadataTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.metadata = tomllib.loads(
|
||||
(ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
def test_registry_identity_and_release_version_are_current(self) -> None:
|
||||
self.assertEqual(self.metadata["project"]["version"], "0.2.0")
|
||||
self.assertEqual(self.metadata["tool"]["comfy"]["PublisherId"], "exportanything")
|
||||
|
||||
def test_registry_compatibility_starts_at_the_tested_baseline(self) -> None:
|
||||
self.assertEqual(
|
||||
self.metadata["tool"]["comfy"]["requires-comfyui"],
|
||||
">=0.30.1",
|
||||
)
|
||||
|
||||
def test_registry_archive_excludes_ci_and_large_demo_media(self) -> None:
|
||||
ignored = {
|
||||
line.strip()
|
||||
for line in (ROOT / ".comfyignore").read_text(encoding="utf-8").splitlines()
|
||||
if line.strip() and not line.lstrip().startswith("#")
|
||||
}
|
||||
self.assertTrue({".github/", "tests/", "*.mp4", "*.gif"} <= ignored)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMFY_ROOT = ROOT.parents[1]
|
||||
SCHEMA = ROOT / "schemas" / "splatstage_show_blueprint.schema.json"
|
||||
|
||||
DIRECTOR_NODE_IDS = {
|
||||
"DiffusionGemmaModelLoader",
|
||||
"DiffusionGemmaContextHub",
|
||||
"DiffusionGemmaH3ReferenceContext",
|
||||
"DiffusionGemmaTargetProfile",
|
||||
"DiffusionGemmaCoTGenerator",
|
||||
"DiffusionGemmaJSONSplitter",
|
||||
"DiffusionGemmaGenerationGate",
|
||||
}
|
||||
EXPERIMENTAL_NODE_IDS = {
|
||||
"DiffusionGemmaSplatStagePlanner",
|
||||
"DiffusionGemmaSplatStageEfficientPlanner",
|
||||
}
|
||||
|
||||
|
||||
def load_nodes_module():
|
||||
if str(COMFY_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(COMFY_ROOT))
|
||||
package_name = f"diffusiongemma_splatstage_fixture_{uuid.uuid4().hex}"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
package_name,
|
||||
ROOT / "__init__.py",
|
||||
submodule_search_locations=[str(ROOT)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("could not load DiffusionGemma Prompt Builder")
|
||||
package = importlib.util.module_from_spec(spec)
|
||||
sys.modules[package_name] = package
|
||||
spec.loader.exec_module(package)
|
||||
return sys.modules[f"{package_name}.nodes"]
|
||||
|
||||
|
||||
class SplatStagePlannerMusicTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.nodes = load_nodes_module()
|
||||
cls.schema = json.loads(SCHEMA.read_text(encoding="utf-8"))
|
||||
|
||||
def test_strict_schema_is_bundled_with_the_package(self) -> None:
|
||||
self.assertEqual(self.nodes._splatstage_blueprint_schema_path(), SCHEMA)
|
||||
|
||||
def test_prompt_requests_a_full_song_before_the_video_excerpt(self) -> None:
|
||||
prompt = self.nodes._splatstage_blueprint_prompt(
|
||||
"make a pop-music video",
|
||||
42,
|
||||
self.schema,
|
||||
duration_seconds=20.0,
|
||||
aspect_ratio="16:9",
|
||||
)
|
||||
self.assertIn("complete 90-second source song", prompt)
|
||||
self.assertIn("final video uses a 20-second excerpt", prompt)
|
||||
self.assertIn("two separately generated performer clips", prompt)
|
||||
self.assertIn("one performer object, one background object, and one FX object", prompt)
|
||||
self.assertIn("never arrays or clip-specific lists", prompt)
|
||||
self.assertIn("16 to 36 non-empty lines", prompt)
|
||||
self.assertIn("SAM3 object-category bundle", prompt)
|
||||
self.assertIn("wide-brimmed mariachi hat", prompt)
|
||||
self.assertIn("electric guitar", prompt)
|
||||
self.assertIn("positive_prompt must use affirmative visual language only", prompt)
|
||||
self.assertIn('"schema":"splatstage.show_blueprint"', prompt)
|
||||
self.assertIn('"version":1', prompt)
|
||||
self.assertIn(
|
||||
'Never put the schema document identifier "splatstage.show_blueprint@1"',
|
||||
prompt,
|
||||
)
|
||||
|
||||
def test_planner_contract_supports_ten_or_twenty_seconds(self) -> None:
|
||||
spec = self.nodes.DiffusionGemmaSplatStagePlanner.INPUT_TYPES()["required"][
|
||||
"duration_seconds"
|
||||
][1]
|
||||
self.assertEqual(spec["min"], 10.0)
|
||||
self.assertEqual(spec["max"], 20.0)
|
||||
self.assertEqual(spec["default"], 20.0)
|
||||
|
||||
def test_full_song_lyric_budget_accepts_48_lines_but_not_49(self) -> None:
|
||||
def blueprint(line_count: int) -> dict:
|
||||
return {
|
||||
"schema": "splatstage.show_blueprint",
|
||||
"version": 1,
|
||||
"project_title": "Song",
|
||||
"creative_summary": "A compact performance video.",
|
||||
"music": {
|
||||
"ace_tags": "bright pop",
|
||||
"vocal_mode": "vocal_hook",
|
||||
"lyrics": "\n".join(
|
||||
f"Original lyric line {index}" for index in range(line_count)
|
||||
),
|
||||
"bpm": 120,
|
||||
"key": "C major",
|
||||
"time_signature": "4",
|
||||
"language": "en",
|
||||
},
|
||||
"performer": {
|
||||
"label": "Singer",
|
||||
"positive_prompt": "One singer performing",
|
||||
"negative_prompt": "crowd",
|
||||
"segmentation_prompt": "the singer",
|
||||
},
|
||||
"background": {
|
||||
"positive_prompt": "Abstract lights",
|
||||
"negative_prompt": "people",
|
||||
},
|
||||
"fx": {
|
||||
"positive_prompt": "Prism sparks",
|
||||
"negative_prompt": "people, text",
|
||||
"blend_mode": "screen",
|
||||
"key_mode": "luminance",
|
||||
"opacity": 0.5,
|
||||
},
|
||||
"editing": {
|
||||
"background_density": "musical",
|
||||
"fx_density": "musical",
|
||||
"music_reactivity": 0.5,
|
||||
},
|
||||
"finish": {"style_preset": "broadcast_clean"},
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_blueprint_errors(
|
||||
blueprint(48), self.schema
|
||||
),
|
||||
[],
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"at most 48" in error
|
||||
for error in self.nodes._splatstage_blueprint_errors(
|
||||
blueprint(49), self.schema
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_segmentation_prompt_uses_separate_sam3_object_categories(self) -> None:
|
||||
valid = {
|
||||
"schema": "splatstage.show_blueprint",
|
||||
"version": 1,
|
||||
"project_title": "Mariachi",
|
||||
"creative_summary": "A cyberpunk mariachi performance.",
|
||||
"music": {
|
||||
"ace_tags": "cyberpunk mariachi",
|
||||
"vocal_mode": "instrumental",
|
||||
"lyrics": "[Instrumental]",
|
||||
"bpm": 128,
|
||||
"key": "G minor",
|
||||
"time_signature": "4",
|
||||
"language": "en",
|
||||
},
|
||||
"performer": {
|
||||
"label": "Lead mariachi",
|
||||
"positive_prompt": "One cyberpunk mariachi playing a glowing guitar",
|
||||
"negative_prompt": "crowd",
|
||||
"segmentation_prompt": (
|
||||
"cyberpunk mariachi musician, black charro suit, "
|
||||
"wide-brimmed mariachi hat, glowing electric guitar"
|
||||
),
|
||||
},
|
||||
"background": {
|
||||
"positive_prompt": "Martian landscape",
|
||||
"negative_prompt": "people",
|
||||
},
|
||||
"fx": {
|
||||
"positive_prompt": "isolated sparks",
|
||||
"negative_prompt": "people, text",
|
||||
"blend_mode": "screen",
|
||||
"key_mode": "luminance",
|
||||
"opacity": 0.5,
|
||||
},
|
||||
"editing": {
|
||||
"background_density": "musical",
|
||||
"fx_density": "musical",
|
||||
"music_reactivity": 0.5,
|
||||
},
|
||||
"finish": {"style_preset": "broadcast_clean"},
|
||||
}
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_blueprint_errors(valid, self.schema),
|
||||
[],
|
||||
)
|
||||
leaked = json.loads(json.dumps(valid))
|
||||
leaked["fx"]["positive_prompt"] = "Neon sparks, no words, text-free overlay"
|
||||
polarity_errors = self.nodes._splatstage_blueprint_errors(leaked, self.schema)
|
||||
self.assertTrue(any("affirmative visual language" in error for error in polarity_errors))
|
||||
invalid = json.loads(json.dumps(valid))
|
||||
invalid["performer"]["segmentation_prompt"] = (
|
||||
"the musician in the charro suit and guitar"
|
||||
)
|
||||
self.assertTrue(
|
||||
any(
|
||||
"separate the performer" in error
|
||||
for error in self.nodes._splatstage_blueprint_errors(
|
||||
invalid, self.schema
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def test_two_option_lane_lists_are_seed_addressed_to_one_coherent_contract(self) -> None:
|
||||
candidate = {
|
||||
"performer": [{"label": "firefighter"}, {"label": "country singer"}],
|
||||
"background": [{"name": "fire station"}, {"name": "burning skyline"}],
|
||||
"fx": [{"name": "embers"}, {"name": "smoke"}],
|
||||
}
|
||||
normalized, report = self.nodes._normalize_splatstage_lane_cardinality(
|
||||
candidate,
|
||||
selection_index=5,
|
||||
)
|
||||
self.assertEqual(normalized["performer"]["label"], "country singer")
|
||||
self.assertEqual(normalized["background"]["name"], "burning skyline")
|
||||
self.assertEqual(normalized["fx"]["name"], "smoke")
|
||||
self.assertEqual(report["performer"]["selected_index"], 1)
|
||||
self.assertIsInstance(candidate["performer"], list)
|
||||
|
||||
def test_malformed_or_oversized_lane_lists_remain_invalid(self) -> None:
|
||||
candidate = {
|
||||
"performer": [{"label": "a"}, {"label": "b"}, {"label": "c"}],
|
||||
"background": [],
|
||||
"fx": ["not an object"],
|
||||
}
|
||||
normalized, report = self.nodes._normalize_splatstage_lane_cardinality(
|
||||
candidate,
|
||||
selection_index=0,
|
||||
)
|
||||
self.assertEqual(normalized, candidate)
|
||||
self.assertEqual(report, {})
|
||||
|
||||
def test_schema_document_id_alias_is_canonicalized_only_for_matching_version(self) -> None:
|
||||
candidate = {
|
||||
"schema": "splatstage.show_blueprint@1",
|
||||
"version": 1,
|
||||
"project_title": "Alias candidate",
|
||||
}
|
||||
normalized, report = self.nodes._normalize_splatstage_blueprint_identity(
|
||||
candidate,
|
||||
self.schema,
|
||||
)
|
||||
self.assertEqual(normalized["schema"], "splatstage.show_blueprint")
|
||||
self.assertEqual(report["input_schema"], "splatstage.show_blueprint@1")
|
||||
self.assertEqual(candidate["schema"], "splatstage.show_blueprint@1")
|
||||
|
||||
wrong_version = dict(candidate, version=2)
|
||||
unchanged, report = self.nodes._normalize_splatstage_blueprint_identity(
|
||||
wrong_version,
|
||||
self.schema,
|
||||
)
|
||||
self.assertEqual(unchanged, wrong_version)
|
||||
self.assertEqual(report, {})
|
||||
|
||||
unknown_alias = dict(candidate, schema="splatstage.show_blueprint@999")
|
||||
unchanged, report = self.nodes._normalize_splatstage_blueprint_identity(
|
||||
unknown_alias,
|
||||
self.schema,
|
||||
)
|
||||
self.assertEqual(unchanged, unknown_alias)
|
||||
self.assertEqual(report, {})
|
||||
|
||||
|
||||
class EfficientSplatStagePlannerTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.nodes = load_nodes_module()
|
||||
cls.schema = json.loads(
|
||||
(ROOT / "schemas" / "splatstage_efficient_show_blueprint.schema.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def valid_blueprint() -> dict:
|
||||
return {
|
||||
"schema": "splatstage.efficient_show_blueprint",
|
||||
"version": 1,
|
||||
"project_title": "Cyber Mariachi",
|
||||
"creative_summary": "A cyberpunk mariachi performance on Mars.",
|
||||
"music": {
|
||||
"ace_tags": "cyber mariachi pop, vocal hook",
|
||||
"vocal_mode": "vocal_hook",
|
||||
"lyrics": "[Verse]\nRed dust in the light\n[Chorus]\nPlay it to the stars",
|
||||
"bpm": 124,
|
||||
"key": "C major",
|
||||
"time_signature": "4",
|
||||
"language": "en",
|
||||
},
|
||||
"performer": {
|
||||
"identity_description": "One cyberpunk mariachi guitarist in a black charro suit",
|
||||
"positive_prompt": "Full-body performance beneath magenta concert light",
|
||||
"negative_prompt": "crowd, duplicate person, text, logos",
|
||||
"segmentation_prompt": "mariachi guitarist, mariachi hat, charro suit, electric guitar",
|
||||
"variations": [
|
||||
"Low-angle strumming with a slow dolly",
|
||||
"Energetic side view with rhythmic footwork",
|
||||
"Centered hero framing with sweeping arm motion",
|
||||
],
|
||||
},
|
||||
"background": {
|
||||
"positive_prompt": "Martian neon concert architecture and red dust",
|
||||
"negative_prompt": "people, performers, crowd, text, logos",
|
||||
"variations": [
|
||||
"Wide canyon stage with drifting haze",
|
||||
"Graphic orbital city vista with slow parallax",
|
||||
],
|
||||
},
|
||||
"fx": {
|
||||
"positive_prompt": "Isolated luminous prismatic guitar-wave geometry on a pure black background",
|
||||
"negative_prompt": "people, faces, text, words, letters, logos, symbols",
|
||||
"blend_mode": "screen",
|
||||
"key_mode": "luminance",
|
||||
"opacity": 0.55,
|
||||
},
|
||||
"editing": {"density": "musical", "music_reactivity": "medium"},
|
||||
"finish": {"style_preset": "broadcast_clean"},
|
||||
}
|
||||
|
||||
def test_schema_requires_exact_candidate_counts_and_affirmative_prompts(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_efficient_blueprint_errors(value, self.schema), []
|
||||
)
|
||||
value["performer"]["variations"] = value["performer"]["variations"][:2]
|
||||
value["fx"]["positive_prompt"] = "Glowing particles, no words, text-free"
|
||||
errors = self.nodes._splatstage_efficient_blueprint_errors(value, self.schema)
|
||||
self.assertTrue(any("variations" in error for error in errors))
|
||||
self.assertTrue(any("affirmative" in error for error in errors))
|
||||
|
||||
def test_prompt_locks_full_song_short_assets_and_semantic_union(self) -> None:
|
||||
prompt = self.nodes._splatstage_efficient_blueprint_prompt(
|
||||
"make a pop-music video", 42, self.schema
|
||||
)
|
||||
self.assertIn("complete 90-second song", prompt)
|
||||
self.assertIn("three separate five-second performer videos", prompt)
|
||||
self.assertIn("canonical performer identity first", prompt)
|
||||
self.assertIn("only concrete silhouette-extending items actually present", prompt)
|
||||
self.assertIn("richly art-directed luminous motif", prompt)
|
||||
self.assertIn("limited color palette", prompt)
|
||||
self.assertIn("rhythmic energy or", prompt)
|
||||
self.assertIn("clear spatial composition", prompt)
|
||||
self.assertIn("Do not name TouchDesigner", prompt)
|
||||
self.assertIn("locked TouchDesigner-inspired", prompt)
|
||||
self.assertIn("affirmative visual language", prompt)
|
||||
|
||||
def test_fx_creative_slot_rejects_software_or_interface_language(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
value["fx"]["positive_prompt"] = (
|
||||
"TouchDesigner application screenshot with a cyan particle vortex"
|
||||
)
|
||||
errors = self.nodes._splatstage_efficient_blueprint_errors(value, self.schema)
|
||||
self.assertTrue(
|
||||
any("locked TouchDesigner-inspired" in error for error in errors)
|
||||
)
|
||||
|
||||
def test_efficient_segmentation_rejects_free_standing_generic_categories(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
value["performer"]["segmentation_prompt"] = (
|
||||
"mariachi guitarist, face and head, hair and worn headwear, "
|
||||
"costume and worn accessories, held instrument and attached props"
|
||||
)
|
||||
errors = self.nodes._splatstage_efficient_blueprint_errors(value, self.schema)
|
||||
self.assertTrue(any("identity-specific items" in error for error in errors))
|
||||
|
||||
def test_efficient_segmentation_uses_identity_and_specific_items_only(self) -> None:
|
||||
value = self.valid_blueprint()
|
||||
value["performer"]["segmentation_prompt"] = (
|
||||
"cyberpunk mariachi guitarist, face and head, hair and worn headwear, "
|
||||
"black charro suit, electric guitar"
|
||||
)
|
||||
normalized = self.nodes._normalize_splatstage_efficient_segmentation(value)
|
||||
segmentation = normalized["performer"]["segmentation_prompt"]
|
||||
self.assertTrue(
|
||||
segmentation.startswith("One cyberpunk mariachi guitarist in a black charro suit")
|
||||
)
|
||||
self.assertNotIn("face and head", segmentation)
|
||||
self.assertNotIn("hair and worn headwear", segmentation)
|
||||
self.assertIn("black charro suit", segmentation)
|
||||
self.assertIn("electric guitar", segmentation)
|
||||
self.assertLessEqual(len(segmentation.split(",")), 6)
|
||||
self.assertEqual(
|
||||
self.nodes._splatstage_efficient_blueprint_errors(normalized, self.schema),
|
||||
[],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalized["performer"]["positive_prompt"],
|
||||
value["performer"]["positive_prompt"],
|
||||
)
|
||||
|
||||
def test_efficient_planner_is_registered_with_safe_widget_contract(self) -> None:
|
||||
self.assertIs(
|
||||
self.nodes.NODE_CLASS_MAPPINGS["DiffusionGemmaSplatStageEfficientPlanner"],
|
||||
self.nodes.DiffusionGemmaSplatStageEfficientPlanner,
|
||||
)
|
||||
required = self.nodes.DiffusionGemmaSplatStageEfficientPlanner.INPUT_TYPES()[
|
||||
"required"
|
||||
]
|
||||
self.assertEqual(required["max_new_tokens"][1]["default"], 3072)
|
||||
self.assertGreaterEqual(required["max_new_tokens"][1]["min"], 1024)
|
||||
self.assertNotIn("duration_seconds", required)
|
||||
|
||||
|
||||
class ExportedNodeSurfaceTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.nodes = load_nodes_module()
|
||||
|
||||
def test_export_map_has_seven_director_and_two_experimental_nodes(self) -> None:
|
||||
expected = DIRECTOR_NODE_IDS | EXPERIMENTAL_NODE_IDS
|
||||
self.assertEqual(set(self.nodes.NODE_CLASS_MAPPINGS), expected)
|
||||
self.assertEqual(set(self.nodes.NODE_DISPLAY_NAME_MAPPINGS), expected)
|
||||
|
||||
def test_experimental_planners_are_visibly_separate(self) -> None:
|
||||
for node_id in DIRECTOR_NODE_IDS:
|
||||
self.assertEqual(
|
||||
self.nodes.NODE_CLASS_MAPPINGS[node_id].CATEGORY,
|
||||
self.nodes.CATEGORY,
|
||||
)
|
||||
for node_id in EXPERIMENTAL_NODE_IDS:
|
||||
self.assertEqual(
|
||||
self.nodes.NODE_CLASS_MAPPINGS[node_id].CATEGORY,
|
||||
self.nodes.EXPERIMENTAL_MOTION_CATEGORY,
|
||||
)
|
||||
self.assertTrue(
|
||||
self.nodes.NODE_DISPLAY_NAME_MAPPINGS[node_id].endswith("(Experimental)")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user