894 lines
37 KiB
JavaScript
894 lines
37 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
|
|
const MIN_GRAPH_HEIGHT = 500;
|
|
const AUDIO_HEIGHT = 44;
|
|
const LOOP_BAR_H = 18;
|
|
const HANDLE_W = 6;
|
|
const BRIDGE_FADE_SEC = 0.005; // 5ms crossfade — short enough to be imperceptible
|
|
|
|
function buildAudioUrl(file) {
|
|
const params = new URLSearchParams({
|
|
filename: file.filename,
|
|
type: file.type,
|
|
subfolder: file.subfolder || "",
|
|
});
|
|
return `/view?${params.toString()}`;
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "RyanOnTheInside.InstantFloatPreview",
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== "InstantFloatPreview") return;
|
|
|
|
// --- Serialization: persist loop state across page refresh ---
|
|
const onSerialize = nodeType.prototype.onSerialize;
|
|
nodeType.prototype.onSerialize = function (data) {
|
|
onSerialize?.apply(this, arguments);
|
|
const loop = this._ifpLoop || this._ifpPendingLoop;
|
|
if (loop) {
|
|
data.ifpLoop = { ...loop };
|
|
}
|
|
};
|
|
|
|
const onConfigure = nodeType.prototype.onConfigure;
|
|
nodeType.prototype.onConfigure = function (data) {
|
|
onConfigure?.apply(this, arguments);
|
|
if (data.ifpLoop) {
|
|
const saved = data.ifpLoop;
|
|
if (this._ifpLoop) {
|
|
this._ifpLoop = saved;
|
|
} else {
|
|
// Widget not created yet; stash for _ifpCreateWidget
|
|
this._ifpPendingLoop = saved;
|
|
}
|
|
}
|
|
};
|
|
|
|
const onExecuted = nodeType.prototype.onExecuted;
|
|
nodeType.prototype.onExecuted = function (message) {
|
|
onExecuted?.apply(this, arguments);
|
|
|
|
// Suppress any default image preview/thumbnail
|
|
if (this.imgs) this.imgs = null;
|
|
if (this.imageIndex != null) this.imageIndex = null;
|
|
|
|
const series = message?.series;
|
|
const title = message?.title?.[0] || "Float Preview";
|
|
const rewindSeconds = message?.rewind_seconds?.[0] ?? 2.0;
|
|
const audioFile = message?.ifp_audio?.[0];
|
|
const audioDuration = message?.ifp_audio_duration?.[0];
|
|
|
|
if (!series || series.length === 0) return;
|
|
|
|
if (!this._ifpContainer) {
|
|
this._ifpCreateWidget();
|
|
}
|
|
|
|
const bpm = message?.bpm?.[0] || 0;
|
|
this._ifpData = { series, title, bpm };
|
|
|
|
// Handle audio
|
|
if (audioFile) {
|
|
this._ifpHandleAudio(audioFile, audioDuration, rewindSeconds);
|
|
} else if (this._ifpAudioShown) {
|
|
this._ifpAudioWrapper.style.display = "none";
|
|
this._ifpAudioShown = false;
|
|
this._ifpAudioDuration = 0;
|
|
}
|
|
|
|
// Draw after a frame so canvas is laid out
|
|
requestAnimationFrame(() => {
|
|
this._ifpDraw();
|
|
if (!this._ifpSized) {
|
|
this._ifpSized = true;
|
|
const sz = this.computeSize();
|
|
sz[0] = Math.max(sz[0], 1680);
|
|
this.setSize(sz);
|
|
this.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
});
|
|
};
|
|
|
|
nodeType.prototype._ifpCreateWidget = function () {
|
|
const container = document.createElement("div");
|
|
container.style.cssText = "display:flex;flex-direction:column;gap:0;min-height:" + MIN_GRAPH_HEIGHT + "px;";
|
|
|
|
const canvas = document.createElement("canvas");
|
|
canvas.style.cssText = "width:100%;flex:1;display:block;cursor:default;min-height:" + MIN_GRAPH_HEIGHT + "px;";
|
|
container.appendChild(canvas);
|
|
|
|
const audioWrapper = document.createElement("div");
|
|
audioWrapper.style.cssText = "width:100%;display:none;flex-shrink:0;";
|
|
container.appendChild(audioWrapper);
|
|
|
|
// Single audio element for native controls and normal playback.
|
|
// Gapless swaps are handled via an AudioBufferSourceNode bridge.
|
|
const audioEl = document.createElement("audio");
|
|
audioEl.controls = true;
|
|
audioEl.preload = "auto";
|
|
audioEl.style.cssText = "width:100%;display:block;";
|
|
audioWrapper.appendChild(audioEl);
|
|
|
|
this._ifpContainer = container;
|
|
this._ifpCanvas = canvas;
|
|
this._ifpAudioWrapper = audioWrapper;
|
|
this._ifpAudioEl = audioEl;
|
|
this._ifpData = null;
|
|
this._ifpRafId = null;
|
|
this._ifpAudioShown = false;
|
|
this._ifpAudioDuration = 0;
|
|
this._ifpSized = false;
|
|
this._ifpHasAudio = false;
|
|
|
|
// Loop state
|
|
this._ifpLoop = { enabled: false, start: 0, end: 1 };
|
|
// Restore saved loop state from onConfigure if it ran before widget creation
|
|
if (this._ifpPendingLoop) {
|
|
this._ifpLoop = this._ifpPendingLoop;
|
|
this._ifpPendingLoop = null;
|
|
}
|
|
this._ifpDrag = null;
|
|
|
|
// Swap / bridge state
|
|
this._ifpSwapping = false;
|
|
this._ifpSwapAbort = null;
|
|
this._ifpBridgeSource = null;
|
|
this._ifpBridgeStartCtxTime = 0;
|
|
this._ifpBridgeOffset = 0;
|
|
|
|
// Web Audio API state (lazy-init on first audio load)
|
|
this._ifpAudioCtx = null;
|
|
this._ifpElementGain = null;
|
|
this._ifpBridgeGain = null;
|
|
|
|
const self = this;
|
|
const widget = this.addDOMWidget("instant_float_preview", "custom", container, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
});
|
|
widget.computeSize = function () {
|
|
const audioH = self._ifpAudioShown ? AUDIO_HEIGHT : 0;
|
|
return [self.size[0], MIN_GRAPH_HEIGHT + audioH + 4];
|
|
};
|
|
|
|
// Redraw when the canvas element is resized (handles node resize + recovery from drag)
|
|
this._ifpResizeObserver = new ResizeObserver(() => {
|
|
this._ifpDraw();
|
|
});
|
|
this._ifpResizeObserver.observe(canvas);
|
|
|
|
// Canvas mouse events for loop bar
|
|
canvas.addEventListener("mousedown", (e) => this._ifpOnMouseDown(e));
|
|
canvas.addEventListener("mousemove", (e) => this._ifpOnMouseMove(e));
|
|
canvas.addEventListener("mouseup", (e) => this._ifpOnMouseUp(e));
|
|
canvas.addEventListener("mouseleave", (e) => this._ifpOnMouseUp(e));
|
|
canvas.addEventListener("dblclick", (e) => this._ifpOnDblClick(e));
|
|
|
|
// Audio event listeners on the single element
|
|
audioEl.addEventListener("timeupdate", () => {
|
|
if (this._ifpSwapping) return;
|
|
this._ifpCheckLoop();
|
|
if (!this._ifpRafId) this._ifpDraw();
|
|
});
|
|
audioEl.addEventListener("play", () => {
|
|
// Resume AudioContext if suspended (browser autoplay policy)
|
|
if (this._ifpAudioCtx?.state === "suspended") {
|
|
this._ifpAudioCtx.resume();
|
|
}
|
|
if (!this._ifpSwapping) this._ifpStartRaf();
|
|
});
|
|
audioEl.addEventListener("pause", () => {
|
|
if (this._ifpSwapping) return;
|
|
this._ifpStopRaf();
|
|
this._ifpDraw();
|
|
});
|
|
audioEl.addEventListener("ended", () => {
|
|
if (this._ifpSwapping) return;
|
|
this._ifpStopRaf();
|
|
this._ifpDraw();
|
|
});
|
|
};
|
|
|
|
// --- Web Audio API setup (called once, lazily) ---
|
|
// Creates the audio graph:
|
|
// <audio> element -> MediaElementSourceNode -> elementGain -> destination
|
|
// AudioBufferSourceNode (bridge) -----------> bridgeGain -> destination
|
|
// Normal playback flows through the element path (elementGain=1, bridgeGain=0).
|
|
// During a swap the bridge path carries audio while the element reloads.
|
|
|
|
nodeType.prototype._ifpEnsureAudioCtx = function () {
|
|
if (this._ifpAudioCtx) return;
|
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
const src = ctx.createMediaElementSource(this._ifpAudioEl);
|
|
const elementGain = ctx.createGain();
|
|
const bridgeGain = ctx.createGain();
|
|
src.connect(elementGain).connect(ctx.destination);
|
|
bridgeGain.connect(ctx.destination);
|
|
elementGain.gain.value = 1;
|
|
bridgeGain.gain.value = 0;
|
|
this._ifpAudioCtx = ctx;
|
|
this._ifpElementGain = elementGain;
|
|
this._ifpBridgeGain = bridgeGain;
|
|
};
|
|
|
|
// --- Loop bar helpers ---
|
|
|
|
nodeType.prototype._ifpGetLayout = function () {
|
|
const canvas = this._ifpCanvas;
|
|
if (!canvas) return null;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const w = rect.width;
|
|
const h = rect.height;
|
|
if (w === 0 || h === 0) return null;
|
|
const marginLeft = 60;
|
|
const marginRight = 20;
|
|
const marginTop = 36;
|
|
const graphX = marginLeft;
|
|
const graphW = w - marginLeft - marginRight;
|
|
const loopBarY = marginTop - LOOP_BAR_H - 2;
|
|
return { w, h, graphX, graphW, marginTop, loopBarY };
|
|
};
|
|
|
|
nodeType.prototype._ifpCanvasPos = function (e) {
|
|
const rect = this._ifpCanvas.getBoundingClientRect();
|
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
|
};
|
|
|
|
nodeType.prototype._ifpXToFrac = function (x, layout) {
|
|
return Math.max(0, Math.min(1, (x - layout.graphX) / layout.graphW));
|
|
};
|
|
|
|
nodeType.prototype._ifpOnMouseDown = function (e) {
|
|
if (!this._ifpAudioShown) return;
|
|
const layout = this._ifpGetLayout();
|
|
if (!layout) return;
|
|
const pos = this._ifpCanvasPos(e);
|
|
const loop = this._ifpLoop;
|
|
|
|
if (pos.y < layout.loopBarY || pos.y > layout.loopBarY + LOOP_BAR_H) return;
|
|
|
|
const frac = this._ifpXToFrac(pos.x, layout);
|
|
const startX = layout.graphX + loop.start * layout.graphW;
|
|
const endX = layout.graphX + loop.end * layout.graphW;
|
|
|
|
if (loop.enabled) {
|
|
if (Math.abs(pos.x - startX) <= HANDLE_W + 2) {
|
|
this._ifpDrag = { type: "start", originStart: loop.start, originEnd: loop.end };
|
|
this._ifpCanvas.style.cursor = "ew-resize";
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (Math.abs(pos.x - endX) <= HANDLE_W + 2) {
|
|
this._ifpDrag = { type: "end", originStart: loop.start, originEnd: loop.end };
|
|
this._ifpCanvas.style.cursor = "ew-resize";
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (frac >= loop.start && frac <= loop.end) {
|
|
this._ifpDrag = { type: "move", originX: frac, originStart: loop.start, originEnd: loop.end };
|
|
this._ifpCanvas.style.cursor = "grab";
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
|
|
this._ifpDrag = { type: "create", originX: frac };
|
|
loop.start = frac;
|
|
loop.end = frac;
|
|
loop.enabled = true;
|
|
this._ifpCanvas.style.cursor = "ew-resize";
|
|
e.preventDefault();
|
|
};
|
|
|
|
nodeType.prototype._ifpOnMouseMove = function (e) {
|
|
const drag = this._ifpDrag;
|
|
if (!drag) {
|
|
if (this._ifpAudioShown && this._ifpLoop.enabled) {
|
|
const layout = this._ifpGetLayout();
|
|
if (!layout) return;
|
|
const pos = this._ifpCanvasPos(e);
|
|
if (pos.y >= layout.loopBarY && pos.y <= layout.loopBarY + LOOP_BAR_H) {
|
|
const loop = this._ifpLoop;
|
|
const startX = layout.graphX + loop.start * layout.graphW;
|
|
const endX = layout.graphX + loop.end * layout.graphW;
|
|
if (Math.abs(pos.x - startX) <= HANDLE_W + 2 || Math.abs(pos.x - endX) <= HANDLE_W + 2) {
|
|
this._ifpCanvas.style.cursor = "ew-resize";
|
|
} else {
|
|
const frac = this._ifpXToFrac(pos.x, layout);
|
|
this._ifpCanvas.style.cursor = (frac >= loop.start && frac <= loop.end) ? "grab" : "crosshair";
|
|
}
|
|
} else {
|
|
this._ifpCanvas.style.cursor = "default";
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const layout = this._ifpGetLayout();
|
|
if (!layout) return;
|
|
const pos = this._ifpCanvasPos(e);
|
|
const frac = this._ifpXToFrac(pos.x, layout);
|
|
const loop = this._ifpLoop;
|
|
|
|
if (drag.type === "create") {
|
|
loop.start = Math.min(drag.originX, frac);
|
|
loop.end = Math.max(drag.originX, frac);
|
|
} else if (drag.type === "start") {
|
|
loop.start = Math.min(frac, loop.end - 0.005);
|
|
} else if (drag.type === "end") {
|
|
loop.end = Math.max(frac, loop.start + 0.005);
|
|
} else if (drag.type === "move") {
|
|
const delta = frac - drag.originX;
|
|
let newStart = drag.originStart + delta;
|
|
let newEnd = drag.originEnd + delta;
|
|
const span = newEnd - newStart;
|
|
if (newStart < 0) { newStart = 0; newEnd = span; }
|
|
if (newEnd > 1) { newEnd = 1; newStart = 1 - span; }
|
|
loop.start = newStart;
|
|
loop.end = newEnd;
|
|
}
|
|
|
|
this._ifpDraw();
|
|
e.preventDefault();
|
|
};
|
|
|
|
nodeType.prototype._ifpOnMouseUp = function () {
|
|
if (this._ifpDrag) {
|
|
const loop = this._ifpLoop;
|
|
if (loop.end - loop.start < 0.01) {
|
|
loop.enabled = false;
|
|
loop.start = 0;
|
|
loop.end = 1;
|
|
}
|
|
this._ifpDrag = null;
|
|
this._ifpCanvas.style.cursor = "default";
|
|
this._ifpDraw();
|
|
}
|
|
};
|
|
|
|
nodeType.prototype._ifpOnDblClick = function (e) {
|
|
if (!this._ifpAudioShown) return;
|
|
const layout = this._ifpGetLayout();
|
|
if (!layout) return;
|
|
const pos = this._ifpCanvasPos(e);
|
|
if (pos.y >= layout.loopBarY && pos.y <= layout.loopBarY + LOOP_BAR_H) {
|
|
this._ifpLoop.enabled = false;
|
|
this._ifpLoop.start = 0;
|
|
this._ifpLoop.end = 1;
|
|
this._ifpDraw();
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
nodeType.prototype._ifpCheckLoop = function () {
|
|
if (this._ifpSwapping) return;
|
|
const loop = this._ifpLoop;
|
|
if (!loop.enabled || !this._ifpAudioDuration) return;
|
|
const audioEl = this._ifpAudioEl;
|
|
const endTime = loop.end * this._ifpAudioDuration;
|
|
const startTime = loop.start * this._ifpAudioDuration;
|
|
if (audioEl.currentTime >= endTime) {
|
|
audioEl.currentTime = startTime;
|
|
}
|
|
};
|
|
|
|
// --- Audio: bridge-based swap using AudioBufferSourceNode ---
|
|
//
|
|
// On re-execution while audio is playing, we pre-fetch and decode the
|
|
// new audio into an AudioBuffer, then play it through an
|
|
// AudioBufferSourceNode ("bridge") with sample-accurate timing. While
|
|
// the bridge carries audio, we reload the <audio> element with the new
|
|
// source, seek it to the right position, resume playback, and crossfade
|
|
// back. This avoids the gap inherent in <audio> element source swaps.
|
|
|
|
nodeType.prototype._ifpHandleAudio = function (audioFile, duration, rewindSeconds) {
|
|
this._ifpAudioDuration = duration || 0;
|
|
|
|
if (!this._ifpAudioShown) {
|
|
this._ifpAudioShown = true;
|
|
this._ifpAudioWrapper.style.display = "block";
|
|
this._ifpSized = false;
|
|
}
|
|
|
|
const newUrl = buildAudioUrl(audioFile);
|
|
const audioEl = this._ifpAudioEl;
|
|
|
|
// Cancel any in-flight swap
|
|
this._ifpCleanupSwap();
|
|
|
|
// Initialize Web Audio routing on first audio load so all
|
|
// playback goes through the AudioContext consistently
|
|
this._ifpEnsureAudioCtx();
|
|
|
|
// First load: just set the src
|
|
if (!this._ifpHasAudio) {
|
|
audioEl.src = newUrl;
|
|
this._ifpHasAudio = true;
|
|
return;
|
|
}
|
|
|
|
// Not playing: simple swap with position restore
|
|
if (audioEl.paused) {
|
|
const prevTime = audioEl.currentTime;
|
|
const targetTime = Math.max(0, prevTime - rewindSeconds);
|
|
audioEl.src = newUrl;
|
|
audioEl.addEventListener("loadeddata", () => {
|
|
audioEl.currentTime = targetTime;
|
|
}, { once: true });
|
|
return;
|
|
}
|
|
|
|
// --- Playing: bridge swap for gapless transition ---
|
|
// Do NOT rewind during live playback. The bridge must start at the
|
|
// element's current position so the crossfade blends nearly-identical
|
|
// content at the same point in time, making the swap imperceptible.
|
|
const ctx = this._ifpAudioCtx;
|
|
if (ctx.state === "suspended") ctx.resume();
|
|
|
|
const abort = new AbortController();
|
|
this._ifpSwapAbort = abort;
|
|
this._ifpSwapping = true;
|
|
|
|
// Pre-fetch and decode the new audio into an AudioBuffer
|
|
fetch(newUrl, { signal: abort.signal })
|
|
.then(r => r.arrayBuffer())
|
|
.then(buf => {
|
|
if (abort.signal.aborted) return;
|
|
return ctx.decodeAudioData(buf);
|
|
})
|
|
.then(audioBuffer => {
|
|
if (abort.signal.aborted || !audioBuffer) return;
|
|
|
|
// Use the LIVE position at the moment the bridge starts,
|
|
// not a stale value captured before the fetch/decode.
|
|
const livePos = audioEl.currentTime;
|
|
const bridgeSrc = ctx.createBufferSource();
|
|
bridgeSrc.buffer = audioBuffer;
|
|
bridgeSrc.connect(this._ifpBridgeGain);
|
|
this._ifpBridgeSource = bridgeSrc;
|
|
this._ifpBridgeOffset = livePos;
|
|
this._ifpBridgeStartCtxTime = ctx.currentTime;
|
|
bridgeSrc.start(0, livePos);
|
|
|
|
// Crossfade: element out, bridge in
|
|
const now = ctx.currentTime;
|
|
this._ifpElementGain.gain.cancelScheduledValues(now);
|
|
this._ifpElementGain.gain.setValueAtTime(1, now);
|
|
this._ifpElementGain.gain.linearRampToValueAtTime(0, now + BRIDGE_FADE_SEC);
|
|
|
|
this._ifpBridgeGain.gain.cancelScheduledValues(now);
|
|
this._ifpBridgeGain.gain.setValueAtTime(0, now);
|
|
this._ifpBridgeGain.gain.linearRampToValueAtTime(1, now + BRIDGE_FADE_SEC);
|
|
|
|
// After fade to bridge completes, reload the <audio> element
|
|
setTimeout(() => {
|
|
if (abort.signal.aborted) return;
|
|
|
|
audioEl.pause();
|
|
audioEl.src = newUrl;
|
|
|
|
audioEl.addEventListener("canplay", () => {
|
|
if (abort.signal.aborted) return;
|
|
|
|
// Sync element to where the bridge currently is
|
|
const bridgePos = (ctx.currentTime - this._ifpBridgeStartCtxTime) + this._ifpBridgeOffset;
|
|
audioEl.currentTime = bridgePos;
|
|
|
|
audioEl.addEventListener("seeked", () => {
|
|
if (abort.signal.aborted) return;
|
|
|
|
audioEl.play().then(() => {
|
|
if (abort.signal.aborted) return;
|
|
|
|
// Crossfade: bridge out, element back in
|
|
const now2 = ctx.currentTime;
|
|
this._ifpBridgeGain.gain.cancelScheduledValues(now2);
|
|
this._ifpBridgeGain.gain.setValueAtTime(1, now2);
|
|
this._ifpBridgeGain.gain.linearRampToValueAtTime(0, now2 + BRIDGE_FADE_SEC);
|
|
|
|
this._ifpElementGain.gain.cancelScheduledValues(now2);
|
|
this._ifpElementGain.gain.setValueAtTime(0, now2);
|
|
this._ifpElementGain.gain.linearRampToValueAtTime(1, now2 + BRIDGE_FADE_SEC);
|
|
|
|
// Finalize after fade completes
|
|
setTimeout(() => {
|
|
this._ifpFinishSwap();
|
|
}, BRIDGE_FADE_SEC * 1000 + 20);
|
|
}).catch(() => {
|
|
// Autoplay blocked; element is loaded but paused
|
|
this._ifpFinishSwap();
|
|
});
|
|
}, { once: true, signal: abort.signal });
|
|
}, { once: true, signal: abort.signal });
|
|
}, BRIDGE_FADE_SEC * 1000 + 10);
|
|
})
|
|
.catch(err => {
|
|
if (abort.signal.aborted) return;
|
|
console.warn("IFP audio swap failed:", err);
|
|
// Fallback: direct swap (will cause a brief gap)
|
|
const fallbackPos = audioEl.currentTime;
|
|
audioEl.src = newUrl;
|
|
audioEl.addEventListener("loadeddata", () => {
|
|
audioEl.currentTime = Math.max(0, fallbackPos);
|
|
audioEl.play().catch(() => {});
|
|
}, { once: true });
|
|
this._ifpFinishSwap();
|
|
});
|
|
};
|
|
|
|
nodeType.prototype._ifpFinishSwap = function () {
|
|
this._ifpStopBridge();
|
|
this._ifpSwapping = false;
|
|
this._ifpSwapAbort = null;
|
|
};
|
|
|
|
nodeType.prototype._ifpStopBridge = function () {
|
|
if (this._ifpBridgeSource) {
|
|
try { this._ifpBridgeSource.stop(); } catch (e) { /* already stopped */ }
|
|
this._ifpBridgeSource.disconnect();
|
|
this._ifpBridgeSource = null;
|
|
}
|
|
};
|
|
|
|
nodeType.prototype._ifpCleanupSwap = function () {
|
|
if (this._ifpSwapAbort) {
|
|
this._ifpSwapAbort.abort();
|
|
this._ifpSwapAbort = null;
|
|
}
|
|
this._ifpStopBridge();
|
|
this._ifpSwapping = false;
|
|
// Reset gains to clean state
|
|
if (this._ifpAudioCtx) {
|
|
const now = this._ifpAudioCtx.currentTime;
|
|
this._ifpElementGain.gain.cancelScheduledValues(now);
|
|
this._ifpElementGain.gain.setValueAtTime(1, now);
|
|
this._ifpBridgeGain.gain.cancelScheduledValues(now);
|
|
this._ifpBridgeGain.gain.setValueAtTime(0, now);
|
|
}
|
|
};
|
|
|
|
// --- RAF ---
|
|
|
|
nodeType.prototype._ifpStartRaf = function () {
|
|
if (this._ifpRafId) return;
|
|
const loop = () => {
|
|
this._ifpCheckLoop();
|
|
this._ifpDraw();
|
|
this._ifpRafId = requestAnimationFrame(loop);
|
|
};
|
|
this._ifpRafId = requestAnimationFrame(loop);
|
|
};
|
|
|
|
nodeType.prototype._ifpStopRaf = function () {
|
|
if (this._ifpRafId) {
|
|
cancelAnimationFrame(this._ifpRafId);
|
|
this._ifpRafId = null;
|
|
}
|
|
};
|
|
|
|
// --- Draw ---
|
|
|
|
nodeType.prototype._ifpDraw = function () {
|
|
const canvas = this._ifpCanvas;
|
|
const data = this._ifpData;
|
|
if (!canvas || !data) return;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const w = rect.width;
|
|
const h = rect.height;
|
|
|
|
// Skip draw if canvas has no layout (e.g. node being dragged).
|
|
// Don't touch canvas.width/height so the last good frame is preserved.
|
|
if (w < 10 || h < 10) return;
|
|
|
|
const bw = Math.round(w * dpr);
|
|
const bh = Math.round(h * dpr);
|
|
if (canvas.width !== bw || canvas.height !== bh) {
|
|
canvas.width = bw;
|
|
canvas.height = bh;
|
|
}
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
const { series, title } = data;
|
|
|
|
// Layout
|
|
const marginLeft = 60;
|
|
const marginRight = 20;
|
|
const marginTop = 36;
|
|
const legendRowHeight = 22;
|
|
const legendRows = Math.ceil(series.length / 3);
|
|
const marginBottom = 20 + legendRows * legendRowHeight;
|
|
const graphX = marginLeft;
|
|
const graphY = marginTop;
|
|
const graphW = w - marginLeft - marginRight;
|
|
const graphH = h - marginTop - marginBottom;
|
|
|
|
if (graphW <= 0 || graphH <= 0) return;
|
|
|
|
// Background gradient
|
|
const bgGrad = ctx.createLinearGradient(0, 0, 0, h);
|
|
bgGrad.addColorStop(0, "#191925");
|
|
bgGrad.addColorStop(1, "#1a1a2a");
|
|
ctx.fillStyle = bgGrad;
|
|
ctx.fillRect(0, 0, w, h);
|
|
|
|
// --- Loop bar (above graph, below title) ---
|
|
if (this._ifpAudioShown) {
|
|
const loopBarY = marginTop - LOOP_BAR_H - 2;
|
|
ctx.fillStyle = "#252535";
|
|
ctx.fillRect(graphX, loopBarY, graphW, LOOP_BAR_H);
|
|
ctx.strokeStyle = "#3a3a4a";
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(graphX, loopBarY, graphW, LOOP_BAR_H);
|
|
|
|
ctx.strokeStyle = "#3a3a4a";
|
|
const tickCount = 20;
|
|
for (let i = 1; i < tickCount; i++) {
|
|
const tx = graphX + (i / tickCount) * graphW;
|
|
const tickH = i % 5 === 0 ? LOOP_BAR_H * 0.5 : LOOP_BAR_H * 0.25;
|
|
ctx.beginPath();
|
|
ctx.moveTo(tx, loopBarY + LOOP_BAR_H);
|
|
ctx.lineTo(tx, loopBarY + LOOP_BAR_H - tickH);
|
|
ctx.stroke();
|
|
}
|
|
|
|
const loop = this._ifpLoop;
|
|
if (loop.enabled) {
|
|
const lx = graphX + loop.start * graphW;
|
|
const lw = (loop.end - loop.start) * graphW;
|
|
|
|
ctx.fillStyle = "rgba(80, 180, 255, 0.25)";
|
|
ctx.fillRect(lx, loopBarY, lw, LOOP_BAR_H);
|
|
ctx.strokeStyle = "rgba(80, 180, 255, 0.7)";
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(lx, loopBarY, lw, LOOP_BAR_H);
|
|
|
|
ctx.fillStyle = "rgba(80, 180, 255, 0.9)";
|
|
ctx.fillRect(lx - HANDLE_W / 2, loopBarY, HANDLE_W, LOOP_BAR_H);
|
|
ctx.fillRect(lx + lw - HANDLE_W / 2, loopBarY, HANDLE_W, LOOP_BAR_H);
|
|
|
|
ctx.fillStyle = "rgba(80, 180, 255, 0.06)";
|
|
ctx.fillRect(lx, graphY, lw, graphH);
|
|
ctx.strokeStyle = "rgba(80, 180, 255, 0.3)";
|
|
ctx.setLineDash([4, 4]);
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(lx, graphY);
|
|
ctx.lineTo(lx, graphY + graphH);
|
|
ctx.moveTo(lx + lw, graphY);
|
|
ctx.lineTo(lx + lw, graphY + graphH);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
}
|
|
|
|
// Y range
|
|
let yMin = Infinity, yMax = -Infinity;
|
|
for (const s of series) {
|
|
for (const v of s.values) {
|
|
if (v < yMin) yMin = v;
|
|
if (v > yMax) yMax = v;
|
|
}
|
|
}
|
|
if (yMin === yMax) yMax = yMin + 1;
|
|
const yRange = yMax - yMin;
|
|
const pad = 0.05 * yRange;
|
|
yMin -= pad;
|
|
yMax += pad;
|
|
|
|
// Grid
|
|
ctx.lineWidth = 1;
|
|
for (let i = 0; i <= 8; i++) {
|
|
const y = graphY + (i * graphH / 8);
|
|
ctx.strokeStyle = i % 2 === 0 ? "#41414b" : "#2d2d37";
|
|
ctx.beginPath();
|
|
ctx.moveTo(graphX, y);
|
|
ctx.lineTo(graphX + graphW, y);
|
|
ctx.stroke();
|
|
}
|
|
for (let i = 0; i <= 12; i++) {
|
|
const x = graphX + (i * graphW / 12);
|
|
ctx.strokeStyle = i % 3 === 0 ? "#41414b" : "#2d2d37";
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, graphY);
|
|
ctx.lineTo(x, graphY + graphH);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Beat markers
|
|
if (data.bpm > 0 && this._ifpAudioDuration > 0) {
|
|
const secPerBeat = 60 / data.bpm;
|
|
const totalBeats = Math.floor(this._ifpAudioDuration / secPerBeat);
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(graphX, graphY, graphW, graphH);
|
|
ctx.clip();
|
|
for (let beat = 1; beat <= totalBeats; beat++) {
|
|
const beatTime = beat * secPerBeat;
|
|
const frac = beatTime / this._ifpAudioDuration;
|
|
const bx = graphX + frac * graphW;
|
|
const isBar = beat % 4 === 0;
|
|
ctx.strokeStyle = isBar ? "rgba(255, 200, 80, 0.4)" : "rgba(255, 200, 80, 0.15)";
|
|
ctx.lineWidth = isBar ? 1.5 : 0.75;
|
|
ctx.setLineDash(isBar ? [] : [3, 5]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(bx, graphY);
|
|
ctx.lineTo(bx, graphY + graphH);
|
|
ctx.stroke();
|
|
}
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
}
|
|
|
|
// Border
|
|
ctx.strokeStyle = "#787890";
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(graphX - 1, graphY - 1, graphW + 2, graphH + 2);
|
|
ctx.strokeStyle = "#c8c8dc";
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(graphX, graphY, graphW, graphH);
|
|
|
|
// Y-axis labels
|
|
ctx.fillStyle = "#8c8ca0";
|
|
ctx.font = "11px monospace";
|
|
ctx.textAlign = "right";
|
|
ctx.textBaseline = "middle";
|
|
for (let i = 0; i <= 4; i++) {
|
|
const yVal = yMin + (yMax - yMin) * (1 - i / 4);
|
|
const yPos = graphY + (i * graphH / 4);
|
|
ctx.fillText(yVal.toFixed(2), graphX - 8, yPos);
|
|
}
|
|
|
|
// X-axis frame labels
|
|
const frameCount = series[0]?.values.length || 1;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "top";
|
|
const xSteps = Math.min(12, frameCount - 1);
|
|
if (xSteps > 0) {
|
|
for (let i = 0; i <= xSteps; i++) {
|
|
const frame = Math.round(i * (frameCount - 1) / xSteps);
|
|
const x = graphX + (frame / Math.max(frameCount - 1, 1)) * graphW;
|
|
ctx.fillStyle = "#8c8ca0";
|
|
ctx.fillText(frame.toString(), x, graphY + graphH + 4);
|
|
}
|
|
}
|
|
|
|
// Draw series lines
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
ctx.rect(graphX, graphY, graphW, graphH);
|
|
ctx.clip();
|
|
|
|
for (const s of series) {
|
|
const [r, g, b] = s.color;
|
|
const values = s.values;
|
|
const len = values.length;
|
|
if (len < 1) continue;
|
|
|
|
const points = new Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
const x = graphX + (i / Math.max(len - 1, 1)) * graphW;
|
|
const yRatio = (values[i] - yMin) / (yMax - yMin);
|
|
const y = graphY + graphH - yRatio * graphH;
|
|
points[i] = [x, y];
|
|
}
|
|
|
|
// Glow
|
|
ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.25)`;
|
|
ctx.lineWidth = 5;
|
|
ctx.lineJoin = "round";
|
|
ctx.lineCap = "round";
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0][0], points[0][1]);
|
|
for (let i = 1; i < len; i++) ctx.lineTo(points[i][0], points[i][1]);
|
|
ctx.stroke();
|
|
|
|
// Main line
|
|
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(points[0][0], points[0][1]);
|
|
for (let i = 1; i < len; i++) ctx.lineTo(points[i][0], points[i][1]);
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
|
|
// Playhead
|
|
if (this._ifpAudioShown && this._ifpAudioDuration > 0) {
|
|
// During a bridge swap, derive position from the bridge source;
|
|
// otherwise read directly from the <audio> element.
|
|
let currentTime;
|
|
if (this._ifpSwapping && this._ifpBridgeSource && this._ifpAudioCtx) {
|
|
currentTime = (this._ifpAudioCtx.currentTime - this._ifpBridgeStartCtxTime) + this._ifpBridgeOffset;
|
|
} else {
|
|
currentTime = this._ifpAudioEl.currentTime;
|
|
}
|
|
const progress = currentTime / this._ifpAudioDuration;
|
|
if (progress >= 0 && progress <= 1) {
|
|
const px = graphX + progress * graphW;
|
|
|
|
ctx.strokeStyle = "rgba(255, 60, 60, 0.8)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(px, graphY);
|
|
ctx.lineTo(px, graphY + graphH);
|
|
ctx.stroke();
|
|
|
|
// Triangle marker
|
|
ctx.fillStyle = "rgba(255, 60, 60, 0.9)";
|
|
ctx.beginPath();
|
|
ctx.moveTo(px, graphY);
|
|
ctx.lineTo(px - 5, graphY - 8);
|
|
ctx.lineTo(px + 5, graphY - 8);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Playhead on loop bar
|
|
const loopBarY = marginTop - LOOP_BAR_H - 2;
|
|
ctx.fillStyle = "rgba(255, 60, 60, 0.9)";
|
|
ctx.fillRect(px - 1, loopBarY, 2, LOOP_BAR_H);
|
|
}
|
|
}
|
|
|
|
// Title bar
|
|
ctx.font = "bold 15px sans-serif";
|
|
const titleW = ctx.measureText(title).width;
|
|
ctx.fillStyle = "rgba(20, 20, 40, 0.7)";
|
|
ctx.fillRect((w - titleW) / 2 - 12, 4, titleW + 24, 24);
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(title, w / 2, 16);
|
|
|
|
// Legend
|
|
const legendY = graphY + graphH + 22;
|
|
const cols = 3;
|
|
const colWidth = graphW / cols;
|
|
ctx.font = "12px monospace";
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "middle";
|
|
for (let i = 0; i < series.length; i++) {
|
|
const s = series[i];
|
|
const [r, g, b] = s.color;
|
|
const row = Math.floor(i / cols);
|
|
const col = i % cols;
|
|
const lx = graphX + col * colWidth;
|
|
const ly = legendY + row * legendRowHeight;
|
|
|
|
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
|
ctx.fillRect(lx, ly - 6, 14, 12);
|
|
ctx.strokeStyle = "#c8c8c8";
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(lx, ly - 6, 14, 12);
|
|
|
|
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
|
ctx.fillText(s.label, lx + 20, ly);
|
|
}
|
|
};
|
|
|
|
// Clean up on node removal
|
|
const onRemoved = nodeType.prototype.onRemoved;
|
|
nodeType.prototype.onRemoved = function () {
|
|
this._ifpStopRaf?.();
|
|
this._ifpCleanupSwap?.();
|
|
if (this._ifpResizeObserver) {
|
|
this._ifpResizeObserver.disconnect();
|
|
this._ifpResizeObserver = null;
|
|
}
|
|
if (this._ifpAudioCtx) {
|
|
this._ifpAudioCtx.close();
|
|
this._ifpAudioCtx = null;
|
|
}
|
|
if (this._ifpAudioEl) {
|
|
this._ifpAudioEl.pause();
|
|
this._ifpAudioEl.src = "";
|
|
}
|
|
onRemoved?.apply(this, arguments);
|
|
};
|
|
},
|
|
});
|