diff --git a/js/load_video/index.js b/js/load_video/index.js index 0ba7cd5..a420b75 100644 --- a/js/load_video/index.js +++ b/js/load_video/index.js @@ -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); + }); }, }); diff --git a/js/load_video/trim_preview.mjs b/js/load_video/trim_preview.mjs index bc37f1c..a100323 100644 --- a/js/load_video/trim_preview.mjs +++ b/js/load_video/trim_preview.mjs @@ -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 ""; diff --git a/js/save_video/index.js b/js/save_video/index.js index 3d0bc59..2d08cc4 100644 --- a/js/save_video/index.js +++ b/js/save_video/index.js @@ -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) { diff --git a/js/shared/load_video_restore.mjs b/js/shared/load_video_restore.mjs index 65e82e0..6959947 100644 --- a/js/shared/load_video_restore.mjs +++ b/js/shared/load_video_restore.mjs @@ -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) { diff --git a/js/shared/timeline_math.mjs b/js/shared/timeline_math.mjs index 15ddd35..bd355be 100644 --- a/js/shared/timeline_math.mjs +++ b/js/shared/timeline_math.mjs @@ -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), diff --git a/js/shared/transform_editor.mjs b/js/shared/transform_editor.mjs index 498674d..ffe8ed0 100644 --- a/js/shared/transform_editor.mjs +++ b/js/shared/transform_editor.mjs @@ -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; diff --git a/js/video_workflow_drop/index.js b/js/video_workflow_drop/index.js index cb9b817..445867d 100644 --- a/js/video_workflow_drop/index.js +++ b/js/video_workflow_drop/index.js @@ -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}` }); } diff --git a/nodes/_media_helpers.py b/nodes/_media_helpers.py index 4a88180..b0a66aa 100644 --- a/nodes/_media_helpers.py +++ b/nodes/_media_helpers.py @@ -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]: diff --git a/nodes/_video_load_helpers.py b/nodes/_video_load_helpers.py index 4dbf5f9..1673930 100644 --- a/nodes/_video_load_helpers.py +++ b/nodes/_video_load_helpers.py @@ -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, diff --git a/nodes/_video_save_helpers.py b/nodes/_video_save_helpers.py index 371673e..3704813 100644 --- a/nodes/_video_save_helpers.py +++ b/nodes/_video_save_helpers.py @@ -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)}, " diff --git a/nodes/node_save_video.py b/nodes/node_save_video.py index c806e77..b82be2a 100644 --- a/nodes/node_save_video.py +++ b/nodes/node_save_video.py @@ -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": [{ diff --git a/tests/load_video_preview.test.mjs b/tests/load_video_preview.test.mjs index 1320a0a..53c09d0 100644 --- a/tests/load_video_preview.test.mjs +++ b/tests/load_video_preview.test.mjs @@ -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"); +}); diff --git a/tests/test_video_regressions.py b/tests/test_video_regressions.py new file mode 100644 index 0000000..40d3880 --- /dev/null +++ b/tests/test_video_regressions.py @@ -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() diff --git a/tests/test_video_save_helpers.py b/tests/test_video_save_helpers.py index 2ea9620..f2a6f7b 100644 --- a/tests/test_video_save_helpers.py +++ b/tests/test_video_save_helpers.py @@ -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: diff --git a/tests/timeline_math.test.mjs b/tests/timeline_math.test.mjs index d85928b..c6b0a2a 100644 --- a/tests/timeline_math.test.mjs +++ b/tests/timeline_math.test.mjs @@ -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}`); + } + } +}); diff --git a/tests/video_lifecycle.test.mjs b/tests/video_lifecycle.test.mjs new file mode 100644 index 0000000..9a549b9 --- /dev/null +++ b/tests/video_lifecycle.test.mjs @@ -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"); +});