初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -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,
});