初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
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,
|
||||
});
|
||||
Reference in New Issue
Block a user