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>
110 lines
3.9 KiB
JavaScript
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 };
|
|
})();
|