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:
Darksidewalker
2026-08-16 13:03:32 +02:00
parent 26c09b6966
commit 99e3e11601
8 changed files with 313 additions and 42 deletions
+38 -7
View File
@@ -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);
+48 -1
View File
@@ -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"}
+2 -1
View File
@@ -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)
+3 -2
View File
@@ -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
View File
@@ -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);
+38 -12
View File
@@ -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():
+25 -9
View File
@@ -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],