Skip to content

LTX-2.3 video-gen lane: NVFP4 model + LoRA studio + video bridge#20

Merged
mabry1985 merged 6 commits into
mainfrom
feature/ltx-video-gen-studio
Jul 13, 2026
Merged

LTX-2.3 video-gen lane: NVFP4 model + LoRA studio + video bridge#20
mabry1985 merged 6 commits into
mainfrom
feature/ltx-video-gen-studio

Conversation

@mabry1985

@mabry1985 mabry1985 commented Jul 13, 2026

Copy link
Copy Markdown
Member

Ships the LTX-2.3 video-generation lane stood up this session on the Blackwell node — a published NVFP4 model, a video-LoRA studio, and the OpenAI-compatible video bridge for protoDirector. All new files (3 dirs), no changes to existing code.

1. experiments/ltx2-nvfp4/ — LTX-2.3-22B → NVFP4 (LIVE on HF)

Quantizer for the LTX-2.3-22B video DiT to NVFP4 on sm120. Lightricks ships fp4 only for the 19B; the 2.3-22B is bf16-only — this fills the gap. Replicates their exact mixed-precision layer policy (fp4 transformer_blocks 1-42; keep block 0 + last 5 + gates + VAE/vocoder bf16) and stamps the _quantization_metadata header ComfyUI's loader needs.

  • Measured (960×544, reproduced 2×): 46.1→22.9 GB (2×), 2.85→1.82 s/it (1.57×), 60→37 GB peak VRAM. Distilled-decode = visually unchanged; full-decode shows mild fp4 artifacting → ship distilled.
  • Live: https://huggingface.co/protoLabsAI/LTX-2.3-22B-distilled-NVFP4 (card + LICENSE + converter). BLOG draft included.

2. experiments/ltx2-lora/ — video-LoRA studio

Make + test LoRAs on the NVFP4 base end-to-end.

  • make_video_lora.py — one command, resumable: caption → preprocess → fix-conditions (the embeds-land-next-to-clips gotcha) → config → train → wire (symlink LoRA into ComfyUI + emit a T2V workflow with LoraLoaderModelOnly on the fp4 base).
  • app.py — Gradio studio (LTX-2 venv, :7862): Train tab (clips → LoRA, streaming log) + Generate & Compare tab (pick a LoRA, generate a video inline, A/B base-vs-LoRA at matched seed, reference-image → I2V first-frame conditioning with a strength anchor).
  • Training pipeline verified on Blackwell (int8-quanto works sm120); a trained LoRA loads + measurably applies on the fp4 base; I2V + compare verified end-to-end.

3. infra/video-bridge/ — OpenAI /v1/videos over ComfyUI+LTX (protoBanana#38 piece 2)

Standalone async video-jobs bridge co-located with ComfyUI — the shape the team agreed on (litellm's native /v1/videos router shadows passthrough routes, so the job layer sits in front of the gateway). Implements the AGREED 3-URL contract (POST → job id, GET status/progress, GET /content mp4), restart-survivable job store, bytes off local disk, reuses protoBanana's ComfyUIClient (inherits the #39 nonce fix). Verified: POST→poll→mp4. Consumer = protoDirector; deploy tracked in homelab-iac#193/#194.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a Gradio interface for training, generating, and comparing LTX-2.3 video LoRAs.
    • Added an OpenAI-compatible asynchronous video generation API with job status and MP4 retrieval.
    • Added ComfyUI workflow support for text-to-video and image-conditioned generation.
    • Added NVFP4 quantization tooling and Blackwell-optimized experiment workflows.
  • Documentation

    • Added setup, usage, reproduction, performance, configuration, and licensing documentation for LoRA training and NVFP4 quantization.
  • Tests

    • Added an end-to-end smoke test for submitting video jobs, polling completion, and downloading results.

mabry1985 and others added 6 commits July 13, 2026 10:30
Quantize LTX-2.3-22B-distilled to NVFP4 mixed-precision for Blackwell/sm120.
Lightricks ships fp4 only for the 19B; 2.3-22B is bf16-only — this fills the gap.

Converter (quantize.py) streams bf16 through comfy_kitchen's own NVFP4 layout,
replicating Lightricks' 19B layer policy (fp4 transformer_blocks 1-42; keep block
0 + last 5 + gates + VAE/vocoder bf16) and stamping the _quantization_metadata
header ComfyUI's loader needs. Measured (960x544, reproduced 2x): 46.1→22.9 GB,
2.85→1.82 s/it (1.57x), ~60→~37 GB peak VRAM. Ship distilled-decode (full-decode
shows mild fp4 artifacting). Card/BLOG/LICENSE + honest numbers.

Live: https://huggingface.co/protoLabsAI/LTX-2.3-22B-distilled-NVFP4

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…8 piece 2)

Standalone video bridge co-located with ComfyUI on protolabs — the shape the team
settled on (litellm's native /v1/videos router shadows passthrough routes, so the
job layer can't live in the gateway). Implements the AGREED 3-URL contract:
POST /v1/videos, GET /v1/videos/{id}, GET /v1/videos/{id}/content.

Reuses protoBanana's ComfyUIClient (inherits the #39 cache-nonce fix); protoBanana
stays image-only. Restart-survivable JSON-backed job store (job_id→prompt_id,
status re-derived from ComfyUI /history); mp4 served off local disk; LTX knobs in
extra_body. Piece 1 = parameterized distilled-decode T2V workflow (workflows/
ltx2-t2v.json). Verified end-to-end: POST→poll→206KB mp4 in ~14s.

Stubbed/next: I2V (input_reference accepted, needs I2V template variant), fine
progress via ws, edge route in homelab-iac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…quests

The multipart branch built body from only {model, prompt} Form params, so any
input_reference (I2V) request silently dropped seconds/size/seed/negative_prompt/
extra_body. Parse the full form instead (coerce seconds/seed numeric, JSON-decode
extra_body). Verified: multipart POST now propagates all contract fields.

Reported by protoDirector review on protoBanana#38.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
make_video_lora.py runs the full chain one-command + resumable: caption →
preprocess → fix-conditions (the embeds-land-next-to-clips gotcha) → config →
train → wire (symlink LoRA into ComfyUI loras/ + emit a T2V workflow with
LoraLoaderModelOnly on the fp4 base). Each stage skips if its output exists.

Proves the "make our own LoRAs" path is now point-a-folder → get-a-LoRA.
Config/wire stages validated against the toy data; full caption/train run is
per-dataset. Runbook (README) documents the one command + the manual stages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
app.py — two-tab UI on the orchestrator: Train (clips→LoRA, streaming log) +
Generate & Compare (pick a trained LoRA, generate a video inline, or A/B
base-vs-LoRA at the same seed side-by-side via ComfyUI). Verified end-to-end:
compare() produced base + LoRA videos at matched seed. Runs in the LTX-2 venv
(gradio 6.10), UI on :7862.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generate & Compare tab now takes an optional reference image (first frame).
When provided, _build uploads it to ComfyUI, points LoadImage at it, and flips
the PrimitiveBoolean bypass (4977) off → LTXVImgToVideoConditionOnly drives I2V
first-frame conditioning; a strength slider controls the anchor. Empty = plain
T2V. Verified: I2V graph wired (bypass=False, image set) + generated a clip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@protoquinn

protoquinn Bot commented Jul 13, 2026

Copy link
Copy Markdown

👀 Quinn is reviewing — verdict (PASS / WARN / FAIL) + findings to follow.

@protoreview protoreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA panel review — PASS

code-review-structural · head cc823065d245 · formal

[review-synthesizer completed: workflow code-review-structural:report]

Overall risk is low given the clean verification pass and absence of flagged defects. There are no items to fix first, and the panel reported no disagreements. Verification confirmed the empty input array, leaving the result unchanged. A structural analysis pass was skipped for this review cycle, so minor integration edge cases may remain unexamined.

[]

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds LTX-2.3 LoRA training and generation tooling, NVFP4 quantization assets and licensing, plus an asynchronous OpenAI-compatible ComfyUI video bridge with workflow injection, job persistence, content retrieval, documentation, and smoke testing.

Changes

LTX-2.3 LoRA tooling

Layer / File(s) Summary
Resumable LoRA pipeline
experiments/ltx2-lora/make_video_lora.py, experiments/ltx2-lora/toy_smoke.yaml, experiments/ltx2-lora/README.md
Adds staged captioning, preprocessing, condition repair, configuration, training, checkpoint reuse, and ComfyUI wiring with corresponding configuration and usage documentation.
Gradio training and generation UI
experiments/ltx2-lora/app.py
Adds training subprocess control, LoRA discovery, ComfyUI prompt construction, optional reference-image and LoRA injection, generation polling, comparison callbacks, and a Gradio server on port 7862.

LTX-2.3 NVFP4 experiment

Layer / File(s) Summary
NVFP4 checkpoint conversion
experiments/ltx2-nvfp4/quantize.py
Converts selected transformer weights to NVFP4, emits scale sidecars, preserves other tensors, and writes ComfyUI quantization metadata.
Quantized model experiment records
experiments/ltx2-nvfp4/README.md, experiments/ltx2-nvfp4/BLOG.md, experiments/ltx2-nvfp4/measurements-multires.json
Documents runtime requirements, layer policy, ComfyUI usage, reproduction steps, quality observations, and measured outputs.
LTX community license
experiments/ltx2-nvfp4/LICENSE
Adds licensing, redistribution, commercial-use, restriction, disclaimer, termination, dispute-resolution, and prohibited-use terms.

ComfyUI video bridge

Layer / File(s) Summary
Workflow template and parameter injection
infra/video-bridge/workflows/ltx2-t2v.json, infra/video-bridge/inject.py
Defines the LTX-2 video workflow and injects prompts, dimensions, duration, frame rate, and seed values into its nodes.
Async job API and persistence
infra/video-bridge/bridge.py
Adds FastAPI video creation, status, content, and health endpoints with ComfyUI-backed status derivation and restart-survivable JSON job storage.
Bridge runtime and verification
infra/video-bridge/README.md, infra/video-bridge/requirements.txt, infra/video-bridge/smoke.py
Documents runtime configuration and routing, declares dependencies, and adds an end-to-end job polling and MP4 retrieval smoke test.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant VideoBridge
  participant WorkflowInjector
  participant ComfyUI
  Client->>VideoBridge: POST /v1/videos
  VideoBridge->>WorkflowInjector: build_workflow(...)
  WorkflowInjector-->>VideoBridge: injected workflow
  VideoBridge->>ComfyUI: submit workflow
  ComfyUI-->>VideoBridge: prompt_id
  VideoBridge-->>Client: queued job
  Client->>VideoBridge: GET /v1/videos/{job_id}
  VideoBridge->>ComfyUI: query history and queue
  ComfyUI-->>VideoBridge: status and output metadata
  VideoBridge-->>Client: job status
  Client->>VideoBridge: GET /v1/videos/{job_id}/content
  VideoBridge-->>Client: MP4 bytes
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the three main additions: NVFP4 model work, LoRA studio tooling, and the video bridge.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/ltx-video-gen-studio

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@protoquinn protoquinn Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Audit — PR #20 | LTX-2.3 video-gen lane: NVFP4 model + LoRA studio + video bridge

VERDICT: PASS


CI Status

  • No CI checks configured for this repo (Gap — not blocking)

Diff Review

  • 15 new files, +2216/-0 — all additions, zero changes to existing code
  • Three self-contained additions: NVFP4 quantizer, LoRA studio, video bridge
  • Code is experiment/infra tooling; hardcoded paths (/mnt/data/…, ~/dev/LTX-2/…) are appropriate for local research scripts

Observations

  • LOW: app.py:107open(path, "rb") in _upload_image has no try/except; a missing ref image will raise uncaught. Acceptable for a local Gradio studio.
  • LOW: app.py:63os.killpg in stop() could raise ProcessLookupError if the process already exited between poll() and killpg. Harmless in practice.
  • LOW: infra/video-bridge/ — job store persistence claim ("restart-survivable") is self-asserted; no unit test included. Acceptable for infra prototype.
  • GAP: Clawpatch unavailable — repo not in project registry; structural review skipped.

No HIGH or CRITICAL findings. All new code, no regressions.

— Quinn, QA Engineer

@protoquinn

protoquinn Bot commented Jul 13, 2026

Copy link
Copy Markdown

Submitted APPROVE review on #20.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@experiments/ltx2-lora/app.py`:
- Around line 22-61: Replace the module-level _proc process registry with
per-session Gradio state. Update the train and stop functions and their UI
bindings to accept and return the session-specific Popen handle, ensuring each
session’s Stop action targets only its own training process and no shared
process state remains.

In `@experiments/ltx2-lora/make_video_lora.py`:
- Around line 122-137: In make_video_lora.py lines 122-137 and app.py lines
95-103, update the workflow-building logic in _build and its corresponding
generator to select an unused node ID before inserting the LoraLoaderModelOnly
node, and use that ID when rewiring guider inputs instead of hardcoded "9001";
also validate the CheckpointLoaderSimple lookup in both sites and raise a clear
error when no checkpoint node is found.
- Around line 63-80: Update stage_fix_conditions to locate each embed using the
latent’s mirrored relative path under clips_dir rather than basename-only rglob
matching. Remove the ineffective parent filter, verify the resolved candidate
corresponds to the expected relative path, and warn or fail without copying when
the mirrored embed is missing or ambiguous so latents cannot be paired with
another clip’s embedding.

In `@experiments/ltx2-nvfp4/LICENSE`:
- Around line 85-86: Add the upstream licensing URL or another stable contact
immediately after the commercial-use requirement in the LICENSE text, preserving
the existing license wording and making the required Licensor contact
actionable.
- Around line 14-17: Restore the `Control` definition in the license text to
require ownership of more than fifty percent (50%), rather than fifty percent or
more. Preserve the remaining ownership and management-control language
unchanged.

In `@experiments/ltx2-nvfp4/measurements-multires.json`:
- Around line 1-57: Align the documented performance claims with verified
measurement data: in experiments/ltx2-nvfp4/measurements-multires.json lines
1-57, add the missing 960×544 records or revise the documentation to use
existing resolutions, populate best_s_per_it with actual timings, add the
claimed duplicate runs, and verify or correct fp4_dog’s anomalous VRAM value.
Update experiments/ltx2-nvfp4/BLOG.md lines 10-18 and
experiments/ltx2-nvfp4/README.md lines 33-38 to match the resulting records,
including correcting the unsupported “fp4 1280×704 ≈ 3.85 s/it” claim.

In `@experiments/ltx2-nvfp4/quantize.py`:
- Line 50: Replace the assert in the CUDA availability check with an explicit
RuntimeError when torch.cuda.is_available() is false, preserving the existing
error message so execution stops clearly before NVFP4.quantize runs.
- Line 83: Update the quant_source assignment to use os.path.basename on
args.src instead of manually splitting on "/"; ensure the os module is imported
and preserve the resulting filename value.

In `@infra/video-bridge/bridge.py`:
- Around line 50-51: Update _load_jobs so its broad exception handler logs the
caught error before returning {}, using the module’s existing logging mechanism
and preserving the current fallback behavior; do not silently discard job-store
load failures.
- Around line 200-208: Validate the ComfyUI metadata before reading the local
file in the `/content` handling flow: resolve the path built from `OUTPUT_DIR`,
`out["subfolder"]`, and `out["filename"]`, then ensure it remains within the
resolved `OUTPUT_DIR`; reject or bypass unsafe traversal paths rather than
serving them. Keep the existing ComfyUI HTTP fallback only for valid paths.

In `@infra/video-bridge/inject.py`:
- Line 19: Align the video bridge’s FPS handling with the workflow template:
change DEFAULT_FPS from 30 to 24, add the N_FPS identifier for node 4978, and
set that node’s inputs.value from the resolved fps in the injection flow so
extra_body overrides reach rendering. Update the README’s documented default
from 30 to 24.
- Around line 34-38: Update parse_size to validate the size format before
unpacking or converting values: require exactly one “x”, ensure both dimensions
are valid positive integers, and raise a clear input-validation error for
malformed or zero-sized values while preserving the existing default for missing
size.

In `@infra/video-bridge/README.md`:
- Around line 46-47: Update the fps default in the README’s seconds-to-frames
documentation from 30 to 24 after correcting DEFAULT_FPS in inject.py, keeping
the surrounding LTX 8n+1 mapping guidance unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9982ce7e-dc98-4e93-becc-e216fdfb3fa6

📥 Commits

Reviewing files that changed from the base of the PR and between f5b11f8 and cc82306.

📒 Files selected for processing (15)
  • experiments/ltx2-lora/README.md
  • experiments/ltx2-lora/app.py
  • experiments/ltx2-lora/make_video_lora.py
  • experiments/ltx2-lora/toy_smoke.yaml
  • experiments/ltx2-nvfp4/BLOG.md
  • experiments/ltx2-nvfp4/LICENSE
  • experiments/ltx2-nvfp4/README.md
  • experiments/ltx2-nvfp4/measurements-multires.json
  • experiments/ltx2-nvfp4/quantize.py
  • infra/video-bridge/README.md
  • infra/video-bridge/bridge.py
  • infra/video-bridge/inject.py
  • infra/video-bridge/requirements.txt
  • infra/video-bridge/smoke.py
  • infra/video-bridge/workflows/ltx2-t2v.json

Comment on lines +22 to +61
_proc = {"p": None}

# ------------------------------- TRAIN -------------------------------
def train(mode, uploads, folder, style, rank, steps, bucket, with_audio, auto_caption, gpu):
if not style or not style.strip():
yield "❌ provide a style / LoRA name"; return
style = style.strip().replace(" ", "_")
work = Path(f"/mnt/data/video-lora/{style}")
if mode == "Upload clips":
if not uploads:
yield "❌ upload at least one clip"; return
clips = work / "raw"; clips.mkdir(parents=True, exist_ok=True)
for f in uploads:
shutil.copy2(f, clips / Path(f).name)
clips_dir = str(clips)
else:
if not folder or not os.path.isdir(folder):
yield f"❌ not a folder on the box: {folder}"; return
clips_dir = folder
cmd = [PY, str(ORCH), clips_dir, style, "--rank", str(int(rank)), "--steps", str(int(steps)),
"--bucket", bucket, "--gpu", str(int(gpu))]
if with_audio: cmd.append("--with-audio")
if not auto_caption: cmd.append("--no-caption")
log = "$ " + " ".join(cmd) + "\n\n"; yield log
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, preexec_fn=os.setsid)
_proc["p"] = p
for line in p.stdout:
log += line; yield log
p.wait(); _proc["p"] = None
log += (f"\n\n✅ DONE — '{style}' LoRA trained + wired. Switch to **Generate & Compare**, hit "
f"↻ Refresh, pick `{style}.safetensors`." if p.returncode == 0
else f"\n\n❌ exited {p.returncode} — see log above.")
yield log

def stop():
p = _proc.get("p")
if p and p.poll() is None:
os.killpg(os.getpgid(p.pid), signal.SIGTERM); return "🛑 stop sent."
return "nothing running."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Global _proc shared across all sessions races when multiple users/tabs use Train concurrently.

_proc = {"p": None} is module-level state shared by every Gradio session. If two users (or two browser tabs) start a training run, the second train() call overwrites _proc["p"], and stop() will only ever be able to target the most-recently-started process — a user's "Stop" click can silently do nothing or (worse) terminate someone else's job. Given server_name="0.0.0.0" this isn't a purely local, single-operator surface.

Use per-session state (e.g. gr.State holding the Popen handle, threaded through train/stop) instead of a module-level dict.

🧰 Tools
🪛 ast-grep (0.44.1)

[error] 45-46: Command coming from incoming request
Context: subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, preexec_fn=os.setsid)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[error] 45-46: Use of unsanitized data to create processes
Context: subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, preexec_fn=os.setsid)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(os-system-unsanitized-data)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/ltx2-lora/app.py` around lines 22 - 61, Replace the module-level
_proc process registry with per-session Gradio state. Update the train and stop
functions and their UI bindings to accept and return the session-specific Popen
handle, ensuring each session’s Stop action targets only its own training
process and no shared process state remains.

Comment on lines +63 to +80
for lat in latents:
rel = lat.relative_to(pre / "latents") # e.g. clips/foo.pt
dst = cond / rel
if dst.exists():
continue
# find the embed: <clips_dir>/**/<stem>.pt (saved next to source video)
matches = list(Path(clips_dir).rglob(lat.name))
embed = next((m for m in matches if m.parent != (pre / "latents" / rel.parent)), None)
if embed is None:
print(f"[fix] WARN no embed found for {rel} (looked for {lat.name} under {clips_dir})")
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(embed, dst); made += 1
print(f"[fix] conditions/: {made} embeds relocated, {len(latents)} latents total")
n_cond = len(list(cond.rglob('*.pt'))) if cond.exists() else 0
if n_cond < len(latents):
sys.exit(f"[fix] conditions ({n_cond}) < latents ({len(latents)}) — training will fail to pair. "
"Check that captions produced embeds for every clip.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Basename-only embed matching can silently pair the wrong caption/latents.

stage_fix_conditions locates the text-embed for each latent via Path(clips_dir).rglob(lat.name) (basename only) and the m.parent != (pre / "latents" / rel.parent) filter is effectively always true (matches come from clips_dir, never from pre), so it doesn't disambiguate anything. If two clips in different subfolders share the same filename, next(...) silently picks an arbitrary match — pairing the wrong caption/embedding with a clip's latents, with no warning, no error, and a training-data integrity issue that later surfaces only as poor model quality.

🐛 Prefer matching by mirrored relative path over basename search
-        # find the embed: <clips_dir>/**/<stem>.pt (saved next to source video)
-        matches = list(Path(clips_dir).rglob(lat.name))
-        embed = next((m for m in matches if m.parent != (pre / "latents" / rel.parent)), None)
+        # prefer the embed at the mirrored relative path; fall back to a basename
+        # search only if the direct path doesn't exist, and fail loudly on ambiguity
+        direct = Path(clips_dir) / rel
+        if direct.exists():
+            embed = direct
+        else:
+            matches = list(Path(clips_dir).rglob(lat.name))
+            if len(matches) > 1:
+                print(f"[fix] WARN ambiguous matches for {lat.name}: {matches} — skipping")
+                embed = None
+            else:
+                embed = matches[0] if matches else None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for lat in latents:
rel = lat.relative_to(pre / "latents") # e.g. clips/foo.pt
dst = cond / rel
if dst.exists():
continue
# find the embed: <clips_dir>/**/<stem>.pt (saved next to source video)
matches = list(Path(clips_dir).rglob(lat.name))
embed = next((m for m in matches if m.parent != (pre / "latents" / rel.parent)), None)
if embed is None:
print(f"[fix] WARN no embed found for {rel} (looked for {lat.name} under {clips_dir})")
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(embed, dst); made += 1
print(f"[fix] conditions/: {made} embeds relocated, {len(latents)} latents total")
n_cond = len(list(cond.rglob('*.pt'))) if cond.exists() else 0
if n_cond < len(latents):
sys.exit(f"[fix] conditions ({n_cond}) < latents ({len(latents)}) — training will fail to pair. "
"Check that captions produced embeds for every clip.")
for lat in latents:
rel = lat.relative_to(pre / "latents") # e.g. clips/foo.pt
dst = cond / rel
if dst.exists():
continue
# prefer the embed at the mirrored relative path; fall back to a basename
# search only if the direct path doesn't exist, and fail loudly on ambiguity
direct = Path(clips_dir) / rel
if direct.exists():
embed = direct
else:
matches = list(Path(clips_dir).rglob(lat.name))
if len(matches) > 1:
print(f"[fix] WARN ambiguous matches for {lat.name}: {matches} — skipping")
embed = None
else:
embed = matches[0] if matches else None
if embed is None:
print(f"[fix] WARN no embed found for {rel} (looked for {lat.name} under {clips_dir})")
continue
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(embed, dst); made += 1
print(f"[fix] conditions/: {made} embeds relocated, {len(latents)} latents total")
n_cond = len(list(cond.rglob('*.pt'))) if cond.exists() else 0
if n_cond < len(latents):
sys.exit(f"[fix] conditions ({n_cond}) < latents ({len(latents)}) — training will fail to pair. "
"Check that captions produced embeds for every clip.")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/ltx2-lora/make_video_lora.py` around lines 63 - 80, Update
stage_fix_conditions to locate each embed using the latent’s mirrored relative
path under clips_dir rather than basename-only rglob matching. Remove the
ineffective parent filter, verify the resolved candidate corresponds to the
expected relative path, and warn or fail without copying when the mirrored embed
is missing or ambiguous so latents cannot be paired with another clip’s
embedding.

Comment on lines +122 to +137
if not os.path.exists(BASE_API):
print(f"[wire] {BASE_API} not found — skip workflow gen (run one fp4 T2V in the UI first to seed it). "
f"Manually: add LoraLoaderModelOnly(lora_name={style}.safetensors) between the checkpoint MODEL and the guider.")
return
api = json.load(open(BASE_API)); api.pop("4823", None) # distilled-only
# find the guider that consumes the checkpoint MODEL (slot 0) on the distilled path
ckpt_id = next((n for n, v in api.items() if v["class_type"] == "CheckpointLoaderSimple"), None)
guiders = [(n, inp) for n, v in api.items() for inp, val in v["inputs"].items()
if isinstance(val, list) and val[0] == ckpt_id and val[1] == 0 and "Guider" in v["class_type"]]
api["9001"] = {"class_type": "LoraLoaderModelOnly",
"inputs": {"model": [ckpt_id, 0], "lora_name": f"{style}.safetensors", "strength_model": 1.0}}
for gid, inp in guiders:
api[gid]["inputs"][inp] = ["9001", 0]
out = COMFY / "user/default/workflows" / f"LTX-2.3_T2V_{style}_LoRA.json"
json.dump(api, open(out, "w"), indent=1)
print(f"[wire] wrote workflow (API) {out} — load via 'Open' or POST to /prompt")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Hardcoded ComfyUI node id "9001" can silently overwrite an existing node in base_api.json. Both sites inject a new LoraLoaderModelOnly node under the literal key "9001" without checking whether that id is already used in the loaded graph — if it is, the assignment silently clobbers the existing node and corrupts the generated/executed workflow with no error surfaced.

  • experiments/ltx2-lora/make_video_lora.py#L122-L137: before api["9001"] = {...}, compute an id guaranteed unused, e.g. new_id = next(str(i) for i in range(9001, 99999) if str(i) not in api), and use new_id in place of the literal "9001".
  • experiments/ltx2-lora/app.py#L95-L103: apply the same unused-id computation before api["9001"] = {...} in _build.

Both sites also assume ckpt_id/ckpt (the CheckpointLoaderSimple lookup) is found; make_video_lora.py defaults to None on miss (silently wiring "model": [None, 0]), while app.py's next(...) has no default and raises StopIteration if missing (caught by the caller's except Exception, but with an unhelpful message). Consider raising a clear error in both places when the checkpoint node isn't found.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 125-125: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(BASE_API)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)


[warning] 135-135: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(out, "w")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

📍 Affects 2 files
  • experiments/ltx2-lora/make_video_lora.py#L122-L137 (this comment)
  • experiments/ltx2-lora/app.py#L95-L103
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/ltx2-lora/make_video_lora.py` around lines 122 - 137, In
make_video_lora.py lines 122-137 and app.py lines 95-103, update the
workflow-building logic in _build and its corresponding generator to select an
unused node ID before inserting the LoraLoaderModelOnly node, and use that ID
when rewiring guider inputs instead of hardcoded "9001"; also validate the
CheckpointLoaderSimple lookup in both sites and raise a clear error when no
checkpoint node is found.

Comment on lines +14 to +17
"Control" means the direct or indirect ownership of fifty percent
(50%) or more of the voting securities or other ownership
interests, or the power to direct the management and policies of
such Entity through voting rights, contract, or otherwise.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the local LICENSE text around the cited lines
sed -n '1,40p' experiments/ltx2-nvfp4/LICENSE

# Look for the exact Control definition in the repository
rg -n 'Control|fifty percent|more than fifty percent|50%' experiments/ltx2-nvfp4/LICENSE

# Fetch the upstream license text for comparison if network access is available
python3 - <<'PY'
import urllib.request, ssl
url = "https://raw.githubusercontent.com/Lightricks/LTX-2/main/LICENSE"
ctx = ssl.create_default_context()
with urllib.request.urlopen(url, context=ctx, timeout=20) as r:
    text = r.read().decode("utf-8", errors="replace")
for i, line in enumerate(text.splitlines(), 1):
    if "Control" in line or "fifty percent" in line or "50%" in line:
        print(f"{i}: {line}")
PY

Repository: protoLabsAI/protoLab

Length of output: 1963


🌐 Web query:

Lightricks LTX-2 LICENSE Control definition "fifty percent" "more than fifty percent"

💡 Result:

In the Lightricks LTX-2 Community License Agreement, the term Control is defined as the direct or indirect ownership of more than fifty percent (50%) of the voting securities or other ownership interests, or the power to direct the management and policies of such entity through voting rights, contract, or otherwise [1][2]. While some secondary sources or specific document variants may inconsistently reference fifty percent or more [3][4], the primary license definition utilized in the core LTX-2 documentation explicitly specifies more than fifty percent (50%) [5][1][2][6].

Citations:


Restore the upstream Control threshold.
Control should stay “more than fifty percent (50%)”; changing it to “50% or more” expands affiliate aggregation and Commercial Entity scope to exactly 50%-owned entities.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/ltx2-nvfp4/LICENSE` around lines 14 - 17, Restore the `Control`
definition in the license text to require ownership of more than fifty percent
(50%), rather than fifty percent or more. Preserve the remaining ownership and
management-control language unchanged.

Comment on lines +85 to +86
Commercial Entities interested in such a commercial license are
required to contact Licensor. Any commercial use of LTX-2 or

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Provide an actionable commercial-licensing contact.

Commercial Entities are required to contact Licensor, but this standalone license provides no address or URL. Add a stable licensing contact so the stated commercial-use path is actionable. The upstream license includes a licensing link. (github.com)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@experiments/ltx2-nvfp4/LICENSE` around lines 85 - 86, Add the upstream
licensing URL or another stable contact immediately after the commercial-use
requirement in the LICENSE text, preserving the existing license wording and
making the required Licensor contact actionable.

Comment on lines +50 to +51
except Exception:
return {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

_load_jobs silently swallows all exceptions, risking undetected job-store corruption.

If JOB_STORE contains invalid JSON, the except Exception returns {} and the next _put overwrites the file with only the new job — all prior job mappings are lost silently. At minimum, log the error so an operator can investigate.

🛡️ Proposed fix
 def _load_jobs() -> dict[str, Any]:
     if JOB_STORE.exists():
         try:
             return json.loads(JOB_STORE.read_text())
-        except Exception:
+        except Exception as e:
+            print(f"WARNING: job store corrupted, starting fresh: {e}", file=sys.stderr)
             return {}
     return {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except Exception:
return {}
except Exception as e:
print(f"WARNING: job store corrupted, starting fresh: {e}", file=sys.stderr)
return {}
🧰 Tools
🪛 Ruff (0.15.20)

[warning] 50-50: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/video-bridge/bridge.py` around lines 50 - 51, Update _load_jobs so its
broad exception handler logs the caught error before returning {}, using the
module’s existing logging mechanism and preserving the current fallback
behavior; do not silently discard job-store load failures.

Comment on lines +200 to +208
path = OUTPUT_DIR / out["subfolder"] / out["filename"]
if path.exists():
data = path.read_bytes()
else:
r = await _client.http.get(f"{COMFY_URL}/view",
params={"filename": out["filename"],
"subfolder": out["subfolder"], "type": out["type"]})
r.raise_for_status()
data = r.content

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Path traversal in /content endpoint via unsanitized ComfyUI output metadata.

out["subfolder"] and out["filename"] come from ComfyUI's /history response and are used directly in path construction. A compromised or misconfigured ComfyUI could return ../../etc/passwd as a filename, causing the bridge to serve arbitrary files. Resolve the path and verify it stays within OUTPUT_DIR.

🔒 Proposed fix
     # read off local disk (co-located); fall back to ComfyUI /view
     path = (OUTPUT_DIR / out["subfolder"] / out["filename"]).resolve()
-    if path.exists():
+    if not str(path).startswith(str(OUTPUT_DIR.resolve())):
+        raise HTTPException(403, "output path outside allowed directory")
+    if path.exists():
         data = path.read_bytes()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
path = OUTPUT_DIR / out["subfolder"] / out["filename"]
if path.exists():
data = path.read_bytes()
else:
r = await _client.http.get(f"{COMFY_URL}/view",
params={"filename": out["filename"],
"subfolder": out["subfolder"], "type": out["type"]})
r.raise_for_status()
data = r.content
path = (OUTPUT_DIR / out["subfolder"] / out["filename"]).resolve()
if not str(path).startswith(str(OUTPUT_DIR.resolve())):
raise HTTPException(403, "output path outside allowed directory")
if path.exists():
data = path.read_bytes()
else:
r = await _client.http.get(f"{COMFY_URL}/view",
params={"filename": out["filename"],
"subfolder": out["subfolder"], "type": out["type"]})
r.raise_for_status()
data = r.content
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/video-bridge/bridge.py` around lines 200 - 208, Validate the ComfyUI
metadata before reading the local file in the `/content` handling flow: resolve
the path built from `OUTPUT_DIR`, `out["subfolder"]`, and `out["filename"]`,
then ensure it remains within the resolved `OUTPUT_DIR`; reject or bypass unsafe
traversal paths rather than serving them. Keep the existing ComfyUI HTTP
fallback only for valid paths.


# node ids in ltx2-t2v.json (ComfyUI API format)
N_POS, N_NEG, N_LATENT, N_FRAMES, N_NOISE = "2483", "2612", "3059", "4979", "4832"
DEFAULT_FPS = 30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

FPS default mismatch: DEFAULT_FPS=30 doesn't match the workflow template's 24.0 fps, and fps is never injected into the workflow. Frame counts are calculated at 30 fps but the video renders at 24 fps, causing all default-duration requests to produce clips ~19–28% longer than requested. The README documents "default 30", perpetuating the mismatch.

  • infra/video-bridge/inject.py#L19: Change DEFAULT_FPS from 30 to 24 to match the template's node 4978 value.
  • infra/video-bridge/inject.py#L52: Add N_FPS = "4978" and inject wf[N_FPS]["inputs"]["value"] = float(fps) so custom fps values from extra_body propagate to the actual render.
  • infra/video-bridge/README.md#L46-47: Update "default 30" to "default 24" after the code fix.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/video-bridge/inject.py` at line 19, Align the video bridge’s FPS
handling with the workflow template: change DEFAULT_FPS from 30 to 24, add the
N_FPS identifier for node 4978, and set that node’s inputs.value from the
resolved fps in the injection flow so extra_body overrides reach rendering.
Update the README’s documented default from 30 to 24.

Comment on lines +34 to +38
def parse_size(size: str | None) -> tuple[int, int]:
if not size:
return 1280, 704
w, h = size.lower().split("x")
return int(w), int(h)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

parse_size lacks input validation.

Malformed size strings ("abc", "1024x768x480", "0x0") produce unhelpful ValueError or unpack tracebacks that surface as 500s in the bridge. Add basic validation.

🛡️ Proposed fix
 def parse_size(size: str | None) -> tuple[int, int]:
     if not size:
         return 1280, 704
-    w, h = size.lower().split("x")
-    return int(w), int(h)
+    try:
+        w, h = size.lower().split("x")
+        w, h = int(w), int(h)
+    except (ValueError, AttributeError):
+        raise ValueError(f"size must be WxH, got {size!r}")
+    if w <= 0 or h <= 0:
+        raise ValueError(f"size dimensions must be positive, got {w}x{h}")
+    return w, h
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def parse_size(size: str | None) -> tuple[int, int]:
if not size:
return 1280, 704
w, h = size.lower().split("x")
return int(w), int(h)
def parse_size(size: str | None) -> tuple[int, int]:
if not size:
return 1280, 704
try:
w, h = size.lower().split("x")
w, h = int(w), int(h)
except (ValueError, AttributeError):
raise ValueError(f"size must be WxH, got {size!r}")
if w <= 0 or h <= 0:
raise ValueError(f"size dimensions must be positive, got {w}x{h}")
return w, h
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/video-bridge/inject.py` around lines 34 - 38, Update parse_size to
validate the size format before unpacking or converting values: require exactly
one “x”, ensure both dimensions are valid positive integers, and raise a clear
input-validation error for malformed or zero-sized values while preserving the
existing default for missing size.

Comment on lines +46 to +47
- **`seconds` → frames** snaps to LTX's `8n+1` at `fps` (default 30). Verify the mapping matches
intended clip length on real requests.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Update fps default documentation after fixing the inject.py mismatch.

Line 46 documents "default 30" which matches the current DEFAULT_FPS but not the template's actual 24.0 fps. Once DEFAULT_FPS is corrected to 24 (see inject.py review), update this line accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@infra/video-bridge/README.md` around lines 46 - 47, Update the fps default in
the README’s seconds-to-frames documentation from 30 to 24 after correcting
DEFAULT_FPS in inject.py, keeping the surrounding LTX 8n+1 mapping guidance
unchanged.

@mabry1985
mabry1985 merged commit 5e4c57b into main Jul 13, 2026
1 check passed
@mabry1985
mabry1985 deleted the feature/ltx-video-gen-studio branch July 13, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant