Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04690e9689 | ||
|
|
c37386c1d4 | ||
|
|
7cb0abaacf |
@@ -4,6 +4,11 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **New node: Load Audio.** Load a sound file and keep only the part you
|
||||
want. The node shows the sound as a wave: drag the IN and OUT handles to
|
||||
trim it, and press play to hear just that part. Set Max length to the
|
||||
longest sound the next step takes, and anything longer shows in red as
|
||||
cut off, so you see exactly what comes out before you run.
|
||||
- **Resizing one node of a selection now resizes them all.** With two or more
|
||||
nodes selected, pull the edge or corner of one: nodes stacked in a column
|
||||
get its width, nodes side by side in a row get its height, and a grid gets
|
||||
|
||||
@@ -162,6 +162,12 @@ Upload a video, or pick one from the Source list, which plays the clip under the
|
||||
|
||||
It returns the frames, audio, frame count, fps, width, height, duration and a core `VIDEO`. Only the part you kept is decoded, and it checks memory before a large load. When you save the frames, wire its fps into the save node.
|
||||
|
||||
### Load Audio
|
||||
|
||||
Upload a sound file, or pick one from the Source list, and the node shows it as a wave. Drag the IN and OUT handles to keep only the part you want, or type the times. Press play to hear just that part. Set **Max length** to the longest sound the next step can take, such as a video's length: anything past it turns red and is cut off, so you see what comes out before you run. Click the red length next to OUT to move OUT to the cut.
|
||||
|
||||
It returns the `audio` you kept and its length in `seconds`. Only that part is decoded. [Help page](js/docs/AUSBOSS_NODES_LoadAudio.md).
|
||||
|
||||
### Video Crop + Rotate + Pad → Frame
|
||||
|
||||
Picks one frame from a video, then gives you the same rotate, crop and pad controls as the image node, plus an optional megapixel resize. Scrub to the frame with the playhead on the node; the editor adds frame-by-frame stepping and playback. It returns the transformed `image`, a `mask` of the added area, a `stitcher`, the chosen `original` frame, and the output `width` and `height`. For a whole clip, use the Clip version below.
|
||||
|
||||
@@ -36,6 +36,7 @@ NODE_MODULES = [
|
||||
"node_layout_boxes",
|
||||
"node_literals",
|
||||
"node_load_image_pad",
|
||||
"node_load_audio",
|
||||
"node_load_video",
|
||||
"node_lora_loader",
|
||||
"node_mask_by_name",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Load Audio
|
||||
|
||||
Loads a sound file and hands on only the part you pick. Use it for a voice
|
||||
clip, a song or any sound a video model or a save node takes, when you want
|
||||
one part of the file and not the whole thing.
|
||||
|
||||
The node shows the whole file as a wave, so you can see where the loud parts
|
||||
are. The part that comes out is bright. Drag the white **IN** and **OUT**
|
||||
handles to set where it starts and stops, drag the bright part to slide it,
|
||||
or click somewhere else on the wave to move it there. You can also type a time
|
||||
under the wave, in seconds (`12.5`) or minutes and seconds (`1:02.5`).
|
||||
|
||||
Press **▶** to hear only the part that comes out. **LOOP** plays it again and
|
||||
again.
|
||||
|
||||
## Max length
|
||||
|
||||
Set **Max length** to the longest sound the next step can take, such as the
|
||||
length of the video you are making. The start stays where it is, and anything
|
||||
past the limit turns red with a dashed line where it is cut. Next to OUT,
|
||||
the node shows how long the sound that comes out is, such as `15.0 s`. It
|
||||
turns red when part of your pick is cut; hold the mouse over it to see how
|
||||
much, and click it to move OUT to the cut. Play stops at the cut, so what you
|
||||
hear is what comes out. `0` means no limit.
|
||||
|
||||
Max length can be wired. When the wire comes from a number node such as
|
||||
Float 🆎, the wave shows the cut for that number; otherwise the length's tooltip says
|
||||
`max wired` and the run still cuts the sound to the wired length.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Source** (`audio`): a sound file in ComfyUI's input folder. **Upload**
|
||||
adds one. Videos are listed as well, for their sound.
|
||||
- **Max length** (`max_seconds`): the longest the sound may be, in seconds.
|
||||
`0` means no limit.
|
||||
- **IN** (`start_seconds`): where the sound starts.
|
||||
- **OUT** (`end_seconds`): where the sound stops. `0` means the end of the
|
||||
file.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `audio`: the part you picked, for any node that takes `AUDIO`.
|
||||
- `seconds`: how long that sound is.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Only the picked part is decoded: decoding stops once the window is full,
|
||||
so a short pick from a long recording stays quick.
|
||||
- The sound keeps its own sample rate and channels.
|
||||
- A start past the end of the file stops the run with a message that says so.
|
||||
- The wave is drawn in the browser from the file itself; nothing extra is
|
||||
stored with the workflow besides the four values above.
|
||||
@@ -27,6 +27,15 @@ needs.
|
||||
- The line underneath shows the last run's seed. The history saves with the
|
||||
workflow.
|
||||
|
||||
## Compact look
|
||||
|
||||
The **gear** beside the number picks the look. **Compact** is one short
|
||||
row: the number, a mode chip and a dice. Tap the chip to go Random → Fixed →
|
||||
+1 → −1. The dice rolls a new seed and pins it. A long seed shrinks to fit.
|
||||
Hover the number to see the last run's seed. The look is saved on the node,
|
||||
and new Seed nodes start in the look you picked last. **Full** brings back
|
||||
the whole card, with the seeds it ran with.
|
||||
|
||||
## Output
|
||||
|
||||
- **seed**: The seed, unchanged.
|
||||
|
||||
@@ -33,6 +33,7 @@ help panel.
|
||||
| Apply a stack of LoRAs | LoRA Loader (`AUSBOSS_NODES_LoraLoader`) |
|
||||
| Krea 2 prompt, and Krea 2 outpainting | Krea 2 Encode (`AUSBOSS_NODES_Krea2Encode`), Krea 2 Outpaint Model Patch (`AUSBOSS_NODES_Krea2OutpaintModelPatch`) |
|
||||
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
|
||||
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
|
||||
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
|
||||
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
|
||||
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Load Audio's window rules for the face, mirroring audio_window in
|
||||
// nodes/_audio_trim_helpers.py so the wave shows exactly what one Run hands
|
||||
// on. Pure: tests/load_audio_window.test.mjs runs it without a browser.
|
||||
|
||||
function finite(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
// The pick, and what comes out of it. `end` 0 means "to the end of the
|
||||
// file". A max above 0 keeps the start and cuts off anything past
|
||||
// start + max. All times in seconds; duration is the file's length.
|
||||
export function outputWindow(durationValue, startValue, endValue, maxValue = 0) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const start = Math.min(Math.max(0, finite(startValue)), duration);
|
||||
const pickedEnd = finite(endValue) > 0 ? Math.min(finite(endValue), duration) : duration;
|
||||
const end = Math.max(start, pickedEnd);
|
||||
const max = Math.max(0, finite(maxValue));
|
||||
const keepEnd = max > 0 && end - start > max ? Math.min(duration, start + max) : end;
|
||||
return { start, end, keepEnd, length: keepEnd - start, cut: Math.max(0, end - keepEnd) };
|
||||
}
|
||||
|
||||
// "0:12.0" style, the way the IN and OUT fields write a time.
|
||||
export function secondsLabel(value) {
|
||||
const seconds = Math.max(0, finite(value));
|
||||
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// The line between IN and OUT: what comes out, and what the max cuts off.
|
||||
export function outputSummary(window, { maxFromLink = false } = {}) {
|
||||
if (!window || window.length <= 0) return "";
|
||||
const parts = [secondsLabel(window.length)];
|
||||
if (window.cut > 0.005) parts.push(`cut ${secondsLabel(window.cut)}`);
|
||||
if (maxFromLink) parts.push("max wired");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// Loud and quiet across the file: for each of `buckets` slices, the lowest
|
||||
// and highest sample of the channels mixed down. Channel data is a list of
|
||||
// Float32Arrays (AudioBuffer.getChannelData). Returns a Float32Array of
|
||||
// [min0, max0, min1, max1, ...].
|
||||
export function wavePeaks(channels, bucketsValue = 1200) {
|
||||
const list = Array.isArray(channels) ? channels.filter(Boolean) : [];
|
||||
const buckets = Math.max(1, Math.floor(finite(bucketsValue, 1200)));
|
||||
const peaks = new Float32Array(buckets * 2);
|
||||
const length = list.length ? list[0].length : 0;
|
||||
if (!length) return peaks;
|
||||
const count = list.length;
|
||||
for (let bucket = 0; bucket < buckets; bucket += 1) {
|
||||
const from = Math.floor((bucket * length) / buckets);
|
||||
const to = Math.max(from + 1, Math.floor(((bucket + 1) * length) / buckets));
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let index = from; index < to && index < length; index += 1) {
|
||||
let sample = 0;
|
||||
for (let channel = 0; channel < count; channel += 1) sample += list[channel][index] || 0;
|
||||
sample /= count;
|
||||
if (sample < low) low = sample;
|
||||
if (sample > high) high = sample;
|
||||
}
|
||||
peaks[bucket * 2] = low;
|
||||
peaks[bucket * 2 + 1] = high;
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
// Which part of the wave a press at `fraction` (0..1 across it) grabs: a
|
||||
// handle within `hitPixels` of it, the inside of the pick to slide it, or
|
||||
// "jump" to move the pick there.
|
||||
export function grabZone(window, durationValue, fraction, widthValue, hitPixels = 10) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const width = Math.max(0, finite(widthValue));
|
||||
if (duration <= 0 || width <= 0) return "none";
|
||||
const x = Math.min(1, Math.max(0, finite(fraction))) * width;
|
||||
const startX = (window.start / duration) * width;
|
||||
const endX = (window.end / duration) * width;
|
||||
const toStart = Math.abs(x - startX);
|
||||
const toEnd = Math.abs(x - endX);
|
||||
if (Math.min(toStart, toEnd) <= hitPixels) return toStart <= toEnd ? "start" : "end";
|
||||
if (x > startX && x < endX) return "move";
|
||||
return "jump";
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
// Load Audio 🆎 - the wave, the trim and the player.
|
||||
//
|
||||
// The card above (js/widget_cards) holds Source and Max length. This panel
|
||||
// draws the whole file as a wave: the part that comes out is bright, the
|
||||
// part a Max length cuts off is red, and the rest is dim. Drag a handle to
|
||||
// move IN or OUT, drag the middle to slide the pick, or click elsewhere to
|
||||
// move it there. Play plays only what comes out. The widgets underneath
|
||||
// (start_seconds, end_seconds, max_seconds) stay the source of truth.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
|
||||
import { parseImageReference } from "../shared/pad_canvas.mjs";
|
||||
import { mediaViewQuery, splitMediaName } from "../shared/video_preview.mjs";
|
||||
import { ensureVideoCss, hideCanvasWidget, makeToolButton } from "../shared/video_ui.mjs";
|
||||
import { MIN_TRIM_SECONDS, dragTrimHandle, slideTrimWindow } from "../load_video/trim_preview.mjs";
|
||||
import { grabZone, outputSummary, outputWindow, secondsLabel, wavePeaks } from "./audio_window.mjs";
|
||||
|
||||
const NODE_NAME = "AUSBOSS_NODES_LoadAudio";
|
||||
const PANEL_WIDGET = "ausboss_load_audio_wave";
|
||||
// The frontend's own player for an audio_upload input. The wave replaces it.
|
||||
const CORE_AUDIO_WIDGET = "audioUI";
|
||||
const MIN_WIDTH = 280;
|
||||
// 8px root padding + the wave's 28px floor + 6px gap + the 22px play, IN/OUT and loop row.
|
||||
const PANEL_MIN_HEIGHT = 64;
|
||||
// Opens small and neat: 300 px wide, with a wave about as tall as a field
|
||||
// (32 px). Drag the corner down for a taller wave.
|
||||
const DEFAULT_WIDTH = 300;
|
||||
const DEFAULT_WAVE_EXTRA = 4;
|
||||
const CUT_RED = "#e0525a";
|
||||
const PEAK_BUCKETS = 1600;
|
||||
const END_EPSILON = 0.03;
|
||||
|
||||
// Decoded once per file and kept small: only the peaks and the length.
|
||||
const peakCache = new Map();
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((widget) => widget.name === name);
|
||||
}
|
||||
|
||||
function roundHundredth(value) {
|
||||
return Math.round(Number(value || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function hideCoreAudio(node) {
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget?.name === CORE_AUDIO_WIDGET) hideCanvasWidget(widget, widget.element);
|
||||
}
|
||||
}
|
||||
|
||||
// A wired Max length: the number the wire carries, when the far end is a
|
||||
// node that shows it (Float, Integer, a Primitive). null when it can't be known.
|
||||
function wiredMax(node) {
|
||||
const input = node.inputs?.find((slot) => slot?.widget?.name === "max_seconds");
|
||||
if (!input || input.link == null) return { wired: false, value: null };
|
||||
const graph = node.graph;
|
||||
const link = graph?.getLink?.(input.link) ?? graph?.links?.get?.(input.link) ?? graph?.links?.[input.link];
|
||||
const origin = link ? graph?.getNodeById?.(link.origin_id) : null;
|
||||
const shown = origin?.widgets?.find((widget) => typeof widget?.value === "number");
|
||||
return { wired: true, value: shown ? Number(shown.value) : null };
|
||||
}
|
||||
|
||||
function maxSeconds(state) {
|
||||
const wired = wiredMax(state.node);
|
||||
if (wired.wired) return { value: wired.value ?? 0, fromLink: true, known: wired.value != null };
|
||||
return { value: Number(state.maxWidget?.value) || 0, fromLink: false, known: true };
|
||||
}
|
||||
|
||||
function currentWindow(state) {
|
||||
return outputWindow(state.duration, state.startWidget.value, state.endWidget.value, maxSeconds(state).value);
|
||||
}
|
||||
|
||||
async function loadPeaks(name, route) {
|
||||
const key = name;
|
||||
if (peakCache.has(key)) return peakCache.get(key);
|
||||
const job = (async () => {
|
||||
const response = await api.fetchApi(route, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`the file could not be read (${response.status})`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const Context = window.AudioContext || window.webkitAudioContext;
|
||||
const context = new Context();
|
||||
try {
|
||||
const buffer = await context.decodeAudioData(bytes);
|
||||
const channels = [];
|
||||
for (let index = 0; index < buffer.numberOfChannels; index += 1) channels.push(buffer.getChannelData(index));
|
||||
return { duration: buffer.duration, peaks: wavePeaks(channels, PEAK_BUCKETS) };
|
||||
} finally {
|
||||
context.close?.();
|
||||
}
|
||||
})();
|
||||
peakCache.set(key, job);
|
||||
job.catch(() => peakCache.delete(key));
|
||||
return job;
|
||||
}
|
||||
|
||||
function ensureAudioCss() {
|
||||
ensureVideoCss();
|
||||
if (document.getElementById("ausboss-load-audio-css")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-load-audio-css";
|
||||
style.textContent = `
|
||||
.ausboss-audio-root .ausboss-video-stage{min-height:28px;background:#050b0b;cursor:pointer;touch-action:none;}
|
||||
.ausboss-audio-root canvas.ausboss-audio-wave{position:absolute;inset:0;width:100%;height:100%;display:block;}
|
||||
.ausboss-audio-root .ausboss-video-stage:not(.is-empty) .ausboss-video-status{display:none;}
|
||||
.ausboss-audio-root .ausboss-audio-row{display:flex;align-items:center;gap:6px;flex:none;padding:0 2px;}
|
||||
.ausboss-audio-root .ausboss-audio-row .ausboss-video-values{flex:1 1 auto;min-width:0;}
|
||||
.ausboss-audio-root .ausboss-audio-play{min-width:30px;font-size:10px;}
|
||||
.ausboss-audio-root .ausboss-audio-length{flex:none;padding:0 2px;color:#7fd9d2;font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:default;}
|
||||
.ausboss-audio-root .ausboss-audio-length:empty{display:none;}
|
||||
.ausboss-audio-root .ausboss-audio-length.has-cut{color:#ff9aa0;cursor:pointer;text-decoration:underline dotted rgba(224,82,90,.8);text-underline-offset:2px;}
|
||||
.ausboss-audio-root .ausboss-audio-row .ausboss-video-tool{flex:none;}
|
||||
.ausboss-audio-root .ausboss-video-values{display:flex;gap:8px;}
|
||||
.ausboss-audio-root .ausboss-video-value input{width:48px;}
|
||||
.ausboss-audio-root .ausboss-audio-length{margin-left:auto;}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function draw(state) {
|
||||
const { canvas } = state;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
const width = Math.max(1, Math.round(rect.width * scale));
|
||||
const height = Math.max(1, Math.round(rect.height * scale));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
if (!state.peaks || state.duration <= 0) return;
|
||||
const win = currentWindow(state);
|
||||
const xOf = (seconds) => (seconds / state.duration) * width;
|
||||
const startX = xOf(win.start);
|
||||
const keepX = xOf(win.keepEnd);
|
||||
const endX = xOf(win.end);
|
||||
// Backdrops: the part that comes out, then the part cut off.
|
||||
ctx.fillStyle = "rgba(0,180,170,0.13)";
|
||||
ctx.fillRect(startX, 0, keepX - startX, height);
|
||||
if (endX > keepX) {
|
||||
ctx.fillStyle = "rgba(224,82,90,0.16)";
|
||||
ctx.fillRect(keepX, 0, endX - keepX, height);
|
||||
}
|
||||
// The wave, one bar per pixel column.
|
||||
const mid = height / 2;
|
||||
const reach = mid * 0.92;
|
||||
const buckets = state.peaks.length / 2;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const from = Math.floor((x / width) * buckets);
|
||||
const to = Math.max(from + 1, Math.floor(((x + 1) / width) * buckets));
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let bucket = from; bucket < to && bucket < buckets; bucket += 1) {
|
||||
low = Math.min(low, state.peaks[bucket * 2]);
|
||||
high = Math.max(high, state.peaks[bucket * 2 + 1]);
|
||||
}
|
||||
if (x >= startX && x < keepX) ctx.fillStyle = BRAND;
|
||||
else if (x >= keepX && x < endX) ctx.fillStyle = CUT_RED;
|
||||
else ctx.fillStyle = "#3a4948";
|
||||
const top = mid - high * reach;
|
||||
ctx.fillRect(x, top, 1, Math.max(1, (high - low) * reach));
|
||||
}
|
||||
// The handles: IN and OUT, and a dashed line where the max cuts off.
|
||||
// The grips stay small on a short wave so they don't hide it.
|
||||
const grip = Math.round(Math.max(3, Math.min(7, rect.height * 0.12)) * scale);
|
||||
const handle = (x, color) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(Math.round(x) - scale, 0, 2 * scale, height);
|
||||
ctx.fillRect(Math.round(x) - 4 * scale, 0, 8 * scale, grip);
|
||||
ctx.fillRect(Math.round(x) - 4 * scale, height - grip, 8 * scale, grip);
|
||||
};
|
||||
handle(startX, "#f4ffff");
|
||||
handle(endX, "#f4ffff");
|
||||
if (endX > keepX + 1) {
|
||||
ctx.strokeStyle = CUT_RED;
|
||||
ctx.lineWidth = 2 * scale;
|
||||
ctx.setLineDash([4 * scale, 3 * scale]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(keepX, 0);
|
||||
ctx.lineTo(keepX, height);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
// The playhead while it plays, or where it paused inside the pick.
|
||||
if (state.audio && !state.audio.paused) {
|
||||
const x = xOf(state.audio.currentTime);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(x - scale / 2, 0, scale, height);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFace(state) {
|
||||
const win = currentWindow(state);
|
||||
const max = maxSeconds(state);
|
||||
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(win.start);
|
||||
if (document.activeElement !== state.endInput) state.endInput.value = formatTimecode(win.end);
|
||||
if (state.duration > 0) {
|
||||
const summary = outputSummary(win, { maxFromLink: max.fromLink && !max.known });
|
||||
state.summary.textContent = win.length > 0 ? secondsLabel(win.length) : "";
|
||||
state.summary.classList.toggle("has-cut", win.cut > 0.005);
|
||||
state.summary.title = win.cut > 0.005
|
||||
? `${summary}. Max length is ${max.value} s, so ${formatTimecode(win.keepEnd)} to ${formatTimecode(win.end)} is cut off (red). Click to move OUT to ${formatTimecode(win.keepEnd)}.`
|
||||
: `${summary}: ${formatTimecode(win.start)} to ${formatTimecode(win.keepEnd)} comes out.`;
|
||||
} else {
|
||||
state.summary.textContent = "";
|
||||
}
|
||||
draw(state);
|
||||
}
|
||||
|
||||
function writeWidget(widget, value) {
|
||||
const next = roundHundredth(value);
|
||||
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
|
||||
widget.value = next;
|
||||
widget.callback?.(next);
|
||||
}
|
||||
|
||||
function writeTrim(state, bounds) {
|
||||
state.writing = true;
|
||||
try {
|
||||
writeWidget(state.startWidget, bounds.start);
|
||||
// OUT at the very end stays 0, the widget's own "to the end".
|
||||
writeWidget(state.endWidget, bounds.end >= state.duration - 0.005 ? 0 : bounds.end);
|
||||
} finally {
|
||||
state.writing = false;
|
||||
}
|
||||
updateFace(state);
|
||||
state.node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function installDrag(state) {
|
||||
const surface = state.stage;
|
||||
const infoAt = (clientX) => {
|
||||
const rect = state.canvas.getBoundingClientRect();
|
||||
const fraction = rect.width ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0;
|
||||
const win = currentWindow(state);
|
||||
return { fraction, win, zone: grabZone(win, state.duration, fraction, rect.width) };
|
||||
};
|
||||
surface.addEventListener("pointermove", (event) => {
|
||||
if (state.dragging || state.duration <= 0) return;
|
||||
const { zone } = infoAt(event.clientX);
|
||||
surface.style.cursor = zone === "start" || zone === "end" ? "ew-resize" : zone === "move" ? "grab" : "pointer";
|
||||
});
|
||||
surface.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0 || state.dragging || state.duration <= 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const initial = infoAt(event.clientX);
|
||||
const bounds = { start: initial.win.start, end: initial.win.end };
|
||||
const anchor = initial.fraction * state.duration;
|
||||
const downX = event.clientX;
|
||||
let moved = false;
|
||||
state.dragging = true;
|
||||
try { surface.setPointerCapture(event.pointerId); } catch (_error) { /* mouse fallback */ }
|
||||
const centred = (fraction) => {
|
||||
const span = bounds.end - bounds.start;
|
||||
return slideTrimWindow(state.duration, bounds, fraction * state.duration - span / 2 - bounds.start);
|
||||
};
|
||||
const apply = (clientX) => {
|
||||
const next = infoAt(clientX);
|
||||
if (initial.zone === "start" || initial.zone === "end") {
|
||||
const now = currentWindow(state);
|
||||
writeTrim(state, dragTrimHandle(state.duration, { start: now.start, end: now.end }, initial.zone, next.fraction, MIN_TRIM_SECONDS));
|
||||
} else if (initial.zone === "move") {
|
||||
writeTrim(state, slideTrimWindow(state.duration, bounds, next.fraction * state.duration - anchor));
|
||||
} else {
|
||||
writeTrim(state, centred(next.fraction));
|
||||
}
|
||||
};
|
||||
const move = (moveEvent) => {
|
||||
if (!(moveEvent.buttons & 1)) { finish(moveEvent); return; }
|
||||
if (!moved && Math.abs(moveEvent.clientX - downX) < 3) return;
|
||||
moved = true;
|
||||
apply(moveEvent.clientX);
|
||||
};
|
||||
const finish = (finishEvent) => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
surface.removeEventListener("pointermove", move);
|
||||
try { surface.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
|
||||
if (!moved && initial.zone === "jump") writeTrim(state, centred(infoAt(finishEvent?.clientX ?? downX).fraction));
|
||||
if (moved || initial.zone === "jump") {
|
||||
if (state.audio && !state.audio.paused) state.audio.currentTime = currentWindow(state).start;
|
||||
notifyAusbossChange();
|
||||
}
|
||||
};
|
||||
surface.addEventListener("pointermove", move);
|
||||
surface.addEventListener("pointerup", finish, { once: true });
|
||||
surface.addEventListener("pointercancel", finish, { once: true });
|
||||
surface.addEventListener("lostpointercapture", finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function loopEnabled(node) {
|
||||
node.properties ??= {};
|
||||
return !!node.properties.ausboss_load_audio_loop;
|
||||
}
|
||||
|
||||
function installPlayer(state) {
|
||||
const audio = state.audio;
|
||||
const stop = () => {
|
||||
if (state.raf) cancelAnimationFrame(state.raf);
|
||||
state.raf = 0;
|
||||
state.playButton.textContent = "▶";
|
||||
state.playButton.classList.remove("active");
|
||||
draw(state);
|
||||
};
|
||||
const tick = () => {
|
||||
if (audio.paused) { stop(); return; }
|
||||
const win = currentWindow(state);
|
||||
if (audio.currentTime >= win.keepEnd - END_EPSILON || audio.currentTime < win.start - 0.25) {
|
||||
if (loopEnabled(state.node)) audio.currentTime = win.start;
|
||||
else { audio.pause(); audio.currentTime = win.start; stop(); return; }
|
||||
}
|
||||
draw(state);
|
||||
state.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
audio.addEventListener("play", () => {
|
||||
state.playButton.textContent = "❚❚";
|
||||
state.playButton.classList.add("active");
|
||||
if (state.raf) cancelAnimationFrame(state.raf);
|
||||
state.raf = requestAnimationFrame(tick);
|
||||
}, { signal: state.abort.signal });
|
||||
audio.addEventListener("pause", stop, { signal: state.abort.signal });
|
||||
state.playButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (state.duration <= 0) return;
|
||||
if (!audio.paused) { audio.pause(); return; }
|
||||
const win = currentWindow(state);
|
||||
if (audio.currentTime < win.start || audio.currentTime >= win.keepEnd - END_EPSILON) audio.currentTime = win.start;
|
||||
audio.play().catch(() => stop());
|
||||
});
|
||||
state.stopPlayer = () => { audio.pause(); stop(); };
|
||||
}
|
||||
|
||||
function commitTimeInput(state, edge, input) {
|
||||
if (state.duration <= 0) return;
|
||||
const seconds = parseTimecode(input.value);
|
||||
const win = currentWindow(state);
|
||||
if (seconds === null) {
|
||||
input.value = formatTimecode(edge === "start" ? win.start : win.end);
|
||||
return;
|
||||
}
|
||||
writeTrim(state, dragTrimHandle(state.duration, { start: win.start, end: win.end }, edge, seconds / state.duration, MIN_TRIM_SECONDS));
|
||||
notifyAusbossChange();
|
||||
input.blur();
|
||||
}
|
||||
|
||||
function watchWidget(state, widget) {
|
||||
if (!widget || widget.__ausbossAudioWatch) return;
|
||||
widget.__ausbossAudioWatch = true;
|
||||
const prior = widget.callback;
|
||||
widget.callback = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
if (!state.writing) updateFace(state);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function buildPanel(node) {
|
||||
if (node.__ausbossLoadAudio) return node.__ausbossLoadAudio;
|
||||
const audioWidget = findWidget(node, "audio");
|
||||
const startWidget = findWidget(node, "start_seconds");
|
||||
const endWidget = findWidget(node, "end_seconds");
|
||||
if (!audioWidget || !startWidget || !endWidget) return null;
|
||||
ensureAudioCss();
|
||||
hideCoreAudio(node);
|
||||
hideCanvasWidget(startWidget);
|
||||
hideCanvasWidget(endWidget);
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "ausboss-video-root ausboss-audio-root";
|
||||
const stage = document.createElement("div");
|
||||
stage.className = "ausboss-video-stage is-empty";
|
||||
stage.title = "Drag a handle to set where the sound starts or stops. Drag the middle to slide it.";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "ausboss-audio-wave";
|
||||
const status = document.createElement("div");
|
||||
status.className = "ausboss-video-status";
|
||||
status.textContent = "Choose or upload a sound file";
|
||||
const playButton = makeToolButton("▶", "Play the part that comes out");
|
||||
playButton.classList.add("ausboss-audio-play");
|
||||
const loopButton = makeToolButton("LOOP", "Play it again and again");
|
||||
stage.append(canvas, status);
|
||||
|
||||
const values = document.createElement("div");
|
||||
values.className = "ausboss-video-values";
|
||||
const makeValue = (labelText, title) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ausboss-video-value";
|
||||
const label = document.createElement("label");
|
||||
label.textContent = labelText;
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.inputMode = "decimal";
|
||||
input.placeholder = "m:ss.s";
|
||||
input.title = title;
|
||||
input.addEventListener("keydown", (event) => {
|
||||
keepKeyInField(event);
|
||||
if (event.key === "Enter") input.dispatchEvent(new Event("change"));
|
||||
});
|
||||
wrap.append(label, input);
|
||||
return { wrap, input };
|
||||
};
|
||||
const startValue = makeValue("IN", "Where the sound starts. Type seconds (12.5) or a time (0:12.5).");
|
||||
const endValue = makeValue("OUT", "Where the sound stops. Type seconds (24) or a time (0:24.0).");
|
||||
// How long the output is, beside OUT and off the wave. Red when Max
|
||||
// length cuts the pick short; the tooltip says how much.
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "ausboss-audio-length";
|
||||
values.append(startValue.wrap, endValue.wrap);
|
||||
const row = document.createElement("div");
|
||||
row.className = "ausboss-audio-row";
|
||||
row.append(playButton, values, summary, loopButton);
|
||||
root.append(stage, row);
|
||||
|
||||
const widget = node.addDOMWidget(PANEL_WIDGET, "ausboss_audio", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
});
|
||||
keepDomWidgetWidthAuto(widget);
|
||||
widget.serialize = false;
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: MIN_WIDTH,
|
||||
minHeight: PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
minNodeSize: [MIN_WIDTH, 200],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
|
||||
const audio = new Audio();
|
||||
audio.preload = "auto";
|
||||
const abort = new AbortController();
|
||||
const state = node.__ausbossLoadAudio = {
|
||||
node, root, stage, canvas, status, playButton, loopButton, summary, audio,
|
||||
startInput: startValue.input, endInput: endValue.input,
|
||||
audioWidget, startWidget, endWidget, maxWidget: findWidget(node, "max_seconds"),
|
||||
duration: 0, peaks: null, abort, writing: false, dragging: false, raf: 0, loading: 0,
|
||||
};
|
||||
|
||||
// Clicking the red length moves OUT to where the sound really ends.
|
||||
summary.addEventListener("click", (event) => {
|
||||
const win = currentWindow(state);
|
||||
if (win.cut <= 0.005) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
writeTrim(state, { start: win.start, end: win.keepEnd });
|
||||
notifyAusbossChange();
|
||||
});
|
||||
|
||||
const updateLoop = () => loopButton.classList.toggle("active", loopEnabled(node));
|
||||
updateLoop();
|
||||
loopButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
node.properties.ausboss_load_audio_loop = !loopEnabled(node);
|
||||
updateLoop();
|
||||
notifyAusbossChange();
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
state.stopPlayer?.();
|
||||
const name = audioWidget.value;
|
||||
const ticket = ++state.loading;
|
||||
state.duration = 0;
|
||||
state.peaks = null;
|
||||
if (!name || typeof name !== "string") {
|
||||
audio.removeAttribute("src");
|
||||
stage.classList.add("is-empty");
|
||||
status.textContent = "Choose or upload a sound file";
|
||||
updateFace(state);
|
||||
return;
|
||||
}
|
||||
const parts = splitMediaName(name);
|
||||
stage.classList.add("is-empty");
|
||||
status.textContent = `Loading ${parts.filename}…`;
|
||||
const type = parseImageReference(name)?.type ?? "input";
|
||||
const route = `/view?${mediaViewQuery({ ...parts, type }, "input")}`;
|
||||
audio.src = api.apiURL(route);
|
||||
try {
|
||||
const result = await loadPeaks(name, route);
|
||||
if (ticket !== state.loading) return;
|
||||
state.duration = result.duration;
|
||||
state.peaks = result.peaks;
|
||||
stage.classList.remove("is-empty");
|
||||
updateFace(state);
|
||||
} catch (error) {
|
||||
if (ticket !== state.loading) return;
|
||||
status.textContent = `This sound could not be shown: ${error?.message || error}`;
|
||||
}
|
||||
};
|
||||
state.refresh = refresh;
|
||||
|
||||
installDrag(state);
|
||||
installPlayer(state);
|
||||
watchWidget(state, startWidget);
|
||||
watchWidget(state, endWidget);
|
||||
watchWidget(state, state.maxWidget);
|
||||
startValue.input.addEventListener("change", () => commitTimeInput(state, "start", startValue.input));
|
||||
endValue.input.addEventListener("change", () => commitTimeInput(state, "end", endValue.input));
|
||||
const observer = new ResizeObserver(() => draw(state));
|
||||
observer.observe(stage);
|
||||
state.observer = observer;
|
||||
|
||||
const priorAudioCallback = audioWidget.callback;
|
||||
audioWidget.callback = function (...args) {
|
||||
const result = priorAudioCallback?.apply(this, args);
|
||||
peakCache.delete(audioWidget.value);
|
||||
refresh();
|
||||
return result;
|
||||
};
|
||||
setTimeout(refresh, 0);
|
||||
return state;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.load_audio",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_NAME) return;
|
||||
// The frontend's Upload button for an audio_upload input looks for its
|
||||
// player widget and fails without one, and it adds that player only to
|
||||
// its own audio nodes. Ask for it here, ahead of the button; the panel
|
||||
// hides it, since the wave plays the sound instead.
|
||||
const required = nodeData.input?.required;
|
||||
if (required && !required.audioUI) {
|
||||
const entries = Object.entries(required);
|
||||
const at = entries.findIndex(([name]) => name === "audio") + 1;
|
||||
entries.splice(at, 0, ["audioUI", ["AUDIO_UI", {}]]);
|
||||
nodeData.input.required = Object.fromEntries(entries);
|
||||
}
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this);
|
||||
// Only for a genuinely new node: onConfigure restores a saved size
|
||||
// after this runs, so a workflow's own size still wins.
|
||||
const floor = Number(this.computeSize?.()?.[1]) || 0;
|
||||
if (floor > 0) this.setSize?.([DEFAULT_WIDTH, floor + DEFAULT_WAVE_EXTRA]);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
queueMicrotask(() => {
|
||||
const state = buildPanel(this);
|
||||
if (state) {
|
||||
hideCoreAudio(this);
|
||||
state.refresh?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
// A wire into Max length changes what comes out; redraw when one comes or goes.
|
||||
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
|
||||
const state = this.__ausbossLoadAudio;
|
||||
if (state) queueMicrotask(() => updateFace(state));
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onRemoved", function () {
|
||||
const state = this.__ausbossLoadAudio;
|
||||
state?.stopPlayer?.();
|
||||
state?.abort?.abort();
|
||||
state?.observer?.disconnect();
|
||||
if (state) state.audio.removeAttribute("src");
|
||||
this.__ausbossLoadAudio = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
+38
-17
@@ -85,7 +85,7 @@ function isSingleFrame(state) {
|
||||
// trim window's IN point.
|
||||
function homeTime(state) {
|
||||
return isSingleFrame(state)
|
||||
? singleFrameTime(state.video.duration, state.startWidget.value)
|
||||
? singleFrameTime(state.video.duration, state.startWidget.value, state.sourceFps, state.sourceFrameCount)
|
||||
: currentBounds(state).start;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ function trimLoadSummary(state, duration, bounds) {
|
||||
// on any error the label simply omits the count and the timecodes stand.
|
||||
async function probeSourceFps(state, name) {
|
||||
state.sourceFps = 0;
|
||||
state.sourceFrameCount = 0;
|
||||
try {
|
||||
const query = new URLSearchParams({ source_mode: "input folder", video: name });
|
||||
const response = await api.fetchApi(`/ausboss/transform/video/metadata?${query}`);
|
||||
@@ -133,14 +134,15 @@ async function probeSourceFps(state, name) {
|
||||
const metadata = await response.json();
|
||||
if (state.videoWidget.value !== name) return; // a newer selection won
|
||||
state.sourceFps = Math.max(0, Number(metadata?.fps) || 0);
|
||||
state.sourceFrameCount = Math.max(0, Number(metadata?.frame_count) || 0);
|
||||
updateTrimFace(state);
|
||||
} catch (_error) {
|
||||
/* no fps, no frame count */
|
||||
}
|
||||
}
|
||||
|
||||
function writeWidget(widget, value) {
|
||||
const next = roundHundredth(value);
|
||||
function writeWidget(widget, value, exact = false) {
|
||||
const next = exact ? value : roundHundredth(value);
|
||||
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
|
||||
widget.value = next;
|
||||
widget.callback?.(next);
|
||||
@@ -166,10 +168,10 @@ function writeTrim(state, bounds, seekEdge = null) {
|
||||
|
||||
function writeFrameTime(state, timeValue) {
|
||||
const duration = Number.isFinite(state.video.duration) ? state.video.duration : 0;
|
||||
const time = singleFrameTime(duration, timeValue);
|
||||
const time = singleFrameTime(duration, timeValue, state.sourceFps, state.sourceFrameCount);
|
||||
state.writing = true;
|
||||
try {
|
||||
writeWidget(state.startWidget, time);
|
||||
writeWidget(state.startWidget, time, true);
|
||||
} finally {
|
||||
state.writing = false;
|
||||
}
|
||||
@@ -182,8 +184,8 @@ function writeFrameTime(state, timeValue) {
|
||||
function updateTrimFace(state) {
|
||||
const duration = Number.isFinite(state.video.duration) ? state.video.duration : 0;
|
||||
if (isSingleFrame(state)) {
|
||||
const time = singleFrameTime(duration, state.startWidget.value);
|
||||
const percent = singleFrameFraction(duration, time) * 100;
|
||||
const time = singleFrameTime(duration, state.startWidget.value, state.sourceFps, state.sourceFrameCount);
|
||||
const percent = singleFrameFraction(duration, time, state.sourceFps, state.sourceFrameCount) * 100;
|
||||
state.selection.style.width = "0%";
|
||||
state.startHandle.style.left = `${percent}%`;
|
||||
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(time);
|
||||
@@ -258,6 +260,9 @@ function installTrimDrag(state) {
|
||||
if (initial.duration <= 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const gesture = new AbortController();
|
||||
state.gestureAbort?.abort();
|
||||
state.gestureAbort = gesture;
|
||||
if (isSingleFrame(state)) {
|
||||
// Frame-picker gesture: the strip is one scrub rail. A click already
|
||||
// sets the frame; a drag keeps refining it until release.
|
||||
@@ -275,15 +280,15 @@ function installTrimDrag(state) {
|
||||
const finish = () => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
range.removeEventListener("pointermove", move);
|
||||
gesture.abort();
|
||||
try { range.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
|
||||
// One notification per gesture: the frame changed on pointerdown.
|
||||
notifyAusbossChange();
|
||||
};
|
||||
range.addEventListener("pointermove", move);
|
||||
range.addEventListener("pointerup", finish, { once: true });
|
||||
range.addEventListener("pointercancel", finish, { once: true });
|
||||
range.addEventListener("lostpointercapture", finish, { once: true });
|
||||
range.addEventListener("pointermove", move, { signal: gesture.signal });
|
||||
range.addEventListener("pointerup", finish, { signal: gesture.signal });
|
||||
range.addEventListener("pointercancel", finish, { signal: gesture.signal });
|
||||
range.addEventListener("lostpointercapture", finish, { signal: gesture.signal });
|
||||
return;
|
||||
}
|
||||
state.dragging = true;
|
||||
@@ -327,7 +332,7 @@ function installTrimDrag(state) {
|
||||
const finish = (finishEvent) => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
range.removeEventListener("pointermove", move);
|
||||
gesture.abort();
|
||||
try { range.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
|
||||
if (!moved && initial.zone === "jump") {
|
||||
const next = infoAt(finishEvent?.clientX ?? downX);
|
||||
@@ -342,10 +347,10 @@ function installTrimDrag(state) {
|
||||
// One notification per gesture, on release — never per pointermove.
|
||||
if (moved || initial.zone === "jump") notifyAusbossChange();
|
||||
};
|
||||
range.addEventListener("pointermove", move);
|
||||
range.addEventListener("pointerup", finish, { once: true });
|
||||
range.addEventListener("pointercancel", finish, { once: true });
|
||||
range.addEventListener("lostpointercapture", finish, { once: true });
|
||||
range.addEventListener("pointermove", move, { signal: gesture.signal });
|
||||
range.addEventListener("pointerup", finish, { signal: gesture.signal });
|
||||
range.addEventListener("pointercancel", finish, { signal: gesture.signal });
|
||||
range.addEventListener("lostpointercapture", finish, { signal: gesture.signal });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -627,6 +632,15 @@ function buildPreview(node) {
|
||||
watchTrimWidget(state, startWidget);
|
||||
watchTrimWidget(state, endWidget);
|
||||
watchFrameWidget(state, frameWidget);
|
||||
for (const widget of [state.everyNthWidget, state.maxFramesWidget]) {
|
||||
if (!widget) continue;
|
||||
const prior = widget.callback;
|
||||
widget.callback = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
updateTrimFace(state);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
applyMode(state);
|
||||
startValue.input.addEventListener("change", () => commitTimeInput(state, "start", startValue.input));
|
||||
endValue.input.addEventListener("change", () => commitTimeInput(state, "end", endValue.input));
|
||||
@@ -668,7 +682,14 @@ app.registerExtension({
|
||||
const state = this.__ausbossLoadVideo;
|
||||
state?.stopTick?.();
|
||||
state?.abort?.abort();
|
||||
state?.gestureAbort?.abort();
|
||||
state?.video?.pause();
|
||||
state?.video?.removeAttribute("src");
|
||||
state?.video?.load();
|
||||
this.__ausbossLoadVideo = null;
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
|
||||
if (this.__ausbossLoadVideo) updateTrimFace(this.__ausbossLoadVideo);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,17 +72,22 @@ export function slideTrimWindow(durationValue, bounds, deltaValue) {
|
||||
// Single-frame mode: the chosen instant, kept just inside the source so the
|
||||
// decoder always finds a frame at or after it. An unknown duration passes the
|
||||
// value through, so a typed time survives until metadata arrives.
|
||||
export function singleFrameTime(durationValue, timeValue) {
|
||||
export function singleFrameTime(durationValue, timeValue, fpsValue = 0, countValue = 0) {
|
||||
const duration = Math.max(0, finiteNumber(durationValue, 0));
|
||||
const time = Math.max(0, finiteNumber(timeValue, 0));
|
||||
if (duration <= 0) return time;
|
||||
const fps = Math.max(0, finiteNumber(fpsValue, 0));
|
||||
if (fps > 0) {
|
||||
const count = Math.max(1, Math.floor(finiteNumber(countValue, 0)) || Math.round(duration * fps));
|
||||
return Math.min(time, (count - 1) / fps);
|
||||
}
|
||||
return Math.min(time, Math.max(0, duration - MIN_TRIM_SECONDS));
|
||||
}
|
||||
|
||||
export function singleFrameFraction(durationValue, timeValue) {
|
||||
export function singleFrameFraction(durationValue, timeValue, fpsValue = 0, countValue = 0) {
|
||||
const duration = Math.max(0, finiteNumber(durationValue, 0));
|
||||
if (duration <= 0) return 0;
|
||||
return clamp(singleFrameTime(duration, timeValue) / duration, 0, 1);
|
||||
return clamp(singleFrameTime(duration, timeValue, fpsValue, countValue) / duration, 0, 1);
|
||||
}
|
||||
|
||||
// Leaving single-frame mode, the picked frame is IN. An OUT at or before it
|
||||
@@ -107,7 +112,7 @@ export function formatFps(value) {
|
||||
// strip - or "" when there is nothing honest to say (unknown fps, an empty
|
||||
// window). The preview cannot re-render frame drops, so it reports them
|
||||
// instead, mirroring the backend's own arithmetic (decode_video_range):
|
||||
// ceil(window x fps) frames in the trim, one kept in every_nth, capped by
|
||||
// source timestamps inside the trim, one kept in every_nth, capped by
|
||||
// max_frames; single-frame mode always loads exactly one.
|
||||
// Mirror of the clip node's snap_frame_count: the largest step*n + 1 not
|
||||
// above count for a rule like "8n+1"; "free" keeps count.
|
||||
@@ -139,7 +144,10 @@ export function loadSummary(
|
||||
if (end <= start) return "";
|
||||
const nth = Math.max(1, Math.floor(finiteNumber(everyNthValue, 1)));
|
||||
const cap = Math.max(0, Math.floor(finiteNumber(maxFramesValue, 0)));
|
||||
let frames = Math.ceil(Math.ceil((end - start) * fps) / nth);
|
||||
// Mirror the backend's inclusive timestamp comparison and epsilon.
|
||||
const first = Math.max(0, Math.ceil((start - 1e-4) * fps));
|
||||
const last = Math.floor((end - 1e-4) * fps);
|
||||
let frames = Math.ceil(Math.max(0, last - first + 1) / nth);
|
||||
if (cap > 0) frames = Math.min(frames, cap);
|
||||
frames = snapFrameCount(frames, frameSnap);
|
||||
if (frames <= 0) return "";
|
||||
|
||||
@@ -191,6 +191,7 @@ function buildPreview(node) {
|
||||
video.loop = enabled;
|
||||
loopButton.classList.toggle("active", enabled);
|
||||
};
|
||||
state.updateLoopButton = updateLoopButton;
|
||||
updateLoopButton();
|
||||
|
||||
loopButton.addEventListener("click", (event) => {
|
||||
@@ -254,7 +255,7 @@ app.registerExtension({
|
||||
queueMicrotask(() => {
|
||||
const state = buildPreview(this);
|
||||
suppressCoreVideoPreview(this);
|
||||
if (state) syncSteps(state);
|
||||
if (state) { state.updateLoopButton?.(); syncSteps(state); }
|
||||
});
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onExecuted", function (message) {
|
||||
|
||||
+123
-12
@@ -19,6 +19,8 @@ import { copyToClipboard } from "../shared/clipboard.mjs";
|
||||
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
|
||||
import { holdNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
|
||||
import { formatSeed, pushSeed, seedFromExecuted } from "../shared/seed_history.mjs";
|
||||
import { SEED_LOOK_MENU, SEED_LOOK_SCOPE, controlChip, nextControl, seedLook, setSeedLook } from "../shared/seed_look.mjs";
|
||||
import { gearIconSvg, loadSettings, openSettingsMenu } from "../shared/settings_menu.mjs";
|
||||
|
||||
const NODE_CLASS = "AUSBOSS_NODES_Seed";
|
||||
const CSS_ID = "ausboss-seed-css";
|
||||
@@ -28,8 +30,19 @@ const PROPERTY = "ausboss_seed_history"; // [seed, newest first]
|
||||
// in a double; a fresh seed rolled here obeys the same ceiling.
|
||||
const ROLL_MAX = 0x4000000000000;
|
||||
const CARD_HEIGHT = 158;
|
||||
const PANEL_DECLARED_HEIGHT = CARD_HEIGHT + 20; // + the frontend's wrapper insets
|
||||
// The compact look is one row: 8px card padding, a 32px row, 8px padding.
|
||||
const COMPACT_CARD_HEIGHT = 50;
|
||||
const PANEL_INSETS = 20; // the frontend's wrapper insets
|
||||
const NODE_WIDTH = 300;
|
||||
const COMPACT_NODE_WIDTH = 290;
|
||||
|
||||
function panelHeight(node) {
|
||||
return (seedLook(node) === "compact" ? COMPACT_CARD_HEIGHT : CARD_HEIGHT) + PANEL_INSETS;
|
||||
}
|
||||
|
||||
function nodeWidthFloor(node) {
|
||||
return seedLook(node) === "compact" ? COMPACT_NODE_WIDTH : NODE_WIDTH;
|
||||
}
|
||||
|
||||
let openMenu = null;
|
||||
|
||||
@@ -59,6 +72,21 @@ function ensureCss() {
|
||||
.ausboss-seed-btn:disabled{opacity:.4;cursor:default;pointer-events:none;}
|
||||
.ausboss-seed-hint{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10.5px;text-align:center;}
|
||||
.ausboss-seed-hint b{color:#9fe3dc;font-weight:600;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;}
|
||||
.ausboss-seed-top{display:flex;align-items:center;gap:6px;min-width:0;}
|
||||
.ausboss-seed-top .ausboss-seed-field{flex:1 1 auto;min-width:0;}
|
||||
.ausboss-seed-gear{display:grid;place-items:center;}
|
||||
.ausboss-seed-chip,.ausboss-seed-dice{display:none;flex:none;height:32px;border:1px solid #2a3437;border-radius:7px;background:#0f1516;color:#d8ecea;font:600 11.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;pointer-events:auto;}
|
||||
.ausboss-seed-chip{min-width:56px;padding:0 7px;}
|
||||
.ausboss-seed-chip.random{border-color:rgba(0,180,170,.55);color:${BRAND};}
|
||||
.ausboss-seed-dice{width:32px;font-size:15px;}
|
||||
.ausboss-seed-chip:hover,.ausboss-seed-dice:hover{border-color:${BRAND};color:#fff;}
|
||||
.ausboss-seed-card.compact{gap:0;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-seg,.ausboss-seed-card.compact .ausboss-seed-row,.ausboss-seed-card.compact .ausboss-seed-hint{display:none;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-chip,.ausboss-seed-card.compact .ausboss-seed-dice{display:block;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-field{height:32px;padding:0 3px 0 9px;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:14px;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-tag,.ausboss-seed-card.compact .ausboss-seed-icon:not(.ausboss-seed-gear){display:none;}
|
||||
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:13.5px;letter-spacing:0;}
|
||||
.ausboss-seed-menu{position:fixed;z-index:10000;min-width:230px;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7dde2;}
|
||||
.ausboss-seed-menu-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;}
|
||||
.ausboss-seed-menu-item{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;}
|
||||
@@ -124,6 +152,7 @@ function render(state) {
|
||||
const linked = seedLinked(node);
|
||||
const value = seedWidget(node)?.value;
|
||||
if (document.activeElement !== state.input) state.input.value = formatSeed(Number(value));
|
||||
fitCompactNumber(state);
|
||||
state.input.disabled = linked;
|
||||
state.field.classList.toggle("linked", linked);
|
||||
state.field.title = linked ? "The seed comes from its input link" : "Click to type a seed";
|
||||
@@ -136,6 +165,11 @@ function render(state) {
|
||||
state.segStep.title = stepping
|
||||
? "Click again to step the other way"
|
||||
: "Increment the seed after each queue (click again for decrement)";
|
||||
const chip = controlChip(control);
|
||||
state.chip.textContent = chip.text;
|
||||
state.chip.title = chip.title;
|
||||
state.chip.classList.toggle("random", control === "randomize");
|
||||
state.card.classList.toggle("compact", seedLook(node) === "compact");
|
||||
state.useLast.disabled = !seeds.length;
|
||||
state.more.disabled = seeds.length < 2;
|
||||
state.hint.textContent = "";
|
||||
@@ -145,6 +179,8 @@ function render(state) {
|
||||
b.textContent = formatSeed(last);
|
||||
state.hint.append(b);
|
||||
if (control === "randomize") state.hint.append(" · rolls a new seed each queue");
|
||||
// The compact look has no hint line: the last run shows on the number.
|
||||
if (seedLook(node) === "compact") state.field.title = `${state.field.title}. Last run: ${formatSeed(last)}`;
|
||||
} else {
|
||||
state.hint.textContent = control === "randomize"
|
||||
? "rolls a new seed each queue"
|
||||
@@ -152,6 +188,16 @@ function render(state) {
|
||||
}
|
||||
}
|
||||
|
||||
// The compact row is narrow, so a long seed gets smaller digits instead of
|
||||
// being cut off: monospace digits are about 0.6 of the font size wide.
|
||||
function fitCompactNumber(state) {
|
||||
const { input } = state;
|
||||
if (seedLook(state.node) !== "compact") { input.style.fontSize = ""; return; }
|
||||
const digits = Math.max(1, String(input.value || "").length);
|
||||
const room = input.clientWidth || 100;
|
||||
input.style.fontSize = `${Math.max(8, Math.min(13.5, room / (0.64 * digits))).toFixed(1)}px`;
|
||||
}
|
||||
|
||||
function showMenu(state) {
|
||||
closeMenu();
|
||||
const seeds = history(state.node);
|
||||
@@ -241,7 +287,9 @@ function buildPanel(node) {
|
||||
input.inputMode = "numeric";
|
||||
input.spellcheck = false;
|
||||
const copy = button("ausboss-seed-icon", "⧉", "Copy the seed");
|
||||
field.append(tag, input, copy);
|
||||
const gear = button("ausboss-seed-icon ausboss-seed-gear", "", "Seed settings: full or compact look");
|
||||
gear.innerHTML = gearIconSvg(12);
|
||||
field.append(tag, input, copy, gear);
|
||||
field.addEventListener("pointerdown", (event) => event.stopPropagation());
|
||||
|
||||
const seg = el("div", "ausboss-seed-seg");
|
||||
@@ -257,11 +305,16 @@ function buildPanel(node) {
|
||||
row.append(newSeed, useLast, more);
|
||||
|
||||
const hint = el("div", "ausboss-seed-hint");
|
||||
card.append(field, seg, row, hint);
|
||||
// Compact look: the mode chip and a dice sit on the number's row.
|
||||
const chip = button("ausboss-seed-chip", "Fixed");
|
||||
const dice = button("ausboss-seed-dice", "🎲", "Roll a fresh seed and pin it (Fixed)");
|
||||
const top = el("div", "ausboss-seed-top");
|
||||
top.append(field, chip, dice);
|
||||
card.append(top, seg, row, hint);
|
||||
root.append(card);
|
||||
|
||||
const state = (node.__ausbossSeed = {
|
||||
node, root, field, tag, input, copy, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
|
||||
node, root, card, field, tag, input, copy, gear, chip, dice, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
|
||||
});
|
||||
|
||||
const commitTyped = () => {
|
||||
@@ -304,6 +357,31 @@ function buildPanel(node) {
|
||||
notifyAusbossChange();
|
||||
});
|
||||
newSeed.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
|
||||
dice.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
|
||||
// The chip walks the same modes as the full card's buttons, and starts a
|
||||
// new run the same way: into Random rolls now, Fixed into +1 steps now.
|
||||
chip.addEventListener("click", () => {
|
||||
const current = controlWidget(node)?.value ?? "fixed";
|
||||
const next = nextControl(current);
|
||||
setControl(node, next);
|
||||
if (!seedLinked(node)) {
|
||||
const value = Number(seed.value) || 0;
|
||||
if (next === "randomize") setSeed(node, rollSeed(), { pin: false });
|
||||
else if (current === "fixed" && next === "increment") setSeed(node, value + 1, { pin: false });
|
||||
}
|
||||
render(state);
|
||||
notifyAusbossChange();
|
||||
});
|
||||
gear.addEventListener("click", () => {
|
||||
openSettingsMenu({
|
||||
scope: SEED_LOOK_SCOPE,
|
||||
schema: SEED_LOOK_MENU,
|
||||
anchor: gear.getBoundingClientRect(),
|
||||
title: "Seed settings",
|
||||
initial: { look: seedLook(node) },
|
||||
onChange: (values) => applyLook(state, values.look),
|
||||
});
|
||||
});
|
||||
useLast.addEventListener("click", () => {
|
||||
const seeds = history(node);
|
||||
if (seeds.length) { setSeed(node, seeds[0]); render(state); }
|
||||
@@ -318,7 +396,7 @@ function buildPanel(node) {
|
||||
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_seed", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => PANEL_DECLARED_HEIGHT,
|
||||
getMinHeight: () => panelHeight(node),
|
||||
});
|
||||
keepDomWidgetWidthAuto(widget);
|
||||
// Not saved with the workflow either: options.serialize only keeps it out
|
||||
@@ -327,18 +405,20 @@ function buildPanel(node) {
|
||||
// A constant-height card: pinned on purpose (see
|
||||
// tests/panel_guards.test.mjs, fixedByDesign).
|
||||
widget.computeSize = (width) => [
|
||||
Math.max(NODE_WIDTH, Number(width || node.size?.[0] || NODE_WIDTH)),
|
||||
PANEL_DECLARED_HEIGHT,
|
||||
Math.max(nodeWidthFloor(node), Number(width || node.size?.[0] || nodeWidthFloor(node))),
|
||||
panelHeight(node),
|
||||
];
|
||||
// Pinned in Nodes 2.0 as well, where a node can be made taller than its card.
|
||||
pinVuePanelHeight(widget);
|
||||
widget.options.minNodeSize = [NODE_WIDTH, 120];
|
||||
widget.options.minNodeSize = [nodeWidthFloor(node), 60];
|
||||
// The frontend skips a pinned card's width, so its floor is held here.
|
||||
holdNodeMinWidth(widget, NODE_WIDTH);
|
||||
holdNodeMinWidth(widget, nodeWidthFloor(node));
|
||||
state.widget = widget;
|
||||
|
||||
new ResizeObserver(() => fitCompactNumber(state)).observe(input);
|
||||
render(state);
|
||||
node.setSize?.([
|
||||
Math.max(node.size?.[0] ?? 0, NODE_WIDTH),
|
||||
Math.max(node.size?.[0] ?? 0, nodeWidthFloor(node)),
|
||||
node.computeSize?.()[1] ?? node.size?.[1],
|
||||
]);
|
||||
// The card is a fixed-height row: width may grow, but a corner drag
|
||||
@@ -356,18 +436,49 @@ function buildPanel(node) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Switch looks: the card, the node's floor and its height follow. Going
|
||||
// compact also makes the node as narrow as the compact row allows, and
|
||||
// going full widens it back to the full card's floor.
|
||||
function applyLook(state, look) {
|
||||
const { node } = state;
|
||||
const before = seedLook(node);
|
||||
const after = setSeedLook(node, look);
|
||||
if (before === after) return;
|
||||
render(state);
|
||||
if (state.widget) {
|
||||
state.widget.options.minNodeSize = [nodeWidthFloor(node), 60];
|
||||
holdNodeMinWidth(state.widget, nodeWidthFloor(node));
|
||||
}
|
||||
const width = after === "compact" ? COMPACT_NODE_WIDTH : Math.max(node.size?.[0] ?? 0, NODE_WIDTH);
|
||||
node.setSize?.([width, node.computeSize?.()[1] ?? node.size?.[1]]);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
notifyAusbossChange();
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "AusBoss.Seed",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_CLASS) return;
|
||||
hideInputsInDef(nodeData, ["seed"]);
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this);
|
||||
// A new node starts in the look last picked from any Seed's gear. A
|
||||
// loaded one keeps its own: onConfigure restores its properties after.
|
||||
if (loadSettings(SEED_LOOK_SCOPE, SEED_LOOK_MENU).look === "compact") setSeedLook(this, "compact");
|
||||
const state = buildPanel(this);
|
||||
if (state && seedLook(this) === "compact") {
|
||||
this.setSize?.([COMPACT_NODE_WIDTH, this.computeSize?.()[1] ?? this.size?.[1]]);
|
||||
}
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
queueMicrotask(() => {
|
||||
const state = buildPanel(this);
|
||||
if (state) render(state);
|
||||
if (!state) return;
|
||||
render(state);
|
||||
// A saved compact Seed: lower the floor to the compact row's.
|
||||
if (state.widget) {
|
||||
state.widget.options.minNodeSize = [nodeWidthFloor(this), 60];
|
||||
holdNodeMinWidth(state.widget, nodeWidthFloor(this));
|
||||
}
|
||||
});
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function restoreOutputLinks(node, outputs, graph) {
|
||||
const targetNode = graph.getNodeById(target.nodeId);
|
||||
if (!targetNode) continue;
|
||||
const current = lookupLink(graph, targetNode.inputs?.[target.slot]?.link);
|
||||
if (current?.origin_id === node.id && current.origin_slot === output.slot) continue; // never stolen
|
||||
if (current?.origin_id === node.id) continue; // never stolen
|
||||
linkSlots(node, output.slot, targetNode, target.slot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// What a video dropped ONTO a Load Video 🆎 node restores. An AusBoss-saved
|
||||
// file carries the workflow that made it; if that workflow holds a Load
|
||||
// Video node, its trim and sampling values repopulate the target node so the
|
||||
// clip reloads the way it was loaded before. Pure parsing and sanitizing
|
||||
// only - the drop wiring lives in js/video_workflow_drop/index.js and
|
||||
// node:test drives this module directly.
|
||||
// Source-workflow parsing and defaults for importing a rendered video.
|
||||
// Embedded source settings only apply when reopening that original source;
|
||||
// applying them to its rendered output would trim and sample it twice.
|
||||
|
||||
export const LOAD_VIDEO_CLASS = "AUSBOSS_NODES_LoadVideo";
|
||||
|
||||
@@ -41,6 +38,13 @@ export function isVideoFileName(name) {
|
||||
return VIDEO_FILE_PATTERN.test(String(name || ""));
|
||||
}
|
||||
|
||||
export function renderedVideoValues() {
|
||||
return {
|
||||
start_seconds: 0, end_seconds: 0, custom_width: 0, custom_height: 0,
|
||||
every_nth: 1, max_frames: 0, single_frame: false,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizedNumber(name, value) {
|
||||
const range = NUMERIC_RANGES[name];
|
||||
const number = Number(value);
|
||||
@@ -65,8 +69,8 @@ function namedValues(raw) {
|
||||
}
|
||||
|
||||
// name -> sanitized value for the first Load Video node in the workflow, or
|
||||
// null when there is none (or nothing usable on it). "video" is never
|
||||
// restored: the dropped file itself is the source now. A save from before
|
||||
// null when there is none (or nothing usable on it). Use these settings
|
||||
// only with the original source, never with a rendered export. A save from before
|
||||
// the single_frame widget existed was a trim, so the toggle restores off
|
||||
// rather than staying whatever the target node happened to have.
|
||||
export function loadVideoRestoreValues(workflow) {
|
||||
|
||||
@@ -20,6 +20,7 @@ const emptyWidget = (inputs, name) => !linked(inputs?.[name]) && blank(inputs?.[
|
||||
|
||||
const picture = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "picture" } : null);
|
||||
const video = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "video" } : null);
|
||||
const sound = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "sound file" } : null);
|
||||
|
||||
function videoSource(inputs) {
|
||||
const mode = inputs?.source_mode;
|
||||
@@ -38,6 +39,7 @@ export const SOURCE_LOADERS = Object.freeze({
|
||||
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
|
||||
// A wired source_image replaces the file, so the file may stay blank.
|
||||
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
|
||||
AUSBOSS_NODES_LoadAudio: sound("audio"),
|
||||
AUSBOSS_NODES_LoadVideo: video("video"),
|
||||
AUSBOSS_NODES_VideoCropRotatePad: videoSource,
|
||||
AUSBOSS_NODES_VideoCropRotatePadClip: videoSource,
|
||||
@@ -110,7 +112,8 @@ export function emptySourceMessage(found) {
|
||||
if (!first) return "";
|
||||
const more = found.length - 1;
|
||||
const tail = more > 0 ? ` (and ${more} more node${more === 1 ? "" : "s"})` : "";
|
||||
return `Load a ${first.kind === "video" ? "video" : "picture"} first: ${first.title}${tail}`;
|
||||
const noun = first.kind === "video" || first.kind === "sound file" ? first.kind : "picture";
|
||||
return `Load a ${noun} first: ${first.title}${tail}`;
|
||||
}
|
||||
|
||||
// "Save Image: exact_name may not ..." names the input it is about, after
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Seed 🆎's two looks, picked from the gear on its card: the full card
|
||||
// (the default), or "compact", one short row with the number, a mode chip
|
||||
// and a new-seed button. The look is kept in the node's properties, so the
|
||||
// node's inputs, saved values and the prompt it sends are the same in both,
|
||||
// and the last look picked is what new Seed nodes start with.
|
||||
//
|
||||
// Data and decisions only (tests/seed_look.test.mjs); js/seed/index.js
|
||||
// draws the card.
|
||||
|
||||
export const SEED_LOOK_PROPERTY = "ausboss_seed_look";
|
||||
export const SEED_LOOKS = ["full", "compact"];
|
||||
export const SEED_LOOK_SCOPE = "seed_look";
|
||||
|
||||
export function seedLook(node) {
|
||||
return node?.properties?.[SEED_LOOK_PROPERTY] === "compact" ? "compact" : "full";
|
||||
}
|
||||
|
||||
// The full card leaves no property behind, so a node set back to it is
|
||||
// saved exactly as one that was never changed.
|
||||
export function setSeedLook(node, look) {
|
||||
if (!node) return "full";
|
||||
node.properties ??= {};
|
||||
if (look === "compact") node.properties[SEED_LOOK_PROPERTY] = "compact";
|
||||
else delete node.properties[SEED_LOOK_PROPERTY];
|
||||
return seedLook(node);
|
||||
}
|
||||
|
||||
// The gear menu (js/shared/settings_menu.mjs). The pick is also kept as the
|
||||
// look new Seed nodes start with.
|
||||
export const SEED_LOOK_MENU = [
|
||||
{
|
||||
key: "look",
|
||||
label: "Look",
|
||||
type: "choice",
|
||||
options: SEED_LOOKS,
|
||||
default: "full",
|
||||
hint: "How this Seed node looks. New Seed nodes start the same way.",
|
||||
optionHints: {
|
||||
full: "the whole card: mode buttons, New seed, Use last run, and the seeds it ran with.",
|
||||
compact: "one short row: the number, a mode chip you tap to change, and a dice for a new seed.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// The compact row's chip: tap to go Random -> Fixed -> Step +1 -> Step -1
|
||||
// -> Random. Values are the frontend's control_after_generate choices.
|
||||
const CYCLE = ["randomize", "fixed", "increment", "decrement"];
|
||||
|
||||
export function nextControl(control) {
|
||||
const index = CYCLE.indexOf(control);
|
||||
return CYCLE[(index + 1) % CYCLE.length] ?? "fixed";
|
||||
}
|
||||
|
||||
export function controlChip(control) {
|
||||
switch (control) {
|
||||
case "randomize": return { text: "Random", title: "Rolls a new seed after each run. Tap for Fixed." };
|
||||
case "increment": return { text: "+1", title: "Adds 1 after each run. Tap for -1." };
|
||||
case "decrement": return { text: "−1", title: "Takes 1 off after each run. Tap for Random." };
|
||||
default: return { text: "Fixed", title: "Keeps this seed for every run. Tap for +1." };
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,10 @@ export function frameWindow(info, startSeconds, endSeconds) {
|
||||
return { first, last };
|
||||
}
|
||||
|
||||
// The seconds to store for a first/last frame pair. The frontend rounds
|
||||
// these FLOAT widgets to hundredths (their step), so the values are chosen
|
||||
// on that grid: IN is the largest hundredth that still keeps `first`, OUT
|
||||
// the smallest that still keeps `last` and drops the frame after it. Both
|
||||
// resolve back to the same frames under the backend's rule, and a window
|
||||
// that reaches the source's end stores 0 so the whole tail always stays in.
|
||||
// The seconds to store for a first/last frame pair. Prefer the widgets'
|
||||
// hundredth-second step where it can represent individual frames; faster
|
||||
// sources need precise timestamps. A window reaching the source's end stores
|
||||
// 0 so the whole tail always stays in.
|
||||
export const SECONDS_GRID = 0.01;
|
||||
|
||||
function gridFloor(value) { return Math.round(Math.floor(value / SECONDS_GRID + 1e-9) * SECONDS_GRID * 1e6) / 1e6; }
|
||||
@@ -70,6 +68,14 @@ export function windowSeconds(info, first, last) {
|
||||
if (!fps || !count) return { start_seconds: 0, end_seconds: 0 };
|
||||
const head = clamp(Math.round(finite(first)), 0, count - 1);
|
||||
const tail = clamp(Math.round(finite(last)), head, count - 1);
|
||||
// Above 100 fps, adjacent frames share a hundredth. Keep their exact
|
||||
// boundaries instead of widening a one-frame selection onto that grid.
|
||||
if (fps > 1 / SECONDS_GRID) {
|
||||
return {
|
||||
start_seconds: head / fps,
|
||||
end_seconds: tail >= count - 1 ? 0 : (tail + 1) / fps,
|
||||
};
|
||||
}
|
||||
return {
|
||||
start_seconds: head === 0 ? 0 : Math.max(0, gridFloor(head / fps + TRIM_EPSILON)),
|
||||
end_seconds: tail >= count - 1 ? 0 : gridCeil(tail / fps + TRIM_EPSILON),
|
||||
|
||||
@@ -1676,13 +1676,15 @@ async function loadVideoFrame(state, serial = ++state.loadSerial, options = {})
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const image = new Image();
|
||||
let adopted = false;
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
image.onload = resolve; image.onerror = () => reject(new Error("Could not display video frame.")); image.src = objectUrl;
|
||||
});
|
||||
if (serial !== state.loadSerial) return;
|
||||
if (serial !== state.loadSerial || state.disposed || controller.signal.aborted) return;
|
||||
if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
|
||||
state.frameObjectUrl = objectUrl; state.image = image;
|
||||
adopted = true;
|
||||
state.scrubPreviewTile = null; // real frame arrived; drop the ghost tile
|
||||
const actualIndex = Number(response.headers.get("X-AusBoss-Frame-Index"));
|
||||
const actualTime = Number(response.headers.get("X-AusBoss-Frame-Time"));
|
||||
@@ -1698,9 +1700,8 @@ async function loadVideoFrame(state, serial = ++state.loadSerial, options = {})
|
||||
if (Number.isFinite(actualTime)) setValue(state.node, "frame_time", actualTime);
|
||||
syncTimelineRange(state);
|
||||
}
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
throw error;
|
||||
} finally {
|
||||
if (!adopted) URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3157,7 +3158,7 @@ export function openTransformEditorForNode(node) {
|
||||
}
|
||||
|
||||
export function disposeTransformNode(node) {
|
||||
const state = node.__ausbossTransformState; if (!state) return; state.disposed = true; liveStates.delete(state); closeEditor(state); state.settle.cancel(); state.panelAbort?.abort(); state.panelResizeObserver?.disconnect(); state.frameController?.abort(); if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
|
||||
const state = node.__ausbossTransformState; if (!state) return; state.disposed = true; state.loadSerial += 1; liveStates.delete(state); closeEditor(state); state.settle.cancel(); state.panelAbort?.abort(); state.panelResizeObserver?.disconnect(); state.frameController?.abort(); if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl);
|
||||
if (node.__ausbossImgsSuppressed) {
|
||||
const descriptor = node.__ausbossImgsDescriptor;
|
||||
if (descriptor) Object.defineProperty(node, "imgs", descriptor); else delete node.imgs;
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
//
|
||||
// Dropping a video ONTO a Load Video 🆎 node is a different gesture with a
|
||||
// different meaning: the node keeps the canvas and takes the file as its
|
||||
// source, and an AusBoss-saved file also hands back the trim and sampling
|
||||
// values its embedded workflow stored - like re-opening the clip the way it
|
||||
// was loaded before. Core routes node-targeted drops through onDragDrop
|
||||
// source. The file already contains any trim, resizing and sampling, so
|
||||
// these controls reset for the imported clip. Core routes node-targeted drops through onDragDrop
|
||||
// before falling back to the canvas path, which is what keeps the two
|
||||
// gestures apart.
|
||||
|
||||
@@ -16,7 +15,7 @@ import { app } from "/scripts/app.js";
|
||||
import { notifyAusbossChange, showToast } from "../shared/index.mjs";
|
||||
import {
|
||||
isVideoFileName,
|
||||
loadVideoRestoreValues,
|
||||
renderedVideoValues,
|
||||
} from "../shared/load_video_restore.mjs";
|
||||
import { looksLikeMp4, mp4WorkflowPayload } from "../shared/mp4_workflow.mjs";
|
||||
|
||||
@@ -42,12 +41,10 @@ async function uploadToInput(file) {
|
||||
return subfolder ? `${subfolder}/${name}` : name;
|
||||
}
|
||||
|
||||
function applyDropToLoadVideo(node, restored, uploadedName) {
|
||||
function applyDropToLoadVideo(node, uploadedName) {
|
||||
const widget = (name) => node.widgets?.find((item) => item.name === name);
|
||||
// Restored values land silently first; the video callback below repaints
|
||||
// the preview once with all of them already in place, so no load-path
|
||||
// callback ever sees (or wipes) a half-restored trim.
|
||||
for (const [name, value] of Object.entries(restored ?? {})) {
|
||||
// Reset before the video callback repaints the imported source.
|
||||
for (const [name, value] of Object.entries(renderedVideoValues())) {
|
||||
const target = widget(name);
|
||||
if (target) target.value = value;
|
||||
}
|
||||
@@ -70,16 +67,8 @@ function applyDropToLoadVideo(node, restored, uploadedName) {
|
||||
// surprise for a drop aimed at one node.
|
||||
async function handleLoadVideoDrop(node, file) {
|
||||
try {
|
||||
let restored = null;
|
||||
if (isMp4File(file) || /\.mov$/i.test(String(file.name || ""))) {
|
||||
// mov shares the ISO box layout, so its tags parse the same way.
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (looksLikeMp4(bytes)) {
|
||||
restored = loadVideoRestoreValues(mp4WorkflowPayload(bytes).workflow);
|
||||
}
|
||||
}
|
||||
const uploadedName = await uploadToInput(file);
|
||||
applyDropToLoadVideo(node, restored, uploadedName);
|
||||
applyDropToLoadVideo(node, uploadedName);
|
||||
} catch (error) {
|
||||
showToast({ severity: "error", summary: "Load Video \u{1F18E}", detail: `The dropped video could not be loaded: ${error?.message || error}` });
|
||||
}
|
||||
|
||||
@@ -384,6 +384,16 @@ const CARDS = {
|
||||
{ widget: "max_frames", label: "Limit" },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_LoadAudio: {
|
||||
// The wave, IN and OUT live in the panel under the card (js/load_audio).
|
||||
minWidth: 280, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
{ widget: "audio", label: "Source", kind: "select",
|
||||
button: { text: "Upload", title: "Upload a sound file into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
|
||||
{ widget: "max_seconds", label: "Max length", suffix: "s", decimals: 1, trimZeros: true,
|
||||
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_LoadImagePad: {
|
||||
minWidth: 340, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
|
||||
@@ -1557,5 +1557,31 @@
|
||||
"AUSBOSS_NODES_WorkflowSwitches": {
|
||||
"display_name": "工作流开关 🆎",
|
||||
"description": "用一张小卡片开关工作流的各个部分。选中组成一个部分的节点,例如一个框和它的保存节点,按 + Switch:它们会得到一个 off | on 开关,名字由你定。每个组也可以有自己的开关。off 会旁路这些节点,图片仍然传到下一步(也可以设置为静音)。on 让它们重新运行。开关也可以改设置,而不是关闭节点:分别保存节点在 on 和 off 时的值,点一下就能把 Steps 设为 8 并打开一行 LoRA。按 Done,节点会缩成只有标签和开关。它从不运行,也没有连线。"
|
||||
},
|
||||
"AUSBOSS_NODES_LoadAudio": {
|
||||
"display_name": "加载音频 🆎",
|
||||
"description": "加载声音文件,只输出你选中的部分。节点把声音画成波形:拖动两个手柄设置起点和终点,按播放只听这一段。把最大长度设为下一步能接收的最长声音,超出的部分会显示为红色并被剪掉,运行前就能看到输出的内容。",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "音频"
|
||||
},
|
||||
"start_seconds": {
|
||||
"name": "起始秒数"
|
||||
},
|
||||
"end_seconds": {
|
||||
"name": "结束秒数"
|
||||
},
|
||||
"max_seconds": {
|
||||
"name": "最大秒数"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "音频"
|
||||
},
|
||||
"1": {
|
||||
"name": "秒数"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Trimmed audio decoding for Load Audio.
|
||||
|
||||
The window rules live in ``audio_window`` so the node and its tests agree on
|
||||
what one Run hands on; ``js/load_audio/audio_window.mjs`` mirrors them for the
|
||||
face. Decoding counts samples from the start of the sound track and stops as
|
||||
soon as the window is full, so a short pick from a long file never decodes
|
||||
the whole file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ._execution_helpers import raise_if_interrupted
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError: # offline tests
|
||||
folder_paths = None
|
||||
|
||||
AUDIO_EXTENSIONS = {
|
||||
".aac",
|
||||
".aif",
|
||||
".aiff",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
".webm",
|
||||
".wma",
|
||||
}
|
||||
# Video files carry sound too: a song is often only on hand inside a clip.
|
||||
SOUND_IN_VIDEO_EXTENSIONS = {".mkv", ".mov", ".mp4", ".m4v"}
|
||||
|
||||
FALLBACK_SAMPLE_RATE = 44100
|
||||
# Shorter than this is no window at all; the face keeps its handles this far apart.
|
||||
MIN_WINDOW_SECONDS = 0.1
|
||||
|
||||
|
||||
def _number(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if math.isfinite(number) else default
|
||||
|
||||
|
||||
def list_input_audio() -> list[str]:
|
||||
"""Sound files (and videos, for their sound) in ComfyUI's input folder."""
|
||||
if folder_paths is None:
|
||||
return [""]
|
||||
root = Path(folder_paths.get_input_directory())
|
||||
kinds = AUDIO_EXTENSIONS | SOUND_IN_VIDEO_EXTENSIONS
|
||||
results = [
|
||||
path.relative_to(root).as_posix()
|
||||
for path in root.rglob("*")
|
||||
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in kinds
|
||||
]
|
||||
return sorted(results, key=str.casefold) or [""]
|
||||
|
||||
|
||||
def audio_window(start_seconds, end_seconds, max_seconds=0.0, duration=None) -> dict:
|
||||
"""The part of a sound file one Run hands on.
|
||||
|
||||
end 0 means "to the end of the file". max_seconds above 0 caps the
|
||||
window's length: the pick keeps its start and anything past
|
||||
start + max_seconds is cut off. ``duration`` is the file's length when it
|
||||
is known; None leaves an open end open.
|
||||
|
||||
Returns start, end (None when open), the end the person picked
|
||||
(``picked_end``) and how many seconds the cap cut off (``cut``).
|
||||
"""
|
||||
start = max(0.0, _number(start_seconds))
|
||||
picked_end = _number(end_seconds)
|
||||
end = picked_end if picked_end > 0 else None
|
||||
if duration is not None:
|
||||
length = max(0.0, _number(duration))
|
||||
start = min(start, length)
|
||||
end = length if end is None else min(end, length)
|
||||
if end is not None:
|
||||
end = max(start, end)
|
||||
picked = end
|
||||
limit = max(0.0, _number(max_seconds))
|
||||
if limit > 0 and (end is None or end - start > limit):
|
||||
end = start + limit
|
||||
if duration is not None:
|
||||
end = min(end, max(0.0, _number(duration)))
|
||||
cut = 0.0
|
||||
if picked is not None and end is not None:
|
||||
cut = max(0.0, picked - end)
|
||||
return {"start": start, "end": end, "picked_end": picked, "cut": cut}
|
||||
|
||||
|
||||
def decode_audio_window(path: Path, start_seconds, end_seconds, max_seconds=0.0) -> dict:
|
||||
"""ComfyUI AUDIO for the window ``audio_window`` describes.
|
||||
|
||||
Raises a plain ValueError for a file with no sound, or a start at or past
|
||||
the end of the sound.
|
||||
"""
|
||||
window = audio_window(start_seconds, end_seconds, max_seconds)
|
||||
start = window["start"]
|
||||
with av.open(str(path)) as container:
|
||||
stream = next((candidate for candidate in container.streams if candidate.type == "audio"), None)
|
||||
if stream is None:
|
||||
raise ValueError(f"{path.name} has no sound in it.")
|
||||
rate = int(stream.rate or FALLBACK_SAMPLE_RATE)
|
||||
first = round(start * rate)
|
||||
last = None if window["end"] is None else round(window["end"] * rate)
|
||||
resampler = av.AudioResampler(format="fltp", layout=stream.layout, rate=rate)
|
||||
chunks: list[np.ndarray] = []
|
||||
position = 0
|
||||
|
||||
def take(frame) -> None:
|
||||
nonlocal position
|
||||
for resampled in resampler.resample(frame):
|
||||
data = resampled.to_ndarray()
|
||||
begin = position
|
||||
position += data.shape[1]
|
||||
if position <= first:
|
||||
continue
|
||||
low = max(0, first - begin)
|
||||
high = data.shape[1] if last is None else min(data.shape[1], last - begin)
|
||||
if high > low:
|
||||
chunks.append(data[:, low:high])
|
||||
|
||||
for frame in container.decode(stream):
|
||||
raise_if_interrupted()
|
||||
take(frame)
|
||||
if last is not None and position >= last:
|
||||
break
|
||||
else:
|
||||
take(None) # the resampler hands back what it still holds
|
||||
if not chunks:
|
||||
if position == 0:
|
||||
raise ValueError(f"{path.name} has no sound in it.")
|
||||
raise ValueError(
|
||||
f"The start ({start:.2f} s) is past the end of {path.name}, "
|
||||
f"which is {position / rate:.2f} s long. Drag the start back into the sound."
|
||||
)
|
||||
data = np.concatenate(chunks, axis=1).astype(np.float32, copy=False)
|
||||
return {
|
||||
"waveform": torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0),
|
||||
"sample_rate": rate,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUDIO_EXTENSIONS",
|
||||
"MIN_WINDOW_SECONDS",
|
||||
"SOUND_IN_VIDEO_EXTENSIONS",
|
||||
"audio_window",
|
||||
"decode_audio_window",
|
||||
"list_input_audio",
|
||||
]
|
||||
+29
-9
@@ -438,7 +438,7 @@ def _start_reaper_locked() -> None:
|
||||
now = time.monotonic()
|
||||
with _SESSIONS_LOCK:
|
||||
for key in [k for k, s in _SESSIONS.items() if now - s.last_used > _SESSION_IDLE_SECONDS]:
|
||||
_SESSIONS.pop(key).close()
|
||||
_evict_session_locked(key)
|
||||
|
||||
threading.Thread(target=reap, daemon=True, name="ausboss-scrub-session-reaper").start()
|
||||
|
||||
@@ -447,31 +447,48 @@ def close_scrub_sessions() -> None:
|
||||
"""Close every open scrub session (releases file handles immediately)."""
|
||||
with _SESSIONS_LOCK:
|
||||
for key in list(_SESSIONS):
|
||||
_SESSIONS.pop(key).close()
|
||||
_evict_session_locked(key, wait=True)
|
||||
|
||||
|
||||
def _get_session(path: Path) -> "_ScrubSession":
|
||||
def _evict_session_locked(key, *, wait=False) -> bool:
|
||||
"""Called under the table lock; never close a decoder in use."""
|
||||
session = _SESSIONS[key]
|
||||
if not session.lock.acquire(blocking=wait):
|
||||
return False
|
||||
try:
|
||||
_SESSIONS.pop(key).close()
|
||||
return True
|
||||
finally:
|
||||
session.lock.release()
|
||||
|
||||
|
||||
def _get_session(path: Path, *, acquire=False) -> "_ScrubSession":
|
||||
key = _file_key(path)
|
||||
with _SESSIONS_LOCK:
|
||||
_start_reaper_locked()
|
||||
session = _SESSIONS.get(key)
|
||||
if session is None:
|
||||
for stale in [other for other in _SESSIONS if other[0] == key[0] and other != key]:
|
||||
_SESSIONS.pop(stale).close()
|
||||
if len(_SESSIONS) >= _SESSION_LIMIT:
|
||||
oldest = min(_SESSIONS, key=lambda item: _SESSIONS[item].last_used)
|
||||
_SESSIONS.pop(oldest).close()
|
||||
_evict_session_locked(stale)
|
||||
for oldest in sorted(_SESSIONS, key=lambda item: _SESSIONS[item].last_used):
|
||||
if len(_SESSIONS) < _SESSION_LIMIT:
|
||||
break
|
||||
_evict_session_locked(oldest)
|
||||
session = _ScrubSession(path)
|
||||
_SESSIONS[key] = session
|
||||
session.last_used = time.monotonic()
|
||||
if acquire:
|
||||
# Acquire before releasing the table lock: eviction cannot slip
|
||||
# between looking up a session and starting its decode.
|
||||
session.lock.acquire()
|
||||
return session
|
||||
|
||||
|
||||
def _session_decode(path: Path, target_index: int, requested_time: float, fps: float):
|
||||
"""Decode via the persistent session. Returns None when the file lacks
|
||||
usable timestamps so the caller can use the stateless fallbacks."""
|
||||
session = _get_session(path)
|
||||
with session.lock:
|
||||
session = _get_session(path, acquire=True)
|
||||
try:
|
||||
stream = session.stream
|
||||
if session.last_index == target_index and session.last_frame is not None:
|
||||
index, time_value = _frame_position(session.last_frame, stream, fps, target_index)
|
||||
@@ -511,6 +528,9 @@ def _session_decode(path: Path, target_index: int, requested_time: float, fps: f
|
||||
return None
|
||||
index, time_value = _frame_position(last, stream, fps, target_index)
|
||||
return last.to_image().convert("RGBA"), index, time_value
|
||||
finally:
|
||||
session.last_used = time.monotonic()
|
||||
session.lock.release()
|
||||
|
||||
|
||||
def seek_target(metadata: dict, seek_mode: str, frame_index: int, frame_time: float) -> tuple[int, float]:
|
||||
|
||||
@@ -400,7 +400,14 @@ def decode_audio_range(path: Path, start_seconds: float, end_seconds: float) ->
|
||||
resampler = av.AudioResampler(format="fltp", layout=stream.layout, rate=rate)
|
||||
chunks: list[np.ndarray] = []
|
||||
first_time: float | None = None
|
||||
raise_if_interrupted()
|
||||
if start > 0 and stream.time_base:
|
||||
# Audio packet boundaries need a little preroll. Seek on the
|
||||
# audio stream, but translate from the video's shared clock.
|
||||
target = max(0.0, start - 1.0) + stream_origin(clock)
|
||||
container.seek(int(target / stream.time_base), stream=stream, backward=True)
|
||||
for frame in container.decode(stream):
|
||||
raise_if_interrupted()
|
||||
time = stream_seconds(frame.time, clock)
|
||||
if time is None:
|
||||
time = 0.0
|
||||
@@ -424,8 +431,8 @@ def decode_audio_range(path: Path, start_seconds: float, end_seconds: float) ->
|
||||
begin = max(0, -lead)
|
||||
length = max(1, round((end - start) * rate))
|
||||
data = data[:, begin : begin + length]
|
||||
if data.size == 0:
|
||||
return silent_audio(end - start)
|
||||
if data.shape[1] < length:
|
||||
data = np.pad(data, ((0, 0), (0, length - data.shape[1])))
|
||||
return {
|
||||
"waveform": torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0),
|
||||
"sample_rate": rate,
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
@@ -249,10 +251,19 @@ def _encode_animation(
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
# A gif/webp frame duration is whole milliseconds, so the playback rate is
|
||||
# quantized here whatever fps asked for; 1 ms is the floor players honour.
|
||||
duration_ms = max(1, int(round(1000.0 / float(fps))))
|
||||
frame_count = int(batch.shape[0])
|
||||
if extension == "gif":
|
||||
# GIF stores centiseconds. Round cumulative boundaries so rounding
|
||||
# each frame cannot shorten the whole animation.
|
||||
if fps > 100:
|
||||
raise ValueError("GIF supports at most 100 fps; choose WebP or a video format.")
|
||||
ticks = 100.0 / float(fps)
|
||||
duration_ms = [
|
||||
max(1, round((index + 1) * ticks) - round(index * ticks)) * 10
|
||||
for index in range(frame_count)
|
||||
]
|
||||
else:
|
||||
duration_ms = max(1, int(round(1000.0 / float(fps))))
|
||||
progress = frame_progress(frame_count)
|
||||
images = []
|
||||
for encoded, frame in enumerate(batch, start=1):
|
||||
@@ -289,6 +300,22 @@ def encode_video(
|
||||
video_format: str = "mp4 h264",
|
||||
) -> tuple[int, int, int]:
|
||||
"""Write the encoded file; returns (width, height, frame_count)."""
|
||||
# Preserve the extension for PyAV/Pillow format detection. Publish only
|
||||
# after the encoder closes successfully; failed runs leave no partial clip.
|
||||
path = Path(path)
|
||||
fd, temporary = tempfile.mkstemp(prefix=f".{path.stem}-", suffix=path.suffix, dir=path.parent)
|
||||
os.close(fd)
|
||||
staging = Path(temporary)
|
||||
try:
|
||||
result = _encode_video(staging, frames, fps, audio, crf, metadata, video_format)
|
||||
raise_if_interrupted()
|
||||
os.replace(staging, path)
|
||||
return result
|
||||
finally:
|
||||
staging.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _encode_video(path, frames, fps, audio, crf, metadata, video_format):
|
||||
if video_format not in VIDEO_FORMATS:
|
||||
raise ValueError(
|
||||
f"Save Video format must be one of {tuple(VIDEO_FORMATS)}, "
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Load Audio 🆎."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from ._audio_trim_helpers import decode_audio_window, list_input_audio
|
||||
from ._execution_helpers import ignore_extra_inputs
|
||||
from ._media_helpers import resolve_input_path
|
||||
|
||||
|
||||
class AusBossLoadAudio:
|
||||
CATEGORY = "🆎 AusBoss/Video"
|
||||
DESCRIPTION = (
|
||||
"Loads a sound file and hands on only the part you pick. The node shows the "
|
||||
"sound as a wave, so you can see where the loud parts are. Drag the two "
|
||||
"handles to set where it starts and stops, and press play to hear just that "
|
||||
"part. Set Max length to the longest sound the next step takes: anything "
|
||||
"longer shows in red as cut off, so you see exactly what comes out before "
|
||||
"you run anything."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"load audio",
|
||||
"audio loader",
|
||||
"trim audio",
|
||||
"cut audio",
|
||||
"song",
|
||||
"voice",
|
||||
"music",
|
||||
"waveform",
|
||||
"ausboss",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio": (
|
||||
list_input_audio(),
|
||||
{
|
||||
"audio_upload": True,
|
||||
"tooltip": "A sound file in ComfyUI's input folder. Use Upload to add one.",
|
||||
},
|
||||
),
|
||||
"start_seconds": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Where the sound starts. Drag the wave's IN handle or type a time.",
|
||||
},
|
||||
),
|
||||
"end_seconds": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Where the sound stops. 0 plays to the end. Drag the OUT handle or type a time.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"max_seconds": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"step": 0.1,
|
||||
"tooltip": (
|
||||
"The longest the sound may be. Anything past it is cut off and shows "
|
||||
"in red. 0 means no limit."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO", "FLOAT")
|
||||
RETURN_NAMES = ("audio", "seconds")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The part of the sound you picked, ready for any node that takes audio.",
|
||||
"How long that sound is, in seconds.",
|
||||
)
|
||||
FUNCTION = "load_audio"
|
||||
|
||||
async def load_audio(self, audio, start_seconds, end_seconds, max_seconds=0.0):
|
||||
path = resolve_input_path(audio)
|
||||
# Decoding blocks for as long as the pick is; a worker thread keeps
|
||||
# ComfyUI's event loop answering, and carries the context ComfyUI
|
||||
# reads to attribute an interrupt to this node.
|
||||
sound = await asyncio.to_thread(
|
||||
decode_audio_window, path, start_seconds, end_seconds, max_seconds
|
||||
)
|
||||
seconds = sound["waveform"].shape[-1] / float(sound["sample_rate"])
|
||||
return (sound, float(seconds))
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, audio, **_values):
|
||||
try:
|
||||
stat = resolve_input_path(audio).stat()
|
||||
except Exception:
|
||||
return float("nan")
|
||||
return f"{audio}:{stat.st_mtime_ns}:{stat.st_size}"
|
||||
|
||||
@classmethod
|
||||
@ignore_extra_inputs
|
||||
def VALIDATE_INPUTS(cls, audio):
|
||||
# Only the source is named: ComfyUI skips its own range checks for
|
||||
# every input named here. resolve_input_path takes a file inside the
|
||||
# input folder, or one named "[output]" or "[temp]", and nothing else.
|
||||
try:
|
||||
resolve_input_path(audio)
|
||||
except Exception as exc:
|
||||
return f"Load Audio: {exc}"
|
||||
return True
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LoadAudio": AusBossLoadAudio}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LoadAudio": "Load Audio 🆎"}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
+28
-13
@@ -244,22 +244,37 @@ class AusBossSaveVideo:
|
||||
)
|
||||
)
|
||||
extension = VIDEO_FORMATS[format][0] if format in VIDEO_FORMATS else "mp4"
|
||||
file = f"{filename}_{counter:05}_.{extension}"
|
||||
output_path = Path(full_output_folder) / file
|
||||
require_output_path(output_path, output_root)
|
||||
# --disable-metadata is the server owner's call and beats the widget,
|
||||
# as it does for Save Image and core's own Save Video.
|
||||
embed = save_metadata and not metadata_disabled()
|
||||
width, height, frame_count = await asyncio.to_thread(
|
||||
encode_video,
|
||||
output_path,
|
||||
frames,
|
||||
float(fps),
|
||||
audio,
|
||||
int(crf),
|
||||
workflow_metadata(prompt, extra_pnginfo) if embed else None,
|
||||
str(format),
|
||||
)
|
||||
|
||||
def save_reserved():
|
||||
# Exclusive creation coordinates concurrent nodes and processes.
|
||||
# Keep reservation and cleanup on the encoder's worker so even
|
||||
# cancellation of the awaiting coroutine cannot strand a name.
|
||||
next_counter = counter
|
||||
while True:
|
||||
file = f"{filename}_{next_counter:05}_.{extension}"
|
||||
output_path = Path(full_output_folder) / file
|
||||
require_output_path(output_path, output_root)
|
||||
try:
|
||||
with output_path.open("xb"):
|
||||
pass
|
||||
break
|
||||
except FileExistsError:
|
||||
next_counter += 1
|
||||
try:
|
||||
dimensions = encode_video(
|
||||
output_path, frames, float(fps), audio, int(crf),
|
||||
workflow_metadata(prompt, extra_pnginfo) if embed else None,
|
||||
str(format),
|
||||
)
|
||||
return file, output_path, dimensions
|
||||
except BaseException:
|
||||
output_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
file, output_path, (width, height, frame_count) = await asyncio.to_thread(save_reserved)
|
||||
return {
|
||||
"ui": {
|
||||
"images": [{
|
||||
|
||||
@@ -67,6 +67,7 @@ PUBLIC_NODE_IDS = {
|
||||
"AUSBOSS_NODES_Krea2OutpaintModelPatch",
|
||||
"AUSBOSS_NODES_LaMaInpaint",
|
||||
"AUSBOSS_NODES_LayoutBoxes",
|
||||
"AUSBOSS_NODES_LoadAudio",
|
||||
"AUSBOSS_NODES_LoadImagePad",
|
||||
"AUSBOSS_NODES_LoadVideo",
|
||||
"AUSBOSS_NODES_LoraLoader",
|
||||
|
||||
@@ -3,8 +3,6 @@ import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BRUSH_SIZE,
|
||||
ADJUST_DEAD_ZONE,
|
||||
adjustAxis,
|
||||
adjustedBrush,
|
||||
brushAdjusts,
|
||||
brushFlips,
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
moveAdjust,
|
||||
startAdjust,
|
||||
steppedBrushSize,
|
||||
steppedSoft,
|
||||
toolKey,
|
||||
} from "../js/shared/brush.mjs";
|
||||
|
||||
@@ -104,53 +101,3 @@ test("the brush under the pointer fades as its marks do", () => {
|
||||
assert.ok(brushProfile(brushReach(80, 1), 80, 1) < 0.01);
|
||||
for (let d = 0; d < 50; d += 5) assert.ok(brushProfile(d, 80, 0.6) >= brushProfile(d + 5, 80, 0.6), "never brighter further out");
|
||||
});
|
||||
|
||||
test("brush values round, clamp and fall back without producing NaN", () => {
|
||||
assert.equal(clampBrushSize("25.6"), 26);
|
||||
assert.equal(clampBrushSize(1), BRUSH_SIZE[0]);
|
||||
for (const value of [undefined, "large", NaN, Infinity, -Infinity]) {
|
||||
assert.equal(clampBrushSize(value, [4, 1000]), 4);
|
||||
assert.equal(clampSoft(value, 20), 20);
|
||||
}
|
||||
assert.equal(clampSoft("24.6"), 25);
|
||||
assert.equal(clampSoft(-5), 0);
|
||||
assert.equal(clampSoft(101), 100);
|
||||
assert.equal(clampSoft(undefined, 120), 100);
|
||||
});
|
||||
|
||||
test("brush keys change small sizes by at least two pixels and stop at the bounds", () => {
|
||||
assert.equal(steppedBrushSize(2, true), 4);
|
||||
assert.equal(steppedBrushSize(4, false), 2);
|
||||
assert.equal(steppedBrushSize(2, false), 2);
|
||||
assert.equal(steppedBrushSize(100, false), 84);
|
||||
assert.equal(steppedBrushSize(2000, true), 2000);
|
||||
assert.equal(steppedSoft(50, true), 75);
|
||||
assert.equal(steppedSoft(50, false), 25);
|
||||
assert.equal(steppedSoft(90, true), 100);
|
||||
assert.equal(steppedSoft(10, false), 0);
|
||||
});
|
||||
|
||||
test("brush adjustment starts at the dead-zone boundary and breaks diagonal ties by size", () => {
|
||||
const edge = ADJUST_DEAD_ZONE;
|
||||
assert.equal(adjustAxis(edge - 1, -(edge - 1)), null);
|
||||
for (const direction of [-1, 1]) {
|
||||
assert.equal(adjustAxis(direction * edge, 0), "size");
|
||||
assert.equal(adjustAxis(0, direction * edge), "soft");
|
||||
assert.equal(adjustAxis(direction * edge, -edge), "size");
|
||||
}
|
||||
});
|
||||
|
||||
test("a tiny blur keeps the hard-brush edge, while a soft brush scales with its size", () => {
|
||||
// Below a quarter pixel of blur the preview uses a solid disc.
|
||||
assert.equal(brushProfile(1, 2, 0.5), 1);
|
||||
assert.equal(brushProfile(1.01, 2, 0.5), 0);
|
||||
assert.equal(brushReach(2, 0.5), 1);
|
||||
for (const soft of [0.25, 0.5, 1]) {
|
||||
assert.equal(brushReach(160, soft), 2 * brushReach(80, soft));
|
||||
for (const distance of [0, 10, 20, 40, 80]) {
|
||||
const value = brushProfile(distance, 80, soft);
|
||||
assert.ok(value >= 0 && value <= 1);
|
||||
assert.equal(brushProfile(distance * 2, 160, soft), value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -89,60 +89,3 @@ test("a failed lookup says why in plain words", () => {
|
||||
assert.equal(civitaiFailureText(new Error("Civitai answered 500.")), "Civitai answered 500.");
|
||||
assert.equal(civitaiFailureText(null), "The Civitai lookup failed.");
|
||||
});
|
||||
|
||||
test("parsing accepts an object at the size limit and rejects oversized or non-text bodies", () => {
|
||||
const body = '{"name":"' + "x".repeat(MAX_CIVITAI_BYTES - 11) + '"}';
|
||||
assert.equal(body.length, MAX_CIVITAI_BYTES);
|
||||
assert.equal(parseCivitaiBody(body).name.length, MAX_CIVITAI_BYTES - 11);
|
||||
assert.throws(() => parseCivitaiBody(body + " "), /too large/);
|
||||
for (const value of [null, undefined, {}, 1]) {
|
||||
assert.throws(() => parseCivitaiBody(value), /too large/);
|
||||
}
|
||||
for (const text of ["true", "false", "0", '"model"']) {
|
||||
assert.throws(() => parseCivitaiBody(text), /not what we expected/);
|
||||
}
|
||||
assert.throws(() => parseCivitaiBody('{"id":'), SyntaxError);
|
||||
});
|
||||
|
||||
test("numeric picture ratings take priority over legacy words and booleans", () => {
|
||||
for (const level of [1, 2, "2"]) {
|
||||
assert.equal(isMildPicture({ nsfwLevel: level, nsfw: "Mature" }), true);
|
||||
}
|
||||
for (const level of [4, 8, 16, 3]) {
|
||||
assert.equal(isMildPicture({ nsfwLevel: level, nsfw: false }), false);
|
||||
}
|
||||
assert.equal(isMildPicture({ nsfw: false }), true);
|
||||
assert.equal(isMildPicture({ nsfw: true }), false);
|
||||
assert.equal(isMildPicture({ nsfwLevel: 0, nsfw: "SOFT" }), true);
|
||||
assert.equal(isMildPicture(null), false);
|
||||
});
|
||||
|
||||
test("picture selection skips malformed entries even when any rating is allowed", () => {
|
||||
const still = { url: picture("still") };
|
||||
const info = { images: [
|
||||
null, {}, { url: 42 },
|
||||
{ url: picture("clip"), type: "video", nsfwLevel: 1 },
|
||||
{ url: "https://example.org/picture.jpeg", nsfwLevel: 1 },
|
||||
still,
|
||||
{ url: picture("later"), nsfwLevel: 1 },
|
||||
] };
|
||||
assert.equal(pickCivitaiPicture(info, true), still, "the first usable still wins without copying it");
|
||||
assert.equal(pickCivitaiPicture(info), info.images[6], "an unrated still is skipped in mild mode");
|
||||
for (const images of [null, {}, "pictures", []]) {
|
||||
assert.equal(pickCivitaiPicture({ images }, true), null);
|
||||
}
|
||||
});
|
||||
|
||||
test("picture resizing drops query and fragment and refuses incomplete picture paths", () => {
|
||||
assert.equal(smallPictureUrl(picture("a") + "?width=999&token=example#preview"),
|
||||
"https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/0b1c2d3e/width=450/a.jpeg");
|
||||
assert.equal(smallPictureUrl("https://image.civitai.com/a.jpeg"), null);
|
||||
assert.equal(smallPictureUrl("https://image.civitai.com/"), null);
|
||||
for (const url of [
|
||||
"https://civitai.com.example.org/file.jpeg",
|
||||
"https://civitai.com@other.example/file.jpeg",
|
||||
"file:///tmp/file.jpeg", "not a url",
|
||||
]) {
|
||||
assert.equal(isCivitaiAddress(url), false, url);
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+20
@@ -490,6 +490,26 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadAudio": {
|
||||
"required": [
|
||||
"audio",
|
||||
"start_seconds",
|
||||
"end_seconds"
|
||||
],
|
||||
"optional": [
|
||||
"max_seconds"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
"AUDIO",
|
||||
"audio"
|
||||
],
|
||||
[
|
||||
"FLOAT",
|
||||
"seconds"
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImagePad": {
|
||||
"required": [
|
||||
"image",
|
||||
|
||||
@@ -145,82 +145,3 @@ test("restoreOutputLinks hands back only the links a replacement took", () => {
|
||||
assert.equal(graph.links.get(saver.inputs[0].link).origin_id, 1);
|
||||
assert.equal(preview.inputs[0].link, untouched, "a link that was never taken stays as it is");
|
||||
});
|
||||
|
||||
test("lookupLink keeps link id zero and tolerates absent tables and stale ids", () => {
|
||||
const link = { id: 0 };
|
||||
for (const links of [new Map([[0, link]]), { 0: link }]) {
|
||||
assert.equal(lookupLink({ links }, 0), link);
|
||||
assert.equal(lookupLink({ links }, 99), null);
|
||||
assert.equal(lookupLink({ links }, undefined), null);
|
||||
}
|
||||
assert.equal(lookupLink({}, 0), null);
|
||||
});
|
||||
|
||||
test("snapshotLinks skips stale links in an older plain-object graph", () => {
|
||||
const graph = { links: {
|
||||
0: { origin_id: 1, origin_slot: 2, target_id: 5, target_slot: 0 },
|
||||
8: { origin_id: 5, origin_slot: 1, target_id: 9, target_slot: 3 },
|
||||
} };
|
||||
const node = {
|
||||
inputs: [{ name: "image", link: 0 }, { name: "empty", link: null }, { name: "stale", link: 99 }],
|
||||
outputs: [{ name: "unused" }, { name: "mask", links: [99, 8] }, { name: "stale", links: [77] }],
|
||||
};
|
||||
const snapshot = snapshotLinks(node, graph);
|
||||
assert.deepEqual(snapshot, {
|
||||
inputs: [{ name: "image", originId: 1, originSlot: 2 }],
|
||||
outputs: [{ name: "mask", slot: 1, targets: [{ nodeId: 9, slot: 3 }] }],
|
||||
});
|
||||
graph.links[0].origin_slot = 4;
|
||||
graph.links[8].target_slot = 0;
|
||||
node.inputs[0].name = "changed";
|
||||
assert.equal(snapshot.inputs[0].originSlot, 2, "the snapshot owns its slot values");
|
||||
assert.equal(snapshot.inputs[0].name, "image");
|
||||
assert.equal(snapshot.outputs[0].targets[0].slot, 3);
|
||||
assert.deepEqual(snapshotLinks({}, graph), { inputs: [], outputs: [] });
|
||||
});
|
||||
|
||||
test("linkSlots refuses redirects outside the target's inputs before calling connectSlots", () => {
|
||||
const { makeNode } = makeGraph();
|
||||
const source = makeNode(1, [], ["out"]);
|
||||
const sink = makeNode(2, ["in"], []);
|
||||
source.connectSlots = () => assert.fail("a refused redirect must not connect");
|
||||
for (const redirect of [null, false, "0", -1, 4, NaN]) {
|
||||
sink.onBeforeConnectInput = function (slot, requested) {
|
||||
assert.equal(this, sink);
|
||||
assert.equal(slot, 0);
|
||||
assert.equal(requested, 0);
|
||||
return redirect;
|
||||
};
|
||||
assert.equal(linkSlots(source, 0, sink, 0), null, String(redirect));
|
||||
}
|
||||
});
|
||||
|
||||
test("restoreOutputLinks skips missing targets and reconnects a disconnected input", () => {
|
||||
const { graph, makeNode } = makeGraph();
|
||||
const original = makeNode(1, [], ["image", "mask"]);
|
||||
const sink = makeNode(2, ["mask"], []);
|
||||
restoreOutputLinks(original, [{ slot: 1, targets: [
|
||||
{ nodeId: 99, slot: 0 }, { nodeId: 2, slot: 0 }, { nodeId: 2, slot: 99 },
|
||||
] }], graph);
|
||||
const link = lookupLink(graph, sink.inputs[0].link);
|
||||
assert.equal(link.origin_id, original.id);
|
||||
assert.equal(link.origin_slot, 1);
|
||||
assert.equal(graph.links.size, 1);
|
||||
});
|
||||
|
||||
test("restoreOutputLinks restores the output slot even when the source node is unchanged", () => {
|
||||
const { graph, makeNode } = makeGraph();
|
||||
const original = makeNode(1, [], ["first image", "second image"]);
|
||||
const sink = makeNode(2, ["image"], []);
|
||||
linkSlots(original, 1, sink, 0);
|
||||
const { outputs } = snapshotLinks(original, graph);
|
||||
linkSlots(original, 0, sink, 0);
|
||||
|
||||
restoreOutputLinks(original, outputs, graph);
|
||||
const restored = lookupLink(graph, sink.inputs[0].link);
|
||||
assert.equal(restored.origin_id, original.id);
|
||||
assert.equal(restored.origin_slot, 1);
|
||||
assert.deepEqual(original.outputs[0].links, []);
|
||||
assert.deepEqual(original.outputs[1].links, [restored.id]);
|
||||
assert.equal(graph.links.size, 1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { grabZone, outputSummary, outputWindow, wavePeaks } from "../js/load_audio/audio_window.mjs";
|
||||
|
||||
test("the face shows what the node hands on", () => {
|
||||
// Mirrors audio_window in nodes/_audio_trim_helpers.py.
|
||||
assert.deepEqual(outputWindow(40, 8, 30, 15), { start: 8, end: 30, keepEnd: 23, length: 15, cut: 7 });
|
||||
assert.deepEqual(outputWindow(40, 8, 12, 15), { start: 8, end: 12, keepEnd: 12, length: 4, cut: 0 });
|
||||
assert.equal(outputWindow(40, 5, 0, 0).end, 40, "OUT 0 means the end of the file");
|
||||
assert.equal(outputWindow(40, 30, 0, 20).keepEnd, 40, "a max past the end stops at the end");
|
||||
assert.equal(outputWindow(10, 50, 0).start, 10, "a start past the end is held at the end");
|
||||
assert.equal(outputWindow(10, "x", null, NaN).length, 10);
|
||||
});
|
||||
|
||||
test("the line between IN and OUT says how long and what is cut", () => {
|
||||
assert.equal(outputSummary(outputWindow(40, 8, 30, 15)), "15.0 s · cut 7.0 s");
|
||||
assert.equal(outputSummary(outputWindow(200, 0, 0, 0)), "3:20.0");
|
||||
assert.equal(outputSummary(outputWindow(40, 8, 30, 0), { maxFromLink: true }), "22.0 s · max wired");
|
||||
assert.equal(outputSummary(outputWindow(0, 0, 0)), "");
|
||||
});
|
||||
|
||||
test("a press grabs the nearest handle, the middle, or jumps", () => {
|
||||
const win = outputWindow(40, 10, 30);
|
||||
assert.equal(grabZone(win, 40, 0.25, 400), "start");
|
||||
assert.equal(grabZone(win, 40, 0.75, 400), "end");
|
||||
assert.equal(grabZone(win, 40, 0.5, 400), "move");
|
||||
assert.equal(grabZone(win, 40, 0.05, 400), "jump");
|
||||
assert.equal(grabZone(win, 0, 0.5, 400), "none");
|
||||
});
|
||||
|
||||
test("peaks keep the loudest sample of each slice, channels mixed", () => {
|
||||
const left = Float32Array.from([0, 0.5, -0.5, 0, 1, -1]);
|
||||
const right = Float32Array.from([0, 0.5, -0.5, 0, 1, -1]);
|
||||
const peaks = wavePeaks([left, right], 2);
|
||||
assert.deepEqual(Array.from(peaks), [-0.5, 0.5, -1, 1]);
|
||||
assert.equal(wavePeaks([], 3).length, 6);
|
||||
});
|
||||
@@ -209,3 +209,18 @@ test("loadSummary reports the snapped count", () => {
|
||||
assert.equal(loadSummary(10, { start: 0, end: 3 }, 30, 1, 0, false, "8n+1"), "89 frames @ 30 fps");
|
||||
assert.equal(loadSummary(10, { start: 0, end: 3 }, 30, 1, 0, false, "free"), "90 frames @ 30 fps");
|
||||
});
|
||||
|
||||
|
||||
test("FRAME picking reaches the final source frame", () => {
|
||||
for (const fps of [24, 30, 60, 240]) {
|
||||
const last = (2 * fps - 1) / fps;
|
||||
assert.equal(singleFrameTime(2, 2, fps), last);
|
||||
assert.equal(singleFrameFraction(2, 2, fps), last / 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("summary counts source timestamps inside an offset window", () => {
|
||||
assert.equal(loadSummary(2, {start: 0.01, end: 0.1}, 12), "1 frame");
|
||||
assert.equal(loadSummary(2, {start: 0.01, end: 0.2}, 12), "2 frames @ 12 fps");
|
||||
assert.equal(loadSummary(2, {start: 0.01, end: 0.2}, 12, 2), "1 frame");
|
||||
});
|
||||
|
||||
@@ -116,6 +116,7 @@ test("a panel that should follow the node's height never declares computeSize",
|
||||
"image_folder",
|
||||
"input_preview",
|
||||
"layout_boxes",
|
||||
"load_audio",
|
||||
"load_image_pad",
|
||||
"load_video",
|
||||
"lora_loader",
|
||||
|
||||
@@ -4,7 +4,6 @@ import test from "node:test";
|
||||
import {
|
||||
ensureNodeMinHeight,
|
||||
fillNodeHeight,
|
||||
growToNodeFloor,
|
||||
holdNodeMinWidth,
|
||||
holdNodeMinHeight,
|
||||
holdVueNodeMinWidth,
|
||||
@@ -320,90 +319,3 @@ test("a new node hugs its floor and a loading workflow keeps its saved height",
|
||||
assert.equal(nodeHeightAfterCardChange({ floor: 469, current: 700, change: "x" }), 700);
|
||||
assert.equal(nodeHeightAfterCardChange(), 0);
|
||||
});
|
||||
|
||||
test("a failed width probe restores the widget and allows the next measurement", () => {
|
||||
const own = () => ({ minWidth: 320, minHeight: 80 });
|
||||
const widget = { computeLayoutSize: own };
|
||||
assert.equal(measureLayoutWidthPadding({ computeSize() { throw new Error("not laid out"); } }, widget), null);
|
||||
assert.equal(widget.computeLayoutSize, own);
|
||||
assert.equal(measureLayoutWidthPadding(frontendNode([widget]), widget), 104);
|
||||
assert.equal(widget.computeLayoutSize, own);
|
||||
});
|
||||
|
||||
test("a width probe cannot recursively start another probe", () => {
|
||||
const widget = { computeLayoutSize: () => ({ minWidth: 320, minHeight: 80 }) };
|
||||
const own = widget.computeLayoutSize;
|
||||
const node = {
|
||||
computeSize() {
|
||||
assert.equal(measureLayoutWidthPadding(this, widget), null);
|
||||
return [widget.computeLayoutSize().minWidth + 80, 200];
|
||||
},
|
||||
};
|
||||
assert.equal(measureLayoutWidthPadding(node, widget), 80);
|
||||
assert.equal(widget.computeLayoutSize, own);
|
||||
});
|
||||
|
||||
test("growing to a changing width floor preserves height and leaves wider nodes alone", () => {
|
||||
const sizes = [];
|
||||
const dirty = [];
|
||||
const host = { style: {} };
|
||||
const node = {
|
||||
size: [240, 480],
|
||||
setSize(size) { sizes.push(size); this.size = size; },
|
||||
setDirtyCanvas(...args) { dirty.push(args); },
|
||||
};
|
||||
const widget = { node, element: { closest: () => host } };
|
||||
let floor = 320;
|
||||
holdNodeMinWidth(widget, () => floor);
|
||||
assert.equal(growToNodeFloor(widget, 1), true);
|
||||
assert.deepEqual(node.size, [320, 480]);
|
||||
assert.equal(host.style.minWidth, "320px");
|
||||
assert.deepEqual(dirty, [[true, true]]);
|
||||
assert.equal(growToNodeFloor(widget, 1), false);
|
||||
floor = 280;
|
||||
assert.equal(growToNodeFloor(widget, 1), false);
|
||||
assert.equal(host.style.minWidth, "280px", "the CSS floor can shrink without shrinking the node");
|
||||
floor = 400;
|
||||
assert.equal(growToNodeFloor(widget, 1), true);
|
||||
assert.deepEqual(sizes, [[320, 480], [400, 480]]);
|
||||
node.size[0] = NaN;
|
||||
assert.equal(growToNodeFloor(widget, 1), false);
|
||||
assert.equal(growToNodeFloor({ node: { size: [100, 100] } }), false);
|
||||
assert.equal(growToNodeFloor(null), false);
|
||||
});
|
||||
|
||||
test("growing a node measures its floor again on the next frame with a bounded retry", () => {
|
||||
const saved = globalThis.requestAnimationFrame;
|
||||
const frames = [];
|
||||
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); };
|
||||
try {
|
||||
let floor = 320;
|
||||
const node = { size: [240, 480], setSize(size) { this.size = size; } };
|
||||
const widget = { node };
|
||||
holdNodeMinWidth(widget, () => floor);
|
||||
assert.equal(growToNodeFloor(widget), true);
|
||||
assert.equal(frames.length, 1);
|
||||
floor = 360;
|
||||
frames.shift()();
|
||||
assert.deepEqual(node.size, [360, 480]);
|
||||
assert.equal(frames.length, 0, "the second pass must not schedule another frame");
|
||||
} finally {
|
||||
if (saved === undefined) delete globalThis.requestAnimationFrame;
|
||||
else globalThis.requestAnimationFrame = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("dynamic panel floors sanitize invalid sizes and retain unrelated options", () => {
|
||||
let width = "240";
|
||||
let height = "160";
|
||||
const widget = { options: { serialize: false } };
|
||||
fillNodeHeight(widget, { minWidth: () => width, minHeight: () => height });
|
||||
assert.deepEqual(widget.computeLayoutSize(), { minWidth: 240, minHeight: 160 });
|
||||
for (const invalid of [-4, NaN, Infinity, undefined, "wide"]) {
|
||||
width = invalid;
|
||||
height = invalid;
|
||||
assert.deepEqual(widget.computeLayoutSize(), { minWidth: 0, minHeight: 0 });
|
||||
}
|
||||
assert.equal(widget.options.serialize, false);
|
||||
assert.equal(fillNodeHeight(null), null);
|
||||
});
|
||||
|
||||
@@ -184,3 +184,18 @@ test("different problems, other kinds of error and other packs' nodes are left a
|
||||
assert.equal(errors[2].errors.length, 2);
|
||||
assert.equal(collapseRepeatedErrors(null), null);
|
||||
});
|
||||
|
||||
test("an empty Load Audio asks for a sound file", () => {
|
||||
const outputs = (classType) => (classType === "SaveAudio"
|
||||
? { output_node: true, input: { required: { audio: ["AUDIO"] } } }
|
||||
: definitionOf(classType));
|
||||
const prompt = {
|
||||
1: node("AUSBOSS_NODES_LoadAudio", { audio: "" }, "Song"),
|
||||
2: node("SaveAudio", { audio: ["1", 0] }),
|
||||
};
|
||||
const found = findEmptySources(prompt, outputs);
|
||||
assert.deepEqual(found.map((entry) => entry.input), ["audio"]);
|
||||
assert.equal(emptySourceMessage(found), "Load a sound file first: Song");
|
||||
prompt[1].inputs.audio = "song.mp3";
|
||||
assert.deepEqual(findEmptySources(prompt, outputs), []);
|
||||
});
|
||||
|
||||
@@ -214,71 +214,3 @@ test("a number box reads a typed sum only where a plain number fails", () => {
|
||||
const source = readFileSync(join(ROOT, "js", "shared", "scrub_input.mjs"), "utf-8");
|
||||
assert.match(source, /Number\.isFinite\(number\) \? number : sumValue\(input\.value\)/);
|
||||
});
|
||||
|
||||
test("formatting keeps significant digits while trimming only fractional zeroes", () => {
|
||||
for (const [value, options, expected] of [
|
||||
[undefined, {}, ""],
|
||||
["12.340", { decimals: 3, minDecimals: 0 }, "12.34"],
|
||||
[-120, { decimals: 3, minDecimals: 0 }, "-120"],
|
||||
[-1.2346, { decimals: 3, minDecimals: 1 }, "-1.235"],
|
||||
[0.0001, { decimals: 4, minDecimals: 0 }, "0.0001"],
|
||||
[12, { decimals: -2 }, "12"],
|
||||
[1, { decimals: 2, minDecimals: -1 }, "1"],
|
||||
]) {
|
||||
assert.equal(formatScrubValue(value, options), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid scrub values use a finite floor or zero before rounding", () => {
|
||||
for (const value of [undefined, NaN, Infinity, -Infinity, "not a number"]) {
|
||||
assert.equal(quantizeScrubValue(value), 0);
|
||||
assert.equal(quantizeScrubValue(value, { min: -2, max: 4 }), -2);
|
||||
}
|
||||
assert.equal(quantizeScrubValue("2.718", { decimals: 2 }), 2.72);
|
||||
assert.equal(quantizeScrubValue(-10, { min: -2, max: 4 }), -2);
|
||||
assert.equal(quantizeScrubValue(10, { min: -2, max: 4 }), 4);
|
||||
});
|
||||
|
||||
test("snapped steps cross zero and take several steps from an off-grid value", () => {
|
||||
for (const [start, count, expected] of [
|
||||
[-9, 1, -8], [-9, -1, -16], [-9, 3, 8],
|
||||
[9, -3, -8], [9, 3, 32], [0, -1, -8], [9, 0, 9],
|
||||
]) {
|
||||
assert.equal(steppedValue(start, count, 8, true), expected, `${start}, ${count}`);
|
||||
}
|
||||
assert.equal(steppedValue("9", -2, 8), -7);
|
||||
});
|
||||
|
||||
test("gesture boundaries accept equal diagonal travel only past the dead zone", () => {
|
||||
for (const direction of [-1, 1]) {
|
||||
assert.equal(isScrubGesture(direction * SCRUB_DEAD_ZONE, 0), false);
|
||||
assert.equal(isScrubGesture(direction * 4, -4), true);
|
||||
assert.equal(isScrubGesture(direction * 4, 5), false);
|
||||
}
|
||||
});
|
||||
|
||||
test("Shift slows dragging when the supplied fine step is equal or larger", () => {
|
||||
const delta = SCRUB_DEAD_ZONE + SCRUB_PIXELS_PER_STEP * SCRUB_SLOW_FACTOR * 2;
|
||||
for (const fineStep of [null, 1, 2]) {
|
||||
const options = { step: 1, fineStep, decimals: 0 };
|
||||
assert.equal(scrubbedValue(10, delta, true, options), 12);
|
||||
assert.equal(scrubbedValue(10, -delta, true, options), 8);
|
||||
assert.equal(scrubbedValue(10, delta, false, options), 18);
|
||||
}
|
||||
});
|
||||
|
||||
test("step precision accepts negative and string increments and rounds at six decimals", () => {
|
||||
for (const [step, expected] of [[-0.025, 3], ["0.000001", 6], [0.1234567, 6], [0, 0], [undefined, 0]]) {
|
||||
assert.equal(stepDecimals(step), expected, String(step));
|
||||
}
|
||||
});
|
||||
|
||||
test("typed sums associate division and subtraction left to right", () => {
|
||||
assert.equal(sumValue("24/4/2"), 3);
|
||||
assert.equal(sumValue("10-3-2"), 5);
|
||||
assert.equal(sumValue("20%6*2"), 4);
|
||||
assert.equal(sumValue("\t-(2 + +3) * -2\t"), 10);
|
||||
assert.equal(sumValue("1" + " ".repeat(199)), 1);
|
||||
assert.equal(sumValue("1" + " ".repeat(200)), null);
|
||||
assert.equal(sumValue("1/(2-2)"), null);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { SEED_LOOK_PROPERTY, controlChip, nextControl, seedLook, setSeedLook } from "../js/shared/seed_look.mjs";
|
||||
|
||||
test("the look lives on the node, and full leaves nothing behind", () => {
|
||||
const node = { properties: {} };
|
||||
assert.equal(seedLook(node), "full");
|
||||
assert.equal(setSeedLook(node, "compact"), "compact");
|
||||
assert.equal(node.properties[SEED_LOOK_PROPERTY], "compact");
|
||||
assert.equal(setSeedLook(node, "full"), "full");
|
||||
assert.deepEqual(node.properties, {});
|
||||
assert.equal(seedLook(null), "full");
|
||||
assert.equal(seedLook({ properties: { [SEED_LOOK_PROPERTY]: "huge" } }), "full");
|
||||
});
|
||||
|
||||
test("the chip walks Random, Fixed, +1, -1 and back", () => {
|
||||
assert.equal(nextControl("randomize"), "fixed");
|
||||
assert.equal(nextControl("fixed"), "increment");
|
||||
assert.equal(nextControl("increment"), "decrement");
|
||||
assert.equal(nextControl("decrement"), "randomize");
|
||||
assert.equal(nextControl("something else"), "randomize");
|
||||
assert.equal(controlChip("randomize").text, "Random");
|
||||
assert.equal(controlChip("fixed").text, "Fixed");
|
||||
assert.equal(controlChip(undefined).text, "Fixed");
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
|
||||
del sys.modules["nodes"]
|
||||
|
||||
from nodes._audio_trim_helpers import audio_window, decode_audio_window
|
||||
|
||||
RATE = 8000
|
||||
|
||||
|
||||
def write_ramp(path: Path, seconds: float) -> None:
|
||||
"""A mono wav whose sample value tells the second it was written at."""
|
||||
samples = int(seconds * RATE)
|
||||
data = (np.arange(samples, dtype=np.float32) / RATE / 100.0).reshape(1, -1)
|
||||
with av.open(str(path), "w") as container:
|
||||
stream = container.add_stream("pcm_f32le", rate=RATE, layout="mono")
|
||||
frame = av.AudioFrame.from_ndarray(data, format="flt", layout="mono")
|
||||
frame.sample_rate = RATE
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode(None):
|
||||
container.mux(packet)
|
||||
|
||||
|
||||
class AudioWindowTests(unittest.TestCase):
|
||||
def test_open_end_stays_open_until_the_length_is_known(self):
|
||||
self.assertEqual(audio_window(2, 0)["end"], None)
|
||||
self.assertEqual(audio_window(2, 0, duration=40)["end"], 40)
|
||||
|
||||
def test_max_keeps_the_start_and_cuts_the_rest(self):
|
||||
window = audio_window(8, 30, 15, duration=40)
|
||||
self.assertEqual((window["start"], window["end"], window["cut"]), (8, 23, 7))
|
||||
|
||||
def test_max_longer_than_the_pick_changes_nothing(self):
|
||||
window = audio_window(8, 12, 15, duration=40)
|
||||
self.assertEqual((window["end"], window["cut"]), (12, 0))
|
||||
|
||||
def test_max_with_an_open_end_caps_it(self):
|
||||
self.assertEqual(audio_window(5, 0, 10)["end"], 15)
|
||||
|
||||
def test_bad_values_fall_back(self):
|
||||
window = audio_window("x", None, float("nan"), duration=10)
|
||||
self.assertEqual((window["start"], window["end"], window["cut"]), (0, 10, 0))
|
||||
|
||||
def test_end_before_start_is_an_empty_pick(self):
|
||||
window = audio_window(6, 4, duration=10)
|
||||
self.assertEqual(window["end"], 6)
|
||||
|
||||
|
||||
class DecodeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.folder.name, "ramp.wav")
|
||||
write_ramp(self.path, 10.0)
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_takes_exactly_the_pick(self):
|
||||
audio = decode_audio_window(self.path, 2.0, 5.0)
|
||||
waveform = audio["waveform"]
|
||||
self.assertEqual(audio["sample_rate"], RATE)
|
||||
self.assertEqual(tuple(waveform.shape), (1, 1, 3 * RATE))
|
||||
self.assertAlmostEqual(float(waveform[0, 0, 0]) * 100, 2.0, places=3)
|
||||
|
||||
def test_max_cuts_the_end(self):
|
||||
audio = decode_audio_window(self.path, 1.0, 9.0, 2.5)
|
||||
self.assertEqual(audio["waveform"].shape[-1], int(2.5 * RATE))
|
||||
|
||||
def test_open_end_runs_to_the_end_of_the_file(self):
|
||||
audio = decode_audio_window(self.path, 7.5, 0.0)
|
||||
self.assertEqual(audio["waveform"].shape[-1], int(2.5 * RATE))
|
||||
|
||||
def test_start_past_the_end_says_so(self):
|
||||
with self.assertRaisesRegex(ValueError, "past the end"):
|
||||
decode_audio_window(self.path, 12.0, 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -43,6 +43,7 @@ NAMED = {
|
||||
"AusBossImageCropRotatePad": ["image"],
|
||||
"AusBossImageFolder": ["source", "folder"],
|
||||
"AusBossLaMaInpaint": ["model"],
|
||||
"AusBossLoadAudio": ["audio"],
|
||||
"AusBossLoadImagePad": ["image", "source_image"],
|
||||
"AusBossLoadVideo": ["video"],
|
||||
"AusBossLoraLoader": ["loras", "on_missing"],
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Concurrency, cancellation and timing regressions using CPU media only."""
|
||||
|
||||
import asyncio
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from nodes import _media_helpers as media
|
||||
from nodes import _video_load_helpers as load
|
||||
from nodes import _video_save_helpers as save
|
||||
from nodes import node_save_video as saver
|
||||
|
||||
|
||||
class Interrupted(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
class VideoRegressions(unittest.TestCase):
|
||||
def test_concurrent_saves_reserve_distinct_destinations(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folders = types.SimpleNamespace(
|
||||
get_output_directory=lambda: tmp,
|
||||
get_save_image_path=lambda *args: (tmp, "video", 1, "", "video"),
|
||||
)
|
||||
barrier = threading.Barrier(2)
|
||||
destinations = []
|
||||
|
||||
def encode(path, *args):
|
||||
destinations.append(path)
|
||||
barrier.wait(timeout=5)
|
||||
path.write_bytes(b"complete")
|
||||
return 16, 16, 1
|
||||
|
||||
async def run():
|
||||
return await asyncio.gather(*[
|
||||
saver.AusBossSaveVideo().save(12, "video", 19, frames=torch.zeros(1, 16, 16, 3))
|
||||
for _ in range(2)
|
||||
])
|
||||
|
||||
with patch.object(saver, "folder_paths", folders), patch.object(saver, "encode_video", encode):
|
||||
results = asyncio.run(run())
|
||||
self.assertEqual(len(set(destinations)), 2)
|
||||
self.assertEqual({Path(r["result"][0]) for r in results}, set(destinations))
|
||||
|
||||
def test_failed_save_removes_its_reservation(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folders = types.SimpleNamespace(
|
||||
get_output_directory=lambda: tmp,
|
||||
get_save_image_path=lambda *args: (tmp, "video", 1, "", "video"),
|
||||
)
|
||||
with patch.object(saver, "folder_paths", folders), patch.object(saver, "encode_video", side_effect=Interrupted):
|
||||
with self.assertRaises(Interrupted):
|
||||
asyncio.run(saver.AusBossSaveVideo().save(12, "video", 19, frames=torch.zeros(1, 16, 16, 3)))
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_eviction_does_not_close_a_busy_decoder(self):
|
||||
class Session:
|
||||
def __init__(self, path):
|
||||
self.lock = threading.Lock()
|
||||
self.last_used = 0
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
paths = [Path(tmp) / str(i) for i in range(4)]
|
||||
for path in paths:
|
||||
path.touch()
|
||||
busy = Session(paths[0])
|
||||
busy.lock.acquire()
|
||||
sessions = {media._file_key(paths[0]): busy}
|
||||
with patch.object(media, "_SESSIONS", sessions), patch.object(media, "_ScrubSession", Session), patch.object(media, "_start_reaper_locked"):
|
||||
for path in paths[1:]:
|
||||
media._get_session(path)
|
||||
self.assertFalse(busy.closed)
|
||||
busy.lock.release()
|
||||
|
||||
def test_interrupted_encoding_publishes_nothing(self):
|
||||
calls = 0
|
||||
|
||||
def interrupt():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 4:
|
||||
raise Interrupted
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp, patch.object(save, "raise_if_interrupted", interrupt):
|
||||
path = Path(tmp) / "clip.mkv"
|
||||
with self.assertRaises(Interrupted):
|
||||
save.encode_video(path, torch.zeros(10, 16, 16, 3), 12, None, 19, video_format="mkv ffv1")
|
||||
self.assertEqual(list(Path(tmp).iterdir()), [])
|
||||
|
||||
def test_gif_distributes_centisecond_rounding_error(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "clip.gif"
|
||||
frames = torch.linspace(0, 1, 24).view(24, 1, 1, 1).expand(24, 16, 16, 3)
|
||||
save.encode_video(path, frames, 24, None, 19, video_format="gif")
|
||||
durations = []
|
||||
with Image.open(path) as image:
|
||||
for index in range(image.n_frames):
|
||||
image.seek(index)
|
||||
durations.append(image.info.get("duration", 0))
|
||||
self.assertEqual(sum(durations), 1000)
|
||||
self.assertTrue(all(duration > 0 and duration % 10 == 0 for duration in durations))
|
||||
|
||||
def test_gif_rejects_rates_above_its_timing_grid(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with self.assertRaisesRegex(ValueError, "100 fps"):
|
||||
save.encode_video(Path(tmp) / "clip.gif", torch.zeros(2, 16, 16, 3), 120, None, 19, video_format="gif")
|
||||
|
||||
def test_audio_tail_is_padded(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "audio.mkv"
|
||||
with av.open(str(path), "w") as container:
|
||||
stream = container.add_stream("pcm_s16le", rate=8000)
|
||||
stream.layout = "mono"
|
||||
frame = av.AudioFrame.from_ndarray(np.full((1, 4000), 1000, np.int16), format="s16", layout="mono")
|
||||
frame.sample_rate = 8000
|
||||
frame.pts = 0
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
audio = load.decode_audio_range(path, 0, 2)
|
||||
self.assertEqual(audio["waveform"].shape[-1], 2 * audio["sample_rate"])
|
||||
self.assertEqual(torch.count_nonzero(audio["waveform"][..., 4000:]).item(), 0)
|
||||
|
||||
def test_audio_checks_cancellation_during_decode(self):
|
||||
stream = types.SimpleNamespace(
|
||||
type="audio", rate=8000, layout="mono", start_time=0, time_base=Fraction(1, 8000),
|
||||
)
|
||||
frame = av.AudioFrame.from_ndarray(np.zeros((1, 4000), np.int16), format="s16", layout="mono")
|
||||
frame.sample_rate = 8000
|
||||
frame.pts = 0
|
||||
frame.time_base = Fraction(1, 8000)
|
||||
container = types.SimpleNamespace(streams=[stream], decode=lambda _: iter([frame]))
|
||||
with patch.object(load.av, "open") as opened, patch.object(load, "raise_if_interrupted", side_effect=[None, Interrupted]) as interrupt:
|
||||
opened.return_value.__enter__.return_value = container
|
||||
with self.assertRaises(Interrupted):
|
||||
load.decode_audio_range(Path("clip.mkv"), 0, 2)
|
||||
self.assertEqual(interrupt.call_count, 2)
|
||||
|
||||
def test_audio_seeks_near_a_late_window_on_the_video_clock(self):
|
||||
video = types.SimpleNamespace(type="video", start_time=5000, time_base=Fraction(1, 1000))
|
||||
audio = types.SimpleNamespace(type="audio", rate=8000, layout="mono", time_base=Fraction(1, 8000))
|
||||
with patch.object(load.av, "open") as opened:
|
||||
container = opened.return_value.__enter__.return_value
|
||||
container.streams = [video, audio]
|
||||
container.decode.return_value = iter([])
|
||||
load.decode_audio_range(Path("clip.mkv"), 30, 32)
|
||||
container.seek.assert_called_once_with(34 * 8000, stream=audio, backward=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -112,13 +113,17 @@ class FakeCoreVideo:
|
||||
|
||||
|
||||
class FakeFolderPaths:
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
last_output = None
|
||||
|
||||
@staticmethod
|
||||
def get_output_directory():
|
||||
return "/tmp"
|
||||
return FakeFolderPaths.temporary.name
|
||||
|
||||
@staticmethod
|
||||
def get_save_image_path(*_args):
|
||||
return "/tmp", "video", 3, "AusBoss", "AusBoss/video"
|
||||
FakeFolderPaths.last_output = tempfile.mkdtemp(dir=FakeFolderPaths.temporary.name)
|
||||
return FakeFolderPaths.last_output, "video", 3, "AusBoss", "AusBoss/video"
|
||||
|
||||
|
||||
class SaveVideoHelperTests(unittest.TestCase):
|
||||
@@ -297,7 +302,7 @@ class SaveVideoHelperTests(unittest.TestCase):
|
||||
filename_prefix="AusBoss/video", crf=19,
|
||||
))
|
||||
# The path the encoder wrote to, verbatim, as the node's one output.
|
||||
self.assertEqual(result["result"], ("/tmp/video_00003_.mp4",))
|
||||
self.assertEqual(result["result"], (str(Path(FakeFolderPaths.last_output) / "video_00003_.mp4"),))
|
||||
self.assertTrue(Path(result["result"][0]).is_absolute())
|
||||
|
||||
def test_symlinked_prefix_is_refused_before_core_creates_folders(self):
|
||||
@@ -380,6 +385,8 @@ class FormatTests(unittest.TestCase):
|
||||
CLIP = (8, 128, 160)
|
||||
|
||||
def encodable(self, spec) -> bool:
|
||||
if "nvenc" in spec.video_codec and os.environ.get("CUDA_VISIBLE_DEVICES") == "":
|
||||
return False
|
||||
if not spec.video_codec:
|
||||
return True
|
||||
try:
|
||||
|
||||
@@ -285,3 +285,15 @@ test("the grip slides the kept part as far as the clip allows", () => {
|
||||
assert.deepEqual(slideRange(long, free), { min: 0, max: 287 - 48 });
|
||||
assert.equal(lengthFillsClip(long, free), false);
|
||||
});
|
||||
|
||||
|
||||
test("individual frames survive timestamp storage at high source rates", () => {
|
||||
for (const fps of [100, 120, 240, 1000]) {
|
||||
const source = { fps, frame_count: fps * 2, duration: 2 };
|
||||
const info = clipInfo(source);
|
||||
for (let frame = 0; frame < source.frame_count; frame += 1) {
|
||||
const seconds = windowSeconds(info, frame, frame);
|
||||
assert.deepEqual(backendWindow(source, seconds.start_seconds, seconds.end_seconds), {first: frame, last: frame}, `${fps} fps, frame ${frame}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import * as trim from "../js/load_video/trim_preview.mjs";
|
||||
import * as videoMath from "../js/shared/video_preview.mjs";
|
||||
import * as timecode from "../js/shared/timecode.mjs";
|
||||
import * as restore from "../js/shared/load_video_restore.mjs";
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(new URL(`../${path}`, import.meta.url), "utf8")
|
||||
.replace(/^import\s[\s\S]*?;\s*$/gm, "").replace(/^export /gm, "");
|
||||
}
|
||||
|
||||
function functionSource(path, name) {
|
||||
const text = source(path);
|
||||
const start = text.search(new RegExp(`(?:async )?function ${name}\\(`));
|
||||
assert.ok(start >= 0, name);
|
||||
const rest = text.slice(start);
|
||||
const next = rest.slice(1).search(/\n(?:async )?function /);
|
||||
return next < 0 ? rest : rest.slice(0, next + 1);
|
||||
}
|
||||
|
||||
class Element extends EventTarget {
|
||||
constructor(tag = "div") {
|
||||
super();
|
||||
this.tagName = tag.toUpperCase(); this.children = []; this.style = {}; this.attrs = {};
|
||||
this.duration = 10; this.readyState = 1; this.currentTime = 0; this.paused = true;
|
||||
this.classList = {
|
||||
values: new Set(), add(value) { this.values.add(value); }, remove(value) { this.values.delete(value); },
|
||||
toggle(value, enabled) { if (enabled) this.values.add(value); else this.values.delete(value); },
|
||||
};
|
||||
}
|
||||
append(...items) { this.children.push(...items); }
|
||||
insertBefore(item) { this.children.push(item); }
|
||||
setAttribute(name, value) { this.attrs[name] = value; }
|
||||
getAttribute(name) { return this.attrs[name]; }
|
||||
removeAttribute(name) { delete this.attrs[name]; }
|
||||
load() { this.loadCalls = (this.loadCalls ?? 0) + 1; }
|
||||
pause() { this.paused = true; this.pauseCalls = (this.pauseCalls ?? 0) + 1; }
|
||||
getBoundingClientRect() { return {left: 0, width: 100}; }
|
||||
setPointerCapture() {}
|
||||
releasePointerCapture() { this.dispatchEvent(new Event("lostpointercapture")); }
|
||||
}
|
||||
|
||||
function pointer(type, x, buttons = 1) {
|
||||
const event = new Event(type);
|
||||
Object.assign(event, {button: 0, buttons, clientX: x, pointerId: 1});
|
||||
return event;
|
||||
}
|
||||
|
||||
for (const single of [false, true]) {
|
||||
test(`cancelled ${single ? "FRAME" : "trim"} gesture removes every listener`, () => {
|
||||
const rail = new Element();
|
||||
const state = {range: rail, video: {duration: 10}, start: 2, end: 8, node: {}};
|
||||
const context = vm.createContext({
|
||||
...trim, AbortController, state, notifyAusbossChange() {},
|
||||
isSingleFrame: () => single, currentBounds: (s) => ({start: s.start, end: s.end}),
|
||||
writeTrim(s, bounds) { s.start = bounds.start; s.end = bounds.end; },
|
||||
writeFrameTime(s, time) { s.start = time; },
|
||||
});
|
||||
vm.runInContext(functionSource("js/load_video/index.js", "installTrimDrag"), context);
|
||||
vm.runInContext("installTrimDrag(state)", context);
|
||||
rail.dispatchEvent(pointer("pointerdown", 20));
|
||||
rail.dispatchEvent(pointer("pointermove", 25));
|
||||
rail.dispatchEvent(pointer("pointerup", 25, 0));
|
||||
rail.dispatchEvent(pointer("pointerdown", 25));
|
||||
rail.dispatchEvent(pointer("pointermove", 30));
|
||||
rail.dispatchEvent(pointer("pointercancel", 30, 0));
|
||||
const cancelled = state.start;
|
||||
rail.dispatchEvent(pointer("pointermove", 40));
|
||||
assert.equal(state.start, cancelled);
|
||||
assert.equal(state.dragging, false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const reason of ["superseded", "disposed", "failed", "adopted"]) {
|
||||
test(`preview object URL ownership after ${reason} image decode`, async () => {
|
||||
const images = [], revoked = [];
|
||||
const state = {node: {}, loadSerial: 1};
|
||||
const context = vm.createContext({
|
||||
state, AbortController, videoParams: () => "", syncTimelineRange() {}, setValue() {},
|
||||
URL: {createObjectURL: () => "blob:frame", revokeObjectURL: (url) => revoked.push(url)},
|
||||
Image: class { constructor() { images.push(this); } },
|
||||
api: {fetchApi: async () => ({ok: true, blob: async () => ({}), headers: {get: () => "0"}})},
|
||||
});
|
||||
vm.runInContext(functionSource("js/shared/transform_editor.mjs", "loadVideoFrame"), context);
|
||||
const pending = vm.runInContext("loadVideoFrame(state, 1, {syncWidgets: false})", context);
|
||||
await new Promise(setImmediate);
|
||||
if (reason === "superseded") state.loadSerial += 1;
|
||||
if (reason === "disposed") { state.disposed = true; state.frameController.abort(); }
|
||||
if (reason === "failed") {
|
||||
images[0].onerror();
|
||||
await assert.rejects(pending, /Could not display/);
|
||||
} else {
|
||||
images[0].onload();
|
||||
await pending;
|
||||
}
|
||||
assert.deepEqual(revoked, reason === "adopted" ? [] : ["blob:frame"]);
|
||||
assert.equal(state.frameObjectUrl, reason === "adopted" ? "blob:frame" : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function previewContext(path) {
|
||||
const extensions = [];
|
||||
const context = vm.createContext({
|
||||
...trim, ...videoMath, ...timecode, AbortController, Event, URLSearchParams,
|
||||
document: {createElement: (tag) => new Element(tag), activeElement: null},
|
||||
app: {registerExtension: (extension) => extensions.push(extension)}, api: {apiURL: (s) => s},
|
||||
chainCallback(object, name, callback) {
|
||||
const prior = object[name];
|
||||
object[name] = function (...args) { prior?.apply(this, args); callback.apply(this, args); };
|
||||
},
|
||||
ensureVideoCss() {}, suppressCoreVideoPreview() {}, hideCanvasWidget() {},
|
||||
keepDomWidgetWidthAuto() {}, fillNodeHeight() {}, VIDEO_MIN_WIDTH: 220, WIDGET_FRAME: 20,
|
||||
makeToolButton: () => new Element("button"), mountStepOverlay: () => ({root: new Element(), hide() {}}),
|
||||
stepRun: () => ({status: "idle"}), addStepConsumer: () => () => {}, watchSteps: () => () => {},
|
||||
notifyAusbossChange() {}, installNameTokens() {}, keepKeyInField() {},
|
||||
setTimeout() {}, queueMicrotask: (callback) => callback(), cancelAnimationFrame() {}, requestAnimationFrame() { return 1; },
|
||||
});
|
||||
vm.runInContext(source(path), context);
|
||||
return {context, extension: extensions[0]};
|
||||
}
|
||||
|
||||
function makeNode() {
|
||||
return {
|
||||
widgets: [
|
||||
{name: "video", value: ""}, {name: "start_seconds", value: 0}, {name: "end_seconds", value: 0},
|
||||
{name: "single_frame", value: false}, {name: "every_nth", value: 1}, {name: "max_frames", value: 0},
|
||||
{name: "step_preview", value: false},
|
||||
],
|
||||
properties: {}, size: [380, 450], setSize(size) { this.size = size; }, setDirtyCanvas() {},
|
||||
addDOMWidget(name, type, element) { const widget = {name, type, element}; this.widgets.push(widget); return widget; },
|
||||
};
|
||||
}
|
||||
|
||||
test("Save Video configuration restores LOOP off on the existing player", () => {
|
||||
const {context, extension} = previewContext("js/save_video/index.js");
|
||||
function Node() {}
|
||||
extension.beforeRegisterNodeDef(Node, {name: "AUSBOSS_NODES_SaveVideo"});
|
||||
const node = Object.assign(new Node(), makeNode());
|
||||
node.onNodeCreated();
|
||||
node.properties.ausboss_save_video_loop = false;
|
||||
node.onConfigure();
|
||||
const state = node.__ausbossSaveVideo;
|
||||
assert.equal(state.video.loop, false);
|
||||
assert.equal(state.loopButton.classList.values.has("active"), false);
|
||||
});
|
||||
|
||||
test("Load Video summary follows sampling widgets and linked inputs", () => {
|
||||
const {context, extension} = previewContext("js/load_video/index.js");
|
||||
function Node() {}
|
||||
extension.beforeRegisterNodeDef(Node, {name: "AUSBOSS_NODES_LoadVideo"});
|
||||
const node = Object.assign(new Node(), makeNode());
|
||||
node.onNodeCreated();
|
||||
const state = node.__ausbossLoadVideo;
|
||||
state.sourceFps = 24;
|
||||
state.video.dispatchEvent(new Event("loadedmetadata"));
|
||||
assert.match(state.durationLabel.textContent, /240 frames @ 24 fps/);
|
||||
state.everyNthWidget.value = 2; state.everyNthWidget.callback?.(2);
|
||||
assert.match(state.durationLabel.textContent, /120 frames @ 12 fps/);
|
||||
state.maxFramesWidget.value = 30; state.maxFramesWidget.callback?.(30);
|
||||
assert.match(state.durationLabel.textContent, /30 frames @ 12 fps/);
|
||||
node.inputs = [{widget: {name: "every_nth"}, link: 1}]; node.onConnectionsChange?.();
|
||||
assert.doesNotMatch(state.durationLabel.textContent, /frames/);
|
||||
});
|
||||
|
||||
test("removing Load Video pauses and unloads the player", () => {
|
||||
const {extension} = previewContext("js/load_video/index.js");
|
||||
function Node() {}
|
||||
extension.beforeRegisterNodeDef(Node, {name: "AUSBOSS_NODES_LoadVideo"});
|
||||
const node = Object.assign(new Node(), makeNode()); node.onNodeCreated();
|
||||
const video = node.__ausbossLoadVideo.video;
|
||||
video.paused = false; video.setAttribute("src", "clip.mp4");
|
||||
node.onRemoved();
|
||||
assert.equal(video.paused, true);
|
||||
assert.equal(video.getAttribute("src"), undefined);
|
||||
assert.equal(video.loadCalls, 1);
|
||||
});
|
||||
|
||||
test("dropping a rendered clip resets trim, resize and sampling before loading it", async () => {
|
||||
const node = makeNode();
|
||||
node.widgets.push({name: "custom_width", value: 640}, {name: "custom_height", value: 480});
|
||||
for (const widget of node.widgets) if (widget.name !== "video") widget.value = 5;
|
||||
const video = node.widgets.find((widget) => widget.name === "video");
|
||||
video.callback = () => {
|
||||
for (const [name, value] of Object.entries({
|
||||
start_seconds: 0, end_seconds: 0, custom_width: 0, custom_height: 0,
|
||||
every_nth: 1, max_frames: 0, single_frame: false,
|
||||
})) {
|
||||
assert.equal(node.widgets.find((widget) => widget.name === name).value, value);
|
||||
}
|
||||
};
|
||||
const errors = [];
|
||||
const context = vm.createContext({
|
||||
...restore, node, notifyAusbossChange() {}, showToast: (error) => errors.push(error),
|
||||
uploadToInput: async () => "trimmed.mp4", isMp4File: () => true, looksLikeMp4: () => true,
|
||||
mp4WorkflowPayload: () => ({workflow: {nodes: [{type: "AUSBOSS_NODES_LoadVideo", widgets_values: ["source.mp4", 5, 8, 640, 480, 2, 10, true]}]}}),
|
||||
});
|
||||
vm.runInContext(functionSource("js/video_workflow_drop/index.js", "applyDropToLoadVideo"), context);
|
||||
vm.runInContext(functionSource("js/video_workflow_drop/index.js", "handleLoadVideoDrop"), context);
|
||||
await vm.runInContext('handleLoadVideoDrop(node, {name: "trimmed.mp4", arrayBuffer: async () => new ArrayBuffer(0)})', context);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.equal(video.value, "trimmed.mp4");
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
CARD_PADDING, GROUP_HEIGHT, ROW_GAP, ROW_HEIGHT, SECTION_HEIGHT, SLOT_OFFSET, SOCKETLESS_CHOICE, SOCKETLESS_TEXT,
|
||||
cardHeight, choiceLabel, comboValues, commitWidgetValue, holdsUnknownValue, markSocketless, resetUnknownValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops,
|
||||
cardHeight, choiceLabel, commitWidgetValue, holdsUnknownValue, markSocketless, resetUnknownValues, rowHeight, rowInLook, rowKind, rowMuted, rowTops,
|
||||
scrubSteps, segmentFits, socketWidgetY, visibleRows, widgetTooltip,
|
||||
} from "../js/shared/widget_card_math.mjs";
|
||||
|
||||
@@ -418,49 +418,3 @@ test("a choice's words come from the row: a map, or a function for lists only th
|
||||
// The pill or the list is chosen from the words a person reads, so two long file names still make a short pill.
|
||||
assert.equal(rowKind({ widget: "fine_edges", labels: row.labels }, { type: "combo", options: { values: ["off", "vitmatte-small-composition-1k/model.safetensors"] } }), "segment");
|
||||
});
|
||||
|
||||
test("dynamic combo values receive the widget and preserve numeric and boolean choices", () => {
|
||||
const values = [0, false, "0"];
|
||||
const widget = { type: "combo", value: 0, options: { values: (own) => {
|
||||
assert.equal(own, widget);
|
||||
return values;
|
||||
} } };
|
||||
assert.equal(comboValues(widget), values);
|
||||
assert.equal(holdsUnknownValue(widget), false);
|
||||
widget.value = false;
|
||||
assert.equal(holdsUnknownValue(widget), false);
|
||||
widget.value = true;
|
||||
assert.equal(holdsUnknownValue(widget), true);
|
||||
for (const value of [undefined, null, "choices", {}]) {
|
||||
assert.deepEqual(comboValues({ options: { values: () => value } }), []);
|
||||
}
|
||||
assert.deepEqual(comboValues(null), []);
|
||||
});
|
||||
|
||||
test("choice labels keep explicit empty and numeric labels and use values only as a fallback", () => {
|
||||
const row = { labels: { off: "", zero: 0, missing: null } };
|
||||
assert.equal(choiceLabel(row, "off"), "");
|
||||
assert.equal(choiceLabel(row, "zero"), 0);
|
||||
assert.equal(choiceLabel(row, "missing"), "missing");
|
||||
assert.equal(choiceLabel({ labels: () => undefined }, 0), "0");
|
||||
assert.equal(choiceLabel(null, false), "false");
|
||||
});
|
||||
|
||||
test("segmented choices include the exact label-length limit and reject the next character", () => {
|
||||
assert.equal(segmentFits(["a".repeat(13), "b".repeat(13)]), true);
|
||||
assert.equal(segmentFits(["a".repeat(13), "b".repeat(14)]), false);
|
||||
assert.equal(segmentFits([0, false]), true);
|
||||
assert.equal(segmentFits([]), false);
|
||||
assert.equal(segmentFits(null), false);
|
||||
});
|
||||
|
||||
test("scrub steps honor legacy increments, explicit precision and the pixel-range boundary", () => {
|
||||
assert.deepEqual(scrubSteps(), { step: 0.1, fineStep: 0.01, decimals: 2 });
|
||||
assert.deepEqual(scrubSteps({}, false), { step: 1, fineStep: 1, decimals: 0 });
|
||||
assert.deepEqual(scrubSteps({ step: 0.5 }), { step: 0.5, fineStep: 0.05, decimals: 2 });
|
||||
assert.deepEqual(scrubSteps({ step: 5, step2: 0.25, precision: 3, min: 0, max: 2 }),
|
||||
{ step: 0.25, fineStep: 0.25, decimals: 3 });
|
||||
assert.equal(scrubSteps({ min: 0, max: 2048 }, false, true).step, 1);
|
||||
assert.equal(scrubSteps({ min: 0, max: 2049 }, false, true).step, 8);
|
||||
assert.equal(scrubSteps({}, false, true).step, 1, "an unknown range is not assumed to be a wide pixel size");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user