Files
trpic/web/scripts/convert/convert.js
Hubert Chen b0e5fa1f5c fix upload link and file style
fix preview image does not show when convert done
2026-07-04 00:44:47 +08:00

490 lines
13 KiB
JavaScript

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(),
requesting = false;
document.getElementById("preview").style.display = "block";
let header = document.getElementById("preview-header");
let qualitySel = document.getElementById("quality-selector");
qualitySel.addEventListener("change", switchQuality.bind(this));
let convertBtn = document.getElementById("convert-button");
convertBtn.addEventListener("click", useConvert.bind(this));
let saveBtn = document.getElementById("save-button");
saveBtn.addEventListener("click", saveConvert.bind(this));
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 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);
let imageSel = document.getElementById("image-selector");
imageSel.addEventListener("change", switchImage.bind(this));
if (imageSel.options.length == 0) {
collectRaw();
} else {
switchImage();
}
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 = qualitySel.selectedIndex;
const maxIndex = qualitySel.options.length - 1;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
qualitySel.selectedIndex = currentIndex < maxIndex ? currentIndex + 1 : maxIndex;
switchQuality();
break;
case 'ArrowUp':
e.preventDefault();
qualitySel.selectedIndex = currentIndex > 0 ? currentIndex - 1 : 0;
switchQuality();
break;
case 'Enter':
e.preventDefault();
let q = qualitySel.value;
if (document.getElementById("q" + q).dataset.converted) {
if (confirm(`save image with ${q}% quality?`)) saveConvert();
} else {
useConvert();
}
}
});
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 = "";
}
});
function useConvert() {
if (requesting) {
alert("Image is converting");
return;
}
// 开始转换后禁用原图选择器
imageSel.disabled = true;
let q = qualitySel.value;
let e = document.getElementById("q" + q);
if (e.dataset.converted) {
alert(`Image already converted with ${q}% quality`);
return;
}
requesting = true;
convertBtn.textContent = "converting"
document.title = `${q}% converting 🔄`
convertBtn.style.backgroundColor = "rgb(50, 80, 80)";
fetch(`/api/task/files/convert/${file.id}/webp/${q}?model=${imageSel.value}`, { method: "POST" })
.then(response => {
response.json().then((resjson) => {
switch (response.status) {
case 200:
e.dataset.converted = true;
document.title = `${q}% - ${resjson.size_str_converted}`
e.textContent += ` - ${resjson.size_str_converted}`;
previewImg.src = ``;
previewImg.src = e.dataset.src = resjson.url;
previewImg.style.opacity = 1
break;
default:
alert(`转换发生错误:${resjson.msg}: ${resjson.error}`);
document.title = "failed ❌"
}
});
})
.finally(() => {
convertBtn.textContent = "convert"
convertBtn.style.backgroundColor = "#444";
requesting = false;
})
.catch(error => {
alert(`请求失败: ${error}`);
});
}
function saveConvert() {
if (requesting) {
alert("Request is already in progress");
return;
}
let q = qualitySel.value;
let e = document.getElementById("q" + q);
if (e.dataset.saved) {
alert("already saved");
return
}
if (!e.dataset.converted) {
alert(`need to convert first`);
return
}
requesting = true;
saveBtn.textContent = "saving"
saveBtn.style.backgroundColor = "rgb(50, 80, 80)";
fetch(`/api/task/files/convert-done/${file.id}/webp/${q}?model=${imageSel.value}`, { method: "POST" })
.then(response => {
response.json().then((resjson) => {
switch (response.status) {
case 200:
e.dataset.saved = true;
saveBtn.textContent = "saved"
break;
default:
alert(`保存图片时发生错误:${resjson.msg}: ${resjson.error}`);
}
});
})
.finally(() => {
saveBtn.style.backgroundColor = "#444";
requesting = false;
})
.catch(error => {
saveBtn.textContent = "save"
alert(`请求失败: ${error}`);
});
}
function switchQuality() {
previewImg.src = ""
previewImg.style.opacity = 0
let e = document.getElementById("q" + qualitySel.value);
if (e.dataset.converted) {
previewImg.src = e.dataset.src;
previewImg.style.opacity = 1
}
}
function switchImage() {
originalImg.src = ""
let img = imageSel.value;
if (img == "orignal") {
originalImg.src = `/files/${file.id}.${file.raw_ext}`;
} else {
originalImg.src = `/files/${file.id}_upscayl_${img}.png`;
}
}
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;
}
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;
const opt = document.createElement("option");
opt.value = "orignal";
opt.textContent = `orignal - ${resjson.size_str}`
imageSel.appendChild(opt);
break;
default:
alert(`发生错误:${resjson.msg}: ${resjson.error}`);
return
}
});
})
.catch(error => {
alert(`请求失败: ${error}`);
});
}
}
});