import { announce, apiJson, registerWorkspace } from "../core.js"; import { clampCropCenter, cropCenterBounds, cropGeometry } from "../media-crop-geometry.js"; import { shouldReuseMediaJobCard } from "../media-import-ui-model.js"; import { renderUiCopy } from "../ui-copy-runtime.js"; const form = document.getElementById("media-upload-form"); const fileInput = document.getElementById("media-source-file"); const uploadButton = document.getElementById("media-upload-button"); const uploadProgress = document.getElementById("media-upload-progress"); const refreshButton = document.getElementById("refresh-media-imports"); const feedback = document.getElementById("media-import-feedback"); const jobsRoot = document.getElementById("media-import-jobs"); const stateNames = { uploading: ["copy.dynamic.media.state_uploading", "上传中"], analyzing: ["copy.dynamic.media.state_analyzing", "分析中"], awaiting_settings: ["copy.dynamic.media.state_awaiting", "等待设置"], queued: ["copy.dynamic.media.state_queued", "排队中"], converting: ["copy.dynamic.media.state_converting", "转换中"], failed: ["copy.dynamic.media.state_failed", "失败"], }; let initialized = false; let pollingTimer = 0; let priorJobs = new Map(); let emptyState = null; function setFeedback(copyId, defaultText, values = {}, error = false) { renderUiCopy(copyId, feedback, defaultText, values); feedback.dataset.state = error ? "error" : "success"; } function makeButton(copyId, text, className = "") { const element = document.createElement("button"); element.type = "button"; renderUiCopy(copyId, element, text); if (className) element.className = className; return element; } function labelControl(copyId, text, control) { const label = document.createElement("label"); const span = document.createElement("span"); renderUiCopy(copyId, span, text); label.append(span, control); return label; } async function cancelJob(id) { try { await apiJson("/api/media-imports/" + id, { method: "DELETE" }); setFeedback("copy.dynamic.media.deleted", "任务和源文件已删除。"); await refreshJobs(); } catch (error) { setFeedback("copy.dynamic.media.delete_failed", "删除任务失败:{error}", { error: error.message }, true); } } function settingsCard(job, card) { const markDirty = () => { card.dataset.localDraft = "true"; }; const stage = document.createElement("div"); stage.className = "media-crop-stage"; stage.dataset.fit = job.settings.fit_mode; const sourcePlane = document.createElement("div"); sourcePlane.className = "media-crop-source"; const image = document.createElement("img"); image.alt = job.filename + " 代表帧"; image.src = job.previews[0] || ""; sourcePlane.append(image); stage.append(sourcePlane); const tabs = document.createElement("div"); tabs.className = "media-preview-tabs"; job.previews.forEach((url, index) => { const tab = makeButton("copy.dynamic.media.preview", "代表帧 " + (index + 1)); renderUiCopy("copy.dynamic.media.preview", tab, "代表帧 {index}", { index: index + 1 }); tab.setAttribute("aria-pressed", index === 0 ? "true" : "false"); tab.addEventListener("click", () => { markDirty(); image.src = url; tabs.querySelectorAll("button").forEach((item) => { item.setAttribute("aria-pressed", item === tab ? "true" : "false"); }); }); tabs.append(tab); }); const grid = document.createElement("div"); grid.className = "media-settings-grid"; const name = document.createElement("input"); name.type = "text"; name.maxLength = 80; name.value = job.name; const fit = document.createElement("select"); [ ["crop", "copy.dynamic.media.mode_crop", "自由取景"], ["contain", "copy.dynamic.media.mode_contain", "完整包含"], ["stretch", "copy.dynamic.media.mode_stretch", "拉伸"], ].forEach((pair) => { const option = document.createElement("option"); option.value = pair[0]; renderUiCopy(pair[1], option, pair[2]); option.selected = pair[0] === job.settings.fit_mode; fit.append(option); }); const zoom = document.createElement("input"); zoom.type = "range"; zoom.min = "1"; zoom.max = "16"; zoom.step = "0.05"; zoom.value = job.settings.zoom; const transparency = document.createElement("input"); transparency.type = "color"; transparency.value = job.settings.transparency_color; const padding = document.createElement("input"); padding.type = "color"; padding.value = job.settings.padding_color; grid.append( labelControl("copy.dynamic.media.output_name", "保存名称", name), labelControl("copy.dynamic.media.fit_mode", "1:1 适配", fit), labelControl("copy.dynamic.media.crop_zoom", "取景缩放", zoom), labelControl("copy.dynamic.media.transparency", "透明像素填充色", transparency), labelControl("copy.dynamic.media.padding", "留白颜色", padding), ); let centerX = Number(job.settings.center_x); let centerY = Number(job.settings.center_y); function sourceSize() { return { width: image.naturalWidth || 1, height: image.naturalHeight || 1, }; } function cropBounds() { const source = sourceSize(); return cropCenterBounds({ viewportWidth: stage.clientWidth || 1, viewportHeight: stage.clientHeight || 1, sourceWidth: source.width, sourceHeight: source.height, zoom: Number(zoom.value), }); } function clampCenter() { const clamped = clampCropCenter({ centerX, centerY }, cropBounds()); centerX = clamped.centerX; centerY = clamped.centerY; } function updateCropVisual() { stage.dataset.fit = fit.value; stage.style.backgroundColor = padding.value; sourcePlane.style.backgroundColor = transparency.value; const source = sourceSize(); if (fit.value === "stretch") { sourcePlane.style.width = "100%"; sourcePlane.style.height = "100%"; sourcePlane.style.left = "0"; sourcePlane.style.top = "0"; return; } if (fit.value === "crop") clampCenter(); const geometry = cropGeometry({ viewportWidth: stage.clientWidth || 1, viewportHeight: stage.clientHeight || 1, sourceWidth: source.width, sourceHeight: source.height, zoom: fit.value === "crop" ? Number(zoom.value) : 1, centerX: fit.value === "crop" ? centerX : 0.5, centerY: fit.value === "crop" ? centerY : 0.5, }); sourcePlane.style.width = geometry.width + "px"; sourcePlane.style.height = geometry.height + "px"; sourcePlane.style.left = geometry.left + "px"; sourcePlane.style.top = geometry.top + "px"; } name.addEventListener("input", markDirty); fit.addEventListener("change", () => { markDirty(); updateCropVisual(); }); zoom.addEventListener("input", () => { markDirty(); updateCropVisual(); }); transparency.addEventListener("input", () => { markDirty(); updateCropVisual(); }); padding.addEventListener("input", () => { markDirty(); updateCropVisual(); }); let drag = null; stage.addEventListener("pointerdown", (event) => { if (fit.value !== "crop") return; drag = { x: event.clientX, y: event.clientY, centerX, centerY }; stage.setPointerCapture(event.pointerId); }); stage.addEventListener("pointermove", (event) => { if (!drag) return; markDirty(); const source = sourceSize(); const geometry = cropGeometry({ viewportWidth: stage.clientWidth || 1, viewportHeight: stage.clientHeight || 1, sourceWidth: source.width, sourceHeight: source.height, zoom: Number(zoom.value), centerX: drag.centerX, centerY: drag.centerY, }); centerX = drag.centerX - (event.clientX - drag.x) / geometry.width; centerY = drag.centerY - (event.clientY - drag.y) / geometry.height; clampCenter(); updateCropVisual(); }); stage.addEventListener("pointerup", () => { drag = null; }); stage.addEventListener("pointercancel", () => { drag = null; }); image.addEventListener("load", updateCropVisual); const cropResizeObserver = new ResizeObserver(updateCropVisual); cropResizeObserver.observe(stage); card.mediaCropDispose = () => cropResizeObserver.disconnect(); updateCropVisual(); const actions = document.createElement("div"); actions.className = "media-job-actions"; const convert = makeButton("copy.dynamic.media.convert", "保存设置并开始转换", "primary-button"); const cancel = makeButton("copy.dynamic.media.cancel_source", "取消并删除源文件", "danger-button"); actions.append(convert, cancel); convert.addEventListener("click", async () => { convert.disabled = true; try { await apiJson("/api/media-imports/" + job.id + "/settings", { method: "PUT", body: JSON.stringify({ name: name.value, fit_mode: fit.value, center_x: centerX, center_y: centerY, zoom: Number(zoom.value), transparency_color: transparency.value, padding_color: padding.value, }), }); await apiJson("/api/media-imports/" + job.id + "/convert", { method: "POST", body: "{}" }); setFeedback("copy.dynamic.media.queued", "任务已加入转换队列。"); await refreshJobs(); } catch (error) { if (error.message === "output name already exists") { setFeedback("copy.dynamic.media.name_conflict", "保存名称已存在,请更换名称。", {}, true); } else { setFeedback("copy.dynamic.media.convert_failed", "无法开始转换:{error}", { error: error.message }, true); } convert.disabled = false; } }); cancel.addEventListener("click", () => cancelJob(job.id)); card.append(tabs, stage, grid, actions); } function renderJob(job) { const card = document.createElement("article"); card.className = "media-job-card"; card.dataset.jobId = job.id; card.dataset.jobState = job.state; card.dataset.serverUpdatedAt = job.updated_at; card.dataset.localDraft = "false"; const heading = document.createElement("div"); heading.className = "media-job-heading"; const title = document.createElement("h3"); title.textContent = job.name; const state = document.createElement("p"); const stateCopy = stateNames[job.state]; if (job.queue_position) { renderUiCopy("copy.dynamic.media.queue_position", state, "{state} · 队列第 {position} 位", { state: stateCopy ? stateCopy[1] : job.state, position: job.queue_position, }); } else if (stateCopy) { renderUiCopy(stateCopy[0], state, stateCopy[1]); } else { state.textContent = job.state; } heading.append(title, state); const source = document.createElement("p"); source.textContent = job.filename + " · " + (job.source_size / 1024 / 1024).toFixed(1) + " MiB"; card.append(heading, source); if (job.state === "awaiting_settings") { settingsCard(job, card); return card; } const progress = document.createElement("progress"); progress.max = 100; progress.value = job.progress || 0; card.append(progress); if (job.error) { const error = document.createElement("p"); error.textContent = job.error; error.className = "settings-feedback"; error.dataset.state = "error"; card.append(error); } const actions = document.createElement("div"); actions.className = "media-job-actions"; if (job.state === "failed") { const retry = makeButton("copy.dynamic.media.retry", "重试", "primary-button"); retry.addEventListener("click", async () => { try { await apiJson("/api/media-imports/" + job.id + "/retry", { method: "POST", body: "{}" }); await refreshJobs(); } catch (error) { setFeedback("copy.dynamic.media.retry_failed", "重试失败:{error}", { error: error.message }, true); } }); actions.append(retry); } const cancel = makeButton("copy.dynamic.media.cancel", "取消并删除", "danger-button"); cancel.addEventListener("click", () => cancelJob(job.id)); actions.append(cancel); card.append(actions); return card; } function renderedCardState(card) { if (!card) return null; return { id: card.dataset.jobId, state: card.dataset.jobState, updatedAt: card.dataset.serverUpdatedAt, dirty: card.dataset.localDraft === "true", }; } function reconcileJobCards(jobs) { const oldCards = new Map( Array.from(jobsRoot.querySelectorAll(":scope > .media-job-card")) .map((card) => [card.dataset.jobId, card]), ); if (jobs.length) { emptyState?.remove(); jobs.forEach((job, index) => { const oldCard = oldCards.get(job.id) || null; const card = shouldReuseMediaJobCard(renderedCardState(oldCard), job) ? oldCard : renderJob(job); if (oldCard && card !== oldCard) { oldCard.mediaCropDispose?.(); oldCard.replaceWith(card); } const position = jobsRoot.children[index] || null; if (position !== card) jobsRoot.insertBefore(card, position); oldCards.delete(job.id); }); oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); }); return; } oldCards.forEach((card) => { card.mediaCropDispose?.(); card.remove(); }); if (!emptyState) { emptyState = document.createElement("p"); emptyState.className = "media-job-empty"; renderUiCopy("copy.dynamic.media.empty", emptyState, "当前没有等待或转换中的任务。"); } if (!emptyState.isConnected) jobsRoot.append(emptyState); } async function refreshJobs() { if (document.hidden) return; try { const result = await apiJson("/api/media-imports"); const next = new Map(result.jobs.map((job) => [job.id, job])); for (const pair of priorJobs) { const id = pair[0], job = pair[1]; if (!next.has(id) && ["queued", "converting"].includes(job.state)) { const message = "“" + job.name + "”转换完成,已加入内容库。"; setFeedback("copy.dynamic.media.completed", "“{name}”转换完成,已加入内容库。", { name: job.name }); announce(message); document.getElementById("refresh-templates")?.click(); document.getElementById("refresh-animations")?.click(); } } priorJobs = next; reconcileJobCards(result.jobs); } catch (error) { setFeedback("copy.dynamic.media.list_failed", "任务列表读取失败:{error}", { error: error.message }, true); } } function upload(file) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/media-imports?filename=" + encodeURIComponent(file.name)); xhr.setRequestHeader("Content-Type", "application/octet-stream"); xhr.responseType = "json"; xhr.upload.addEventListener("progress", (event) => { if (event.lengthComputable) uploadProgress.value = event.loaded * 100 / event.total; }); xhr.addEventListener("load", () => { if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response); else reject(new Error(xhr.response?.detail || xhr.statusText || "上传失败")); }); xhr.addEventListener("error", () => reject(new Error("上传连接中断"))); xhr.send(file); }); } form.addEventListener("submit", async (event) => { event.preventDefault(); const file = fileInput.files[0]; if (!file) return; uploadButton.disabled = true; uploadProgress.hidden = false; uploadProgress.value = 0; try { await upload(file); form.reset(); setFeedback("copy.dynamic.media.uploaded", "上传完成,设备正在分析媒体。"); await refreshJobs(); } catch (error) { setFeedback("copy.dynamic.media.upload_failed", "上传失败:{error}", { error: error.message }, true); } finally { uploadButton.disabled = false; uploadProgress.hidden = true; } }); refreshButton.addEventListener("click", refreshJobs); document.addEventListener("visibilitychange", () => { window.clearInterval(pollingTimer); if (!document.hidden && initialized) { refreshJobs(); pollingTimer = window.setInterval(refreshJobs, 2000); } }); async function initialize() { if (!initialized) { initialized = true; pollingTimer = window.setInterval(refreshJobs, 2000); } await refreshJobs(); } registerWorkspace({ id: "media-import", title: "媒体内容转换", elementId: "workspace-media-import", order: 45, persistence: "server", capabilities: ["media-upload", "background-conversion"], onEnter: initialize, });