Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3569a1a24e |
@@ -166,7 +166,7 @@ It returns the frames, audio, frame count, fps, width, height, duration and a co
|
||||
|
||||
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).
|
||||
It returns the `audio` you kept and its length in `seconds`. Decoding reads from the beginning to the cut, so later selections take longer to load. [Help page](js/docs/AUSBOSS_NODES_LoadAudio.md).
|
||||
|
||||
### Video Crop + Rotate + Pad → Frame
|
||||
|
||||
|
||||
@@ -44,8 +44,10 @@ Float 🆎, the wave shows the cut for that number; otherwise the length's toolt
|
||||
|
||||
## 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.
|
||||
- Only the picked part is returned. Decoding starts at the beginning,
|
||||
discards sound before IN and stops at OUT or the Max length cut. A late
|
||||
selection in a long recording takes longer to load.
|
||||
- Preview playback pauses when you switch tabs or hide the page.
|
||||
- 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
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
// 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.
|
||||
import { inputNumber } from "../shared/clip_rate.mjs";
|
||||
import { dragTrimHandle } from "../load_video/trim_preview.mjs";
|
||||
|
||||
// Calculated outputs stay unknown until execution, even if the source has numbers.
|
||||
export function wiredMax(node) {
|
||||
const input = node.inputs?.find((slot) => slot.name === "max_seconds" || slot.widget?.name === "max_seconds");
|
||||
if (input?.link == null) return { wired: false, value: null };
|
||||
return { wired: true, value: inputNumber(node, "max_seconds", null) };
|
||||
}
|
||||
|
||||
export function typedTrimBounds(duration, bounds, edge, seconds) {
|
||||
// OUT 0 is the saved widget's "to the end", before handle clamping.
|
||||
if (edge === "end" && seconds === 0) return { start: bounds.start, end: duration };
|
||||
return dragTrimHandle(duration, bounds, edge, seconds / duration);
|
||||
}
|
||||
|
||||
function finite(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
|
||||
+14
-14
@@ -16,7 +16,7 @@ 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";
|
||||
import { grabZone, outputSummary, outputWindow, secondsLabel, typedTrimBounds, wavePeaks, wiredMax } from "./audio_window.mjs";
|
||||
|
||||
const NODE_NAME = "AUSBOSS_NODES_LoadAudio";
|
||||
const PANEL_WIDGET = "ausboss_load_audio_wave";
|
||||
@@ -50,18 +50,6 @@ function hideCoreAudio(node) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 };
|
||||
@@ -317,12 +305,24 @@ function installPlayer(state) {
|
||||
state.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
audio.addEventListener("play", () => {
|
||||
// Hidden tabs suspend animation frames, so never keep playing there.
|
||||
if (document.hidden) { audio.pause(); return; }
|
||||
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 });
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) audio.pause();
|
||||
}, { signal: state.abort.signal });
|
||||
audio.addEventListener("ended", () => {
|
||||
const win = currentWindow(state);
|
||||
if (!document.hidden && loopEnabled(state.node) && win.length > 0) {
|
||||
audio.currentTime = win.start;
|
||||
audio.play().catch(() => stop());
|
||||
}
|
||||
}, { signal: state.abort.signal });
|
||||
state.playButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -343,7 +343,7 @@ function commitTimeInput(state, edge, input) {
|
||||
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));
|
||||
writeTrim(state, typedTrimBounds(state.duration, { start: win.start, end: win.end }, edge, seconds));
|
||||
notifyAusbossChange();
|
||||
input.blur();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export function inputNumber(node, name, fallback) {
|
||||
for (let depth = 0; depth < 16; depth++) {
|
||||
if (seen.has(linkId)) return null;
|
||||
seen.add(linkId);
|
||||
const link = graph?.links?.[linkId] ?? graph?._links?.get?.(linkId);
|
||||
const link = graph?.getLink?.(linkId) ?? graph?.links?.get?.(linkId) ?? graph?.links?.[linkId] ?? graph?._links?.get?.(linkId);
|
||||
const source = graph?.getNodeById?.(link?.origin_id);
|
||||
if (!source || (source.mode ?? 0) !== 0) return null;
|
||||
if (source.type === "Reroute") {
|
||||
@@ -18,7 +18,7 @@ export function inputNumber(node, name, fallback) {
|
||||
continue;
|
||||
}
|
||||
if (!["PrimitiveFloat", "PrimitiveInt", "AUSBOSS_NODES_Integer", "AUSBOSS_NODES_Float"].includes(source.type) || link.origin_slot !== 0) return null;
|
||||
if (source.inputs?.some((slot) => slot.name === "value" && slot.link != null)) return null;
|
||||
if (source.inputs?.some((slot) => (slot.name === "value" || slot.widget?.name === "value") && slot.link != null)) return null;
|
||||
const value = source.widgets?.find((widget) => widget.name === "value")?.value;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
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.
|
||||
soon as the window is full. Samples before the window are decoded and discarded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -107,7 +106,10 @@ def decode_audio_window(path: Path, start_seconds, end_seconds, max_seconds=0.0)
|
||||
"""
|
||||
window = audio_window(start_seconds, end_seconds, max_seconds)
|
||||
start = window["start"]
|
||||
with av.open(str(path)) as container:
|
||||
# Read only this already-validated file. An empty protocol whitelist
|
||||
# prevents demuxers from opening any referenced files or network URLs,
|
||||
# including playlists disguised with an ordinary audio extension.
|
||||
with path.open("rb") as source, av.open(source, options={"protocol_whitelist": ""}) 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.")
|
||||
|
||||
@@ -90,7 +90,7 @@ class AusBossLoadAudio:
|
||||
|
||||
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
|
||||
# Decoding reads from the beginning through OUT; 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(
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { grabZone, outputSummary, outputWindow, wavePeaks } from "../js/load_audio/audio_window.mjs";
|
||||
import { grabZone, outputSummary, outputWindow, typedTrimBounds, wavePeaks, wiredMax } from "../js/load_audio/audio_window.mjs";
|
||||
|
||||
function maxLink(source, originSlot = 0, linkStore = "object") {
|
||||
const link = { origin_id: 1, origin_slot: originSlot };
|
||||
const graph = { getNodeById: () => source };
|
||||
if (linkStore === "map") graph.links = new Map([[2, link]]);
|
||||
else if (linkStore === "getter") graph.getLink = () => link;
|
||||
else graph.links = { 2: link };
|
||||
return { inputs: [{ name: "max_seconds", widget: { name: "max_seconds" }, link: 2 }], graph };
|
||||
}
|
||||
|
||||
test("wired Max length only reads the connected output of an unlinked literal", () => {
|
||||
assert.deepEqual(wiredMax({}), { wired: false, value: null });
|
||||
const video = { type: "AUSBOSS_NODES_LoadVideo", widgets: [{ name: "start_seconds", value: 4 }, { name: "end_seconds", value: 14 }] };
|
||||
assert.deepEqual(wiredMax(maxLink(video, 7)), { wired: true, value: null });
|
||||
for (const type of ["AUSBOSS_NODES_Float", "AUSBOSS_NODES_Integer", "PrimitiveFloat", "PrimitiveInt"]) {
|
||||
const source = { type, widgets: [{ name: "unrelated", value: 4 }, { name: "value", value: 10 }] };
|
||||
for (const store of ["object", "map", "getter"]) {
|
||||
assert.deepEqual(wiredMax(maxLink(source, 0, store)), { wired: true, value: 10 });
|
||||
}
|
||||
assert.equal(wiredMax(maxLink(source, 1)).value, null);
|
||||
source.inputs = [{ name: "value", widget: { name: "value" }, link: 9 }];
|
||||
assert.equal(wiredMax(maxLink(source)).value, null);
|
||||
source.inputs = [];
|
||||
source.mode = 4;
|
||||
assert.equal(wiredMax(maxLink(source)).value, null);
|
||||
source.mode = 0;
|
||||
source.widgets[1].value = NaN;
|
||||
assert.equal(wiredMax(maxLink(source)).value, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("typing OUT zero restores the file end while other typed times keep handle limits", () => {
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "end", 0), { start: 2, end: 10 });
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "end", 1), { start: 2, end: 2.1 });
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "end", 7), { start: 2, end: 7 });
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "start", 0), { start: 0, end: 5 });
|
||||
});
|
||||
|
||||
test("the face shows what the node hands on", () => {
|
||||
// Mirrors audio_window in nodes/_audio_trim_helpers.py.
|
||||
|
||||
@@ -86,5 +86,50 @@ class DecodeTests(unittest.TestCase):
|
||||
decode_audio_window(self.path, 12.0, 0.0)
|
||||
|
||||
|
||||
class ReferenceFileTests(unittest.TestCase):
|
||||
def test_hls_cannot_read_a_segment_outside_the_source_folder(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
source_folder = root / "input"
|
||||
source_folder.mkdir()
|
||||
outside = root / "outside.aac"
|
||||
with av.open(str(outside), "w") as container:
|
||||
stream = container.add_stream("aac", rate=48000, layout="mono")
|
||||
frame = av.AudioFrame.from_ndarray(
|
||||
np.zeros((1, 48000), dtype=np.float32), format="fltp", layout="mono"
|
||||
)
|
||||
frame.sample_rate = 48000
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode(None):
|
||||
container.mux(packet)
|
||||
for reference in ("../outside.aac", outside.as_posix(), outside.as_uri()):
|
||||
for suffix in (".m3u8", ".wav"):
|
||||
with self.subTest(reference=reference, suffix=suffix):
|
||||
playlist = source_folder / f"song{suffix}"
|
||||
playlist.write_text(
|
||||
"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n"
|
||||
f"#EXTINF:1.0,\n{reference}\n#EXT-X-ENDLIST\n"
|
||||
)
|
||||
with self.assertRaises(av.error.FFmpegError):
|
||||
decode_audio_window(playlist, 0, 0)
|
||||
|
||||
def test_concat_cannot_follow_references_even_with_an_audio_extension(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
source_folder = root / "input"
|
||||
source_folder.mkdir()
|
||||
outside = root / "outside.wav"
|
||||
write_ramp(outside, 1)
|
||||
# A safe-looking relative name can still lead outside the allowed roots.
|
||||
(source_folder / "segment.wav").symlink_to(outside)
|
||||
for suffix in (".ffconcat", ".wav", ".mp3"):
|
||||
with self.subTest(suffix=suffix):
|
||||
playlist = source_folder / f"song{suffix}"
|
||||
playlist.write_text("ffconcat version 1.0\nfile 'segment.wav'\n")
|
||||
with self.assertRaises(av.error.FFmpegError):
|
||||
decode_audio_window(playlist, 0, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user