MiniMax H3 Director: resolution/aspect/input-scaling panel + external overwrite inputs
- New Resolution panel (Aspect / Resolution / Input scaling, all Auto default) driving hidden width/height on MiniMax 16px grid; Auto aspect follows the first visual reference (4:3 fallback), Auto resolution sets 768px short side; aspect/MP/pixel presets plus CUSTOM values - Input scaling delegates to DaSiWa_TorchResize (Off/Auto/Target/Fit/Fill and crop/Fit and pad/Long side divisible crop); Auto never upscales sources at or below a 2048px short edge - external_prompt_overwrite replaces external_prompt (legacy input migrated in-UI); external_width/height_overwrite replace the canvas and bypass Director sizing/preprocessing when both are connected - Toolbar consolidated (Mode / Prompt Mode / actions), video dimension probing for auto aspect, progress doc for the feature
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 `<Picture N>`, `<Video N>`, and `<Audio N>` label lines plus a task-prefixed summary line so you can focus on editing instead of boilerplate.
|
||||
@@ -283,7 +284,7 @@ Understanding the data path makes wiring and debugging easier.
|
||||
|
||||
### Upstream inputs (what feeds into the Director)
|
||||
|
||||
- **width / height / duration** widgets: define output resolution and length.
|
||||
- **Resolution panel / duration:** the Resolution panel under the mode controls drives the hidden width/height widgets. **Aspect: Auto** reads the first image or video reference and preserves its aspect; **Resolution: Auto** sets the resulting short side to 768 px. Common horizontal/vertical aspect choices plus DaSiWa MP and fixed-resolution presets are rounded to MiniMax's 16-pixel grid. Both selectors offer **CUSTOM** values for manual aspect, MP, or exact pixels. All three selectors default to **Auto**. The third **Input scaling** selector preprocesses visual references through the included DaSiWa Torch Resize implementation before they reach H3: **Off** preserves the original tensor, **Auto** preserves its aspect with a 2048-px short edge only when that would downscale the source (smaller inputs pass through unchanged), **Target - Selected Aspect & Resolution** stretches it to the selected Director canvas, and **Fit**, **Fill and crop**, **Fit and pad**, and **Long side with divisible crop** use the corresponding Torch Resize aspect modes against that canvas. Audio is never resized. When both external dimension overwrite inputs are connected, these Director calculations and preprocessing are disabled.
|
||||
- **Optional model sockets** (`fl2va_model`, `ref2va_model`): connect only the model matching your current mode; the Guide uses them lazily.
|
||||
- All media is managed inside the node UI (upload/paste/drop), but paths ultimately live in ComfyUI's `input/` folder.
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"feature": "MiniMax H3 Director resolution, aspect, and input scaling panel",
|
||||
"status": "implemented_and_verified",
|
||||
"last_updated": "2026-08-16",
|
||||
"implementation_reference": {
|
||||
"frontend": "js/minimax_h3_director.js",
|
||||
"serialized_state": "timeline_data.resolution (including input_scaling)",
|
||||
"canonical_backend_widgets": ["width", "height"],
|
||||
"alignment_pixels": 16,
|
||||
"auto_resolution_short_side_pixels": 768,
|
||||
"no_media_auto_aspect_fallback": "4:3",
|
||||
"no_media_auto_canvas_pixels": [1024, 768],
|
||||
"fallback_scope": "Auto aspect with no active image/video, including T2VA",
|
||||
"input_scaling": {
|
||||
"implementation": "nodes.helper_minimax_h3_director.scale_input_media delegates to DaSiWa_TorchResize",
|
||||
"modes": ["Off", "Auto", "Target", "Fit", "Fill and crop", "Fit and pad", "Long side with divisible crop"],
|
||||
"auto_short_edge_pixels": 2048,
|
||||
"default": "Auto",
|
||||
"auto_upscale": "disabled; sources with a short edge of 2048px or less pass through unchanged",
|
||||
"target_semantics": "Stretch to the selected 16-aligned Director canvas",
|
||||
"scope": "Decoded image and video reference tensors only; audio passes through unchanged"
|
||||
},
|
||||
"external_dimension_overwrite": {
|
||||
"inputs": ["external_width_overwrite", "external_height_overwrite"],
|
||||
"requirements": "Both inputs must be present and positive; alignment is controlled by the upstream user",
|
||||
"behavior": "Uses the external canvas, returns it via width/height, disables Director canvas calculations and input preprocessing"
|
||||
}
|
||||
},
|
||||
"acceptance_criteria": [
|
||||
{"id": "aspect-manual", "status": "complete", "requirement": "A manual aspect selection recalculates width and height immediately, including without media."},
|
||||
{"id": "aspect-auto-media", "status": "complete", "requirement": "Auto aspect uses the first active image or video dimensions immediately after it is added, removed, or reordered."},
|
||||
{"id": "aspect-auto-empty", "status": "complete", "requirement": "Auto aspect without visual media uses the MiniMax 4:3 fallback and Auto resolution yields 1024x768."},
|
||||
{"id": "resolution-selection", "status": "complete", "requirement": "The selector visibly retains its selected preset rather than reverting to Auto."},
|
||||
{"id": "resolution-readout", "status": "complete", "requirement": "The panel readout names the selected aspect and resolution and shows the resulting 16-aligned canvas."},
|
||||
{"id": "serialization", "status": "complete", "requirement": "Resolution state and canonical width/height widgets update through callbacks for workflow serialization and queue execution."},
|
||||
{"id": "input-scaling", "status": "complete", "requirement": "A third selector persists input_scaling in timeline_data.resolution and applies the included DaSiWa Torch Resize implementation to decoded image/video references before guide construction."},
|
||||
{"id": "verification", "status": "complete", "requirement": "Focused Python/JS tests, JS syntax, Python compilation, and diff whitespace checks pass after the 4:3 fallback adjustment and input-scaling addition."}
|
||||
],
|
||||
"findings": [
|
||||
{
|
||||
"id": "2026-08-16-selector-initialization",
|
||||
"status": "fixed",
|
||||
"detail": "The select value was assigned before options were appended. Browsers can retain the first Auto option instead of the intended serialized value.",
|
||||
"resolution": "Options are appended first, then the persisted value is assigned; input/change events update serialized state."
|
||||
},
|
||||
{
|
||||
"id": "2026-08-16-no-media-resolution",
|
||||
"status": "fixed",
|
||||
"detail": "resolveCanvas returned null for Auto aspect with no visual media and for custom fixed pixels when no aspect could be resolved.",
|
||||
"resolution": "Auto aspect now falls back to MiniMax 4:3 (1024x768 at the 768px short side), and fixed custom pixels resolve before aspect calculation."
|
||||
}
|
||||
],
|
||||
"verification": [
|
||||
{"command": "node .tests/test_minimax_h3_director.mjs", "result": "passed"},
|
||||
{"command": "python -m pytest .tests/test_minimax_h3_director.py", "result": "38 passed"},
|
||||
{"command": "node --check js/minimax_h3_director.js", "result": "passed"},
|
||||
{"command": "python -m py_compile nodes/helper_minimax_h3_director.py nodes/nodes_minimax_h3_director.py", "result": "passed"},
|
||||
{"command": "git diff --check", "result": "passed"}
|
||||
]
|
||||
}
|
||||
+99
-10
@@ -11,8 +11,19 @@ const TASK_TYPES_JS = ["keyframe completion","reference generation","video editi
|
||||
const VISUAL_MARKERS_JS = ["fully_preserved","partially_preserved","attribute_transfer","weak_reference"];
|
||||
const AUDIO_MARKERS_JS = ["fully_copy","partially_copy","reference","weak_reference"];
|
||||
const ALL_MARKERS_JS = [...new Set([...VISUAL_MARKERS_JS,...AUDIO_MARKERS_JS])];
|
||||
const DEFAULT_STATE = { version: 1, items: [], prompt_blocks: [], builder_state: null };
|
||||
const DEFAULT_STATE = { version: 1, items: [], prompt_blocks: [], builder_state: null, resolution: null };
|
||||
const MAX = { image: 9, video: 3, audio: 3, total: 12 };
|
||||
const MINIMAX_MULTIPLE = 16;
|
||||
const ASPECT_OPTIONS = [["auto", "Auto"], ["1:1", "1:1"], ["16:9", "16:9"], ["9:16", "9:16"], ["2:1", "2:1"], ["1:2", "1:2"], ["3:2", "3:2"], ["2:3", "2:3"], ["4:3", "4:3"], ["3:4", "3:4"], ["4:5", "4:5"], ["5:4", "5:4"], ["custom", "CUSTOM"]];
|
||||
const RESOLUTION_PRESETS = {
|
||||
"144p": 0.0352, "240p": 0.0977, "360p": 0.22, "480p": 0.391, "540p": 0.494, "576p": 0.396,
|
||||
"720p": 0.879, "900p": 1.373, "1024p": 1.00, "1080p": 1.978, "1152p": 2.25, "1440p": 3.516,
|
||||
"2160p": 7.91, "2K": 3.906, "4K": 7.91,
|
||||
"0.26 MP - Preview": 0.26, "0.36 MP - Small": 0.36, "0.52 MP - SD": 0.52, "0.65 MP - Balanced": 0.65,
|
||||
"0.83 MP - HD": 0.83, "1.00 MP - 1024p": 1.00, "1.05 MP - HD+": 1.05, "1.20 MP - HD++": 1.20,
|
||||
"1.35 MP - 2K lite": 1.35, "1.55 MP - 2K": 1.55, "1.65 MP - 2K+": 1.65, "1.75 MP - QHD": 1.75,
|
||||
"2.10 MP - FHD": 2.10, "3.30 MP - QHD+": 3.30, "4.75 MP - 2K Pro": 4.75, "6.50 MP - Production": 6.50, "8.30 MP - UHD": 8.30,
|
||||
};
|
||||
const REPOSITORY_URL = "https://github.com/darksidewalker/ComfyUI-DaSiWa-Nodes/blob/main/docs/minimax_h3_director.md";
|
||||
const IMAGE_EXTENSIONS = new Set(["avif", "bmp", "gif", "heic", "heif", "jpeg", "jpg", "jxl", "png", "tif", "tiff", "webp"]);
|
||||
const VIDEO_EXTENSIONS = new Set(["3gp", "avi", "flv", "m2ts", "m4v", "mkv", "mov", "mp4", "mpeg", "mpg", "mts", "ts", "webm", "wmv"]);
|
||||
@@ -123,16 +134,40 @@ function install(node) {
|
||||
if (builderWidget) { builderWidget.hidden = true; builderWidget.draw = () => {}; builderWidget.computeSize = () => [0, -4]; }
|
||||
const modeWidget = node.widgets?.find(w => w.name === "mode");
|
||||
if (modeWidget) { modeWidget.hidden = true; modeWidget.draw = () => {}; modeWidget.computeSize = () => [0, -4]; }
|
||||
const widthWidget = node.widgets?.find(w => w.name === "width");
|
||||
const heightWidget = node.widgets?.find(w => w.name === "height");
|
||||
for (const widget of [widthWidget, heightWidget]) { if (widget) { widget.hidden = true; widget.draw = () => {}; widget.computeSize = () => [0, -4]; } }
|
||||
const prompt = () => node.widgets?.find(w => w.name === "prompt");
|
||||
const promptWidget = prompt(); if (promptWidget) { promptWidget.hidden = true; promptWidget.draw = () => {}; promptWidget.computeSize = () => [0, -4]; }
|
||||
const externalPromptWidget = () => node.widgets?.find(w => w.name === "external_prompt");
|
||||
const externalPromptInput = () => node.inputs?.find(i => i.name === "external_prompt");
|
||||
function migrateLegacyExternalPromptInput() {
|
||||
const legacyIndex = node.inputs?.findIndex(input => input.name === "external_prompt") ?? -1;
|
||||
if (legacyIndex < 0) return;
|
||||
const overwriteIndex = node.inputs?.findIndex(input => input.name === "external_prompt_overwrite") ?? -1;
|
||||
const legacy = node.inputs[legacyIndex];
|
||||
if (overwriteIndex >= 0) {
|
||||
const overwrite = node.inputs[overwriteIndex];
|
||||
if (legacy.link != null && overwrite.link == null) {
|
||||
node.removeInput(overwriteIndex);
|
||||
legacy.name = "external_prompt_overwrite";
|
||||
return;
|
||||
}
|
||||
node.removeInput(legacyIndex);
|
||||
return;
|
||||
}
|
||||
legacy.name = "external_prompt_overwrite";
|
||||
}
|
||||
migrateLegacyExternalPromptInput();
|
||||
const externalPromptWidget = () => node.widgets?.find(w => w.name === "external_prompt_overwrite");
|
||||
const externalPromptInput = () => node.inputs?.find(i => i.name === "external_prompt_overwrite");
|
||||
const externalCanvasInput = name => node.inputs?.find(i => i.name === name);
|
||||
const hasExternalCanvas = () => externalCanvasInput("external_width_overwrite")?.link != null && externalCanvasInput("external_height_overwrite")?.link != null;
|
||||
const hasExternalPrompt = () => {
|
||||
if (externalPromptInput()?.link != null) return true;
|
||||
const w = externalPromptWidget();
|
||||
return Boolean(w && String(w.value || "").trim().length > 0);
|
||||
};
|
||||
let lastExternalPromptLinked = hasExternalPrompt();
|
||||
let lastExternalCanvasLinked = hasExternalCanvas();
|
||||
const status = document.createElement("div"); status.className = "ds-h3-status ds-h3-info-field"; status.textContent = ""; status.style.display = "none";
|
||||
const timeline = document.createElement("div"); timeline.className = "ds-h3 ds-h3-root"; timeline.tabIndex = 0;
|
||||
const setStatus = (message, isError = false) => { status.textContent = message; status.classList.toggle("error", isError); };
|
||||
@@ -450,7 +485,7 @@ function install(node) {
|
||||
window.addEventListener("keydown", previewCloseFn);
|
||||
}
|
||||
const syncAttachedPrompts = () => { const attached = state.items.filter(item => String(item.prompt || "").trim()).map((item, index) => ({ id: `attached-${item.id}`, text: String(item.prompt).trim(), enabled: item.enabled !== false, start: Number(item.start) || 0, duration: Number(item.duration) || 1, order: index })); if (attached.length || state.items.every(item => !item.prompt)) state.prompt_blocks = attached; };
|
||||
const mutate = fn => { fn(state); syncAttachedPrompts(); state.items.forEach((x, i) => { x.order = i; }); state.prompt_blocks.forEach((x, i) => { x.order = i; }); emit(); render(); };
|
||||
const mutate = fn => { fn(state); syncAttachedPrompts(); state.items.forEach((x, i) => { x.order = i; }); state.prompt_blocks.forEach((x, i) => { x.order = i; }); applyResolution?.(); emit(); render(); };
|
||||
const activeItems = () => state.items.filter(x => x.enabled !== false);
|
||||
const isReferenceMode = () => mode() === "REF2VA";
|
||||
const allowsType = type => isReferenceMode() || ((mode() === "I2VA" || mode() === "FL2VA" || mode() === "L2VA") && type === "image");
|
||||
@@ -478,6 +513,23 @@ function install(node) {
|
||||
timeline.addEventListener("keydown", event => { if (event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLInputElement) return; if ((event.key === "Delete" || event.key === "Backspace") && selectedId) { const sel = state.items.find(x => x.id === selectedId); if (sel && isLockedSlot(sel)) { setStatus(`${mediaReferenceName(sel.type)} ${sel.slot + 1} is locked in L2VA mode`, true); return; } event.preventDefault(); event.stopPropagation(); remove(selectedId); } });
|
||||
const move = (id, delta) => mutate(s => { const i = s.items.findIndex(x => x.id === id); const j = i + delta; if (i >= 0 && j >= 0 && j < s.items.length) [s.items[i], s.items[j]] = [s.items[j], s.items[i]]; });
|
||||
const replace = (id, value) => mutate(s => { const x = s.items.find(i => i.id === id); if (x) x.value = value; });
|
||||
const resolutionState = () => ({ aspect: "auto", resolution: "auto", input_scaling: "Auto", custom_aspect_w: 16, custom_aspect_h: 9, custom_mp: 1, custom_width: 1344, custom_height: 768, ...(state.resolution || {}) });
|
||||
const snap16 = value => Math.max(MINIMAX_MULTIPLE, Math.round(Number(value) / MINIMAX_MULTIPLE) * MINIMAX_MULTIPLE);
|
||||
const sourceDimensions = () => activeItems().filter(item => (item.type === "image" || item.type === "video") && Number(item.source_width) > 0 && Number(item.source_height) > 0).sort((a, b) => a.order - b.order)[0];
|
||||
const selectedAspect = settings => {
|
||||
if (settings.aspect === "auto") { const source = sourceDimensions(); return source ? Number(source.source_width) / Number(source.source_height) : 4 / 3; }
|
||||
if (settings.aspect === "custom") return Math.max(1, Number(settings.custom_aspect_w) || 16) / Math.max(1, Number(settings.custom_aspect_h) || 9);
|
||||
const [w, h] = String(settings.aspect).split(":").map(Number); return w > 0 && h > 0 ? w / h : null;
|
||||
};
|
||||
const setCanvasWidgets = (width, height) => { for (const [widget, value] of [[widthWidget, width], [heightWidget, height]]) { if (widget) { widget.value = value; widget.callback?.(value); } } node.setDirtyCanvas?.(true, true); };
|
||||
const resolveCanvas = settings => {
|
||||
if (settings.resolution === "custom" && settings.custom_mode === "fixed") return [snap16(settings.custom_width), snap16(settings.custom_height)];
|
||||
const aspect = selectedAspect(settings); if (!aspect) return null;
|
||||
if (settings.resolution === "auto") { const shortSide = 768; return aspect >= 1 ? [snap16(shortSide * aspect), shortSide] : [shortSide, snap16(shortSide / aspect)]; }
|
||||
if (settings.resolution === "custom") { const pixels = Math.max(.01, Number(settings.custom_mp) || 1) * 1024 * 1024; const h = Math.sqrt(pixels / aspect); return [snap16(h * aspect), snap16(h)]; }
|
||||
const pixels = Number(RESOLUTION_PRESETS[settings.resolution]) * 1024 * 1024; const h = Math.sqrt(pixels / aspect); return [snap16(h * aspect), snap16(h)];
|
||||
};
|
||||
const applyResolution = () => { if (hasExternalCanvas()) return; const canvas = resolveCanvas(resolutionState()); if (canvas) setCanvasWidgets(...canvas); };
|
||||
|
||||
const addPath = type => { if (!allowsType(type)) return; if (count(state, type) >= MAX[type] || activeItems().length >= MAX.total) { status.textContent = `Limit reached: ${MAX[type]} ${type}s / ${MAX.total} files.`; return; } fileInput.accept = type === "image" ? "image/*" : type === "video" ? "video/*" : "audio/*"; fileInput.click(); };
|
||||
const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.multiple = true; fileInput.accept = "image/*,video/*,audio/*"; fileInput.hidden = true;
|
||||
@@ -503,6 +555,17 @@ function install(node) {
|
||||
media.onerror = () => done(null);
|
||||
});
|
||||
}
|
||||
async function probeDimensions(value, type) {
|
||||
if (type !== "image" && type !== "video") return null;
|
||||
const media = document.createElement(type === "video" ? "video" : "img");
|
||||
media.preload = "metadata";
|
||||
media.src = viewUrl(value);
|
||||
return await new Promise(resolve => {
|
||||
const done = () => { const width = type === "video" ? media.videoWidth : media.naturalWidth; const height = type === "video" ? media.videoHeight : media.naturalHeight; media.remove(); resolve(width > 0 && height > 0 ? { source_width: width, source_height: height } : null); };
|
||||
if (type === "video") media.onloadedmetadata = done; else media.onload = done;
|
||||
media.onerror = () => { media.remove(); resolve(null); };
|
||||
});
|
||||
}
|
||||
async function captureFirstFrame(videoUrl) {
|
||||
return await new Promise(resolve => {
|
||||
const vid = document.createElement("video");
|
||||
@@ -535,19 +598,35 @@ function install(node) {
|
||||
mutate(s => { const item = s.items.find(x => x.id === id); if (item) item.waveform_peaks = peaks; });
|
||||
} catch (error) { console.warn("[MiniMax H3 Director] Audio waveform decode failed", error); }
|
||||
}
|
||||
async function acceptFile(file, targetLane = null) { const type = mediaTypeFor(file); const validLane = targetLane === "visual" ? type === "image" || type === "video" : targetLane === "audio" ? type === "audio" : true; const modeSupportsType = !!type && allowsType(type); const lane = type === "audio" ? "audio" : "visual"; if (!type || !validLane || !modeSupportsType) { const requirement = mode() === "FL2VA" ? "FL2VA supports image references only; video and audio are unavailable." : targetLane ? `Drop ${targetLane === "audio" ? "audio" : "image or video"} files on this lane.` : "This media type is not available in the selected MiniMax mode."; setStatus(requirement, true); return; } if (count(state, type) >= MAX[type] || activeItems().length >= MAX.total) { setStatus(`Limit reached: ${MAX[type]} ${type}s / ${MAX.total} files.`, true); return; } const laneAvail = availableSlots(lane); const laneOccupied = new Set(activeItems().filter(x => laneForItem(x) === lane).map(x => x.slot).filter(Number.isInteger)); const laneFree = laneAvail.some(slot => !laneOccupied.has(slot)); if (!laneFree) { setStatus(`No free ${lane} slot is available.`, true); return; } try { const value = await uploadFile(file, status); const sourceDuration = await probeDuration(value, type); if (sourceDuration !== null && sourceDuration < 2) { setStatus(`${file.name}: MiniMax references must be at least 2 seconds.`, true); return; } const duration = sourceDuration === null ? null : Math.min(sourceDuration, 15); let thumbnail = null; if (type === "video") { thumbnail = await captureFirstFrame(viewUrl(value)); } const item = { type, value, thumbnail, ...(duration !== null ? { duration, source_duration: sourceDuration } : {}), ...((type === "video" || type === "audio") ? { trim_start: 0, trim_end: duration } : {}) }; addItem(item); const added = state.items[state.items.length - 1]; if (type === "audio") void extractWaveform(value, added.id); setStatus(sourceDuration > 15 ? `${file.name} added; cropped to the first 15 seconds.` : `${file.name} added.`); } catch (error) { setStatus(error.message, true); } }
|
||||
async function acceptFile(file, targetLane = null) { const type = mediaTypeFor(file); const validLane = targetLane === "visual" ? type === "image" || type === "video" : targetLane === "audio" ? type === "audio" : true; const modeSupportsType = !!type && allowsType(type); const lane = type === "audio" ? "audio" : "visual"; if (!type || !validLane || !modeSupportsType) { const requirement = mode() === "FL2VA" ? "FL2VA supports image references only; video and audio are unavailable." : targetLane ? `Drop ${targetLane === "audio" ? "audio" : "image or video"} files on this lane.` : "This media type is not available in the selected MiniMax mode."; setStatus(requirement, true); return; } if (count(state, type) >= MAX[type] || activeItems().length >= MAX.total) { setStatus(`Limit reached: ${MAX[type]} ${type}s / ${MAX.total} files.`, true); return; } const laneAvail = availableSlots(lane); const laneOccupied = new Set(activeItems().filter(x => laneForItem(x) === lane).map(x => x.slot).filter(Number.isInteger)); const laneFree = laneAvail.some(slot => !laneOccupied.has(slot)); if (!laneFree) { setStatus(`No free ${lane} slot is available.`, true); return; } try { const value = await uploadFile(file, status); const [sourceDuration, dimensions] = await Promise.all([probeDuration(value, type), probeDimensions(value, type)]); if (sourceDuration !== null && sourceDuration < 2) { setStatus(`${file.name}: MiniMax references must be at least 2 seconds.`, true); return; } const duration = sourceDuration === null ? null : Math.min(sourceDuration, 15); let thumbnail = null; if (type === "video") { thumbnail = await captureFirstFrame(viewUrl(value)); } const item = { type, value, thumbnail, ...dimensions, ...(duration !== null ? { duration, source_duration: sourceDuration } : {}), ...((type === "video" || type === "audio") ? { trim_start: 0, trim_end: duration } : {}) }; addItem(item); applyResolution(); const added = state.items[state.items.length - 1]; if (type === "audio") void extractWaveform(value, added.id); setStatus(sourceDuration > 15 ? `${file.name} added; cropped to the first 15 seconds.` : `${file.name} added.`); } catch (error) { setStatus(error.message || "Upload failed", true); } }
|
||||
const render = () => {
|
||||
timeline.replaceChildren();
|
||||
const selected = state.items.find(item => item.id === selectedId);
|
||||
const modeGroup = document.createElement("div"); modeGroup.className = "ds-h3-mode-group"; modeGroup.style.display = "flex"; modeGroup.style.flexDirection = "column"; modeGroup.style.alignItems = "flex-start"; modeGroup.style.gap = "4px"; modeGroup.style.padding = "6px"; modeGroup.style.background = "#0d1217"; modeGroup.style.border = "1px solid #344452"; modeGroup.style.borderRadius = "6px";
|
||||
const topRow = document.createElement("div"); topRow.className = "ds-h3-modebar"; topRow.style.flexWrap = "nowrap"; topRow.style.justifyContent = "space-between"; topRow.style.gap = "6px"; topRow.style.padding = "0"; topRow.style.border = "0"; topRow.style.background = "transparent";
|
||||
const modesSide = document.createElement("span"); modesSide.className = "ds-h3-actions"; modesSide.style.gap = "4px"; ["T2VA", "I2VA", "FL2VA", "L2VA", "REF2VA"].forEach(value => { const button = document.createElement("button"); button.textContent = value; button.classList.toggle("active", mode() === value); button.onclick = () => { if (modeWidget) { modeWidget.value = value; modeWidget.callback?.(value); } if (selectedLane === "audio" && value !== "REF2VA") selectedLane = "visual"; render(); }; modesSide.append(button); });
|
||||
const rightSide = document.createElement("span"); rightSide.className = "ds-h3-actions"; rightSide.style.gap = "4px"; const hasContent = state.items.length || state.prompt_blocks?.length || hasBuilderContent() || String(promptWidget?.value || "").trim(); if (hasContent) { const clearButton = document.createElement("button"); clearButton.className = "ds-h3-clear-btn"; clearButton.textContent = "Clear"; clearButton.title = "Remove all media and prompts"; clearButton.onclick = clearAll; rightSide.append(clearButton); } if (selected) { if (!isLockedSlot(selected)) { const removeButton = document.createElement("button"); removeButton.className = "ds-h3-remove-btn"; removeButton.textContent = "Remove"; removeButton.title = `Remove selected ${selected.type}`; removeButton.onclick = () => remove(selected.id); rightSide.append(removeButton); } else { setStatus(`${mediaReferenceName(selected.type)} ${selected.slot + 1} is locked in L2VA mode`, true); } } const docsButton = document.createElement("button"); docsButton.className = "ds-h3-docs"; docsButton.textContent = "?"; docsButton.title = "Open MiniMax H3 Director documentation on GitHub"; docsButton.onclick = () => window.open(REPOSITORY_URL, "_blank", "noopener,noreferrer"); rightSide.append(docsButton);
|
||||
const styleButton = document.createElement("button"); styleButton.className = "ds-h3-prompt-mode-btn"; const styleLabel = promptStyle(); styleButton.textContent = `Prompt: ${styleLabel === "simple" ? "Simple" : "Structured"}`; styleButton.title = "Toggle between one free-text prompt and the structured builder"; styleButton.onclick = () => { if (styleLabel !== "simple") builderState.simple_prompt = previewTextFor(mode(), false); builderState.prompt_mode = styleLabel === "simple" ? "structured" : "simple"; emit(); render(); }; rightSide.append(styleButton);
|
||||
topRow.append(modesSide, rightSide); modeGroup.append(topRow);
|
||||
const topRow = document.createElement("div"); topRow.className = "ds-h3-modebar"; topRow.style.flexWrap = "nowrap"; topRow.style.gap = "8px"; topRow.style.padding = "0"; topRow.style.border = "0"; topRow.style.background = "transparent"; topRow.style.width = "100%";
|
||||
const controlGroup = () => { const group = document.createElement("span"); group.className = "ds-h3-actions"; group.style.cssText = "gap:4px;flex-wrap:nowrap;white-space:nowrap"; return group; };
|
||||
const modesSide = controlGroup(); const modeLabel = document.createElement("span"); modeLabel.textContent = "Mode:"; modeLabel.style.cssText = "color:#9fb3c2;font-weight:600"; modesSide.append(modeLabel); ["T2VA", "I2VA", "FL2VA", "L2VA", "REF2VA"].forEach(value => { const button = document.createElement("button"); button.textContent = value; button.classList.toggle("active", mode() === value); button.onclick = () => { if (modeWidget) { modeWidget.value = value; modeWidget.callback?.(value); } if (selectedLane === "audio" && value !== "REF2VA") selectedLane = "visual"; render(); }; modesSide.append(button); });
|
||||
const promptSide = controlGroup(); promptSide.style.cssText += ";padding-left:8px;border-left:1px solid #344452"; const promptLabel = document.createElement("span"); promptLabel.textContent = "Prompt Mode:"; promptLabel.style.cssText = "color:#9fb3c2;font-weight:600"; promptSide.append(promptLabel); const styleLabel = promptStyle(); [["simple", "Simple"], ["structured", "Structured"]].forEach(([value, label]) => { const promptButton = document.createElement("button"); promptButton.className = "ds-h3-prompt-mode-btn"; promptButton.textContent = label; promptButton.classList.toggle("active", styleLabel === value); promptButton.style.cssText = styleLabel === value ? "background:rgba(126,235,167,.28)!important;color:#d7ffe3!important;border-color:rgba(126,235,167,.9)!important;box-shadow:0 0 8px rgba(126,235,167,.45)" : "background:rgba(126,235,167,.08)!important;color:#bff3d0!important;border-color:rgba(126,235,167,.4)!important"; promptButton.title = `Use the ${label.toLowerCase()} prompt editor`; promptButton.onclick = () => { if (styleLabel === value) return; if (value === "simple") builderState.simple_prompt = previewTextFor(mode(), false); builderState.prompt_mode = value; emit(); render(); }; promptSide.append(promptButton); });
|
||||
const actionsSide = controlGroup(); actionsSide.style.cssText += ";padding-left:8px;border-left:1px solid #344452"; const hasContent = state.items.length || state.prompt_blocks?.length || hasBuilderContent() || String(promptWidget?.value || "").trim(); if (selected) { if (!isLockedSlot(selected)) { const removeButton = document.createElement("button"); removeButton.className = "ds-h3-remove-btn"; removeButton.textContent = "Remove"; removeButton.title = `Remove selected ${selected.type}`; removeButton.onclick = () => remove(selected.id); actionsSide.append(removeButton); } else { setStatus(`${mediaReferenceName(selected.type)} ${selected.slot + 1} is locked in L2VA mode`, true); } } if (hasContent) { const clearButton = document.createElement("button"); clearButton.className = "ds-h3-clear-btn"; clearButton.textContent = "Clear"; clearButton.title = "Remove all media and prompts"; clearButton.onclick = clearAll; actionsSide.append(clearButton); }
|
||||
const spacer = document.createElement("span"); spacer.style.flex = "1";
|
||||
const docsButton = document.createElement("button"); docsButton.className = "ds-h3-docs"; docsButton.textContent = "?"; docsButton.title = "Open MiniMax H3 Director documentation on GitHub"; docsButton.onclick = () => window.open(REPOSITORY_URL, "_blank", "noopener,noreferrer");
|
||||
topRow.append(modesSide, promptSide, actionsSide, spacer, docsButton); modeGroup.append(topRow);
|
||||
const modeHint = { T2VA: "T2VA · no input frame", I2VA: "I2VA · one opening-frame slot", FL2VA: "FL2VA · opening and closing-frame slots", L2VA: "L2VA · one closing-frame slot" }; const hint = document.createElement("div"); hint.className = "ds-h3-mode-hint"; hint.style.fontSize = "11px"; hint.style.color = "#9fb3c2"; hint.style.margin = "0"; hint.textContent = modeHint[mode()] || `REF2VA · up to ${MAX.image} image, ${MAX.video} video, and ${MAX.audio} audio slots · ${MAX.total} combined files maximum`; modeGroup.append(hint);
|
||||
timeline.append(modeGroup);
|
||||
|
||||
const resolutionPanel = document.createElement("div"); resolutionPanel.className = "ds-h3-resolution-panel"; resolutionPanel.style.cssText = "width:100%;box-sizing:border-box;display:flex;flex-wrap:wrap;align-items:end;gap:6px;padding:7px;margin-top:6px;background:#0d1217;border:1px solid #344452;border-radius:6px";
|
||||
const settings = resolutionState(); const externalCanvas = hasExternalCanvas(); const currentCanvas = externalCanvas ? ["external", "external"] : (resolveCanvas(settings) || [Number(widthWidget?.value) || 1344, Number(heightWidget?.value) || 768]);
|
||||
const updateResolution = patch => mutate(s => { s.resolution = { ...resolutionState(), ...patch }; });
|
||||
const addSelect = (label, value, options, onChange, aspectChoices = false) => { const field = document.createElement("label"); field.style.cssText = "display:flex;flex-direction:column;gap:3px;font-size:10px;color:#9fb3c2;min-width:150px"; field.textContent = label; const select = document.createElement("select"); select.className = "ds-h3-resolution-select"; options.forEach(([id, text]) => { const option = document.createElement("option"); option.value = id; option.textContent = aspectChoices && id.includes(":") ? `▭ ${text}` : text; select.append(option); }); select.value = value; select.oninput = event => onChange(event.target.value); select.onchange = event => onChange(event.target.value); field.append(select); resolutionPanel.append(field); };
|
||||
const disableWhenExternal = () => { if (externalCanvas) resolutionPanel.querySelectorAll("select,input").forEach(control => { control.disabled = true; }); };
|
||||
addSelect("ASPECT", settings.aspect, ASPECT_OPTIONS, aspect => updateResolution({ aspect }), true);
|
||||
addSelect("RESOLUTION", settings.resolution, [["auto", "Auto · short side 768 px"], ...Object.keys(RESOLUTION_PRESETS).map(name => [name, name]), ["custom", "CUSTOM"]], resolution => updateResolution({ resolution }));
|
||||
addSelect("INPUT SCALING", settings.input_scaling, [["Off", "Off"], ["Auto", "Auto · short edge 2048 px"], ["Target", "Target · Selected Aspect & Resolution"], ["Fit", "Fit"], ["Fill and crop", "Fill and crop"], ["Fit and pad", "Fit and pad"], ["Long side with divisible crop", "Long side with divisible crop"]], input_scaling => updateResolution({ input_scaling }));
|
||||
if (settings.aspect === "custom") { for (const [name, key] of [["W", "custom_aspect_w"], ["H", "custom_aspect_h"]]) { const field = document.createElement("label"); field.style.cssText = "display:flex;flex-direction:column;gap:3px;font-size:10px;color:#9fb3c2;width:56px"; field.textContent = `RATIO ${name}`; const input = document.createElement("input"); input.type = "number"; input.min = "1"; input.step = "1"; input.value = settings[key]; input.onchange = event => updateResolution({ [key]: Math.max(1, Number(event.target.value) || 1) }); field.append(input); resolutionPanel.append(field); } }
|
||||
if (settings.resolution === "custom") { addSelect("CUSTOM", settings.custom_mode || "mp", [["mp", "Megapixels"], ["fixed", "Fixed pixels"]], custom_mode => updateResolution({ custom_mode })); const customKey = (settings.custom_mode || "mp") === "mp" ? "custom_mp" : "custom_width"; if (customKey === "custom_mp") { const field = document.createElement("label"); field.style.cssText = "display:flex;flex-direction:column;gap:3px;font-size:10px;color:#9fb3c2;width:76px"; field.textContent = "MP"; const input = document.createElement("input"); input.type = "number"; input.min = ".01"; input.step = ".01"; input.value = settings.custom_mp; input.onchange = event => updateResolution({ custom_mp: Number(event.target.value) || settings.custom_mp }); field.append(input); resolutionPanel.append(field); } else { const dimensions = document.createElement("div"); dimensions.className = "ds-h3-fixed-dimensions"; dimensions.style.cssText = "display:flex;gap:6px;align-items:end"; const dimensionField = (label, value, onChange) => { const field = document.createElement("label"); field.style.cssText = "display:flex;flex-direction:column;gap:3px;font-size:10px;color:#9fb3c2;width:76px"; field.textContent = label; const input = document.createElement("input"); input.type = "number"; input.min = "16"; input.step = "16"; input.value = value; input.onchange = event => onChange(Number(event.target.value) || value); field.append(input); return field; }; const widthField = dimensionField("WIDTH", settings.custom_width, custom_width => updateResolution({ custom_width })); const heightField = dimensionField("HEIGHT", settings.custom_height, custom_height => updateResolution({ custom_height })); dimensions.append(widthField, heightField); resolutionPanel.append(dimensions); } }
|
||||
disableWhenExternal();
|
||||
const readout = document.createElement("span"); readout.style.cssText = "margin-left:auto;font-size:11px;color:#7ee19d;white-space:nowrap"; readout.textContent = externalCanvas ? "External width/height overwrite · Director sizing and input scaling disabled" : `${settings.aspect === "auto" ? "Auto aspect" : settings.aspect} · ${settings.resolution === "auto" ? "Auto 768px" : settings.resolution} · ${currentCanvas[0]} × ${currentCanvas[1]} · 16px`; resolutionPanel.append(readout); timeline.append(resolutionPanel);
|
||||
|
||||
const lengthWidget = node.widgets?.find(w => w.name === "duration"); const timelineSeconds = Math.max(1, Number(lengthWidget?.value) || 5); lastTimelineLength = timelineSeconds;
|
||||
|
||||
ensureLayout();
|
||||
@@ -651,6 +730,11 @@ function install(node) {
|
||||
lastExternalPromptLinked = linked;
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
const externalCanvasLinked = hasExternalCanvas();
|
||||
if (externalCanvasLinked !== lastExternalCanvasLinked) {
|
||||
lastExternalCanvasLinked = externalCanvasLinked;
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
};
|
||||
node.__dasiwaH3RestorePersistedState = () => requestAnimationFrame(restorePersistedState);
|
||||
node.__dasiwaH3State = () => state; node.__dasiwaH3Render = render;
|
||||
@@ -668,6 +752,11 @@ function install(node) {
|
||||
lastExternalPromptLinked = linked;
|
||||
render();
|
||||
}
|
||||
const externalCanvasLinked = hasExternalCanvas();
|
||||
if (externalCanvasLinked !== lastExternalCanvasLinked) {
|
||||
lastExternalCanvasLinked = externalCanvasLinked;
|
||||
render();
|
||||
}
|
||||
}, 300);
|
||||
syncNodeBounds();
|
||||
requestAnimationFrame(syncNodeBounds);
|
||||
|
||||
@@ -5,16 +5,17 @@ regression-tested from a source checkout.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import math
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .nodes_scaling import DaSiWa_TorchResize
|
||||
|
||||
FPS = 24
|
||||
AUDIO_LATENT_FPS = 40
|
||||
CANVAS_MULTIPLE = 32
|
||||
CANVAS_MULTIPLE = 16
|
||||
BASE_SHORT_EDGE = 768
|
||||
MAX_PIXELS = 768 * 1344
|
||||
REF_IMAGE_SHORT_EDGE = 2048
|
||||
@@ -62,22 +63,47 @@ def temporal_shape(length: int) -> tuple[int, int, int]:
|
||||
|
||||
|
||||
def adapt_canvas(width: int, height: int) -> tuple[int, int]:
|
||||
return scale_canvas_to_short_edge(width, height)
|
||||
|
||||
|
||||
def scale_canvas_to_short_edge(width: int, height: int, short_edge: int = BASE_SHORT_EDGE) -> tuple[int, int]:
|
||||
"""Preserve a source aspect while setting its shorter output side for H3."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("reference dimensions must be positive")
|
||||
ratio = width / height
|
||||
if ratio >= 1:
|
||||
nom_w, nom_h = BASE_SHORT_EDGE * ratio, BASE_SHORT_EDGE
|
||||
else:
|
||||
nom_w, nom_h = BASE_SHORT_EDGE, BASE_SHORT_EDGE / ratio
|
||||
if nom_w * nom_h > MAX_PIXELS:
|
||||
scale = math.sqrt(MAX_PIXELS / (nom_w * nom_h))
|
||||
nom_w, nom_h = nom_w * scale, nom_h * scale
|
||||
scale = float(short_edge) / min(width, height)
|
||||
scaled_width, scaled_height = width * scale, height * scale
|
||||
return (
|
||||
max(CANVAS_MULTIPLE, round(nom_w / CANVAS_MULTIPLE) * CANVAS_MULTIPLE),
|
||||
max(CANVAS_MULTIPLE, round(nom_h / CANVAS_MULTIPLE) * CANVAS_MULTIPLE),
|
||||
max(CANVAS_MULTIPLE, round(scaled_width / CANVAS_MULTIPLE) * CANVAS_MULTIPLE),
|
||||
max(CANVAS_MULTIPLE, round(scaled_height / CANVAS_MULTIPLE) * CANVAS_MULTIPLE),
|
||||
)
|
||||
|
||||
|
||||
INPUT_SCALING_MODES = ("Off", "Auto", "Target", "Fit", "Fill and crop", "Fit and pad", "Long side with divisible crop")
|
||||
|
||||
|
||||
def scale_input_media(image, behavior: str, target_width: int, target_height: int):
|
||||
"""Apply Director input preprocessing through the existing DaSiWa Torch Resize node."""
|
||||
behavior = str(behavior or "Off")
|
||||
if behavior not in INPUT_SCALING_MODES:
|
||||
raise ValueError(f"unsupported MiniMax Director input scaling behavior: {behavior}")
|
||||
if behavior == "Off":
|
||||
return image
|
||||
if not hasattr(image, "shape") or len(image.shape) != 4:
|
||||
return image
|
||||
resize = DaSiWa_TorchResize()
|
||||
if behavior == "Auto":
|
||||
source_short_edge = min(int(image.shape[1]), int(image.shape[2]))
|
||||
if source_short_edge <= REF_IMAGE_SHORT_EDGE:
|
||||
return image
|
||||
return resize.resize(image, "Multiplier", "Fit", target_width, target_height,
|
||||
REF_IMAGE_SHORT_EDGE / source_short_edge, "Lanczos", True,
|
||||
CANVAS_MULTIPLE, "0, 0, 0", "center", 0, 16.0, 64)[0]
|
||||
aspect_mode = "Stretch" if behavior == "Target" else behavior
|
||||
return resize.resize(image, "Target resolution", aspect_mode, target_width, target_height,
|
||||
1.0, "Lanczos", True, CANVAS_MULTIPLE, "0, 0, 0", "center",
|
||||
0, 16.0, 64)[0]
|
||||
|
||||
|
||||
def assemble_prompt(prompt: str = "", prompt_blocks=None) -> str:
|
||||
parts = []
|
||||
if prompt and prompt.strip():
|
||||
|
||||
@@ -5,7 +5,7 @@ from .helper_logging import log_dasiwa
|
||||
from .helper_minimax_h3_director import (
|
||||
align_frame_count, assemble_prompt, audio_duration, load_audio,
|
||||
load_embedded_video_audio, load_image, load_video, normalize_guide,
|
||||
validate_reference_limits,
|
||||
scale_input_media, validate_reference_limits,
|
||||
)
|
||||
from .helper_minimax_h3_prompt_builder import (
|
||||
build_prompt, default_builder_state, normalize_ref_schema, validate_builder_state,
|
||||
@@ -28,8 +28,8 @@ class MiniMaxH3Director:
|
||||
"required": {
|
||||
"mode": (["T2VA", "I2VA", "FL2VA", "L2VA", "REF2VA"], {"default": "FL2VA"}),
|
||||
"prompt": ("STRING", {"default": "", "multiline": True}),
|
||||
"width": ("INT", {"default": 1344, "min": 32, "max": 8192, "step": 32}),
|
||||
"height": ("INT", {"default": 768, "min": 32, "max": 8192, "step": 32}),
|
||||
"width": ("INT", {"default": 1344, "min": 16, "max": 8192, "step": 16}),
|
||||
"height": ("INT", {"default": 768, "min": 16, "max": 8192, "step": 16}),
|
||||
"duration": ("INT", {"default": 5, "min": 1, "max": 1000}),
|
||||
"ref_image_size": (["match", "max"], {"default": "match"}),
|
||||
"timeline_data": ("STRING", {"default": "{\"version\":1,\"items\":[],\"prompt_blocks\":[]}", "multiline": False, "hidden": True}),
|
||||
@@ -38,7 +38,9 @@ class MiniMaxH3Director:
|
||||
"optional": {
|
||||
"fl2va_model": ("MODEL", {"lazy": True}),
|
||||
"ref2va_model": ("MODEL", {"lazy": True}),
|
||||
"external_prompt": ("STRING", {"default": "", "multiline": True, "forceInput": True}),
|
||||
"external_width_overwrite": ("INT", {"min": 1, "max": 8192, "step": 1, "forceInput": True}),
|
||||
"external_height_overwrite": ("INT", {"min": 1, "max": 8192, "step": 1, "forceInput": True}),
|
||||
"external_prompt_overwrite": ("STRING", {"default": "", "multiline": True, "forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,13 +50,15 @@ class MiniMaxH3Director:
|
||||
CATEGORY = "DaSiWa Nodes/MiniMax H3"
|
||||
|
||||
def check_lazy_status(self, mode, prompt, width, height, duration, ref_image_size, timeline_data, builder_state,
|
||||
fl2va_model=None, ref2va_model=None, external_prompt=None):
|
||||
fl2va_model=None, ref2va_model=None, external_width_overwrite=None,
|
||||
external_height_overwrite=None, external_prompt_overwrite=None, external_prompt=None):
|
||||
selected_name = "ref2va_model" if mode == "REF2VA" else "fl2va_model"
|
||||
selected_model = ref2va_model if mode == "REF2VA" else fl2va_model
|
||||
return [selected_name] if selected_model is None else []
|
||||
|
||||
def build_guide(self, mode, prompt, width, height, duration, ref_image_size, timeline_data, builder_state="",
|
||||
fl2va_model=None, ref2va_model=None, external_prompt=None):
|
||||
fl2va_model=None, ref2va_model=None, external_width_overwrite=None,
|
||||
external_height_overwrite=None, external_prompt_overwrite=None, external_prompt=None):
|
||||
# Preserve direct Python callers that used the pre-builder positional model argument.
|
||||
if builder_state is not None and not isinstance(builder_state, str):
|
||||
if fl2va_model is None:
|
||||
@@ -64,6 +68,13 @@ class MiniMaxH3Director:
|
||||
raise ValueError("builder_state must be JSON text")
|
||||
if mode not in BASE_MODES | {"REF2VA"}:
|
||||
raise ValueError(f"unsupported MiniMax Director mode: {mode}")
|
||||
external_canvas = external_width_overwrite is not None or external_height_overwrite is not None
|
||||
if external_canvas:
|
||||
if external_width_overwrite is None or external_height_overwrite is None:
|
||||
raise ValueError("both external width overwrite and external height overwrite are required")
|
||||
width, height = int(external_width_overwrite), int(external_height_overwrite)
|
||||
if width < 1 or height < 1:
|
||||
raise ValueError("external width overwrite and external height overwrite must be positive")
|
||||
length = align_frame_count(int(duration) * 24)
|
||||
try:
|
||||
state = json.loads(timeline_data or "{}")
|
||||
@@ -71,6 +82,7 @@ class MiniMaxH3Director:
|
||||
raise ValueError(f"MiniMax Director timeline_data is invalid JSON: {exc}") from exc
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError("MiniMax Director timeline_data must contain an object")
|
||||
input_scaling = "Off" if external_canvas else (state.get("resolution") or {}).get("input_scaling", "Auto")
|
||||
try:
|
||||
builder = json.loads(builder_state) if builder_state else state.get("builder_state", {})
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
@@ -107,6 +119,7 @@ class MiniMaxH3Director:
|
||||
value = item.get("value", item.get("tensor"))
|
||||
if isinstance(value, str) and input_directory:
|
||||
value = load_image(value, input_directory)
|
||||
value = scale_input_media(value, input_scaling, width, height)
|
||||
if mode == "I2VA":
|
||||
first_frame = value
|
||||
elif mode == "L2VA" or (mode == "FL2VA" and item.get("slot", index) == 1):
|
||||
@@ -125,6 +138,7 @@ class MiniMaxH3Director:
|
||||
video_mode = item.get("media_mode", "video")
|
||||
if kind == "image":
|
||||
value = load_image(value, input_directory) if isinstance(value, str) and input_directory else value
|
||||
value = scale_input_media(value, input_scaling, width, height)
|
||||
ref_images[f"ref_image_{len(ref_images) + 1}"] = value
|
||||
images.append(item)
|
||||
elif kind == "audio":
|
||||
@@ -136,6 +150,7 @@ class MiniMaxH3Director:
|
||||
raise ValueError(f"unsupported video media mode: {video_mode}")
|
||||
if video_mode in {"video", "video_audio"}:
|
||||
video = load_video(value, input_directory, trim_start=trim_start, trim_end=trim_end) if isinstance(value, str) and input_directory else value
|
||||
video = scale_input_media(video, input_scaling, width, height)
|
||||
ref_videos[f"ref_video_{len(ref_videos) + 1}"] = video
|
||||
video_duration = float(video.shape[0]) / 24.0 if hasattr(video, "shape") else item.get("duration")
|
||||
videos.append({**item, "duration": video_duration})
|
||||
@@ -158,8 +173,9 @@ class MiniMaxH3Director:
|
||||
validate_reference_limits(images=images, videos=videos, audios=audios)
|
||||
|
||||
blocks = state.get("prompt_blocks", [])
|
||||
if isinstance(external_prompt, str) and external_prompt.strip():
|
||||
resolved = external_prompt
|
||||
prompt_overwrite = external_prompt_overwrite if external_prompt_overwrite is not None else external_prompt
|
||||
if isinstance(prompt_overwrite, str) and prompt_overwrite.strip():
|
||||
resolved = prompt_overwrite
|
||||
else:
|
||||
resolved = build_prompt(merged)
|
||||
if not any(str(merged.get(key) or "").strip() for key in ("imd", "soundscape")) and mode != "REF2VA":
|
||||
@@ -168,7 +184,7 @@ class MiniMaxH3Director:
|
||||
log_dasiwa("MiniMax H3 Director", f"[{issue['level'].upper()}] {issue['msg']}")
|
||||
guide = {
|
||||
"version": 2, "mode": mode, "prompt": prompt, "prompt_blocks": blocks, "resolved_prompt": resolved,
|
||||
"width": width, "height": height, "length": length, "ref_image_size": ref_image_size,
|
||||
"width": width, "height": height, "length": length, "ref_image_size": ref_image_size, "input_scaling": input_scaling,
|
||||
"first_frame": first_frame, "last_frame": last_frame, "ref_images": ref_images, "ref_videos": ref_videos,
|
||||
"ref_video_audios": ref_video_audios, "ref_audios": ref_audios, "builder_state": merged,
|
||||
"timeline": [{key: item.get(key) for key in ("id", "type", "start", "duration", "order", "trim_start", "trim_end") if key in item} for _, item in items],
|
||||
|
||||
Reference in New Issue
Block a user