482 lines
21 KiB
JavaScript
482 lines
21 KiB
JavaScript
/**
|
|
* 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
|
|
* they appear in node.properties or are injected into the graph prompt.
|
|
*
|
|
* 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";
|
|
import { api } from "../../../scripts/api.js";
|
|
|
|
const WAVE_H = 150;
|
|
const INFO_H = 32;
|
|
const BTN_H = 36;
|
|
const LABEL_H = 18;
|
|
const NODE_W = 360;
|
|
const WIDGET_H = WAVE_H + INFO_H + BTN_H + LABEL_H + 12;
|
|
|
|
const C = {
|
|
bg: "#0f172a",
|
|
barPlayed: "#4ade80",
|
|
barUnplayed: "#1e3a2f",
|
|
barTrimmed: "#0f1f17",
|
|
playhead: "#f8fafc",
|
|
accent: "#22d3ee",
|
|
trimHandle: "#f59e0b",
|
|
trimFill: "rgba(245,158,11,0.08)",
|
|
text: "#94a3b8",
|
|
textBright: "#e2e8f0",
|
|
border: "#1e293b",
|
|
btnBg: "#1e293b",
|
|
};
|
|
|
|
const AUDIO_EXTS = new Set(["mp3","wav","wave","flac","ogg","aac","m4a","opus","weba"]);
|
|
const HANDLE_HIT = 10;
|
|
|
|
function isAudioFile(f) {
|
|
if (!f) return false;
|
|
if (f.type?.startsWith("audio/")) return true;
|
|
return AUDIO_EXTS.has(f.name?.split(".").pop()?.toLowerCase());
|
|
}
|
|
|
|
function fmt(s) {
|
|
if (!isFinite(s)||s<0) s=0;
|
|
const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sc=Math.floor(s%60),ms=Math.floor((s%1)*1000);
|
|
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}:${String(sc).padStart(2,"0")}.${String(ms).padStart(3,"0")}`;
|
|
}
|
|
function fmtShort(s) {
|
|
if (!isFinite(s)||s<0) s=0;
|
|
return `${Math.floor(s/60)}:${(s%60).toFixed(1).padStart(4,"0")}`;
|
|
}
|
|
function parseTime(str) {
|
|
if (!str) return null;
|
|
const p=str.trim().split(":");
|
|
let sec=0;
|
|
if (p.length===3) sec=+p[0]*3600 + +p[1]*60 + +p[2];
|
|
else if (p.length===2) sec=+p[0]*60 + +p[1];
|
|
else sec=+p[0];
|
|
return isFinite(sec)?sec:null;
|
|
}
|
|
function safeStr(v) {
|
|
if (!v) return "";
|
|
if (typeof v==="string") return v;
|
|
if (Array.isArray(v)) return String(v[0]||"");
|
|
return String(v);
|
|
}
|
|
|
|
async function decodeWaveformPeaks(buf, numBars=220) {
|
|
const ctx=new (window.AudioContext||window.webkitAudioContext)();
|
|
try {
|
|
const dec=await ctx.decodeAudioData(buf);
|
|
const ch=dec.getChannelData(0), block=Math.floor(ch.length/numBars);
|
|
const peaks=[];
|
|
for (let i=0;i<numBars;i++) {
|
|
let max=0,base=i*block;
|
|
for (let j=0;j<block;j++){const v=Math.abs(ch[base+j]);if(v>max)max=v;}
|
|
peaks.push(max);
|
|
}
|
|
const gm=Math.max(...peaks,0.001);
|
|
return {peaks:peaks.map(p=>p/gm), duration:dec.duration};
|
|
} finally { ctx.close(); }
|
|
}
|
|
|
|
// ─── Widget Factory ───────────────────────────────────────────────────────────
|
|
function createAudioWidget(node, nativeComboWidget) {
|
|
let peaks=[], duration=0, audioEl=null;
|
|
let playState="stopped", playheadFrac=0, animFrame=null;
|
|
let isDragOver=false, trimStart=0, trimEnd=0;
|
|
let dragTarget=null, hoveredZone=null;
|
|
|
|
// Hide the native combo widget — keep it for filename serialisation
|
|
const ZERO=()=>[0,0];
|
|
nativeComboWidget.computeSize=ZERO;
|
|
nativeComboWidget.draw=()=>{};
|
|
nativeComboWidget.mouse=()=>false;
|
|
|
|
function trimJson() {
|
|
const trimmed = duration>0 && (trimStart>0 || trimEnd<duration);
|
|
return trimmed ? JSON.stringify({s:trimStart,e:trimEnd}) : '{"s":0,"e":0}';
|
|
}
|
|
|
|
function isTrimmed() { return duration>0 && (trimStart>0||trimEnd<duration); }
|
|
|
|
// ── Audio ─────────────────────────────────────────────────────────────────
|
|
function setupAudio(url) {
|
|
if (audioEl){audioEl.pause();audioEl.src="";}
|
|
audioEl=new Audio(url);
|
|
audioEl.addEventListener("ended",()=>{
|
|
playState="stopped"; playheadFrac=duration>0?trimStart/duration:0;
|
|
cancelAnimationFrame(animFrame); node.setDirtyCanvas(true,false);
|
|
});
|
|
audioEl.addEventListener("timeupdate",()=>{
|
|
if (duration>0&&trimEnd>0&&audioEl.currentTime>=trimEnd){
|
|
audioEl.pause(); audioEl.currentTime=trimStart;
|
|
playState="stopped"; playheadFrac=trimStart/duration;
|
|
cancelAnimationFrame(animFrame); node.setDirtyCanvas(true,false);
|
|
}
|
|
});
|
|
}
|
|
|
|
function clampPlayhead() {
|
|
if (!duration) return;
|
|
const lo=trimStart/duration, hi=trimEnd/duration;
|
|
if (playheadFrac<lo) playheadFrac=lo;
|
|
if (playheadFrac>hi) playheadFrac=hi;
|
|
if (audioEl) audioEl.currentTime=playheadFrac*duration;
|
|
}
|
|
|
|
function tick() {
|
|
if (audioEl?.duration) playheadFrac=audioEl.currentTime/audioEl.duration;
|
|
node.setDirtyCanvas(true,false);
|
|
if (playState==="playing") animFrame=requestAnimationFrame(tick);
|
|
}
|
|
|
|
async function loadAudioFile(fname, restoredStart, restoredEnd) {
|
|
if (!fname?.trim()) return;
|
|
nativeComboWidget.value=fname;
|
|
const url=api.apiURL(`/view?filename=${encodeURIComponent(fname)}&type=input`);
|
|
try {
|
|
const res=await fetch(url), buf=await res.arrayBuffer();
|
|
const result=await decodeWaveformPeaks(buf.slice(0));
|
|
peaks=result.peaks; duration=result.duration;
|
|
} catch(e) { console.error("[AudioLoader] decode error",e); peaks=[];duration=0; }
|
|
|
|
if (restoredStart!==undefined && (restoredStart>0||(restoredEnd>0&&restoredEnd<duration))) {
|
|
trimStart=restoredStart; trimEnd=restoredEnd>0?restoredEnd:duration;
|
|
} else { trimStart=0; trimEnd=duration; }
|
|
|
|
// Update node's hidden trim_json property so it gets included in the prompt
|
|
node._alTrimJson = trimJson();
|
|
|
|
setupAudio(url);
|
|
playState="stopped"; playheadFrac=duration>0?trimStart/duration:0;
|
|
node.setDirtyCanvas(true,false);
|
|
}
|
|
|
|
async function uploadFile(file) {
|
|
const fd=new FormData(); fd.append("image",file,file.name);
|
|
try {
|
|
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); }
|
|
}
|
|
|
|
function promptTrim(which) {
|
|
const cur=which==="start"?fmt(trimStart):fmt(trimEnd);
|
|
const label=which==="start"?"Trim Start (HH:MM:SS.mmm)":"Trim End (HH:MM:SS.mmm)";
|
|
const result=window.prompt(label,cur);
|
|
if (result===null) return;
|
|
const t=parseTime(result.trim());
|
|
if (t===null) return;
|
|
if (which==="start") trimStart=Math.max(0,Math.min(t,trimEnd-0.001));
|
|
else trimEnd=Math.min(duration,Math.max(t,trimStart+0.001));
|
|
clampPlayhead();
|
|
node._alTrimJson=trimJson();
|
|
node.setDirtyCanvas(true,false);
|
|
}
|
|
|
|
// ── Master draw ───────────────────────────────────────────────────────────
|
|
const master=node.addWidget("AL_MASTER","_al_master",null,()=>{},{serialize:false});
|
|
master.computeSize=()=>[NODE_W,WIDGET_H+20];
|
|
|
|
master.draw=function(ctx,node,width,y) {
|
|
const px=10,pw=width-20,waveX=px+10,waveW=pw-20;
|
|
const fname=nativeComboWidget.value||"";
|
|
const tsF=duration>0?trimStart/duration:0, teF=duration>0?trimEnd/duration:1;
|
|
const tsX=waveX+tsF*waveW, teX=waveX+teF*waveW;
|
|
|
|
ctx.fillStyle=C.bg; ctx.strokeStyle=isDragOver?C.accent:C.border; ctx.lineWidth=isDragOver?2:1;
|
|
ctx.beginPath(); ctx.roundRect(px,y,pw,WAVE_H,8); ctx.fill();
|
|
ctx.beginPath(); ctx.roundRect(px,y,pw,WAVE_H,8); ctx.stroke();
|
|
|
|
if (peaks.length>0) {
|
|
ctx.fillStyle=C.trimFill; ctx.fillRect(tsX,y+4,teX-tsX,WAVE_H-8);
|
|
const barW=waveW/peaks.length,cy=y+WAVE_H/2,maxH=WAVE_H/2-14;
|
|
for (let i=0;i<peaks.length;i++) {
|
|
const frac=i/peaks.length, inTrim=frac>=tsF&&frac<teF, played=frac<=playheadFrac&&inTrim;
|
|
const bx=waveX+i*barW, bh=Math.max(2,peaks[i]*maxH);
|
|
ctx.fillStyle=inTrim?(played?C.barPlayed:C.barUnplayed):C.barTrimmed;
|
|
ctx.beginPath(); ctx.roundRect(bx,cy-bh,Math.max(1,barW-1),bh*2,1); ctx.fill();
|
|
}
|
|
const dh=(hx,side)=>{
|
|
const hov=hoveredZone===(side==="left"?"trimL":"trimR");
|
|
ctx.strokeStyle=hov?"#fbbf24":C.trimHandle; ctx.lineWidth=hov?3:2;
|
|
ctx.beginPath(); ctx.moveTo(hx,y+4); ctx.lineTo(hx,y+WAVE_H-4); ctx.stroke();
|
|
const tw=12,th=16,tx=side==="left"?hx:hx-tw;
|
|
ctx.fillStyle=hov?"#fbbf24":C.trimHandle;
|
|
ctx.beginPath(); ctx.roundRect(tx,y+4,tw,th,3); ctx.fill();
|
|
ctx.fillStyle="#0f172a"; ctx.font="bold 9px monospace"; ctx.textAlign="center";
|
|
ctx.fillText(side==="left"?"◀":"▶",hx+(side==="left"?tw/2:-tw/2),y+15); ctx.textAlign="left";
|
|
};
|
|
dh(tsX,"left"); dh(teX,"right");
|
|
const phX=waveX+playheadFrac*waveW;
|
|
ctx.strokeStyle=C.playhead; ctx.lineWidth=2;
|
|
ctx.beginPath(); ctx.moveTo(phX,y+6); ctx.lineTo(phX,y+WAVE_H-6); ctx.stroke();
|
|
ctx.fillStyle=C.playhead;
|
|
ctx.beginPath(); ctx.moveTo(phX-5,y+6); ctx.lineTo(phX+5,y+6); ctx.lineTo(phX,y+15); ctx.closePath(); ctx.fill();
|
|
} else {
|
|
ctx.fillStyle=isDragOver?C.accent:"#334155"; ctx.font="bold 13px monospace"; ctx.textAlign="center";
|
|
ctx.fillText(isDragOver?"Drop audio file here":"🎵 Drag & drop audio / select below",px+pw/2,y+WAVE_H/2-8);
|
|
ctx.font="11px monospace"; ctx.fillStyle="#64748b";
|
|
ctx.fillText("MP3 · WAV · FLAC · OGG · AAC · M4A",px+pw/2,y+WAVE_H/2+12); ctx.textAlign="left";
|
|
}
|
|
|
|
const infoY=y+WAVE_H+4;
|
|
if (duration>0) {
|
|
ctx.fillStyle=C.text; ctx.font="10px monospace"; ctx.textAlign="left";
|
|
ctx.fillText(`total: ${fmtShort(duration)}`,px+2,infoY+12);
|
|
if (isTrimmed()) {
|
|
ctx.fillStyle=C.trimHandle; ctx.font="bold 10px monospace"; ctx.textAlign="center";
|
|
ctx.fillText(`▶ ${fmtShort(trimEnd-trimStart)}`,px+pw/2,infoY+12);
|
|
}
|
|
}
|
|
const tsHov=hoveredZone==="trimLLabel",teHov=hoveredZone==="trimRLabel";
|
|
ctx.font=(tsHov?"bold ":"")+"10px monospace"; ctx.fillStyle=tsHov?C.accent:C.trimHandle;
|
|
ctx.textAlign="left"; ctx.fillText(`✎ ${fmtShort(trimStart)}`,px+2,infoY+26);
|
|
ctx.font=(teHov?"bold ":"")+"10px monospace"; ctx.fillStyle=teHov?C.accent:C.trimHandle;
|
|
ctx.textAlign="right"; ctx.fillText(`${fmtShort(trimEnd)} ✎`,px+pw-2,infoY+26); ctx.textAlign="left";
|
|
|
|
const labelY=infoY+INFO_H+2;
|
|
if (fname) {
|
|
ctx.fillStyle="#64748b"; ctx.font="10px monospace"; ctx.textAlign="center";
|
|
ctx.fillText(fname.length>44?fname.slice(0,42)+"…":fname,px+pw/2,labelY+12); ctx.textAlign="left";
|
|
}
|
|
|
|
const btnY=labelY+LABEL_H+2,btnSz=28,btnGap=8;
|
|
const playBx=px+pw/2-btnSz-btnGap/2, stopBx=px+pw/2+btnGap/2;
|
|
const db=(bx,label,active,hov)=>{
|
|
ctx.fillStyle=active?C.accent:(hov?"#253550":C.btnBg);
|
|
ctx.strokeStyle=active?C.accent:(hov?C.accent:"#334155"); ctx.lineWidth=1;
|
|
ctx.beginPath(); ctx.roundRect(bx,btnY,btnSz,btnSz,6); ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle=active?"#0f172a":C.textBright; ctx.font="14px sans-serif"; ctx.textAlign="center";
|
|
ctx.fillText(label,bx+btnSz/2,btnY+btnSz/2+5); ctx.textAlign="left";
|
|
};
|
|
db(playBx,playState==="playing"?"⏸":"▶",playState==="playing",hoveredZone==="play");
|
|
db(stopBx,"⏹",false,hoveredZone==="stop");
|
|
|
|
master._z={waveX,waveW,waveY:y,waveBot:y+WAVE_H,infoY,infoBot:infoY+INFO_H,tsX,teX,playBx,stopBx,btnY,btnSz,px,pw};
|
|
};
|
|
|
|
master.mouse=function(event,pos,node) {
|
|
if (!master._z) return false;
|
|
const [mx,my]=pos, z=master._z;
|
|
const inWave=my>=z.waveY&&my<=z.waveBot, inInfo=my>=z.infoY&&my<=z.infoBot;
|
|
const inBtn=bx=>mx>=bx&&mx<=bx+z.btnSz&&my>=z.btnY&&my<=z.btnY+z.btnSz;
|
|
|
|
if (event.type==="pointermove") {
|
|
let h=null;
|
|
if (inWave&&peaks.length>0) {
|
|
if (Math.abs(mx-z.tsX)<=HANDLE_HIT) h="trimL";
|
|
else if (Math.abs(mx-z.teX)<=HANDLE_HIT) h="trimR";
|
|
}
|
|
if (inInfo&&my>z.infoY+14) h=mx<z.px+z.pw/2?"trimLLabel":"trimRLabel";
|
|
if (inBtn(z.playBx)) h="play";
|
|
if (inBtn(z.stopBx)) h="stop";
|
|
if (h!==hoveredZone){hoveredZone=h;node.setDirtyCanvas(true,false);}
|
|
}
|
|
|
|
if (dragTarget) {
|
|
if (event.type==="pointermove") {
|
|
const frac=Math.max(0,Math.min(1,(mx-z.waveX)/z.waveW)),t=frac*duration;
|
|
if (dragTarget==="trimL") trimStart=Math.max(0,Math.min(t,trimEnd-0.001));
|
|
else if (dragTarget==="trimR") trimEnd=Math.min(duration,Math.max(t,trimStart+0.001));
|
|
else { playheadFrac=Math.max(trimStart/duration,Math.min(trimEnd/duration,frac)); if(audioEl)audioEl.currentTime=playheadFrac*duration; }
|
|
clampPlayhead(); node._alTrimJson=trimJson(); node.setDirtyCanvas(true,false); return true;
|
|
}
|
|
if (event.type==="pointerup"){dragTarget=null;return true;}
|
|
return true;
|
|
}
|
|
|
|
if (event.type==="pointerdown") {
|
|
if (inWave&&peaks.length>0) {
|
|
if (Math.abs(mx-z.tsX)<=HANDLE_HIT){dragTarget="trimL";return true;}
|
|
if (Math.abs(mx-z.teX)<=HANDLE_HIT){dragTarget="trimR";return true;}
|
|
dragTarget="playhead"; return true;
|
|
}
|
|
if (inInfo&&my>z.infoY+14){mx<z.px+z.pw/2?promptTrim("start"):promptTrim("end");return true;}
|
|
if (inBtn(z.playBx)){handlePlayPause();return true;}
|
|
if (inBtn(z.stopBx)){handleStop();return true;}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
function handlePlayPause() {
|
|
if (!audioEl) return;
|
|
if (playState==="playing"){audioEl.pause();playState="paused";cancelAnimationFrame(animFrame);}
|
|
else {
|
|
if (duration>0&&audioEl.currentTime>=trimEnd-0.01){audioEl.currentTime=trimStart;playheadFrac=trimStart/duration;}
|
|
audioEl.play(); playState="playing"; animFrame=requestAnimationFrame(tick);
|
|
}
|
|
node.setDirtyCanvas(true,false);
|
|
}
|
|
function handleStop() {
|
|
if (!audioEl) return;
|
|
audioEl.pause(); audioEl.currentTime=trimStart;
|
|
playheadFrac=duration>0?trimStart/duration:0; playState="stopped";
|
|
cancelAnimationFrame(animFrame); node.setDirtyCanvas(true,false);
|
|
}
|
|
|
|
master._setDragOver=v=>{isDragOver=v;node.setDirtyCanvas(true,false);};
|
|
node.onDragOver=function(e){const has=[...(e.dataTransfer?.items??[])].some(i=>i.kind==="file");master._setDragOver(has);return has;};
|
|
node.onDragDrop=async function(e){master._setDragOver(false);const file=e.dataTransfer?.files?.[0];if(file&&isAudioFile(file)){await uploadFile(file);return true;}return false;};
|
|
const origRemoved=node.onRemoved?.bind(node);
|
|
node.onRemoved=function(){if(audioEl){audioEl.pause();audioEl.src="";}cancelAnimationFrame(animFrame);origRemoved?.();};
|
|
|
|
return {loadAudioFile, uploadFile};
|
|
}
|
|
|
|
// ─── Register ─────────────────────────────────────────────────────────────────
|
|
app.registerExtension({
|
|
name: "Axces2000.AudioLoader",
|
|
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== "AudioLoader") return;
|
|
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function() {
|
|
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; }
|
|
|
|
const w = createAudioWidget(this, comboW);
|
|
this._alWidgets = w;
|
|
|
|
// A zero-size STRING widget that ComfyUI will serialise into the prompt
|
|
// automatically — this is how trim_json reliably reaches Python.
|
|
const trimWidget = this.addWidget("STRING", "trim_json", '{"s":0,"e":0}', ()=>{}, {serialize: true});
|
|
trimWidget.computeSize = () => [0, -4]; // invisible but present
|
|
trimWidget.draw = () => {};
|
|
this._alTrimWidget = trimWidget;
|
|
|
|
// Keep _alTrimJson in sync: any write also updates the widget value.
|
|
Object.defineProperty(this, "_alTrimJson", {
|
|
get: () => trimWidget.value,
|
|
set: (v) => { trimWidget.value = v; },
|
|
configurable: true,
|
|
});
|
|
|
|
this.addWidget("button","📁 Browse file",null,()=>{
|
|
const inp=document.createElement("input"); inp.type="file"; inp.accept="audio/*";
|
|
inp.onchange=async e=>{const file=e.target.files[0];if(file)await this._alWidgets?.uploadFile(file);};
|
|
inp.click();
|
|
});
|
|
|
|
// Only set default size on fresh creation; onConfigure will run
|
|
// afterward for saved workflows and must not have its size overwritten.
|
|
if (!this._alSizeSet) {
|
|
this.size=[NODE_W+20, WIDGET_H+80];
|
|
this._alSizeSet = true;
|
|
}
|
|
|
|
const initialFile = safeStr(comboW.value);
|
|
if (initialFile.trim()) {
|
|
setTimeout(()=>w.loadAudioFile(initialFile),100);
|
|
}
|
|
};
|
|
|
|
// No graphToPrompt patch needed — trim_json is serialised via the
|
|
// hidden STRING widget added in onNodeCreated below.
|
|
|
|
const onConfigure = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function(config) {
|
|
onConfigure?.apply(this, arguments);
|
|
if (!this._alWidgets) return;
|
|
|
|
const vals = config.widgets_values ?? [];
|
|
const fname = safeStr(vals[0]);
|
|
|
|
// 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 = this._alTrimWidget?.value ?? null;
|
|
if (savedTrim && savedTrim !== '{"s":0,"e":0}') {
|
|
const p = JSON.parse(savedTrim);
|
|
ts = p.s ?? 0; te = p.e ?? 0;
|
|
} else if (config.axces2000_trim) {
|
|
ts = config.axces2000_trim.s ?? 0;
|
|
te = config.axces2000_trim.e ?? 0;
|
|
}
|
|
} catch(e) {}
|
|
|
|
if (fname) {
|
|
setTimeout(()=>this._alWidgets.loadAudioFile(fname,ts,te),150);
|
|
}
|
|
};
|
|
|
|
const origSerializeNode = nodeType.prototype.serialize;
|
|
nodeType.prototype.serialize = function() {
|
|
const data = origSerializeNode ? origSerializeNode.call(this) : {};
|
|
if (this._alTrimJson && this._alTrimJson !== '{"s":0,"e":0}') {
|
|
try {
|
|
data.axces2000_trim = JSON.parse(this._alTrimJson);
|
|
} catch(e) {}
|
|
}
|
|
return data;
|
|
};
|
|
},
|
|
});
|