Direct v2v saved before the video-only flag keeps taking its video from references; render polls survive one unanswered reply; LTX project HTTP test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzJLJWVu7RZnbRqm3nYAnX
This commit is contained in:
Claude
2026-10-27 08:48:01 +00:00
parent eac7ecd582
commit ed4de1e8af
6 changed files with 78 additions and 6 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ export async function buildInputs({ project, scene, slots, expand, nodeInfo, fra
// a VIDEO first (it is then not also sent as a reference: nothing takes it there), else the first picture (which stays a
// reference too, so an identity input still gets it). A video-only pipeline (video-to-video) looks at videos alone and is
// never blanked by "From a prompt".
const takesVideo = Boolean(source && source.role.video_frames), videoOnly = Boolean(source && source.role.video_only);
const takesVideo = Boolean(source && source.role.video_frames), videoOnly = Boolean(source && (source.role.video_only || (slots || []).some((s) => s.node === "VAEEncode" && ((s.inputs || {}).pixels || [])[0] === source.slot.id))); // a pipeline that encodes the start itself is video-to-video, whether or not it was saved saying so
const wanted = (id) => kinds.get(id) === "video" || (!videoOnly && kinds.get(id) === "image");
const blank = project.generation_mode === "t2v" && !videoOnly;
const fromRefs = takesVideo && !scene.source_image && !blank
+11
View File
@@ -183,3 +183,14 @@ test("an LTX image-to-video pipeline starts from the first reference picture whe
assert.equal((await run({ references: ["pic"] }, { generation_mode: "t2v" })).inputs.src.media_id, ""); // "From a prompt": no picture
learn([]);
});
test("a direct video-to-video pipeline saved without the video-only flag is still video-to-video (an encoder is fed by the start slot)", async () => {
const { learn } = await import("../../shell/bin.js");
learn([{ id: "vid", name: "v.mp4", kind: "video" }, { id: "pic", name: "p.png", kind: "image" }]);
const slots = [{ id: "src", node: "FunUILoadMedia", roles: [{ at: "assets.source_image", input: "media_id", video_frames: true }], inputs: {} },
{ id: "enc", node: "VAEEncode", inputs: { pixels: ["src", 1] } }];
const r = await buildInputs({ project: { video: {}, generation_mode: "t2v" }, scene: { text: "x", references: ["pic", "vid"] }, slots, expand: async () => null });
assert.equal(r.inputs.src.media_id, "vid");
assert.equal(r.inputs.src.full, true);
learn([]);
});
+6 -1
View File
@@ -9,6 +9,11 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
export async function runJob(path, body) {
const { job_id: job } = await call("POST", path, body);
let state;
do { await wait(1500); state = await call("GET", `${path}/${job}`); } while (state.state === "queued" || state.state === "running");
let blips = 0;
// A poll the server did not answer (a restart, a proxy's page) is asked again: the job runs on without the page.
do {
await wait(1500);
try { state = await call("GET", `${path}/${job}`); blips = 0; } catch (err) { if (err.status && err.status < 500 || ++blips > 20) throw err; state = { state: "running" }; }
} while (state.state === "queued" || state.state === "running");
return state;
}
+36
View File
@@ -0,0 +1,36 @@
"""An LTX project as Dex runs it, over HTTP on the real route table: the direct video-to-video preset (also as saved with
the old wiring), a project with a video in References and Video input switched on, and every call the app makes at start-up
and for a render. Each must answer JSON: a page that gets anything else fails on 'r is null'."""
import pytest
from core.tests.test_routes_pipeline import _request, registered, server # noqa: F401
def _preset(pid):
from modules.models.ltx.pipeline import presets
return next(p["slots"] for p in presets() if p["id"] == pid)
@pytest.mark.parametrize("pid", ["ltx23_video_to_video_direct", "ltx23_video_to_video", "ltx23_image_to_video", "ltx23_anchor_guide"])
def test_every_startup_and_render_call_answers_json_on_an_ltx_project(server, pid):
slots = _preset(pid)
old = [dict(s, inputs={k: (["source_image", 0] if v == ["source_image", 1] else v) for k, v in s["inputs"].items()}) for s in slots]
for saved in (slots, old):
status, body = _request(server, "POST", "/fun/api/pipeline", {"slots": saved})
assert status == 200 and "raw" not in body, (status, body)
users = [(s["id"], k) for s in body["slots"] for k, v in s["inputs"].items() if isinstance(v, list) and v[0] == "source_image"]
assert all(next(s for s in body["slots"] if s["id"] == i)["inputs"][k][1] == 1 for i, k in users), "the old wiring was not brought up to date"
status, body = _request(server, "GET", "/fun/api/pipeline")
assert status == 200 and "slots" in body, body
traits = "audio_stream,ltx_av"
for path in ("/fun/api/story/markers", f"/fun/api/modules?traits={traits}", "/fun/api/modules", "/fun/api/shortcuts",
"/fun/api/readiness", "/fun/api/system", "/fun/api/addons", "/fun/api/m/render/library", "/fun/api/pipeline/presets"):
status, body = _request(server, "GET", path)
assert status < 500 and "raw" not in body, (path, status, body)
status, body = _request(server, "GET", f"/fun/api/modules?traits={traits}")
ids = [m["id"] for m in body.get("modules", [])]
assert "conditioning_shot_camera" not in ids, "shot camera must be hidden for LTX"
assert "conditioning_shot_camera" in [m["id"] for m in body.get("incompatible", [])]
+14 -4
View File
@@ -174,14 +174,24 @@ OPTIONAL_NODES = {"FunUIIdentityTransfer": {"pass": {"0": "model", "1": "positiv
def upgrade(slots, _outputs=None):
"""A pipeline saved while the picture inputs still read the loader's first output: a start slot that takes whole
videos feeds them from the frames output now (output 0 is always one frame, so such a pipeline ran on a single frame)."""
"""Pipelines saved by an earlier build, brought up to date:
* the picture inputs read the loader's first output (always one frame): a start slot that takes whole videos feeds them
from the frames output now;
* the direct video-to-video pipeline, saved before its start slot said it takes videos only."""
start = next((s for s in slots if s.get("id") == "source_image" and s.get("node") == "FunUILoadMedia"
and any(r.get("video_frames") for r in s.get("roles") or [])), None)
if not start:
return slots
return [dict(s, inputs={k: (["source_image", 1] if isinstance(v, (list, tuple)) and list(v) == ["source_image", 0] else v)
for k, v in (s.get("inputs") or {}).items()}) for s in slots]
encoded = any(s.get("node") == "VAEEncode" and (s.get("inputs") or {}).get("pixels") in (["source_image", 0], ["source_image", 1]) for s in slots)
out = []
for s in slots:
inputs = {k: (["source_image", 1] if isinstance(v, (list, tuple)) and list(v) == ["source_image", 0] else v)
for k, v in (s.get("inputs") or {}).items()}
item = dict(s, inputs=inputs)
if s is start and encoded:
item["roles"] = [dict(r, video_only=True) if r.get("video_frames") else r for r in s.get("roles") or []]
out.append(item)
return out
def optional_nodes():
+10
View File
@@ -232,3 +232,13 @@ def test_h3_only_features_are_not_offered_on_ltx(comfyui):
have = ltx.probe_traits({"audio_adaln_single.linear.weight"})
assert traits.missing_for(spec, have) == ["shot_prompts"]
assert not set(H3_ONLY) & set(have)
def test_a_direct_v2v_pipeline_saved_before_video_only_still_ignores_pictures_and_the_prompt_switch():
from modules.models.ltx.pipeline import presets, upgrade
slots = next(p["slots"] for p in presets() if p["id"] == "ltx23_video_to_video_direct")
old = [dict(s, roles=[{k: v for k, v in r.items() if k != "video_only"} for r in s.get("roles") or []]) if s["id"] == "source_image" else s for s in slots]
assert not any(r.get("video_only") for s in old for r in s.get("roles") or [])
assert upgrade(old) == slots
i2v = next(p["slots"] for p in presets() if p["id"] == "ltx23_image_to_video")
assert not any(r.get("video_only") for s in upgrade(i2v) for r in s.get("roles") or []) # i2v keeps taking pictures