8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
512 lines
17 KiB
JavaScript
512 lines
17 KiB
JavaScript
import {
|
|
form, urlInput, submitBtn, errorEl, jobBox, jobTitleEl, jobStageEl,
|
|
jobDetailEl, jobCancelBtn, progressEl, titleEl, bpmChip, keyChip,
|
|
eventSource, setEventSource, setCurrentJobId, currentJobId,
|
|
selectedStems,
|
|
} from "./state.js";
|
|
import { destroyPlayer, wireUpAudio, setWaveformLoading, updateFooterTrack } from "./player.js";
|
|
import { stagePhrases } from "./phrases.js";
|
|
import { addTrackToLibrary, setCurrentTrack, updateTrackStatus, applyStemPresenceCards } from "./catalog.js";
|
|
import { initSections } from "./sections.js";
|
|
|
|
// Playful stage label rotation (Claude-Code-style flair). The backend
|
|
// emits truthful stage strings; we surface them in the small #job-detail
|
|
// line so progress is debuggable, while #job-stage rotates whimsy.
|
|
const ROTATION_MS = 2500;
|
|
let phraseTimerId = null;
|
|
let lastStatus = null;
|
|
let jobPollTimerId = null;
|
|
const renderedJobs = new Set();
|
|
const jobSources = new Map();
|
|
|
|
const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]);
|
|
|
|
function setSubmitProcessing(processing) {
|
|
submitBtn.disabled = processing;
|
|
submitBtn.classList.toggle("loading", processing);
|
|
document.querySelector(".strip-sq-process")?.classList.toggle("loading", processing);
|
|
const label = submitBtn.querySelector("span");
|
|
if (label) label.textContent = processing ? "Processing" : "Process";
|
|
}
|
|
|
|
function pickPhrase(status) {
|
|
const pool = stagePhrases[status] || stagePhrases.default;
|
|
return pool[Math.floor(Math.random() * pool.length)];
|
|
}
|
|
|
|
function setOverlayPhrase(text) {
|
|
const el = document.getElementById("waveLoadingPhrase");
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
function startPhraseRotation(status) {
|
|
stopPhraseRotation();
|
|
const phrase = pickPhrase(status);
|
|
jobStageEl.textContent = phrase;
|
|
setOverlayPhrase(phrase);
|
|
phraseTimerId = setInterval(() => {
|
|
const p = pickPhrase(status);
|
|
jobStageEl.textContent = p;
|
|
setOverlayPhrase(p);
|
|
}, ROTATION_MS);
|
|
}
|
|
|
|
function stopPhraseRotation() {
|
|
if (phraseTimerId) {
|
|
clearInterval(phraseTimerId);
|
|
phraseTimerId = null;
|
|
}
|
|
jobStageEl.textContent = "";
|
|
}
|
|
|
|
function stopJobPolling() {
|
|
if (jobPollTimerId) {
|
|
clearInterval(jobPollTimerId);
|
|
jobPollTimerId = null;
|
|
}
|
|
}
|
|
|
|
export function showError(message) {
|
|
errorEl.textContent = "";
|
|
const msg = document.createElement("div");
|
|
msg.className = "error-msg";
|
|
msg.textContent = message;
|
|
const retry = document.createElement("button");
|
|
retry.className = "retry-btn";
|
|
retry.type = "button";
|
|
retry.textContent = "Try again";
|
|
retry.addEventListener("click", () => {
|
|
errorEl.classList.add("hidden");
|
|
urlInput.focus();
|
|
urlInput.select();
|
|
});
|
|
errorEl.append(msg, retry);
|
|
errorEl.classList.remove("hidden");
|
|
}
|
|
|
|
export function reset() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
setEventSource(null);
|
|
}
|
|
stopJobPolling();
|
|
stopPhraseRotation();
|
|
lastStatus = null;
|
|
destroyPlayer();
|
|
errorEl.classList.add("hidden");
|
|
errorEl.textContent = "";
|
|
jobBox.classList.add("hidden");
|
|
jobCancelBtn.classList.add("hidden");
|
|
jobTitleEl.textContent = "";
|
|
jobStageEl.textContent = "";
|
|
jobDetailEl.textContent = "";
|
|
progressEl.value = 0;
|
|
setSubmitProcessing(false);
|
|
setCurrentJobId(null);
|
|
}
|
|
|
|
function applyState(state) {
|
|
if (state.job_id) {
|
|
addTrackToLibrary({
|
|
id: state.job_id,
|
|
title: state.title || urlInput.value || "Processing track",
|
|
channel: state.status === "done" ? "Extracted" : "Processing",
|
|
thumb: state.thumbnail,
|
|
stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems],
|
|
selectedStems: state.selected_stems || [...selectedStems],
|
|
audioStems: state.stems || [],
|
|
status: state.status,
|
|
duration: state.duration,
|
|
bpm: state.bpm,
|
|
key: state.key,
|
|
scale: state.scale,
|
|
keyConfidence: state.key_confidence,
|
|
lufs: state.lufs,
|
|
peakDb: state.peak_db,
|
|
stemPresence: state.stem_presence,
|
|
sourceUrl: jobSources.get(state.job_id) || urlInput.value,
|
|
createdAt: state.created_at,
|
|
});
|
|
setCurrentTrack(state.job_id);
|
|
}
|
|
if (state.title) {
|
|
jobTitleEl.textContent = state.title;
|
|
titleEl.textContent = state.title;
|
|
}
|
|
if (state.bpm) bpmChip.textContent = `${state.bpm} BPM`;
|
|
if (state.key) keyChip.textContent = state.key;
|
|
if (state.title || state.bpm || state.key || state.thumbnail) {
|
|
updateFooterTrack({
|
|
title: state.title,
|
|
thumbnail: state.thumbnail,
|
|
key: state.key,
|
|
bpm: state.bpm,
|
|
stemCount: state.stems ? state.stems.filter((s) => s.name !== "original").length : null,
|
|
});
|
|
}
|
|
const summaryKey = document.getElementById("summary-key");
|
|
const summaryBpm = document.getElementById("summary-bpm");
|
|
const summaryScale = document.getElementById("summary-scale");
|
|
const summaryScaleName = document.getElementById("summary-scale-name");
|
|
const summaryConfidence = document.getElementById("summary-confidence");
|
|
const summaryConfidenceLabel = document.getElementById("summary-confidence-label");
|
|
const summaryLufs = document.getElementById("summary-lufs");
|
|
const summaryPeak = document.getElementById("summary-peak");
|
|
const summaryDuration = document.getElementById("summary-duration");
|
|
if (summaryKey && state.key) summaryKey.textContent = state.key;
|
|
if (summaryBpm && state.bpm) summaryBpm.textContent = String(state.bpm);
|
|
if (summaryScale && state.scale) summaryScale.textContent = state.scale;
|
|
if (summaryScaleName && state.scale) summaryScaleName.textContent = state.scale;
|
|
if (summaryLufs && state.lufs != null) summaryLufs.textContent = state.lufs.toFixed(1);
|
|
if (summaryPeak && state.peak_db != null) summaryPeak.textContent = `Peak ${state.peak_db.toFixed(1)} dB`;
|
|
if (summaryDuration && state.duration) {
|
|
const m = Math.floor(state.duration / 60);
|
|
const s = Math.floor(state.duration % 60).toString().padStart(2, "0");
|
|
summaryDuration.textContent = `${m.toString().padStart(2, "0")}:${s}`;
|
|
}
|
|
if (summaryConfidence && state.key_confidence != null) {
|
|
const confidence = Math.max(0, Math.min(100, Number(state.key_confidence)));
|
|
const confSpan = document.createElement("span");
|
|
confSpan.textContent = `${confidence}%`;
|
|
summaryConfidence.textContent = "";
|
|
summaryConfidence.appendChild(confSpan);
|
|
summaryConfidence.style.setProperty("--confidence-pct", confidence);
|
|
summaryConfidence.classList.remove("hidden");
|
|
summaryConfidenceLabel?.classList.remove("hidden");
|
|
}
|
|
const summaryDr = document.getElementById("summary-dr");
|
|
const summaryDrLabel = document.getElementById("summary-dr-label");
|
|
const summaryStability = document.getElementById("summary-stability");
|
|
const summaryStabilityLabel = document.getElementById("summary-stability-label");
|
|
if (summaryDr && state.dynamic_range != null) summaryDr.textContent = String(state.dynamic_range);
|
|
if (summaryDrLabel && state.dynamic_range != null) {
|
|
const dr = state.dynamic_range;
|
|
summaryDrLabel.textContent = dr < 7 ? "Compressed" : dr < 10 ? "Moderate" : dr < 14 ? "High" : "Wide";
|
|
}
|
|
if (summaryStability && state.tempo_stability != null) {
|
|
summaryStability.textContent = `${state.tempo_stability}%`;
|
|
summaryStability.className = "meta-card-value" + (state.tempo_stability >= 80 ? " stability-high" : "");
|
|
}
|
|
if (summaryStabilityLabel && state.tempo_stability != null) {
|
|
const s = state.tempo_stability;
|
|
summaryStabilityLabel.textContent = s >= 90 ? "Very Stable" : s >= 70 ? "Stable" : s >= 50 ? "Moderate" : "Variable";
|
|
}
|
|
if (state.stem_presence != null) {
|
|
applyStemPresenceCards(state.stem_presence);
|
|
}
|
|
// Stage label is owned by the phrase-rotation timer below; we don't
|
|
// overwrite it from each SSE tick. The truthful backend stage goes
|
|
// to the small detail line instead.
|
|
jobDetailEl.textContent = state.stage || "";
|
|
progressEl.value = Math.round((state.progress || 0) * 100);
|
|
|
|
// Cancel button is visible exactly while the job is in a non-terminal state.
|
|
const terminal = TERMINAL_STATUSES.has(state.status);
|
|
jobCancelBtn.classList.toggle("hidden", terminal);
|
|
|
|
if (state.status !== lastStatus) {
|
|
if (terminal) stopPhraseRotation();
|
|
else startPhraseRotation(state.status);
|
|
lastStatus = state.status;
|
|
}
|
|
|
|
if (state.status === "error") {
|
|
stopJobPolling();
|
|
updateTrackStatus(state.job_id, "error");
|
|
setWaveformLoading(false);
|
|
showError(state.error || "Unknown error");
|
|
setSubmitProcessing(false);
|
|
} else if (state.status === "cancelled") {
|
|
stopJobPolling();
|
|
updateTrackStatus(state.job_id, "cancelled");
|
|
setWaveformLoading(false);
|
|
jobBox.classList.add("hidden");
|
|
setSubmitProcessing(false);
|
|
} else if (state.status === "done") {
|
|
stopJobPolling();
|
|
updateTrackStatus(state.job_id, "done");
|
|
jobBox.classList.add("hidden");
|
|
if (!renderedJobs.has(state.job_id)) {
|
|
renderedJobs.add(state.job_id);
|
|
wireUpAudio(
|
|
state.job_id,
|
|
state.stems || [],
|
|
state.duration || 0,
|
|
state.thumbnail,
|
|
state.mix_url ?? null,
|
|
state.title || "",
|
|
null,
|
|
state.has_video ?? false,
|
|
);
|
|
initSections(state.job_id, state.sections, state.duration || 0);
|
|
}
|
|
setSubmitProcessing(false);
|
|
}
|
|
}
|
|
|
|
async function probeJob(jobId) {
|
|
const r = await fetch(`/api/jobs/${jobId}`);
|
|
if (!r.ok) {
|
|
if (r.status === 404) throw new Error("Job no longer exists on the server");
|
|
throw new Error(`Job probe failed: ${r.status}`);
|
|
}
|
|
const s = await r.json();
|
|
applyState(s);
|
|
return s;
|
|
}
|
|
|
|
function startJobPolling(jobId) {
|
|
stopJobPolling();
|
|
const tick = async () => {
|
|
try {
|
|
const s = await probeJob(jobId);
|
|
if (TERMINAL_STATUSES.has(s.status)) stopJobPolling();
|
|
} catch (err) {
|
|
console.warn("[job] REST fallback failed:", err);
|
|
}
|
|
};
|
|
tick();
|
|
jobPollTimerId = setInterval(tick, 1000);
|
|
}
|
|
|
|
// Connect (or reconnect) to the SSE stream for a job. On unexpected
|
|
// disconnect we probe /api/jobs/{id} to decide: if the job is already
|
|
// terminal, accept its final state; otherwise reconnect with backoff.
|
|
// Falls back to REST polling only after SSE exhausts its retry budget.
|
|
function connectEvents(jobId) {
|
|
let attempt = 0;
|
|
let stopped = false;
|
|
|
|
const open = () => {
|
|
const es = new EventSource(`/api/jobs/${jobId}/events`);
|
|
setEventSource(es);
|
|
|
|
es.onmessage = (ev) => {
|
|
attempt = 0; // any successful frame resets backoff
|
|
let s;
|
|
try { s = JSON.parse(ev.data); } catch { return; }
|
|
// Defer by one tick so synchronous user event handlers (clicks,
|
|
// input events) always complete before SSE state is applied.
|
|
setTimeout(() => {
|
|
applyState(s);
|
|
if (TERMINAL_STATUSES.has(s.status)) {
|
|
stopped = true;
|
|
es.close();
|
|
setEventSource(null);
|
|
}
|
|
}, 0);
|
|
};
|
|
|
|
es.onerror = async () => {
|
|
if (stopped) return;
|
|
es.close();
|
|
setEventSource(null);
|
|
|
|
// Probe REST once before declaring failure -- handles dev-server
|
|
// reloads and brief network blips where the job is actually fine.
|
|
try {
|
|
const s = await probeJob(jobId);
|
|
if (TERMINAL_STATUSES.has(s.status)) {
|
|
stopped = true;
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
if (err.message === "Job no longer exists on the server") {
|
|
stopped = true;
|
|
showError(err.message);
|
|
setSubmitProcessing(false);
|
|
return;
|
|
}
|
|
// Network down -- fall through to backoff.
|
|
}
|
|
|
|
attempt += 1;
|
|
if (attempt > 6) {
|
|
// SSE gave up — activate REST polling as the fallback.
|
|
startJobPolling(jobId);
|
|
return;
|
|
}
|
|
// 0.5s, 1s, 2s, 4s, 8s, 16s
|
|
const delay = 500 * Math.pow(2, attempt - 1);
|
|
setTimeout(() => { if (!stopped) open(); }, delay);
|
|
};
|
|
};
|
|
|
|
open();
|
|
}
|
|
|
|
async function cancelCurrentJob() {
|
|
const id = currentJobId;
|
|
if (!id) return;
|
|
jobCancelBtn.disabled = true;
|
|
jobCancelBtn.textContent = "Cancelling…";
|
|
try {
|
|
await fetch(`/api/jobs/${id}/cancel`, { method: "POST" });
|
|
// The next SSE frame (or the REST probe in connectEvents) will
|
|
// surface the cancelled state and hide the button via applyState.
|
|
} catch {
|
|
/* SSE will reflect the result regardless */
|
|
} finally {
|
|
jobCancelBtn.disabled = false;
|
|
jobCancelBtn.textContent = "Cancel";
|
|
}
|
|
}
|
|
|
|
function sanitizeFilename(name) {
|
|
// Strip extension, collapse whitespace, cap at 120 chars — mirrors the
|
|
// backend _sanitize_title() so title and sourceUrl match on both sides.
|
|
return name
|
|
.replace(/\.[^.]+$/, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim()
|
|
.slice(0, 120);
|
|
}
|
|
|
|
// Programmatic URL import — re-uses the full studio/SSE pipeline (same as the
|
|
// import form's URL path). Used by the library "Sync again" auto-restore to
|
|
// re-download + re-separate a track whose backend audio was swept. Takes over
|
|
// the studio like a normal import. Returns the new job id, or null on failure.
|
|
export async function importFromUrl(url, { title, stems } = {}) {
|
|
if (!url || url.startsWith("local:")) return null; // local files can't auto-restore
|
|
reset();
|
|
setSubmitProcessing(true);
|
|
setWaveformLoading(true, "");
|
|
const stemSel = stems?.length ? stems : [...selectedStems];
|
|
|
|
let jobId;
|
|
try {
|
|
const res = await fetch("/api/jobs", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ url, stems: stemSel }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || res.statusText);
|
|
jobId = data.job_id;
|
|
} catch (err) {
|
|
showError(`Failed to restore track: ${err.message}`);
|
|
setSubmitProcessing(false);
|
|
return null;
|
|
}
|
|
|
|
setCurrentJobId(jobId);
|
|
jobSources.set(jobId, url);
|
|
// Merges into the existing library entry by sourceUrl (replaceTrackId),
|
|
// preserving its folder placement; status updates as SSE frames arrive.
|
|
addTrackToLibrary({
|
|
id: jobId,
|
|
title: title || url || "Processing track",
|
|
channel: "Processing",
|
|
thumb: "",
|
|
stems: stemSel,
|
|
selectedStems: stemSel,
|
|
audioStems: [],
|
|
status: "processing",
|
|
bpm: null,
|
|
key: null,
|
|
scale: null,
|
|
keyConfidence: null,
|
|
lufs: null,
|
|
peakDb: null,
|
|
sourceUrl: url,
|
|
});
|
|
setCurrentTrack(jobId);
|
|
|
|
jobBox.classList.add("hidden");
|
|
jobCancelBtn.classList.add("hidden");
|
|
startPhraseRotation("queued");
|
|
lastStatus = "queued";
|
|
connectEvents(jobId);
|
|
return jobId;
|
|
}
|
|
|
|
export function wireJobForm() {
|
|
jobCancelBtn.addEventListener("click", cancelCurrentJob);
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
reset();
|
|
setSubmitProcessing(true);
|
|
|
|
const fileInput = document.getElementById("fileInput");
|
|
// Prefer _file cache: browsers (WKWebView, Chromium) silently clear
|
|
// fileInput.files after a fetch() submission, breaking re-submits.
|
|
const file = fileInput?._file ?? fileInput?.files?.[0] ?? null;
|
|
const sanitized = file ? sanitizeFilename(file.name) : null;
|
|
const sourceUrl = file ? `local:${sanitized}` : urlInput.value;
|
|
const displayTitle = sanitized ?? (urlInput.value || "Processing track");
|
|
|
|
const postUrlText = document.getElementById("post-url-text");
|
|
if (postUrlText) postUrlText.textContent = displayTitle;
|
|
|
|
// Show overlay immediately for both paths. File uploads show "Uploading…"
|
|
// in the overlay phrase until the fetch completes and SSE takes over.
|
|
setWaveformLoading(true, file ? "Uploading…" : "");
|
|
if (file) {
|
|
lastStatus = "queued";
|
|
}
|
|
|
|
let fetchInit;
|
|
if (file) {
|
|
const fd = new FormData();
|
|
fd.append("file", file);
|
|
fd.append("stems", JSON.stringify([...selectedStems]));
|
|
fetchInit = { method: "POST", body: fd };
|
|
} else {
|
|
fetchInit = {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
url: urlInput.value,
|
|
// Backend uses this to decide whether to ffmpeg-amix a
|
|
// "selected stems" track (mix.wav) at the end of the pipeline.
|
|
stems: [...selectedStems],
|
|
}),
|
|
};
|
|
}
|
|
|
|
let jobId;
|
|
try {
|
|
const res = await fetch("/api/jobs", fetchInit);
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.detail || res.statusText);
|
|
jobId = data.job_id;
|
|
} catch (err) {
|
|
if (file) jobBox.classList.add("hidden");
|
|
showError(`Failed to start job: ${err.message}`);
|
|
setSubmitProcessing(false);
|
|
return;
|
|
}
|
|
|
|
setCurrentJobId(jobId);
|
|
jobSources.set(jobId, sourceUrl);
|
|
addTrackToLibrary({
|
|
id: jobId,
|
|
title: displayTitle,
|
|
channel: "Processing",
|
|
thumb: "",
|
|
stems: [...selectedStems],
|
|
selectedStems: [...selectedStems],
|
|
audioStems: [],
|
|
status: "processing",
|
|
bpm: null,
|
|
key: null,
|
|
scale: null,
|
|
keyConfidence: null,
|
|
lufs: null,
|
|
peakDb: null,
|
|
sourceUrl,
|
|
});
|
|
setCurrentTrack(jobId);
|
|
|
|
// Both paths: keep job box hidden, overlay drives the UI.
|
|
// Start phrase rotation now that the job exists on the server.
|
|
jobBox.classList.add("hidden");
|
|
jobCancelBtn.classList.add("hidden");
|
|
startPhraseRotation("queued");
|
|
lastStatus = "queued";
|
|
|
|
connectEvents(jobId);
|
|
});
|
|
}
|