Files
olivv-cs-ComfyUI-FunPack/app/legacy/waveform.js
T
digital-garbageandClaude Sonnet 5 29ac44ffcd feat: v4 UI port Phase A start -- module categories, legacy frontend served live
Two pieces of the "port v4's exact UI onto v5's backend" plan:

- core/contract.py: new optional ModuleSpec.category field (closed set:
  continuity, guidance, conditioning, sampling, post, system), validated in
  core/schema.py, threaded through core/registry.py's FIELDS and
  to_manifest(). Presentation grouping, independent of STAGE (execution
  order) -- a module can be stage="sampling" and category="guidance" at
  once. Only the 4 existing modules that actually render a settings panel
  (alg, sharpen, appearance, full_control) got a category assigned; the
  other 12 have none to categorize (no SETTINGS block) and stay
  uncategorised, which is valid.

- v4's frontend (movie_editor/frontend/*, ~29k lines, 61 files) copied
  verbatim into app/legacy/. core/routes.py's "/" route now serves
  legacy/index.html instead of the composer entry point; a <base> tag in
  that file resolves its own relative script/link tags against
  /funpack/app/legacy/ regardless of what URL it's served at, so every
  file moved as a unit with zero per-file path edits. Verified live: all
  61 assets load 200 OK, the real v4 onboarding screen renders pixel-for-
  pixel (it's the actual DOM/CSS, not a reimplementation), and several v4
  API calls (/api/media, /api/projects, /api/git/status) already succeed
  against v5's real backend by coincidence of shared route naming -- the
  rest 404/405 as expected, since api.js's ~90 functions haven't been
  rewired to v5's actual routes yet (next piece of this plan).

The old composer entry point (app/index.html, app/composer/**) stays in
the tree, unreferenced, until a later cleanup pass once nothing imports it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 23:58:51 +03:00

110 lines
3.9 KiB
JavaScript

/** Decode audio assets to peak arrays and paint timeline waveforms. */
window.FunPackWaveform = (function () {
const _peaks = new Map();
function _bucketCount(widthPx) {
return Math.max(32, Math.min(512, Math.floor((widthPx || 200) / 2)));
}
async function peaksFromUrl(key, url, buckets) {
const cacheKey = `${key}:${buckets}`;
if (_peaks.has(cacheKey)) return _peaks.get(cacheKey);
const job = (async () => {
const res = await fetch(url);
if (!res.ok) throw new Error(`fetch ${res.status}`);
const buf = await res.arrayBuffer();
const ctx = new AudioContext();
try {
const decoded = await ctx.decodeAudioData(buf.slice(0));
const ch = decoded.getChannelData(0);
const block = Math.max(1, Math.floor(ch.length / buckets));
const peaks = new Float32Array(buckets);
for (let i = 0; i < buckets; i++) {
let max = 0;
const start = i * block;
for (let j = 0; j < block; j++) {
max = Math.max(max, Math.abs(ch[start + j] || 0));
}
peaks[i] = max;
}
return { peaks, durationSec: decoded.duration };
} finally {
ctx.close();
}
})();
_peaks.set(cacheKey, job);
return job;
}
function _sizeCanvas(canvas) {
const w = Math.max(1, canvas.clientWidth || canvas.offsetWidth || 100);
const h = Math.max(1, canvas.clientHeight || canvas.offsetHeight || 28);
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
return { w: canvas.width, h: canvas.height, dpr };
}
function paint(canvas, peaks, opts) {
if (!canvas || !peaks || !peaks.length) return;
const g = canvas.getContext("2d");
if (!g) return;
const { w, h, dpr } = _sizeCanvas(canvas);
g.setTransform(1, 0, 0, 1, 0, 0);
g.clearRect(0, 0, w, h);
const fill = (opts && opts.color) || "rgba(45, 212, 191, 0.55)";
const mid = h / 2;
let slice = peaks;
const totalSec = opts?.totalDurationSec;
if (totalSec > 0 && opts?.durationSec > 0) {
const inSec = Math.max(0, opts.inSec || 0);
const startIdx = Math.floor((inSec / totalSec) * peaks.length);
const endIdx = Math.ceil(((inSec + opts.durationSec) / totalSec) * peaks.length);
slice = peaks.subarray(startIdx, Math.max(startIdx + 1, endIdx));
}
const step = w / slice.length;
g.fillStyle = fill;
for (let i = 0; i < slice.length; i++) {
const amp = slice[i] * mid * 0.92;
const x = i * step;
g.fillRect(x, mid - amp, Math.max(1, step * 0.85), amp * 2);
}
if (dpr !== 1) g.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function paintPlaceholder(canvas, opts) {
if (!canvas) return;
const g = canvas.getContext("2d");
if (!g) return;
const { w, h } = _sizeCanvas(canvas);
g.clearRect(0, 0, w, h);
const fill = (opts && opts.color) || "rgba(45, 138, 106, 0.35)";
g.fillStyle = fill;
const mid = h / 2;
const n = Math.max(24, Math.floor(w / 4));
for (let i = 0; i < n; i++) {
const t = i / n;
const amp = (0.15 + 0.12 * Math.sin(t * 28) + 0.08 * Math.sin(t * 11)) * mid;
g.fillRect(i * (w / n), mid - amp, Math.max(1, w / n * 0.7), amp * 2);
}
}
function attach(canvas, key, url, opts) {
if (!canvas || !url) {
paintPlaceholder(canvas, opts);
return;
}
const buckets = _bucketCount((opts && opts.width) || canvas.clientWidth || 200);
peaksFromUrl(key, url, buckets).then((data) => {
if (!canvas.isConnected) return;
const paintOpts = { ...(opts || {}), totalDurationSec: data.durationSec };
paint(canvas, data.peaks, paintOpts);
if (opts && typeof opts.onDuration === "function" && data.durationSec > 0) {
opts.onDuration(data.durationSec);
}
}).catch(() => paintPlaceholder(canvas, opts));
}
return { attach, paint, paintPlaceholder, peaksFromUrl };
})();