The graph builder is now parameterized by model family instead of assuming LTXAV. H3 keeps the core's shape but three of its nodes do not apply: LTXVConditioning (stamps LTX's frame_rate onto conditioning, which H3 has no field for), LTXVConcatAVLatent (H3's own EmptyMiniMaxH3LatentAV emits both streams, so the latent feeds the sampler directly) and LTXVAudioVAEDecode (reads an output_sample_rate H3's audio VAE does not define — it would raise). Requirements, wirable ports, role targets, guided-wiring hints and the port picker all follow the family, so a loader can no longer be wired into a node the graph never emits. The family is a Models setting, asked and never guessed from a checkpoint filename: a wrong guess fails deep inside ComfyUI at generate time instead of in the panel that can fix it. Video references now work, rather than being refused. FunPack owns the pipeline and already requires ffmpeg for preview/export, so minimax_h3 decodes reference clips itself — once, at 24 fps — and derives both views from that single decode: the 2 fps timestamped frames Qwen sees, and the full-rate latent block the DiT packs. A clip's own soundtrack rides with it, presented as its <Audio j> label immediately before its <Video k>, which is what ties the two together. Capped at 15s because reference tokens ride every sampling step. Editor UI: a Reference media view (H3 only — there is no LTX equivalent to grey out) with per-type 1-based tags shown exactly as the prompt must spell them, reorder controls that renumber both sides at once, and a soundtrack picker on video entries. Warning chips beside Generate for the LTX-only sampler settings H3 switches off, so that is known before a generation is spent rather than after; Easy Gen gets the same warnings on its strip and the same family selector (it already loads models.js and pipeline_caps.js). Verified live in the Cutting Room: the family toggle flips requirements, ports and hints both ways and persists per project; tags renumber on reorder; the H3 chips replace the Best-FaceID chip on H3 and hand back to it on LTXAV. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
490 lines
18 KiB
Python
490 lines
18 KiB
Python
"""Movie Editor server helpers."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from aiohttp import web
|
|
|
|
import movie_editor.server as srv
|
|
from movie_editor.backend import bridge
|
|
from movie_editor.backend.timeline import (
|
|
Project, Scene, build_combined_prompt, build_generation_scene_segments,
|
|
)
|
|
from movie_editor.server import (
|
|
_build_render_filter,
|
|
_clip_bytes_for_media,
|
|
_clip_needs_trim,
|
|
_has_graphics_export_content,
|
|
_parse_has_scenes,
|
|
_parse_prompt_variants,
|
|
_playback_render_from_query,
|
|
_project_models,
|
|
_resolve_run_seed,
|
|
_run_sampler_inputs,
|
|
_run_studio_inputs,
|
|
_scene_playback_clip_spec,
|
|
_shortcut_seed,
|
|
_timeline_duration_sec,
|
|
)
|
|
|
|
CONTINUE = "__funpack_continue__"
|
|
|
|
|
|
def test_temp_kind_classifies_media_and_skips_other():
|
|
assert srv._temp_kind(".mp4") == "video"
|
|
assert srv._temp_kind(".PNG") == "image"
|
|
assert srv._temp_kind(".wav") == "audio"
|
|
assert srv._temp_kind(".txt") is None
|
|
assert srv._temp_kind(".json") is None
|
|
|
|
|
|
def test_list_temp_media_lists_media_newest_first(tmp_path, monkeypatch):
|
|
(tmp_path / "old.png").write_bytes(b"x")
|
|
(tmp_path / "new.mp4").write_bytes(b"yy")
|
|
(tmp_path / "notes.txt").write_text("skip me")
|
|
sub = tmp_path / "previews"
|
|
sub.mkdir()
|
|
(sub / "clip.webm").write_bytes(b"zzz")
|
|
import os
|
|
os.utime(tmp_path / "old.png", (1000, 1000))
|
|
os.utime(tmp_path / "new.mp4", (2000, 2000))
|
|
os.utime(sub / "clip.webm", (1500, 1500))
|
|
|
|
fake = type("FP", (), {"get_temp_directory": staticmethod(lambda: str(tmp_path))})
|
|
monkeypatch.setitem(__import__("sys").modules, "folder_paths", fake)
|
|
|
|
files = srv._list_temp_media()
|
|
names = [f["filename"] for f in files]
|
|
assert "notes.txt" not in names
|
|
assert names == ["new.mp4", "clip.webm", "old.png"] # newest mtime first
|
|
clip = next(f for f in files if f["filename"] == "clip.webm")
|
|
assert clip["subfolder"] == "previews"
|
|
assert clip["kind"] == "video"
|
|
FRESH = "__funpack_fresh_prompt__"
|
|
|
|
|
|
def _project(scenes, rating="", **kw):
|
|
p = Project(
|
|
name="t",
|
|
scenes=[Scene.from_dict(s) for s in scenes],
|
|
conditioning_slot="funpack",
|
|
sampler_slot="funpack",
|
|
**{k: v for k, v in kw.items() if k != "scenes"},
|
|
)
|
|
if not scenes:
|
|
p.scenes = [Scene(id="s1", text="a", rating=rating)]
|
|
elif rating:
|
|
p.scenes[0].rating = rating
|
|
return p
|
|
|
|
|
|
def test_studio_inputs_user_rating():
|
|
p = _project([], rating="Perfect")
|
|
si = _run_studio_inputs(p, p.scenes)
|
|
assert si["rating"] == "Perfect"
|
|
|
|
|
|
def test_studio_inputs_multi_scene_ratings():
|
|
p = _project(scenes=[
|
|
{"id": "s1", "text": "a", "rating": "Perfect"},
|
|
{"id": "s2", "text": "b", "rating": "Missing action"},
|
|
{"id": "s3", "text": "c"},
|
|
])
|
|
active = p.scenes
|
|
si = _run_studio_inputs(p, active)
|
|
assert si["rating"] == CONTINUE
|
|
assert si["_movie_editor_scene_ratings"] == [
|
|
{"index": 0, "rating": "Perfect"},
|
|
{"index": 1, "rating": "Missing action"},
|
|
]
|
|
|
|
|
|
def test_studio_inputs_single_scene_in_chain_keeps_global_rating():
|
|
p = _project(scenes=[{"id": "s1", "text": "a", "rating": "Nailed it"}])
|
|
si = _run_studio_inputs(p, p.scenes)
|
|
assert si["rating"] == "Nailed it"
|
|
assert "_movie_editor_scene_ratings" not in si
|
|
|
|
|
|
def test_studio_inputs_continue_when_unrated():
|
|
p = _project([])
|
|
si = _run_studio_inputs(p, p.scenes)
|
|
assert si["rating"] == CONTINUE
|
|
|
|
|
|
def test_studio_inputs_fresh_prompt_when_changed():
|
|
p = _project([])
|
|
si = _run_studio_inputs(p, p.scenes, prompt_changed=True)
|
|
assert si["rating"] == FRESH
|
|
|
|
|
|
def test_continue_rating_is_valid_studio_combo_value():
|
|
"""ComfyUI rejects /prompt overrides not in the node's rating combo list."""
|
|
root = Path(__file__).resolve().parents[2]
|
|
src = (root / "conditioning.py").read_text(encoding="utf-8")
|
|
assert 'MOVIE_EDITOR_CONTINUE_RATING = "__funpack_continue__"' in src
|
|
assert "MOVIE_EDITOR_CONTINUE_RATING" in src
|
|
assert 'MOVIE_EDITOR_FRESH_PROMPT_RATING = "__funpack_fresh_prompt__"' in src
|
|
assert "MOVIE_EDITOR_FRESH_PROMPT_RATING" in src
|
|
assert "V2_RATING_LABELS" in src
|
|
|
|
|
|
def test_image_solo_sampler_inputs_no_prior_guides():
|
|
full = _project(scenes=[
|
|
{"id": "s1", "text": "a", "source": {"type": "carry"}},
|
|
{"id": "s2", "text": "b", "source": {"type": "image", "media_ref": "img2"}},
|
|
])
|
|
solo = Project.from_dict(full.to_dict())
|
|
solo.scenes = [full.scenes[1]]
|
|
samp = _run_sampler_inputs(solo, 1, full=full)
|
|
assert samp["frame_overlap"] == 0
|
|
assert samp["carry_i2v_guides"] is False
|
|
assert "funpack_scene_guides" not in samp
|
|
|
|
|
|
def test_mixed_solo_sampler_inputs():
|
|
full = _project(scenes=[
|
|
{"id": "s1", "text": "a", "source": {"type": "image", "media_ref": "img1"}},
|
|
{"id": "s2", "text": "b", "source": {"type": "mixed", "media_ref": "img2"}},
|
|
])
|
|
solo = Project.from_dict(full.to_dict())
|
|
solo.scenes = [full.scenes[1]]
|
|
samp = _run_sampler_inputs(solo, 1, full=full)
|
|
assert samp["frame_overlap"] == 0
|
|
assert samp["carry_i2v_guides"] is False
|
|
import json
|
|
guides = json.loads(samp["funpack_scene_guides"])
|
|
assert guides["scenes"][0][0]["media_ref"] == "img1"
|
|
|
|
|
|
def test_carry_chain_auto_mid_scene_guide():
|
|
import json
|
|
full = _project(scenes=[
|
|
{"id": "s1", "text": "a"},
|
|
{"id": "s2", "text": "b", "source": {"type": "carry"}},
|
|
])
|
|
samp = _run_sampler_inputs(full, 2, full=full)
|
|
assert samp["mid_scene_guide"] is True
|
|
assert samp["carry_i2v_guides"] is False
|
|
guides = json.loads(samp["funpack_scene_guides"])
|
|
assert guides["scenes"][1][0]["source"] == "template"
|
|
|
|
|
|
def test_identity_pin_media_refs_without_mixed_anchors():
|
|
import json
|
|
from movie_editor.server import _attach_scene_anchors
|
|
|
|
full = _project(
|
|
continuity_settings={"identity_pin_ref": "pin1"},
|
|
scenes=[{"id": "s1", "text": "a"}, {"id": "s2", "text": "b", "source": {"type": "carry"}}],
|
|
)
|
|
samp = _run_sampler_inputs(full, 2, full=full)
|
|
assert "funpack_scene_guides" in samp
|
|
media_pack = {
|
|
"primary": None,
|
|
"by_scene": {"_ref_pin1": {"filename": "funpack_movie_pin1.png", "media_ref": "pin1"}},
|
|
}
|
|
_attach_scene_anchors(samp, media_pack, full)
|
|
refs = json.loads(samp["funpack_scene_media_refs"])
|
|
assert refs["pin1"] == "funpack_movie_pin1.png"
|
|
assert "funpack_scene_anchors" not in samp
|
|
|
|
|
|
def test_prepare_media_extra_refs_do_not_become_primary(monkeypatch, tmp_path):
|
|
from movie_editor import server
|
|
from movie_editor.backend import config, media
|
|
|
|
indir = tmp_path / "input"
|
|
indir.mkdir()
|
|
mediadir = tmp_path / "media"
|
|
mediadir.mkdir()
|
|
(mediadir / "pin1.png").write_bytes(b"fake")
|
|
|
|
monkeypatch.setattr(config, "MEDIA_DIR", mediadir)
|
|
monkeypatch.setattr(media, "path_for", lambda mid: mediadir / "pin1.png" if mid == "pin1" else None)
|
|
|
|
class FP:
|
|
@staticmethod
|
|
def get_input_directory():
|
|
return str(indir)
|
|
|
|
monkeypatch.setitem(__import__("sys").modules, "folder_paths", FP)
|
|
|
|
p = _project(scenes=[{"id": "s1", "text": "a", "source": {"type": "carry"}}])
|
|
pack = server._prepare_media(p, ["pin1"])
|
|
assert pack is not None
|
|
assert pack["primary"] is None
|
|
assert pack["by_scene"]["_ref_pin1"]["filename"].startswith("funpack_movie_pin1")
|
|
|
|
|
|
def test_studio_inputs_skips_custom_conditioning():
|
|
p = _project([])
|
|
p.conditioning_slot = "custom"
|
|
assert _run_studio_inputs(p, p.scenes) == {}
|
|
|
|
|
|
def test_corrupt_project_surfaces_clean_error_not_500(monkeypatch):
|
|
# A corrupt / schema-incompatible project file used to 500 with no detail on open
|
|
# (while list_projects still lists it). _project_or_404 must turn that into a clear,
|
|
# recoverable 4xx with a reason — never a bare unhandled crash.
|
|
# server.py nulls its `web` when ComfyUI's server module is absent (test env); use real aiohttp.
|
|
monkeypatch.setattr(srv, "web", web)
|
|
def _boom(_pid):
|
|
raise ValueError("bad schema\nsecond line of noise")
|
|
monkeypatch.setattr(srv.projects, "get", _boom)
|
|
with pytest.raises(web.HTTPUnprocessableEntity) as ei:
|
|
srv._project_or_404("p1")
|
|
reason = ei.value.reason
|
|
assert "could not be loaded" in reason
|
|
assert "\n" not in reason # reason must stay a single header-safe line
|
|
|
|
|
|
def test_missing_project_still_404(monkeypatch):
|
|
monkeypatch.setattr(srv, "web", web)
|
|
monkeypatch.setattr(srv.projects, "get", lambda _pid: None)
|
|
with pytest.raises(web.HTTPNotFound):
|
|
srv._project_or_404("nope")
|
|
|
|
|
|
def test_generation_prompt_has_no_injected_scene_markers():
|
|
# The generation prompt must be the verbatim text the user wrote — never an injected
|
|
# 'scene N' delimiter. Boundaries travel structurally via build_generation_scene_segments.
|
|
p = _project(scenes=[
|
|
{"id": "s1", "text": "a red car drives down a street", "source": {"type": "image", "media_ref": "img1"}},
|
|
{"id": "s2", "text": "the car stops at a cafe", "source": {"type": "carry"}},
|
|
{"id": "s3", "text": "a woman exits the cafe", "source": {"type": "carry"}},
|
|
])
|
|
gen = build_combined_prompt(p, for_generation=True)
|
|
assert "scene 1" not in gen.lower()
|
|
assert "scene 2" not in gen.lower()
|
|
assert gen == "a red car drives down a street the car stops at a cafe a woman exits the cafe"
|
|
# boundaries are carried structurally instead — one entry per generative scene
|
|
seg = build_generation_scene_segments(p)
|
|
assert seg["scenes"] == [
|
|
"a red car drives down a street", "the car stops at a cafe", "a woman exits the cafe"]
|
|
|
|
|
|
def test_score_slider_sampler_inputs_pass_through():
|
|
# FreeSliders score_slider is a plain sampler knob (like embed_guidance): the editor
|
|
# sets it in sampler_inputs and _run_sampler_inputs must carry it through untouched
|
|
# so builder.py can stamp it onto the Chain Sampler node.
|
|
full = _project(
|
|
scenes=[{"id": "s1", "text": "a"}, {"id": "s2", "text": "b", "source": {"type": "carry"}}],
|
|
sampler_inputs={"score_slider": True, "score_slider_strength": 2.0,
|
|
"embed_guidance_source": "relative"},
|
|
)
|
|
samp = _run_sampler_inputs(full, 2, full=full)
|
|
assert samp["score_slider"] is True
|
|
assert samp["score_slider_strength"] == 2.0
|
|
assert samp["embed_guidance_source"] == "relative"
|
|
|
|
|
|
def test_shortcut_seed_uses_sampler_inputs():
|
|
p = _project([], sampler_inputs={"seed": 4242})
|
|
assert _shortcut_seed(p) == 4242
|
|
|
|
|
|
def test_shortcut_seed_random_when_unset():
|
|
p = _project([])
|
|
assert _shortcut_seed(p) == 0
|
|
|
|
|
|
def test_resolve_run_seed_fixed():
|
|
p = _project([], sampler_inputs={"seed": 99})
|
|
assert _resolve_run_seed(p) == 99
|
|
|
|
|
|
def test_resolve_run_seed_random_when_unset():
|
|
p = _project([])
|
|
seeds = {_resolve_run_seed(p) for _ in range(8)}
|
|
assert len(seeds) > 1
|
|
|
|
|
|
def test_resolve_comfy_media_path_temp(monkeypatch, tmp_path):
|
|
from movie_editor import server
|
|
|
|
temp = tmp_path / "temp"
|
|
temp.mkdir()
|
|
clip = temp / "clip.mp4"
|
|
clip.write_bytes(b"x")
|
|
|
|
class FP:
|
|
@staticmethod
|
|
def get_output_directory():
|
|
return str(tmp_path / "out")
|
|
|
|
@staticmethod
|
|
def get_temp_directory():
|
|
return str(temp)
|
|
|
|
monkeypatch.setitem(__import__("sys").modules, "folder_paths", FP)
|
|
assert server._resolve_comfy_media_path("clip.mp4", "", "temp") == str(clip)
|
|
assert server._resolve_comfy_media_path("nope.mp4", "", "temp") is not None
|
|
|
|
|
|
def test_parse_has_scenes():
|
|
assert _parse_has_scenes({"scenes": [{"text": "a"}]})
|
|
assert not _parse_has_scenes({"scenes": []})
|
|
assert not _parse_has_scenes(None)
|
|
|
|
|
|
def test_parse_prompt_variants_partial_success(monkeypatch):
|
|
def ok(_prompt, seed=0):
|
|
return {"anchor": "", "scenes": [{"index": 0, "text": "a"}], "transitions": []}
|
|
|
|
def boom(_prompt):
|
|
raise RuntimeError("verbatim blew up")
|
|
|
|
monkeypatch.setattr(bridge, "parse_timeline", ok)
|
|
monkeypatch.setattr(bridge, "parse_timeline_raw", ok)
|
|
monkeypatch.setattr(bridge, "parse_timeline_verbatim", boom)
|
|
|
|
payload, errors = _parse_prompt_variants("scene one", seed=0)
|
|
assert _parse_has_scenes(payload["parsed_raw"])
|
|
assert errors == {"parsed_verbatim": "RuntimeError: verbatim blew up"}
|
|
|
|
|
|
def test_parse_prompt_variants_all_fail(monkeypatch):
|
|
def boom(*_args, **_kwargs):
|
|
raise KeyError()
|
|
|
|
monkeypatch.setattr(bridge, "parse_timeline", boom)
|
|
monkeypatch.setattr(bridge, "parse_timeline_raw", boom)
|
|
monkeypatch.setattr(bridge, "parse_timeline_verbatim", boom)
|
|
|
|
payload, errors = _parse_prompt_variants("scene one", seed=0)
|
|
assert payload["parsed"] is None
|
|
assert len(errors) == 3
|
|
assert errors["parsed"] == "KeyError (no message)"
|
|
|
|
|
|
def test_generate_filters_video_clips():
|
|
from movie_editor.backend.timeline import Project, Scene, SceneSource, is_video_clip
|
|
from movie_editor import server
|
|
|
|
assert server.is_video_clip is is_video_clip
|
|
p = Project(scenes=[
|
|
Scene(text="gen", source=SceneSource(type="carry")),
|
|
Scene(text="", source=SceneSource(type="video", media_ref="v1")),
|
|
])
|
|
active = [s for s in p.scenes if not s.excluded and not is_video_clip(s)]
|
|
assert len(active) == 1
|
|
assert active[0].text == "gen"
|
|
|
|
|
|
def test_clip_bytes_copy_from_media_bin(tmp_path, monkeypatch):
|
|
from movie_editor.backend import config, media
|
|
|
|
monkeypatch.setattr(config, "DATA_DIR", tmp_path)
|
|
monkeypatch.setattr(config, "MEDIA_DIR", tmp_path / "media")
|
|
entry = media.save_upload("take.mp4", b"MP4DATA")
|
|
assert _clip_needs_trim({"bin_media_ref": entry["id"]}) is False
|
|
assert _clip_bytes_for_media({"bin_media_ref": entry["id"]}) == b"MP4DATA"
|
|
assert _clip_needs_trim({"bin_media_ref": entry["id"], "in": 1.0}) is True
|
|
|
|
|
|
def test_scene_playback_clip_spec_chain_offsets():
|
|
p = _project(scenes=[
|
|
{"id": "s1", "text": "a"},
|
|
{"id": "s2", "text": "b", "source_in": 0.5},
|
|
{"id": "s3", "text": "c", "source_dur": 2.5},
|
|
], frame_rate=25, num_frames_per_scene=97)
|
|
p.scene_renders = {
|
|
"s1": {"inSec": 0, "media": {"filename": "chain.mp4", "subfolder": "", "type": "output"}},
|
|
"s2": {"inSec": 3.88, "media": {"filename": "chain.mp4", "subfolder": "", "type": "output"}},
|
|
"s3": {"inSec": 7.4, "media": {"filename": "chain.mp4", "subfolder": "", "type": "output"}},
|
|
}
|
|
c1 = _scene_playback_clip_spec(p, "s1")
|
|
c2 = _scene_playback_clip_spec(p, "s2")
|
|
c3 = _scene_playback_clip_spec(p, "s3")
|
|
assert c1["in"] == 0
|
|
assert c1["dur"] == 97 / 25
|
|
assert c2["in"] == 3.88 + 0.5
|
|
assert c3["in"] == 7.4
|
|
assert c3["dur"] == 2.5
|
|
assert c1["filename"] == c2["filename"] == c3["filename"] == "chain.mp4"
|
|
|
|
|
|
def test_scene_playback_clip_spec_live_render_query():
|
|
p = _project(scenes=[{"id": "s1", "text": "a"}, {"id": "s2", "text": "b", "source_in": 0.5}])
|
|
p.scene_renders = {}
|
|
override = _playback_render_from_query({
|
|
"filename": "fresh.mp4",
|
|
"subfolder": "out",
|
|
"type": "output",
|
|
"render_in": "3.88",
|
|
})
|
|
c = _scene_playback_clip_spec(p, "s2", render_override=override)
|
|
assert c["filename"] == "fresh.mp4"
|
|
assert c["subfolder"] == "out"
|
|
assert c["in"] == 3.88 + 0.5
|
|
assert _playback_render_from_query({}) is None
|
|
|
|
|
|
def test_ghost_playback_clip_spec_from_query():
|
|
"""Ghost segments: the scene is gone from the project, so the trim window (in/dur)
|
|
must come entirely from the query — and malformed/incomplete queries return None
|
|
(the handler then 404s instead of encoding garbage)."""
|
|
from movie_editor.server import _ghost_playback_clip_spec
|
|
c = _ghost_playback_clip_spec({
|
|
"filename": "chain.mp4", "subfolder": "out", "type": "output",
|
|
"render_in": "7.4", "dur": "2.5",
|
|
})
|
|
assert c == {"filename": "chain.mp4", "subfolder": "out", "type": "output",
|
|
"in": 7.4, "dur": 2.5}
|
|
assert _ghost_playback_clip_spec({"filename": "chain.mp4"}) is None # no dur
|
|
assert _ghost_playback_clip_spec({"dur": "2.5"}) is None # no filename
|
|
assert _ghost_playback_clip_spec({"filename": "c.mp4", "dur": "oops"}) is None # bad float
|
|
# render_in defaults to 0 (ghost of the chain's first scene).
|
|
c0 = _ghost_playback_clip_spec({"filename": "c.mp4", "dur": "4.0"})
|
|
assert c0["in"] == 0 and c0["type"] == "output"
|
|
|
|
|
|
def test_project_models_keeps_empty_slots(monkeypatch):
|
|
from movie_editor.backend import nodes
|
|
|
|
monkeypatch.setattr(nodes, "load_models", lambda: {"slots": [{"id": "global", "role": "unet"}]})
|
|
p = _project(scenes=[{"id": "s1", "text": "a"}])
|
|
p.models = {"slots": [], "full_control": False}
|
|
assert _project_models(p)["slots"] == []
|
|
assert _project_models(p)["slots"] != nodes.load_models()["slots"]
|
|
|
|
|
|
def test_timeline_duration_sec_from_overlays_and_audio():
|
|
p = _project(scenes=[])
|
|
p.scenes = []
|
|
p.overlay_tracks = [{"start_sec": 2, "duration_sec": 5}]
|
|
p.audio_tracks = [{"start_sec": 10, "source_dur": 3}]
|
|
assert _timeline_duration_sec(p) == 13
|
|
assert _has_graphics_export_content(p) is True
|
|
|
|
|
|
def test_build_render_filter_blank_canvas():
|
|
filt, has_audio = _build_render_filter(
|
|
[],
|
|
tracks=[{"start_sec": 0, "volume": 1.0}],
|
|
keep_original=False,
|
|
base_input=1,
|
|
blank_canvas={"w": 768, "h": 512, "fps": 25, "dur": 8.0},
|
|
)
|
|
assert "[0:v]format=yuv420p,setsar=1[vbase]" in filt
|
|
assert "[aout]" in filt
|
|
assert has_audio is True
|
|
|
|
# ── MiniMax H3 reference media ────────────────────────────────────────────────
|
|
|
|
def test_h3_references_round_trip_on_the_project():
|
|
"""The ORDER is the contract — Studio numbers the prompt tags from this list and the
|
|
Chain Sampler encodes the same list, so persistence must not reorder or coerce it."""
|
|
from movie_editor.backend.timeline import Project
|
|
refs = [
|
|
{"kind": "image", "filename": "face.png"},
|
|
{"kind": "video", "filename": "walk.mp4", "audio": "walk.wav"},
|
|
{"kind": "audio", "filename": "voice.wav"},
|
|
]
|
|
p = Project.from_dict({"h3_references": refs})
|
|
assert p.h3_references == refs
|
|
assert Project.from_dict(p.to_dict()).h3_references == refs
|
|
# absent on every project that predates the feature, never None
|
|
assert Project.from_dict({}).h3_references == []
|