diff --git a/.tests/test_minimax_h3_director.mjs b/.tests/test_minimax_h3_director.mjs index deacc64..635c2ed 100644 --- a/.tests/test_minimax_h3_director.mjs +++ b/.tests/test_minimax_h3_director.mjs @@ -7,7 +7,7 @@ source = source.replace( 'import { app } from "../../../scripts/app.js";\nimport { api } from "../../../scripts/api.js";', "const app = { registerExtension() {} }; const api = {};" ); -source += "\nexport { mediaTypeFor, REPOSITORY_URL };"; +source += "\nexport { mediaTypeFor, REPOSITORY_URL, MINIMAX_MULTIPLE, ASPECT_OPTIONS, RESOLUTION_PRESETS };"; assert.match(source, /lane\.ondrop = event => \{ if \(!supported\)/, "each timeline lane must own its direct drop handler"); assert.match(source, /acceptLaneDrop\(event, targetLane\)/, "supported lane drops must use the direct lane upload path"); @@ -38,20 +38,47 @@ assert.match(source, /Math\.log1p\(9 \* peaks\[peakIndex\]\)/, "audio waveform a assert.doesNotMatch(source, /if \(item\.type === "audio"\) return; if \(event\.target !== clip\) return;/, "audio slots must be horizontally movable"); assert.match(source, /window\.open\(REPOSITORY_URL, "_blank", "noopener,noreferrer"\)/, "the help button must open the GitHub documentation safely"); assert.match(source, /const promptStyle = \(\) => \{ const v = builderState\?\.prompt_mode;/, "the builder must expose a validated prompt-style accessor"); -assert.match(source, /styleButton\.className = "ds-h3-prompt-mode-btn"/, "the mode bar must render a prompt-style toggle button"); -assert.match(source, /builderState\.prompt_mode = styleLabel === "simple" \? "structured" : "simple"/, "the toggle must flip between the simple and structured styles"); +assert.match(source, /modeLabel\.textContent = "Mode:"/, "the mode bar must label the mode selector"); +assert.match(source, /promptLabel\.textContent = "Prompt Mode:"/, "the mode bar must label the prompt-style selector"); +assert.match(source, /\[\["simple", "Simple"\], \["structured", "Structured"\]\]/, "the prompt-style selector must expose separate Simple and Structured buttons"); +assert.match(source, /promptButton\.classList\.toggle\("active", styleLabel === value\)/, "the selected prompt style must be highlighted"); +assert.match(source, /spacer\.style\.flex = "1"/, "the mode bar must use an automatic spacer before the help button"); assert.match(source, /function buildSimpleForm\(panel\)/, "simple mode must render its own one-field prompt form"); assert.match(source, /createBuilderField\("Prompt", builderState\.simple_prompt/, "simple mode must bind the visible field to serialized simple_prompt state"); -assert.match(source, /if \(styleLabel !== "simple"\) builderState\.simple_prompt = previewTextFor\(mode\(\), false\);/, "switching into simple mode must seed the single field from the current builder prompt"); -assert.match(source, /\.ds-h3-prompt-mode-btn\{[^`]*box-shadow:0 0 8px rgba\(126,235,167/, "the prompt-style toggle must use the green-glow accent"); +assert.match(source, /if \(value === "simple"\) builderState\.simple_prompt = previewTextFor\(mode\(\), false\);/, "switching into simple mode must seed the single field from the current builder prompt"); +assert.match(source, /promptButton\.className = "ds-h3-prompt-mode-btn"/, "prompt-style choices must use the prompt-mode button styling"); assert.match(source, /function showPromptPreview\(\) \{[\s\S]*?const promptText = previewTextFor\(m, hasExternalPrompt\(\)\);/, "the prompt preview must render the selected style"); -assert.doesNotMatch(source, /builderState\.prompt_mode = styleLabel === "structured" \? "simple" : "structured"/, "the toggle must not invert the wrong way"); +assert.match(source, /builderState\.prompt_mode = value/, "prompt-style buttons must set the explicitly selected mode"); assert.match(source, /const resetBuilderState = \(\) => \{ builderState = DEFAULT_BUILDER_STATE\(mode\(\)\); builderState\.mode = mode\(\); \};/, "Clear must reset all serialized builder fields to the current mode defaults"); assert.match(source, /const clearAll = \(\) => \{ selectedId = null; resetBuilderState\(\);/, "Clear must reset prompt/text fields as well as selected media"); assert.match(source, /const hasContent = state\.items\.length \|\| state\.prompt_blocks\?\.length \|\| hasBuilderContent\(\) \|\| String\(promptWidget\?\.value \|\| ""\)\.trim\(\);/, "Clear must remain available when only builder text is filled"); +assert.match(source, /function probeDimensions\(value, type\)/, "visual uploads must probe image and video source dimensions"); +assert.match(source, /source_width: width, source_height: height/, "source dimensions must persist with the media item"); +assert.match(source, /ds-h3-resolution-panel/, "the resolution controls must render in their own panel"); +assert.match(source, /Auto · short side 768 px/, "Auto resolution must describe the MiniMax 768px short-side default"); +assert.match(source, /MINIMAX_MULTIPLE = 16/, "MiniMax output dimensions must align to 16 pixels"); +assert.match(source, /source \? Number\(source\.source_width\) \/ Number\(source\.source_height\) : 4 \/ 3/, "Auto aspect must use the MiniMax T2VA 4:3 fallback without visual media"); +assert.match(source, /settings\.resolution === "custom" && settings\.custom_mode === "fixed"\) return \[snap16\(settings\.custom_width\), snap16\(settings\.custom_height\)\]/, "fixed custom pixels must resolve without a media-derived aspect"); +assert.match(source, /options\.forEach\([\s\S]*?select\.value = value; select\.oninput/, "resolution selects must set their value after options exist and react on input"); +assert.match(source, /Auto aspect" : settings\.aspect\} · \$\{settings\.resolution === "auto" \? "Auto 768px" : settings\.resolution\}/, "the readout must identify the actual selected aspect and resolution"); +assert.match(source, /INPUT SCALING/, "the resolution panel must expose the third input-scaling dropdown"); +assert.match(source, /input_scaling: "Auto"/, "input scaling must default to Auto alongside Aspect and Resolution"); +assert.match(source, /\["Off", "Off"\], \["Auto", "Auto · short edge 2048 px"\], \["Target", "Target · Selected Aspect & Resolution"\], \["Fit", "Fit"\]/, "input scaling must expose the reused Torch Resize behaviours"); +assert.match(source, /external_width_overwrite/, "the Director frontend must recognize the external width overwrite input"); +assert.match(source, /external_height_overwrite/, "the Director frontend must recognize the external height overwrite input"); +assert.match(source, /external_prompt_overwrite/, "the external prompt input must be named as an overwrite"); +assert.match(source, /function migrateLegacyExternalPromptInput\(\)/, "restored Director nodes must migrate the old external prompt socket"); +assert.match(source, /legacy\.name = "external_prompt_overwrite"/, "a linked legacy prompt socket must be renamed instead of duplicated"); +assert.match(source, /hasExternalCanvas\(\).*return;/, "external dimensions must prevent Director canvas recalculation"); +assert.match(source, /Director sizing and input scaling disabled/, "the UI must disclose external canvas overwrite mode"); +assert.match(source, /ds-h3-fixed-dimensions/, "fixed custom resolution must group its dimensions in a dedicated row"); +assert.match(source, /display:flex;gap:6px/, "fixed custom resolution dimensions must use a horizontal flex layout"); +assert.match(source, /dimensionField\("WIDTH", settings\.custom_width/, "fixed custom resolution must label the width field"); +assert.match(source, /dimensionField\("HEIGHT", settings\.custom_height/, "fixed custom resolution must label the height field"); + const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; -const { mediaTypeFor, REPOSITORY_URL } = await import(moduleUrl); +const { mediaTypeFor, REPOSITORY_URL, MINIMAX_MULTIPLE, ASPECT_OPTIONS, RESOLUTION_PRESETS } = await import(moduleUrl); for (const [name, type] of [ ["reference.webp", "image"], @@ -66,3 +93,7 @@ for (const [name, type] of [ assert.equal(mediaTypeFor({ name: "reference.bin", type: "" }), null, "unknown files must not be uploaded as media"); assert.equal(mediaTypeFor({ name: "renamed.bin", type: "audio/ogg" }), "audio", "known MIME types take precedence over extensions"); assert.equal(REPOSITORY_URL, "https://github.com/darksidewalker/ComfyUI-DaSiWa-Nodes/blob/main/docs/minimax_h3_director.md"); +assert.equal(MINIMAX_MULTIPLE, 16); +assert.equal(ASPECT_OPTIONS[0][0], "auto"); +assert.equal(ASPECT_OPTIONS.at(-1)[0], "custom"); +assert.equal(RESOLUTION_PRESETS["8.30 MP - UHD"], 8.30); diff --git a/.tests/test_minimax_h3_director.py b/.tests/test_minimax_h3_director.py index 044b391..1973a7f 100644 --- a/.tests/test_minimax_h3_director.py +++ b/.tests/test_minimax_h3_director.py @@ -7,7 +7,10 @@ from pathlib import Path import numpy as np import pytest -from nodes.helper_minimax_h3_director import audio_duration, load_audio, load_embedded_video_audio, load_video +from nodes.helper_minimax_h3_director import ( + audio_duration, load_audio, load_embedded_video_audio, load_video, + scale_canvas_to_short_edge, scale_input_media, +) from nodes.helper_minimax_h3_prompt_builder import ( PROMPT_MODES, build_base_prompt, build_prompt, build_ref_prompt, default_builder_state, ) @@ -27,6 +30,28 @@ def test_base_prompt_builder_includes_fl2va_alignment_and_schema(): assert "overall_soundscape: wind" in prompt +def test_minimax_auto_canvas_sets_the_short_edge_to_768_on_a_16_pixel_grid(): + assert scale_canvas_to_short_edge(1920, 1080) == (1360, 768) + assert scale_canvas_to_short_edge(1080, 1920) == (768, 1360) + assert scale_canvas_to_short_edge(1024, 1024) == (768, 768) + + +def test_director_input_scaling_reuses_torch_resize_without_upscaling_auto_inputs(): + torch = __import__("torch") + image = torch.zeros((1, 3000, 6000, 3)) + small_image = torch.zeros((1, 100, 200, 3)) + + auto = scale_input_media(image, "Auto", 1024, 768) + small_auto = scale_input_media(small_image, "Auto", 1024, 768) + target = scale_input_media(image, "Target", 1024, 768) + off = scale_input_media(image, "Off", 1024, 768) + + assert auto.shape == (1, 2048, 4096, 3) + assert small_auto is small_image + assert target.shape == (1, 768, 1024, 3) + assert off is image + + def test_ref_prompt_builder_emits_all_required_sections(): state = default_builder_state("REF2VA") state["ref"].update({ @@ -94,6 +119,28 @@ def test_director_blank_external_prompt_falls_back_to_builder(): assert resolved_for("MY OWN PROMPT") == "MY OWN PROMPT" +def test_director_external_dimension_overwrites_replace_canvas_before_guide_construction(): + guide, _, resolved, output_width, output_height, *_ = director.MiniMaxH3Director().build_guide( + "I2VA", "internal prompt", 1344, 768, 5, "match", + json.dumps({"items": [{"type": "image", "value": "opening.png", "slot": 0}]}), + external_width_overwrite=1023, + external_height_overwrite=577, + external_prompt_overwrite="external prompt", + ) + + assert (output_width, output_height) == (1023, 577) + assert (guide["width"], guide["height"]) == (1023, 577) + assert resolved == "external prompt" + + +def test_director_external_dimension_overwrites_require_a_complete_pair(): + with pytest.raises(ValueError, match="both external width overwrite and external height overwrite"): + director.MiniMaxH3Director().build_guide( + "T2VA", "", 1344, 768, 5, "match", json.dumps({"items": []}), + external_width_overwrite=1024, + ) + + def test_prompt_modes_constant_lists_both_styles(): assert set(PROMPT_MODES) == {"simple", "structured"} diff --git a/README.md b/README.md index 7bd1aec..e5257fc 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Timeline-based authoring for MiniMax H3 text/image/video generation and referenc - REF2VA: simplified six-section free-text builder (subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music) with helper buttons: **Insert [Shot N]** places shot markers at cursor, **Prefill Labels & Summary** auto-generates Picture/Video/Audio labels from your inserted media, and **Preview Prompt** shows the exact assembled prompt in a popup with copy-to-clipboard. - **VALIDATED LIMITS:** 2–15 second reference windows; max 15s combined visual and audio duration each; strict path-safety under ComfyUI's input directory. - **NATIVE ROUTING & LAZY LOADING:** hands validated data to ComfyUI's built-in MiniMaxH3ImageToVideo / MiniMaxH3ReferenceToVideo nodes; only the selected FL2VA or REF2VA model is requested. -- **EXTERNAL PROMPT INPUT:** optional `external_prompt` (STRING) input — when connected and non-empty it overrides the assembled builder output, so you can feed a fully hand-written prompt from any upstream node. +- **RESOLUTION PANEL:** Aspect / Resolution / Input scaling selectors (all default to **Auto**) drive the output canvas on MiniMax's 16-px grid — Auto aspect follows your first visual reference, Auto resolution sets a 768 px short side, plus fixed aspect, MP and pixel presets with CUSTOM values. Input scaling (Off / Auto / Target / Fit / Fill / Fit+pad / Divisible crop) preprocesses visual references via the included Torch Resize before they reach H3. +- **EXTERNAL OVERWRITE INPUTS:** optional `external_prompt_overwrite` (STRING) replaces the assembled builder output; connect both `external_width_overwrite` and `external_height_overwrite` (INT) to replace the Director canvas and bypass its sizing and input preprocessing entirely. [Full documentation, UI guide, and prompting reference →](docs/minimax_h3_director.md) diff --git a/docs/minimax_h3_director.md b/docs/minimax_h3_director.md index eb2b40e..a30912b 100644 --- a/docs/minimax_h3_director.md +++ b/docs/minimax_h3_director.md @@ -8,7 +8,8 @@ Since the last GitHub release (August 2026): ### New features -- **External prompt input:** new optional `external_prompt` (STRING) input. When connected and non-empty it overrides the assembled builder output, so you can feed a fully hand-written MiniMax H3 prompt from any upstream node. When left empty the normal prompt builder runs unchanged. +- **Resolution panel:** new Aspect / Resolution / Input scaling selectors under the mode controls (all default to **Auto**). Auto aspect follows the first active visual reference (4:3 fallback with no media); Auto resolution sets a 768 px short side. Fixed aspect ratios, DaSiWa MP and fixed-resolution presets, and CUSTOM values all snap to MiniMax's 16-px grid. Input scaling (Off / Auto / Target / Fit / Fill and crop / Fit and pad / Long side with divisible crop) preprocesses visual references through the included DaSiWa Torch Resize before they reach H3 — Auto never upscales sources at or below a 2048 px short edge. +- **External overwrite inputs:** optional `external_prompt_overwrite` (STRING) replaces the assembled builder output when connected and non-empty. Connect both `external_width_overwrite` and `external_height_overwrite` (INT) to replace the Director canvas; they accept arbitrary positive values and their alignment is the user's responsibility. An active complete dimension pair disables the Director Aspect/Resolution/Input Scaling controls and passes visual media through unchanged. - **REF2VA prompt builder redesigned:** simplified to six free-text fields (subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music). Headers are added automatically when the prompt is sent upstream. Legacy v1 structured-builder data is merged backward-compatibly into these fields. - **Insert [Shot N] button:** opens a small dialog asking for a shot number, then inserts `[Shot N] ` at the current cursor position in the appropriate text area — no more manual typing. - **Prefill Labels & Summary button (REF2VA):** scans your inserted media and generates ``, `