diff --git a/app/ui/features/generate/inputs.js b/app/ui/features/generate/inputs.js index 95d28ec2..66361c3b 100644 --- a/app/ui/features/generate/inputs.js +++ b/app/ui/features/generate/inputs.js @@ -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 diff --git a/app/ui/features/generate/inputs.test.js b/app/ui/features/generate/inputs.test.js index e3b81918..965bf237 100644 --- a/app/ui/features/generate/inputs.test.js +++ b/app/ui/features/generate/inputs.test.js @@ -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([]); +}); diff --git a/app/ui/features/render/job.js b/app/ui/features/render/job.js index 733ad950..ffe015d4 100644 --- a/app/ui/features/render/job.js +++ b/app/ui/features/render/job.js @@ -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; } diff --git a/core/tests/test_routes_ltx_project.py b/core/tests/test_routes_ltx_project.py new file mode 100644 index 00000000..2cf5ea23 --- /dev/null +++ b/core/tests/test_routes_ltx_project.py @@ -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", [])] diff --git a/modules/models/ltx/pipeline.py b/modules/models/ltx/pipeline.py index 8ac349b8..8a66a0bb 100644 --- a/modules/models/ltx/pipeline.py +++ b/modules/models/ltx/pipeline.py @@ -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(): diff --git a/modules/models/ltx/tests/test_ltx.py b/modules/models/ltx/tests/test_ltx.py index 76358f49..19a2a19c 100644 --- a/modules/models/ltx/tests/test_ltx.py +++ b/modules/models/ltx/tests/test_ltx.py @@ -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