audio loader pitch change added and multiple sound files creation bug fixed
This commit is contained in:
+180
-19
@@ -1,5 +1,8 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import tempfile
|
||||
import asyncio
|
||||
import folder_paths
|
||||
|
||||
audio_extensions = ["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus"]
|
||||
@@ -22,6 +25,20 @@ class AudioLoaderNode:
|
||||
},
|
||||
"optional": {
|
||||
"normalize": ("BOOLEAN", {"default": False}),
|
||||
"pitch_shift_semitones": ("FLOAT", {
|
||||
"default": 0.0,
|
||||
"min": -24.0,
|
||||
"max": 24.0,
|
||||
"step": 0.5,
|
||||
"tooltip": (
|
||||
"Shifts the pitch of the extra 'audio_pitched' output by this "
|
||||
"many semitones (positive = higher, negative = lower) while "
|
||||
"keeping duration/tempo unchanged. Useful for nudging low "
|
||||
"female (contralto) or high male voices into a range that a "
|
||||
"voice-gender classifier (e.g. LTX Sound-to-Video) reads "
|
||||
"correctly. The main 'audio' output is never altered."
|
||||
),
|
||||
}),
|
||||
},
|
||||
"hidden": {
|
||||
# trim_json is written by the JS widget and passed here.
|
||||
@@ -31,8 +48,12 @@ class AudioLoaderNode:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO", "INT", "FLOAT", "STRING")
|
||||
RETURN_NAMES = ("audio", "sample_rate", "duration_seconds", "metadata")
|
||||
# audio_pitched is appended at the END of the tuple (not inserted
|
||||
# between existing outputs) so saved workflows that already wired the
|
||||
# sample_rate/duration_seconds/metadata sockets keep pointing at the
|
||||
# same output indices after this update.
|
||||
RETURN_TYPES = ("AUDIO", "INT", "FLOAT", "STRING", "AUDIO")
|
||||
RETURN_NAMES = ("audio", "sample_rate", "duration_seconds", "metadata", "audio_pitched")
|
||||
FUNCTION = "load_audio"
|
||||
CATEGORY = "audio"
|
||||
OUTPUT_NODE = False
|
||||
@@ -63,8 +84,55 @@ class AudioLoaderNode:
|
||||
except Exception as e: last_error = e
|
||||
raise RuntimeError(f"Could not load audio. Last error: {last_error}")
|
||||
|
||||
def _pitch_shift(self, waveform, sample_rate, n_steps):
|
||||
"""
|
||||
Returns a copy of `waveform` shifted by `n_steps` semitones, at the
|
||||
same length (duration/tempo unchanged). Uses torchaudio's
|
||||
phase-vocoder based PitchShift transform (resample -> time-stretch
|
||||
-> resample), which is what keeps tempo constant while pitch moves.
|
||||
"""
|
||||
if abs(n_steps) < 1e-6:
|
||||
return waveform.clone()
|
||||
|
||||
# Guard against clips too short for the transform's internal STFT
|
||||
# (default n_fft=512); avoids a cryptic crash on tiny trims.
|
||||
min_samples = 2048
|
||||
if waveform.shape[-1] < min_samples:
|
||||
print(
|
||||
f"[AudioLoader] Clip too short ({waveform.shape[-1]} samples) to "
|
||||
f"pitch-shift reliably — returning an unshifted copy for audio_pitched."
|
||||
)
|
||||
return waveform.clone()
|
||||
|
||||
try:
|
||||
import torch
|
||||
import torchaudio
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"torchaudio is required for pitch shifting but could not be imported: {e}"
|
||||
)
|
||||
|
||||
shifter = torchaudio.transforms.PitchShift(
|
||||
sample_rate=sample_rate,
|
||||
n_steps=n_steps,
|
||||
)
|
||||
shifted = shifter(waveform)
|
||||
|
||||
# The internal resample/time-stretch chain can round to a slightly
|
||||
# different sample count — force it back to the source length so
|
||||
# audio_pitched always matches audio's duration exactly.
|
||||
target_len = waveform.shape[-1]
|
||||
cur_len = shifted.shape[-1]
|
||||
if cur_len > target_len:
|
||||
shifted = shifted[..., :target_len]
|
||||
elif cur_len < target_len:
|
||||
shifted = torch.nn.functional.pad(shifted, (0, target_len - cur_len))
|
||||
|
||||
return shifted
|
||||
|
||||
def load_audio(self, audio, normalize=False,
|
||||
trim_json='{"s":0,"e":0}', unique_id="0"):
|
||||
trim_json='{"s":0,"e":0}', unique_id="0",
|
||||
pitch_shift_semitones=0.0):
|
||||
audio_path = folder_paths.get_annotated_filepath(audio)
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
@@ -98,11 +166,19 @@ class AudioLoaderNode:
|
||||
dur = waveform.shape[-1] / sample_rate
|
||||
filename = os.path.basename(audio_path)
|
||||
trim_info = f" | Trim: {t_start:.3f}s – {t_end:.3f}s" if do_trim else ""
|
||||
|
||||
# Pitch-shifted twin of the (already trimmed/normalized) waveform.
|
||||
# Computed from the same source as `audio`, so both outputs stay in
|
||||
# sync with the same trim/normalize settings — only pitch differs.
|
||||
pitch_shift_semitones = float(pitch_shift_semitones or 0.0)
|
||||
pitched_waveform = self._pitch_shift(waveform, sample_rate, pitch_shift_semitones)
|
||||
pitch_info = f" | Pitch: {pitch_shift_semitones:+.2f}st" if pitch_shift_semitones != 0.0 else ""
|
||||
|
||||
metadata = (
|
||||
f"File: {filename} | Sample Rate: {sample_rate}Hz | "
|
||||
f"Channels: {waveform.shape[0]} | Samples: {waveform.shape[-1]} | "
|
||||
f"Duration: {dur:.3f}s | Size: {os.path.getsize(audio_path)/1024:.1f}KB"
|
||||
f"{trim_info}"
|
||||
f"{trim_info}{pitch_info}"
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -110,22 +186,25 @@ class AudioLoaderNode:
|
||||
sample_rate,
|
||||
dur,
|
||||
metadata,
|
||||
{"waveform": pitched_waveform.unsqueeze(0), "sample_rate": sample_rate},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, audio, normalize=False,
|
||||
trim_json='{"s":0,"e":0}', unique_id="0"):
|
||||
trim_json='{"s":0,"e":0}', unique_id="0",
|
||||
pitch_shift_semitones=0.0):
|
||||
try:
|
||||
p = folder_paths.get_annotated_filepath(audio)
|
||||
if os.path.exists(p):
|
||||
return f"{os.path.getmtime(p)}|{normalize}|{trim_json}"
|
||||
return f"{os.path.getmtime(p)}|{normalize}|{trim_json}|{pitch_shift_semitones}"
|
||||
except Exception:
|
||||
pass
|
||||
return f"{audio}|{normalize}|{trim_json}"
|
||||
return f"{audio}|{normalize}|{trim_json}|{pitch_shift_semitones}"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, audio, normalize=False,
|
||||
trim_json='{"s":0,"e":0}', unique_id="0"):
|
||||
trim_json='{"s":0,"e":0}', unique_id="0",
|
||||
pitch_shift_semitones=0.0):
|
||||
if not folder_paths.exists_annotated_filepath(audio):
|
||||
return f"Audio file does not exist: {audio}"
|
||||
return True
|
||||
@@ -134,6 +213,43 @@ class AudioLoaderNode:
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
def _hash_file(path, chunk_size=1 << 20):
|
||||
"""MD5 checksum of a file's contents, read in chunks to bound memory."""
|
||||
h = hashlib.md5()
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _name_family(input_dir, filename):
|
||||
"""
|
||||
Yields (candidate_name, full_path, exists) for filename, filename_1,
|
||||
filename_2, ... — the same numbering scheme upload_audio already used
|
||||
to dodge collisions — stopping right after the first name that doesn't
|
||||
exist yet (that's the next free slot).
|
||||
"""
|
||||
base, ext = os.path.splitext(filename)
|
||||
counter = 0
|
||||
while True:
|
||||
candidate = filename if counter == 0 else f"{base}_{counter}{ext}"
|
||||
path = os.path.join(input_dir, candidate)
|
||||
exists = os.path.exists(path)
|
||||
yield candidate, path, exists
|
||||
if not exists:
|
||||
return
|
||||
counter += 1
|
||||
|
||||
|
||||
# Guards the "compare against existing files, then finalize" step of an
|
||||
# upload so two near-simultaneous uploads of identical content can't both
|
||||
# decide there's no match and race to create two separate numbered copies.
|
||||
_upload_lock = asyncio.Lock()
|
||||
|
||||
@PromptServer.instance.routes.post("/axces2000/audio_trim")
|
||||
async def set_audio_trim(request):
|
||||
"""JS posts trim JSON here; we echo it back so the prompt can include it."""
|
||||
@@ -159,18 +275,63 @@ async def upload_audio(request):
|
||||
ext = os.path.splitext(filename)[1].lower().lstrip(".")
|
||||
if ext not in audio_extensions:
|
||||
return web.Response(status=400, text=f"Unsupported format: {ext}")
|
||||
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
save_path = os.path.join(input_dir, filename)
|
||||
base, extension = os.path.splitext(filename)
|
||||
counter = 1
|
||||
while os.path.exists(save_path):
|
||||
filename = f"{base}_{counter}{extension}"
|
||||
save_path = os.path.join(input_dir, filename)
|
||||
counter += 1
|
||||
with open(save_path, "wb") as f:
|
||||
while chunk := await field.read_chunk(8192):
|
||||
f.write(chunk)
|
||||
return web.json_response({"name": filename, "subfolder": "", "type": "input"})
|
||||
_, ext_with_dot = os.path.splitext(filename)
|
||||
|
||||
# Stream the upload straight to a hidden temp file in the same folder
|
||||
# (so the eventual rename below is atomic, same filesystem) while
|
||||
# hashing it as the bytes arrive — no extra read pass just to checksum.
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(
|
||||
prefix=".axces2000_upload_", suffix=ext_with_dot, dir=input_dir
|
||||
)
|
||||
try:
|
||||
hasher = hashlib.md5()
|
||||
with os.fdopen(tmp_fd, "wb") as f:
|
||||
while chunk := await field.read_chunk(8192):
|
||||
f.write(chunk)
|
||||
hasher.update(chunk)
|
||||
upload_hash = hasher.hexdigest()
|
||||
upload_size = os.path.getsize(tmp_path)
|
||||
|
||||
async with _upload_lock:
|
||||
# Walk voice.wav, voice_1.wav, voice_2.wav, ... — same chain
|
||||
# this endpoint always used to dodge name collisions. If any
|
||||
# existing file in that chain is byte-identical to what was
|
||||
# just uploaded, reuse its name instead of writing a new
|
||||
# numbered duplicate of the same content.
|
||||
for candidate, path, exists in _name_family(input_dir, filename):
|
||||
if not exists:
|
||||
os.replace(tmp_path, path)
|
||||
return web.json_response({
|
||||
"name": candidate, "subfolder": "", "type": "input",
|
||||
"deduped": False,
|
||||
})
|
||||
try:
|
||||
same_content = (
|
||||
os.path.getsize(path) == upload_size
|
||||
and _hash_file(path) == upload_hash
|
||||
)
|
||||
except OSError:
|
||||
# File vanished/unreadable mid-check — treat as no
|
||||
# match and keep scanning the rest of the chain.
|
||||
continue
|
||||
if same_content:
|
||||
os.remove(tmp_path)
|
||||
return web.json_response({
|
||||
"name": candidate, "subfolder": "", "type": "input",
|
||||
"deduped": True,
|
||||
})
|
||||
|
||||
# Unreachable in practice — _name_family always terminates at a
|
||||
# free slot — but fail loudly rather than silently drop the upload.
|
||||
raise RuntimeError("Could not find a destination for the uploaded file")
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"AudioLoader": AudioLoaderNode}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
/**
|
||||
* AudioLoader Widget for ComfyUI v2.6
|
||||
* AudioLoader Widget for ComfyUI v2.9
|
||||
*
|
||||
* v2.9 — pitch_shift_semitones (an auto-generated FLOAT widget) could reach
|
||||
* the backend as `null` in some ComfyUI frontend builds, which crashes
|
||||
* ComfyUI's own prompt validator (float(None)) before the node ever runs.
|
||||
* Fixed by giving that widget a serializeValue() that always emits a real
|
||||
* finite number. See the comment at its creation site below for details.
|
||||
*
|
||||
* v2.8 — The server's /upload/audio route now dedups by content checksum:
|
||||
* re-uploading a file that's byte-identical to one already sitting under
|
||||
* the same name (or same-name_N) reuses that existing file instead of
|
||||
* writing a new numbered duplicate. Purely server-side; the only JS change
|
||||
* is logging data.deduped to the console for visibility.
|
||||
*
|
||||
* trim_json is declared as a hidden input in Python's INPUT_TYPES.
|
||||
* ComfyUI passes hidden inputs automatically in the prompt — but only if
|
||||
@@ -8,6 +20,17 @@
|
||||
* We use the cleanest available hook: override the node's getExtraInfo()
|
||||
* which ComfyUI calls when building the prompt, injecting trim_json there.
|
||||
* Fallback: also set it via a beforeQueued hook on the audio combo widget.
|
||||
*
|
||||
* v2.7 — Added `pitch_shift_semitones`, a plain optional FLOAT input on the
|
||||
* Python side. ComfyUI auto-generates its widget from INPUT_TYPES just like
|
||||
* `normalize`, so it needs no special handling here. The one thing that
|
||||
* *does* need care: adding a new auto-widget shifts every widget's position
|
||||
* in node.widgets / widgets_values. The old trim-restore code below read
|
||||
* widgets_values by a hardcoded index (vals[3]), which would silently break
|
||||
* as soon as any new widget was added anywhere before it. It now reads the
|
||||
* trim_json widget's own `.value` instead — LiteGraph already restores each
|
||||
* widget's value from widgets_values (by position) before onConfigure runs,
|
||||
* so this is correct and immune to future widget-order changes.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
@@ -164,6 +187,9 @@ function createAudioWidget(node, nativeComboWidget) {
|
||||
const res=await api.fetchApi("/upload/audio",{method:"POST",body:fd});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data=await res.json();
|
||||
if (data.deduped) {
|
||||
console.log(`[AudioLoader] "${file.name}" is byte-identical to existing "${data.name}" — reusing it instead of uploading a duplicate.`);
|
||||
}
|
||||
await loadAudioFile(data.name);
|
||||
} catch(e) { console.error("[AudioLoader] upload error",e); alert("Upload failed: "+e.message); }
|
||||
}
|
||||
@@ -344,6 +370,29 @@ app.registerExtension({
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
this.serialize_widgets = true;
|
||||
|
||||
// pitch_shift_semitones is a plain auto-generated FLOAT widget (it's
|
||||
// just declared as an optional input in Python's INPUT_TYPES — no
|
||||
// custom widget code needed for it). In some ComfyUI frontend builds
|
||||
// a freshly-created optional numeric widget can end up with a null
|
||||
// value instead of the declared default, and ComfyUI's own backend
|
||||
// validator does an unconditional `float(value)` on any input whose
|
||||
// key is present in the prompt — float(None) raises, which surfaces
|
||||
// to the user as "failed to validate prompt" before the node ever
|
||||
// runs (our own Python-side None-guards never get a chance to help,
|
||||
// since that crash happens in ComfyUI's core, upstream of our code).
|
||||
// Widgets are serialised via widget.serializeValue() when present,
|
||||
// so pinning one down here guarantees a real number is always sent,
|
||||
// regardless of what the widget's raw .value happens to be.
|
||||
const pitchW = this.widgets?.find(w => w.name === "pitch_shift_semitones");
|
||||
if (pitchW) {
|
||||
const safeNumber = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
if (!Number.isFinite(Number(pitchW.value))) pitchW.value = 0;
|
||||
pitchW.serializeValue = () => safeNumber(pitchW.value);
|
||||
}
|
||||
|
||||
const comboW = this.widgets?.find(w => w.name === "audio");
|
||||
if (!comboW) { console.error("[AudioLoader] combo widget not found"); return; }
|
||||
|
||||
@@ -394,11 +443,16 @@ app.registerExtension({
|
||||
const vals = config.widgets_values ?? [];
|
||||
const fname = safeStr(vals[0]);
|
||||
|
||||
// Restore trim: prefer the serialised trim_json widget (vals[3]),
|
||||
// fall back to the legacy axces2000_trim property for old workflows.
|
||||
// Restore trim: LiteGraph already restores each widget's `.value`
|
||||
// from widgets_values (matched by array position) before onConfigure
|
||||
// runs, so we read the trim_json widget's own current value directly
|
||||
// instead of indexing into widgets_values ourselves. This keeps trim
|
||||
// restore correct no matter how many other widgets (e.g. the
|
||||
// pitch_shift_semitones auto-widget) get added before it.
|
||||
// Falls back to the legacy axces2000_trim property for old workflows.
|
||||
let ts=0, te=0;
|
||||
try {
|
||||
const savedTrim = vals[3] ?? null;
|
||||
const savedTrim = this._alTrimWidget?.value ?? null;
|
||||
if (savedTrim && savedTrim !== '{"s":0,"e":0}') {
|
||||
const p = JSON.parse(savedTrim);
|
||||
ts = p.s ?? 0; te = p.e ?? 0;
|
||||
|
||||
+58
-4
@@ -1,5 +1,17 @@
|
||||
/**
|
||||
* AudioLoader Widget for ComfyUI v2.6
|
||||
* AudioLoader Widget for ComfyUI v2.9
|
||||
*
|
||||
* v2.9 — pitch_shift_semitones (an auto-generated FLOAT widget) could reach
|
||||
* the backend as `null` in some ComfyUI frontend builds, which crashes
|
||||
* ComfyUI's own prompt validator (float(None)) before the node ever runs.
|
||||
* Fixed by giving that widget a serializeValue() that always emits a real
|
||||
* finite number. See the comment at its creation site below for details.
|
||||
*
|
||||
* v2.8 — The server's /upload/audio route now dedups by content checksum:
|
||||
* re-uploading a file that's byte-identical to one already sitting under
|
||||
* the same name (or same-name_N) reuses that existing file instead of
|
||||
* writing a new numbered duplicate. Purely server-side; the only JS change
|
||||
* is logging data.deduped to the console for visibility.
|
||||
*
|
||||
* trim_json is declared as a hidden input in Python's INPUT_TYPES.
|
||||
* ComfyUI passes hidden inputs automatically in the prompt — but only if
|
||||
@@ -8,6 +20,17 @@
|
||||
* We use the cleanest available hook: override the node's getExtraInfo()
|
||||
* which ComfyUI calls when building the prompt, injecting trim_json there.
|
||||
* Fallback: also set it via a beforeQueued hook on the audio combo widget.
|
||||
*
|
||||
* v2.7 — Added `pitch_shift_semitones`, a plain optional FLOAT input on the
|
||||
* Python side. ComfyUI auto-generates its widget from INPUT_TYPES just like
|
||||
* `normalize`, so it needs no special handling here. The one thing that
|
||||
* *does* need care: adding a new auto-widget shifts every widget's position
|
||||
* in node.widgets / widgets_values. The old trim-restore code below read
|
||||
* widgets_values by a hardcoded index (vals[3]), which would silently break
|
||||
* as soon as any new widget was added anywhere before it. It now reads the
|
||||
* trim_json widget's own `.value` instead — LiteGraph already restores each
|
||||
* widget's value from widgets_values (by position) before onConfigure runs,
|
||||
* so this is correct and immune to future widget-order changes.
|
||||
*/
|
||||
|
||||
import { app } from "../../../scripts/app.js";
|
||||
@@ -164,6 +187,9 @@ function createAudioWidget(node, nativeComboWidget) {
|
||||
const res=await api.fetchApi("/upload/audio",{method:"POST",body:fd});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data=await res.json();
|
||||
if (data.deduped) {
|
||||
console.log(`[AudioLoader] "${file.name}" is byte-identical to existing "${data.name}" — reusing it instead of uploading a duplicate.`);
|
||||
}
|
||||
await loadAudioFile(data.name);
|
||||
} catch(e) { console.error("[AudioLoader] upload error",e); alert("Upload failed: "+e.message); }
|
||||
}
|
||||
@@ -344,6 +370,29 @@ app.registerExtension({
|
||||
onNodeCreated?.apply(this, arguments);
|
||||
this.serialize_widgets = true;
|
||||
|
||||
// pitch_shift_semitones is a plain auto-generated FLOAT widget (it's
|
||||
// just declared as an optional input in Python's INPUT_TYPES — no
|
||||
// custom widget code needed for it). In some ComfyUI frontend builds
|
||||
// a freshly-created optional numeric widget can end up with a null
|
||||
// value instead of the declared default, and ComfyUI's own backend
|
||||
// validator does an unconditional `float(value)` on any input whose
|
||||
// key is present in the prompt — float(None) raises, which surfaces
|
||||
// to the user as "failed to validate prompt" before the node ever
|
||||
// runs (our own Python-side None-guards never get a chance to help,
|
||||
// since that crash happens in ComfyUI's core, upstream of our code).
|
||||
// Widgets are serialised via widget.serializeValue() when present,
|
||||
// so pinning one down here guarantees a real number is always sent,
|
||||
// regardless of what the widget's raw .value happens to be.
|
||||
const pitchW = this.widgets?.find(w => w.name === "pitch_shift_semitones");
|
||||
if (pitchW) {
|
||||
const safeNumber = (v) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
if (!Number.isFinite(Number(pitchW.value))) pitchW.value = 0;
|
||||
pitchW.serializeValue = () => safeNumber(pitchW.value);
|
||||
}
|
||||
|
||||
const comboW = this.widgets?.find(w => w.name === "audio");
|
||||
if (!comboW) { console.error("[AudioLoader] combo widget not found"); return; }
|
||||
|
||||
@@ -394,11 +443,16 @@ app.registerExtension({
|
||||
const vals = config.widgets_values ?? [];
|
||||
const fname = safeStr(vals[0]);
|
||||
|
||||
// Restore trim: prefer the serialised trim_json widget (vals[3]),
|
||||
// fall back to the legacy axces2000_trim property for old workflows.
|
||||
// Restore trim: LiteGraph already restores each widget's `.value`
|
||||
// from widgets_values (matched by array position) before onConfigure
|
||||
// runs, so we read the trim_json widget's own current value directly
|
||||
// instead of indexing into widgets_values ourselves. This keeps trim
|
||||
// restore correct no matter how many other widgets (e.g. the
|
||||
// pitch_shift_semitones auto-widget) get added before it.
|
||||
// Falls back to the legacy axces2000_trim property for old workflows.
|
||||
let ts=0, te=0;
|
||||
try {
|
||||
const savedTrim = vals[3] ?? null;
|
||||
const savedTrim = this._alTrimWidget?.value ?? null;
|
||||
if (savedTrim && savedTrim !== '{"s":0,"e":0}') {
|
||||
const p = JSON.parse(savedTrim);
|
||||
ts = p.s ?? 0; te = p.e ?? 0;
|
||||
|
||||
Reference in New Issue
Block a user