class MetaParser { static parse() { let file; const metas = document.querySelectorAll("meta[name]"); metas.forEach(meta => { const name = meta.getAttribute("name"); const data = this.metaToObject(meta); if (name === "file") { file = data; } }); return file; } static metaToObject(meta) { const obj = {}; for (const attr of meta.attributes) { let value = attr.value; if (value === "true") { value = true; } else if (value === "false") { value = false; } else if (Number.isSafeInteger(value) && !isNaN(value) && value.trim() !== "") { value = Number(value); } obj[attr.name] = value; } return obj; } } class Viewer { constructor(wrapper, content, header, image) { this.wrapper = wrapper; this.content = content; this.header = header; this.rawImage = image; this.wrapper.addEventListener('wheel', this.wheel, { passive: false }) this.wrapper.addEventListener('pointerdown', this.down) this.wrapper.addEventListener('pointermove', this.move) this.wrapper.addEventListener('pointerup', this.up) this.wrapper.addEventListener('pointercancel', this.up) this.state = { x: 0, y: 0, scale: 1 } this.tap = { startX: 0, startY: 0, moved: false, isMultiTouch: false } this.input = { pointers: new Map(), wheel: null, dirty: false } this.scale = { min: 0.5, max: 30 } this.prevPan = null this.prevPinch = null document.body.style.touchAction = "none"; this.loop(); } down = (e) => { this.wrapper.style.cursor = "grabbing" this.wrapper.setPointerCapture(e.pointerId) this.input.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, }) if (this.input.pointers.size === 1) { this.tap.startX = e.clientX this.tap.startY = e.clientY this.tap.moved = false this.tap.isMultiTouch = false } else { this.tap.isMultiTouch = true } this.input.dirty = true } move = (e) => { const p = this.input.pointers.get(e.pointerId) if (!p) return p.x = e.clientX p.y = e.clientY this.tap.moved = Math.hypot( e.clientX - this.tap.startX, e.clientY - this.tap.startY ) > 5 this.input.dirty = true } up = (e) => { this.wrapper.style.cursor = "" this.input.pointers.delete(e.pointerId) if (this.input.pointers.size === 0 && !this.tap.moved && !this.tap.isMultiTouch) { this.rawImage.style.opacity = this.rawImage.style.opacity === "0" ? "1" : "0"; this.header.style.display = this.rawImage.style.opacity === "0" ? "none" : "flex"; } this.input.dirty = true } wheel = (e) => { e.preventDefault() this.input.wheel = { x: e.clientX, y: e.clientY, delta: e.deltaY, } this.input.dirty = true } loop = () => { this.update() requestAnimationFrame(this.loop); } update() { if (!this.input.dirty) return this.input.dirty = false const pts = [...this.input.pointers.values()] if (this.input.wheel) { const { x, y, delta } = this.input.wheel const scaleFactor = Math.exp(-delta * 0.001) this.zoomAt(x, y, scaleFactor) this.input.wheel = null } if (pts.length === 1) { this.pan(pts[0]) } else if (pts.length >= 2) { this.pinch(pts[0], pts[1]) } if (pts.length < 2) this.prevPinch = null if (pts.length !== 1) this.prevPan = null this.render() } pan(p) { if (!this.prevPan) { this.prevPan = { ...p } return } this.state.x += p.x - this.prevPan.x this.state.y += p.y - this.prevPan.y this.prevPan = { ...p } } pinch(p1, p2) { const center = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2, } const dist = Math.hypot(p1.x - p2.x, p1.y - p2.y) if (!this.prevPinch) { this.prevPinch = { center, dist } return } const scaleFactor = dist / this.prevPinch.dist this.zoomAt(center.x, center.y, scaleFactor) this.state.x += center.x - this.prevPinch.center.x this.state.y += center.y - this.prevPinch.center.y this.prevPinch = { center, dist } } zoomAt(cx, cy, scaleFactor) { const rect = this.content.getBoundingClientRect() const dx = cx - (rect.left + rect.width / 2) const dy = cy - (rect.top + rect.height / 2) const prevScale = this.state.scale const newScale = Math.max(this.scale.min, Math.min(this.scale.max, prevScale * scaleFactor)) const ratio = newScale / prevScale this.state.x -= dx * (ratio - 1) this.state.y -= dy * (ratio - 1) this.state.scale = newScale } render() { const { x, y, scale } = this.state this.content.style.transform = `translate(${x}px, ${y}px) scale(${scale})` } reset() { this.state.x = 0 this.state.y = 0 this.state.scale = 1 this.render(); } } document.addEventListener('DOMContentLoaded', () => { let file = MetaParser.parse(), model = "", remote = true, upscaylURL = localStorage.getItem("upscayl_url"), upscaylToken = localStorage.getItem("upscayl_token"), requesting = false; let modelSel = document.getElementById("model-selector"); modelSel.addEventListener("change", switchModel.bind(this)); let upscaylBtn = document.getElementById("upscayl-button"); upscaylBtn.addEventListener("click", useUpscayl.bind(this)); let uploadBtn = document.getElementById("upload-button"); uploadBtn.addEventListener("click", uploadImage.bind(this)); let goConvertBtn = document.getElementById("go-convert-button"); goConvertBtn.addEventListener("click", () => { window.location.href = `/convert/${file.id}`; }); let pixelatedBtn = document.getElementById("pixelated-button"); pixelatedBtn.addEventListener("click", switchPixelated.bind(this)); let backgroundBtn = document.getElementById("background-button"); backgroundBtn.addEventListener("click", switchBackground.bind(this)); let resetBtn = document.getElementById("reset-button"); resetBtn.addEventListener("click", resetImage.bind(this)); let removeBtn = document.getElementById("remove-button"); removeBtn.addEventListener("click", () => { if (confirm("Remove upscayl-server API URL and Token?")) { removeTokens(); window.location.reload(); } }); if (modelSel.children.length == 1) { remote = false; uploadBtn.style.display = ""; for (; !upscaylURL;) upscaylURL = prompt("Enter upscayl-server API URL") for (; !upscaylToken;) upscaylToken = prompt("Enter upscayl-server API Token") if (upscaylURL || upscaylToken) removeBtn.style.display = ""; fetch(`${upscaylURL}/models`, { headers: { 'Authorization': upscaylToken } }) .then(res => { res.json().then((resjson) => { if (!res.ok) { alert(`Error: ${resjson.error}`) removeTokens(); } else { localStorage.setItem("upscayl_url", upscaylURL) localStorage.setItem("upscayl_token", upscaylToken) resjson.models.forEach((m) => { const opt = document.createElement("option"); opt.id = opt.value = opt.textContent = m; modelSel.appendChild(opt); }) } }); }) .catch((err) => { console.error(err); alert(`Error while fetching models: ${err}`); }); } document.getElementById("preview").style.display = "block"; let header = document.getElementById("preview-header"); let previewView = document.getElementById("preview-view"); let imageWrapper = document.getElementById("image-wrapper"); let previewImg = document.getElementById("preview-img"); let originalImg = document.getElementById("original-img"); new Viewer(previewView, imageWrapper, header, previewImg); document.addEventListener('keydown', (e) => { if (e.ctrlKey) { previewImg.style.opacity = 0 header.style.display = "none" } if (e.shiftKey) { previewView.style.imageRendering = "pixelated"; pixelatedBtn.style.backgroundColor = "#4d7543"; } let currentIndex = modelSel.selectedIndex; const maxIndex = modelSel.options.length - 1; switch (e.key) { case 'ArrowDown': e.preventDefault(); modelSel.selectedIndex = currentIndex < maxIndex ? currentIndex + 1 : maxIndex; switchModel(); break; case 'ArrowUp': e.preventDefault(); modelSel.selectedIndex = currentIndex > 0 ? currentIndex - 1 : 0; switchModel(); break; case 'Enter': e.preventDefault(); if (!remote && model && document.getElementById(model).dataset.converted) { if (confirm(`upload ${model} image?`)) uploadImage(); } else { useUpscayl(); } } }); document.addEventListener('keyup', (e) => { switch (e.key) { case 'Control': previewImg.style.opacity = 1 header.style.display = "flex" break case 'Shift': previewView.style.imageRendering = "auto"; pixelatedBtn.style.backgroundColor = ""; } }); let originalSize = document.getElementById("original-size"); if (originalSize.textContent == "") { collectRaw(); } else { originalImg.src = `/files/${file.id}.${file.raw_ext}`; } function removeTokens() { localStorage.removeItem("upscayl_url") localStorage.removeItem("upscayl_token") } function uploadImage() { if (remote) { // remote 模式不需要上传 alert("image already in the server"); return; } if (requesting) { alert("need waiting for previous request to finish"); return; } if (!model) { alert(`select a model and convert first`); return } let e = document.getElementById(model); if (e.dataset.uploaded) { alert("already saved"); return } if (!e.dataset.converted) { alert(`need to convert first`); return } requesting = true; uploadBtn.textContent = "uploading" document.title = `${model} uploading ⬆` uploadBtn.style.backgroundColor = "rgb(50, 80, 80)"; fetch(previewImg.src) .then(f => { return new Promise(async (resolve, reject) => { const blob = await f.blob(); const formData = new FormData(); formData.append('file', blob, 'tmp.png'); let res = await fetch(`/api/task/files/upscayl/${file.id}/${model}`, { method: 'PUT', body: formData }) let resjson = await res.json() switch (res.status) { case 200: e.dataset.uploaded = true; uploadBtn.textContent = "uploaded" document.title = `${model} uploaded ☑` goConvertBtn.style.display = ""; break; default: alert(`保存 upscayl 图片时发生错误:${resjson.msg}: ${resjson.error}`); document.title = "upload failed ❌" reject(new Error(resjson.error)); } resolve(); }) }) .finally(() => { uploadBtn.style.backgroundColor = "#444"; requesting = false; }) .catch(error => { uploadBtn.textContent = "upload" alert(`请求失败: ${error}`); }); } function useUpscayl() { if (requesting) { alert("need waiting for previous request to finish"); return; } if (!model) { alert(`select a model to convert`); return } let e = document.getElementById(model); if (e.dataset.converted) { alert(`already converted with ${model}`) return; } requesting = true; upscaylBtn.textContent = "waiting" document.title = `${model} processing 🔄` upscaylBtn.style.backgroundColor = "rgb(50, 80, 80)"; if (remote) { fetch(`/api/task/files/upscayl/${file.id}/${model}`, { method: "POST" }) .then(response => { response.json().then((resjson) => { switch (response.status) { case 200: e.dataset.converted = true; document.title = `${model} ☑` e.textContent += ` - ${resjson.size_str}` previewImg.src = ``; previewImg.src = e.dataset.src = resjson.url; goConvertBtn.style.display = ""; break; default: alert(`upscayl 发生错误:${resjson.msg}: ${resjson.error}`); document.title = "upscayl failed ❌" } }); }) .finally(() => { upscaylBtn.textContent = "upscayl" upscaylBtn.style.backgroundColor = "#444"; requesting = false; }) .catch(error => { alert(`请求失败: ${error}`); }); } else { fetch(`/files/${file.id}.${file.raw_ext}`) .then(res => { return new Promise(async (resolve, reject) => { const blob = await res.blob(); const formData = new FormData(); formData.append("model", model); formData.append('file', blob, 'tmp.png'); let taskres = await fetch(`${upscaylURL}/tasks`, { method: "POST", headers: { 'Authorization': upscaylToken }, body: formData }); function poll(t) { if (t.status === "done") { e.dataset.converted = true; document.title = `${model} ☑` e.textContent += ` - ${t.size_str}`; previewImg.src = ``; previewImg.src = e.dataset.src = `${upscaylURL}${t.url}`; uploadBtn.style.backgroundColor = "rgb(70, 120, 95)"; resolve(); } else if (t.status === "error") { reject(new Error(t.error)) } else { setTimeout(async () => { poll(await (await fetch(`${upscaylURL}/tasks/${t.id}`)).json()); }, 2000); } } poll(await taskres.json()); }); }) .finally(() => { upscaylBtn.textContent = "upscayl" upscaylBtn.style.backgroundColor = "#444"; requesting = false; }) .catch(error => { alert(`请求失败: ${error}`); }); } } function switchModel() { uploadBtn.textContent = "upload" uploadBtn.style.backgroundColor = ""; previewImg.src = "" previewImg.style.opacity = 0 model = modelSel.value; if (model != "select model") { let e = document.getElementById(model); if (e.dataset.uploaded) { uploadBtn.textContent = "uploaded" } else if (e.dataset.converted) { uploadBtn.style.backgroundColor = "rgb(70, 120, 95)"; } previewImg.style.opacity = 1 previewImg.src = e.dataset.src ? e.dataset.src : ""; } else { model = "" } } function switchPixelated() { if (previewView.style.imageRendering === "pixelated") { previewView.style.imageRendering = "auto"; pixelatedBtn.style.backgroundColor = ""; } else { previewView.style.imageRendering = "pixelated"; pixelatedBtn.style.backgroundColor = "#4d7543"; } } function switchBackground() { if (backgroundBtn.textContent == "black") { backgroundBtn.textContent = "white" backgroundBtn.style.color = "#000" backgroundBtn.style.background = "#ddd" document.body.style.background = "#fff" previewImg.style.background = "#fff" originalImg.style.background = "#fff" } else { backgroundBtn.textContent = "black" backgroundBtn.style.color = "" backgroundBtn.style.background = "" document.body.style.background = "#000" previewImg.style.background = "#000" originalImg.style.background = "#000" } } function resetImage() { if (requesting) { alert("another request is already in progress"); return; } if (!confirm(`Reset to original image?`)) return; requesting = true; resetBtn.textContent = "resetting" resetBtn.style.backgroundColor = "rgb(50, 80, 80)"; fetch(`/api/task/files/convert-reset/${file.id}`, { method: "POST" }) .then(response => { response.json().then((resjson) => { switch (response.status) { case 200: break; default: alert(`发生错误:${resjson.msg}: ${resjson.error}`); } }); }) .finally(() => { resetBtn.textContent = "reset" resetBtn.style.backgroundColor = "#444"; requesting = false; }) .catch(error => { alert(`请求失败: ${error}`); }); } function collectRaw() { if (confirm(`The original image could not be loaded. Need try download it to the server?`)) { fetch(`/api/task/files/convert-raw/${file.id}`, { method: "POST" }) .then(response => { response.json().then((resjson) => { switch (response.status) { case 200: originalImg.src = resjson.url; originalSize.textContent = resjson.size_str; break; default: alert(`发生错误:${resjson.msg}: ${resjson.error}`); return } }); }) .catch(error => { alert(`请求失败: ${error}`); }); } } });