js/app.js
GitHub
/* 主线程应用:上传 → 提炼(Worker 里跑管线)→ 逐笔回放 */'use strict';const MAXSIDE = 1024;const $ = (id) => document.getElementById(id);let W = 0, H = 0; // 画布尺寸let STROKES = [], CLIPS = [], STATS = null;let target = null; // 目标图 ImageData(用于对比)let painted = null, mt = null; // 已完成的画(离屏)、主画布上下文let tmpC = null, tmp = null; // 单笔合成用的临时画布let idx = 0, acc = 0, playing = false, done = false, lastT = performance.now();let speed = 2, cursor = null, guide = null, showTip = true, showGuide = true;let clipCache = new Map(), lastClip = -1;let worker = null;let cumMs = null; // 每笔结束时的累计时间(含抬笔移动与停顿)let keys = []; // 关键帧快照 {i, data} 或 {i, rgb}(管线侧光栅)let keyEvery = 0;let KEYFRAMES = [];let selfChecking = false; // 成品自检(找白点)进行中:期间 hud 不覆盖状态行let rushing = false; // "看成品":快进到画完(每帧画一大批,画面看得见在飞速推进)let draggingSeek = false; // 正在拖进度条:这期间 hud 不要回写滑块位置let checkMsg = ''; // 自检结论(挂在状态行末尾,一直都在)let extracting = false; // 正在提炼:期间不接受第二次选图,避免两个任务在 worker 里串味// "像人在画"需要的三段时间轴:抬笔移动 travel + 换阶段停顿 beat + 落笔绘制 paintlet travels = null, beats = null, msPaint = null;let targetMs = 240000;let airPath = null; // 抬笔移动的弧线(画在纸面上的淡虚线)const lastEnd = [0, 0]; // 上一笔收笔的位置(抬笔弧线的起点)const PHASES = ['铺底色', '铺色', '题字', '点睛'];// ---------------- 工具 ----------------const clamp = (v, a, b) => (v < a ? a : (v > b ? b : v));function hash01(a, b) { const x = Math.sin(a * 127.1 + b * 311.7) * 43758.5453; return x - Math.floor(x);}function ribbonPath(st) { const p = new Path2D(); const r = st.rib; p.moveTo(r[0], r[1]); for (let i = 2; i < r.length; i += 2) p.lineTo(r[i], r[i + 1]); p.closePath(); return p;}function tipAt(st, u) { const tp = st.tp, n = tp.length / 2; if (n < 2) return { x: tp[0], y: tp[1], dx: 1, dy: 0, single: true }; let total = 0; for (let i = 1; i < n; i++) total += Math.hypot(tp[i * 2] - tp[(i - 1) * 2], tp[i * 2 + 1] - tp[(i - 1) * 2 + 1]); const want = clamp(u, 0, 1) * total; let run = 0; for (let i = 1; i < n; i++) { const ax = tp[(i - 1) * 2], ay = tp[(i - 1) * 2 + 1]; const bx = tp[i * 2], by = tp[i * 2 + 1]; const seg = Math.hypot(bx - ax, by - ay); if (run + seg >= want || i === n - 1) { const f = seg > 1e-6 ? (want - run) / seg : 1; const x = ax + (bx - ax) * f, y = ay + (by - ay) * f; const dx = bx - ax, dy = by - ay, ln = Math.hypot(dx, dy) || 1; return { x, y, dx: dx / ln, dy: dy / ln, single: false }; } run += seg; } return { x: tp[0], y: tp[1], dx: 1, dy: 0, single: false };}// ---------------- 上传与提炼 ----------------function fitImage(img) { let w = img.naturalWidth || img.width, h = img.naturalHeight || img.height; const s = Math.min(1, MAXSIDE / Math.max(w, h)); w = Math.max(8, Math.round(w * s)); h = Math.max(8, Math.round(h * s)); const c = document.createElement('canvas'); c.width = w; c.height = h; const x = c.getContext('2d'); x.imageSmoothingEnabled = true; x.imageSmoothingQuality = 'high'; x.fillStyle = '#ffffff'; x.fillRect(0, 0, w, h); x.drawImage(img, 0, 0, w, h); return c;}/* 提示同时写两处:#note 常驻在选图框下面(提炼开始前也看得见), #status 只在进度条那块显示时才可见——原来错误只写 #status,等于永远看不到 */function note(msg, kind) { const n = $('note'); if (n) { n.textContent = msg; n.className = 'note' + (msg ? ' show ' + (kind || 'info') : ''); } const s = $('status'); if (s && $('stage').style.display !== 'none') s.textContent = msg;}function setExtracting(on) { extracting = on; const dz = $('drop'); if (dz) { dz.classList.toggle('busy', on); dz.setAttribute('aria-busy', on ? 'true' : 'false'); } document.querySelectorAll('[data-sample]').forEach((b) => { b.disabled = on; });}function startExtract(imgCanvas) { if (extracting) { note('上一张还在提炼,等它跑完再换。', 'warn'); return; } W = imgCanvas.width; H = imgCanvas.height; const ctx2 = imgCanvas.getContext('2d'); target = ctx2.getImageData(0, 0, W, H); $('stage').style.display = 'block'; $('canvasWrap').style.display = 'none'; $('welcome').style.display = 'none'; note('正在提炼…', 'info'); setExtracting(true); $('bar').style.width = '0%'; const detail = parseInt($('detail').value, 10); const opts = { colors: parseInt($('colors').value, 10) || 0, // 0 = 自动(按纹理强度定) style: $('style').value, detail, targetSec: detail === 0 ? 120 : (detail === 2 ? 480 : 240), }; targetMs = opts.targetSec * 1000; if (!worker) { worker = new Worker('js/worker.js?v=21'); worker.onmessage = onWorker; worker.onerror = (e) => { setExtracting(false); note('出错:' + e.message, 'err'); }; } const copy = new Uint8ClampedArray(target.data); // 原数据留着做对比 worker.postMessage({ px: copy.buffer, W, H, opts }, [copy.buffer]);}function onWorker(e) { const d = e.data; if (d.type === 'progress') { $('bar').style.width = (d.p * 100).toFixed(0) + '%'; if (d.msg) $('status').textContent = d.msg; return; } if (d.type === 'error') { setExtracting(false); note('出错:' + d.msg, 'err'); return; } setExtracting(false); STROKES = d.res.strokes; CLIPS = d.res.clips; STATS = d.res.stats; KEYFRAMES = (d.res.keys || []).map((k) => ({ i: k.i, rgb: k.rgb })); buildCanvases(); resetPlayback(); checkMsg = ''; $('stage').style.display = 'none'; $('canvasWrap').style.display = 'block'; fitView(); // 画布刚可见时才量得到容器宽度(buildCanvases 里那次量到的是 0) $('stat').innerHTML = '共 <em>' + STATS.strokes + '</em> 笔 · 约 <em>' + Math.round(STATS.totalMs / 1000) + '</em> 秒画完(1×)· 铺底色 → 铺色 → 题字 → 点睛' + (STATS.photo ? ' · 已按照片自动用 <em>' + STATS.colors + '</em> 色' : (STATS.colors ? ' · ' + STATS.colors + ' 色' : '')); for (const id of ['expVideo', 'expJson', 'expPng']) { const b = $(id); if (b) b.disabled = false; } $('expMsg').textContent = ''; // 开播;白点自检在后台画布上推进,补笔备好后追加到时间轴末尾(不挡着画画) playing = true; $('play').textContent = '暂停'; startSelfCheck($('stat').innerHTML);}// ---------------- 画布 ----------------function buildCanvases() { const main = $('cv'); main.width = W; main.height = H; mt = main.getContext('2d'); painted = document.createElement('canvas'); painted.width = W; painted.height = H; painted.getContext('2d').fillStyle = '#ffffff'; painted.getContext('2d').fillRect(0, 0, W, H); tmpC = document.createElement('canvas'); tmpC.width = W; tmpC.height = H; tmp = tmpC.getContext('2d'); clipCache = new Map(); lastClip = -1; buildTimeline(); keyEvery = Math.max(300, Math.ceil(STROKES.length / 8)); keys = []; fitView();}// 时间轴:每笔 = 抬笔移动(短) + 换阶段停顿(偶尔) + 落笔绘制;总时长仍然锁在 4 分钟function buildTimeline() { const n = STROKES.length; travels = new Float64Array(n); beats = new Float64Array(n); msPaint = new Float64Array(n); const ends = new Float64Array(n * 2); let ex = 0, ey = 0, raw = 0, prevPh = -1; for (let i = 0; i < n; i++) { const st = STROKES[i]; const tp = st.tp; if (i > 0) { const d = Math.hypot(tp[0] - ex, tp[1] - ey); // 手就在旁边就顺势接着画(不占时间);隔得远才抬笔"飞"过去 travels[i] = d < 40 ? 0 : Math.min(200, 30 + (d - 40) * 0.35); } const ph = st.ph === undefined ? 1 : st.ph; if (prevPh >= 0 && ph !== prevPh) beats[i] = ph === 3 ? 700 : 420; // 换阶段前停一下,像在回看 prevPh = ph; ex = tp[tp.length - 2]; ey = tp[tp.length - 1]; ends[i * 2] = ex; ends[i * 2 + 1] = ey; raw += st.ms; } // 抬笔移动的总量封顶:细碎补笔动辄几千次"飞过去",不封顶会把 4 分钟撑到七八分钟 const cap = targetMs * 0.2; let sumT = 0; for (let i = 0; i < n; i++) sumT += travels[i]; if (sumT > cap) { const sc = cap / sumT; for (let i = 0; i < n; i++) travels[i] *= sc; } let nonPaint = 0; for (let i = 0; i < n; i++) nonPaint += travels[i] + beats[i]; const budget = Math.max(n * 3, targetMs - nonPaint); const k = raw > 0 ? budget / raw : 1; cumMs = new Float64Array(n + 1); for (let i = 0; i < n; i++) { msPaint[i] = Math.max(3, STROKES[i].ms * k); cumMs[i + 1] = cumMs[i] + travels[i] + beats[i] + msPaint[i]; } if (STATS && n) STATS.totalMs = cumMs[n]; if (n) { lastEnd[0] = STROKES[0].tp[0]; lastEnd[1] = STROKES[0].tp[1]; }}function fitView() { const wrap = $('canvasWrap'); if (!W || !wrap.clientWidth) return; // 容器还没显示(宽度为 0)时量出来是负数,等比算出来的宽度会被浏览器丢掉 const maxW = wrap.clientWidth - 8, maxH = window.innerHeight - 190; const s = Math.min(maxW / W, maxH / H, 1); const cv = $('cv'); cv.style.width = Math.round(W * s) + 'px'; cv.style.height = Math.round(H * s) + 'px';}window.addEventListener('resize', fitView);function clipCanvasFor(c) { if (clipCache.has(c)) return clipCache.get(c); const cl = CLIPS[c]; const cv = document.createElement('canvas'); cv.width = cl.w; cv.height = cl.h; const x = cv.getContext('2d'); const id = x.createImageData(cl.w, cl.h); const m = cl.mask; for (let i = 0; i < m.length; i++) { id.data[i * 4] = 255; id.data[i * 4 + 1] = 255; id.data[i * 4 + 2] = 255; id.data[i * 4 + 3] = m[i] ? 255 : 0; } x.putImageData(id, 0, 0); if (clipCache.size > 40) { // 简单的容量控制 const first = clipCache.keys().next().value; clipCache.delete(first); } clipCache.set(c, cv); return cv;}// 画一笔(u=1 为整笔)。u<1 时只把"半成品"放进临时画布,不落到成品let pending = -1, pendRect = null;function paintStroke(i, u, dest) { const st = STROKES[i]; const cl = CLIPS[st.c]; const t = tipAt(st, u); cursor = { x: t.x, y: t.y, r: Math.max(2.5, st.w * 0.5), pen: st.pen, color: st.color }; guide = st.tp; const rgb = 'rgb(' + st.color[0] + ',' + st.color[1] + ',' + st.color[2] + ')'; const rect = { x: cl.x, y: cl.y, w: cl.w, h: cl.h }; tmp.setTransform(1, 0, 0, 1, 0, 0); tmp.clearRect(rect.x, rect.y, rect.w, rect.h); tmp.save(); tmp.fillStyle = rgb; tmp.strokeStyle = rgb; if (u >= 1) { // 整笔:先填笔形,再沿笔形描一圈同色细边(约 0.7px)。 // 这样抗锯齿产生的"半透明毛边"变成实心,两个色块相接处不会露出 1px 白缝 // ——白点的根源之一就是这个(详见 README 的坑)。 const pth = ribbonPath(st); tmp.lineWidth = 1.4; tmp.lineJoin = 'round'; tmp.lineCap = 'round'; tmp.fill(pth); tmp.stroke(pth); } else { tmp.clip(ribbonPath(st)); // 半成品:笔形 ∩ 已扫过的部分 tmp.clip(leadPath(t, st)); tmp.fillRect(rect.x, rect.y, rect.w, rect.h); tmp.restore(); tmp.save(); // 笔尖前方的湿边(半透明) tmp.clip(ribbonPath(st)); const nx = -t.dy, ny = t.dx, spread = Math.max(4, st.w * 1.3); const fwd = st.pen ? 0 : Math.max(2, st.w * 0.9); const wet = new Path2D(); wet.moveTo(t.x + nx * spread, t.y + ny * spread); wet.lineTo(t.x + t.dx * fwd + nx * spread, t.y + t.dy * fwd + ny * spread); wet.lineTo(t.x + t.dx * fwd - nx * spread, t.y + t.dy * fwd - ny * spread); wet.lineTo(t.x - nx * spread, t.y - ny * spread); wet.closePath(); tmp.fillStyle = rgb; tmp.globalAlpha = 0.42; tmp.fill(wet); } tmp.restore(); tmp.globalCompositeOperation = 'destination-in'; // 用遮罩裁形 tmp.drawImage(clipCanvasFor(st.c), rect.x, rect.y); tmp.globalCompositeOperation = 'source-over'; const ctx = (dest || painted).getContext('2d'); ctx.drawImage(tmpC, rect.x, rect.y, rect.w, rect.h, rect.x, rect.y, rect.w, rect.h); if (u >= 1) { pending = -1; pendRect = null; lastEnd[0] = t.x; lastEnd[1] = t.y; // 记下收笔位置,下一笔的抬笔弧线从这里起 return true; } pending = i; pendRect = rect; return false;}// 抬笔移动:笔尖离开纸面,沿一条浅弧线飞到下一笔的起笔处(不落色,只留下淡淡的虚线)function travelTo(i, f) { const st = STROKES[i]; const tp = st.tp; const ox = lastEnd[0], oy = lastEnd[1]; const dx = tp[0] - ox, dy = tp[1] - oy; const d = Math.hypot(dx, dy) || 1; const bow = Math.min(46, d * 0.22); const mx = (ox + tp[0]) / 2 - dy / d * bow; const my = (oy + tp[1]) / 2 + dx / d * bow; const e = f * f * (3 - 2 * f); // 起步慢、中间快、落下轻 cursor = { x: (1 - e) * (1 - e) * ox + 2 * (1 - e) * e * mx + e * e * tp[0], y: (1 - e) * (1 - e) * oy + 2 * (1 - e) * e * my + e * e * tp[1], r: Math.max(2, st.w * 0.5), pen: st.pen, color: st.color, air: true, }; airPath = [ox, oy, mx, my, tp[0], tp[1]]; guide = null;}function endTravel() { airPath = null; }// 笔尖前沿:以笔尖为中心、往后拉一块"已扫过"的多边形function leadPath(t, st) { const behind = st.pen ? 0 : Math.max(2, st.w * 0.45); const spread = Math.max(4, st.w * 1.3); const seed = st.ms * 7 + st.tp.length; const p = new Path2D(); const nx = -t.dy, ny = t.dx; const back = 4000; p.moveTo(t.x - t.dx * back + nx * spread, t.y - t.dy * back + ny * spread); for (let k = 1; k <= 12; k++) { // 锯齿状前沿 const f = k / 12; const j = (hash01(seed, k) - 0.5) * spread * 0.55; const along = -behind * f; p.lineTo(t.x + t.dx * along + nx * (spread + j), t.y + t.dy * along + ny * (spread + j)); } for (let k = 12; k >= 1; k--) { const f = k / 12; const j = (hash01(seed + 3, k) - 0.5) * spread * 0.55; const along = -behind * f; p.lineTo(t.x + t.dx * along - nx * (spread + j), t.y + t.dy * along - ny * (spread + j)); } p.closePath(); return p;}// ---------------- 播放循环 ----------------function flushMain() { if (!mt || !painted) return; // 还没选图、画布尚未建立 mt.setTransform(1, 0, 0, 1, 0, 0); mt.fillStyle = '#ffffff'; mt.fillRect(0, 0, W, H); if ($('cmp').checked && target) { mt.putImageData(target, 0, 0); return; } mt.drawImage(painted, 0, 0); if (pending >= 0 && !done && pendRect) { // 半成品浮在成品之上 mt.drawImage(tmpC, pendRect.x, pendRect.y, pendRect.w, pendRect.h, pendRect.x, pendRect.y, pendRect.w, pendRect.h); } if (cursor && showTip && !done) drawCursor(mt, cursor); if (guide && showGuide && !done) drawGuide(mt, guide, cursor); if (airPath && showGuide && !done) drawAir(mt, airPath); // 抬笔移动的浅弧线}function drawAir(ctx, p) { ctx.save(); ctx.setLineDash([5, 7]); ctx.strokeStyle = 'rgba(70,70,82,0.22)'; ctx.lineWidth = Math.max(1, W / 1024) * 1.2; ctx.beginPath(); ctx.moveTo(p[0], p[1]); ctx.quadraticCurveTo(p[2], p[3], p[4], p[5]); ctx.stroke(); ctx.restore();}function drawCursor(ctx, c) { ctx.save(); ctx.translate(c.x, c.y); if (c.air) { // 抬笔:空心小圈 + 一点芯,看得出笔离开了纸面 ctx.strokeStyle = 'rgba(45,45,54,0.55)'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.arc(0, 0, Math.max(2.8, c.r * 0.85), 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = 'rgba(45,45,54,0.45)'; ctx.beginPath(); ctx.arc(0, 0, Math.max(1.1, c.r * 0.26), 0, Math.PI * 2); ctx.fill(); ctx.restore(); return; } if (c.pen) { ctx.fillStyle = 'rgba(30,30,36,0.92)'; ctx.beginPath(); ctx.arc(0, 0, Math.max(2.2, c.r * 0.5), 0, Math.PI * 2); ctx.fill(); } else { ctx.strokeStyle = 'rgba(40,40,48,0.75)'; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.arc(0, 0, c.r * 1.15, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = 'rgba(' + c.color[0] + ',' + c.color[1] + ',' + c.color[2] + ',0.85)'; ctx.beginPath(); ctx.arc(0, 0, Math.max(2, c.r * 0.55), 0, Math.PI * 2); ctx.fill(); } ctx.restore();}function drawGuide(ctx, tp, c) { if (!c) return; ctx.save(); ctx.setLineDash([6, 6]); ctx.strokeStyle = 'rgba(60,60,70,0.32)'; ctx.lineWidth = Math.max(1, W / 1024); ctx.beginPath(); ctx.moveTo(tp[0], tp[1]); for (let i = 2; i < tp.length; i += 2) ctx.lineTo(tp[i], tp[i + 1]); ctx.stroke(); ctx.restore();}let lastTickAt = 0;function tick() { const now = performance.now(); const gap = now - lastTickAt; // 距上一帧多久(被节流时会是秒/分钟级) lastTickAt = now; const dt = Math.min(100, now - lastT); lastT = now; if (rushing && !done) { // 快进到成品:每帧画 2500 笔 let n = 0; while (idx < STROKES.length && n++ < 2500) { paintStroke(idx, 1); idx++; } if (idx >= STROKES.length) { rushing = false; finish(); } } else if (playing && !done) { acc += dt * speed; let guard = 0; while (idx < STROKES.length && guard++ < 400) { const tot = travels[idx] + beats[idx] + msPaint[idx]; if (acc < tot) break; acc -= tot; paintStroke(idx, 1); idx++; if (keyEvery && idx % keyEvery === 0 && idx < STROKES.length) snapshotKey(idx); if (idx >= STROKES.length) { finish(); break; } } if (idx < STROKES.length && !done) { const tv = travels[idx] + beats[idx]; if (acc < tv) travelTo(idx, acc / Math.max(1, tv)); // 还在抬笔移动 else { endTravel(); paintStroke(idx, clamp((acc - tv) / msPaint[idx], 0, 1)); } } } if (checkState) stepSelfCheck(gap); // 成品自检:按帧分片推进(帧慢就多干点) flushMain(); hud(); schedule();}// 页面在后台时 rAF 不触发:退回定时器,保证"离开标签页也会继续画"function schedule() { if (document.hidden) setTimeout(tick, 100); else requestAnimationFrame(tick);}// 看门狗:窗口未聚焦时 rAF 可能被节流甚至暂停,这里保证动画继续推进setInterval(function () { if (performance.now() - lastTickAt > 50) tick();}, 50);function restoreKey(k, ctx) { if (k.data) { ctx.putImageData(k.data, 0, 0); return; } const W2 = W, H2 = H; if (!restoreKey.buf || restoreKey.buf.width !== W2 || restoreKey.buf.height !== H2) { restoreKey.buf = ctx.createImageData(W2, H2); const dd = restoreKey.buf.data; for (let i = 3; i < dd.length; i += 4) dd[i] = 255; } const img = restoreKey.buf, dd = img.data, rgb = k.rgb; for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) { dd[j] = rgb[i]; dd[j + 1] = rgb[i + 1]; dd[j + 2] = rgb[i + 2]; } ctx.putImageData(img, 0, 0);}function allKeys() { return KEYFRAMES.concat(keys).sort((a, b) => a.i - b.i); }function snapshotKey(i) { try { keys.push({ i, data: painted.getContext('2d').getImageData(0, 0, W, H) }); } catch (e) { /* 忽略:内存不足就放弃关键帧 */ }}function hud() { if (!STATS || selfChecking) return; const n = STROKES.length; const st = n ? STROKES[Math.min(idx, n - 1)] : null; const cur = done ? STATS.totalMs : (cumMs[idx] + acc); const p = STATS.totalMs > 0 ? cur / STATS.totalMs : 0; $('fill').style.width = (p * 100).toFixed(2) + '%'; $('clock').textContent = fmt(cur / 1000) + ' / ' + fmt(STATS.totalMs / 1000); const sk = $('seek'); if (sk && !draggingSeek) sk.value = String(Math.round(p * 1000)); // 进度条跟着跑(拖动时别抢) const ph = done ? 4 : (st && st.ph !== undefined ? st.ph : 1); setPhase(ph); if (!done) { $('stat').innerHTML = '阶段 <em>' + (PHASES[ph] || '') + '</em> · 第 <em>' + Math.min(idx + 1, n) + '</em> / ' + n + ' 笔 · ' + (p * 100).toFixed(0) + '%' + checkMsg; }}function setPhase(ph) { const el = $('phases'); if (!el) return; for (let i = 0; i < el.children.length; i++) { el.children[i].className = i === ph ? 'on' : (i < ph ? 'done' : ''); }}function fmt(s) { s = Math.max(0, Math.round(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');}function resetPlayback() { idx = 0; acc = 0; done = false; rushing = false; cursor = null; guide = null; airPath = null; pending = -1; pendRect = null; const ctx = painted.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, W, H); keys = []; if (STROKES.length) { lastEnd[0] = STROKES[0].tp[0]; lastEnd[1] = STROKES[0].tp[1]; } playing = true; $('play').textContent = '暂停'; $('stat').textContent = '准备开始…';}function jumpTo(sec) { const tgt = sec * 1000; const cur = done ? cumMs[STROKES.length] : (cumMs[idx] + acc); // 向前小步:直接在当前画面上续画,不用重来 if (tgt >= cur - 50 && tgt - cur < 45000 && !done) { advanceTo(tgt); return; } const ctx = painted.getContext('2d'); ctx.setTransform(1, 0, 0, 1, 0, 0); let ci = 0; while (ci < STROKES.length && cumMs[ci + 1] <= tgt) ci++; let k = null; for (const kk of allKeys()) { if (kk.i <= ci) k = kk; else break; } if (k) restoreKey(k, ctx); else { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, W, H); } done = false; idx = k ? k.i : 0; acc = 0; airPath = null; if (STROKES.length) { const pi = Math.max(0, Math.min(idx - 1, STROKES.length - 1)); const tp = STROKES[pi].tp; lastEnd[0] = tp[tp.length - 2]; lastEnd[1] = tp[tp.length - 1]; } advanceTo(tgt);}function advanceTo(tgt) { while (idx < STROKES.length && cumMs[idx + 1] <= tgt) { paintStroke(idx, 1); idx++; if (keyEvery && idx % keyEvery === 0 && idx < STROKES.length) snapshotKey(idx); } if (idx >= STROKES.length) { finish(); return; } acc = Math.max(0, tgt - cumMs[idx]); const tv = travels[idx] + beats[idx]; if (acc < tv) { travelTo(idx, acc / Math.max(1, tv)); } else { endTravel(); paintStroke(idx, clamp((acc - tv) / msPaint[idx], 0, 1)); }}// ---------------- 成品自检:空白点归零 ----------------// 管线是拿它自己的光栅模型判"白点"的;网页端是浏览器的抗锯齿多边形 + 1 位硬遮罩裁剪,// 两者在笔形边缘上会有细微出入。所以成品出来之后,在**真实画布**上再查一遍:// 该有颜色却还是白的像素,按连通块补一个"方点笔"(严格掩膜,只盖这几个像素),迭代到 0。// "算白"的两个门槛必须满足:原图门槛 ≤ 画布门槛。// 这样"把目标色画上去"一定能消掉白点(否则会出现补了还是白点的死循环);// 同时画布门槛取得严一点(244),浅色区块不会被误判成白点而乱补。// "没涂上色"的两个口径(都必须满足"原图门槛 ≤ 画布门槛",否则补上目标色后仍算白点、消不掉):// ① 纸面根本没被涂上(WHITE_PAPER = 纯白):只要原图明显不是白就补——保证"全满的图"成品上没有空白处;// ② 只被抗锯齿毛边蹭到 / 被浅色笔盖错(WHITE_CANVAS + 原图明显更深)。const WHITE_PAPER = 254;const WHITE_CANVAS = 244;const WHITE_TARGET = 228;const isWhite = (r, g, b, t) => r >= t && g >= t && b >= t;function whiteDots(dest) { let im; try { im = (dest || painted).getContext('2d').getImageData(0, 0, W, H).data; } catch (e) { return []; } const tgt = target.data; const m = new Uint8Array(W * H); let any = 0; for (let p = 0; p < W * H; p++) { const q = p * 4; const cmn = Math.min(im[q], im[q + 1], im[q + 2]); const tmn = Math.min(tgt[q], tgt[q + 1], tgt[q + 2]); if (!((cmn >= WHITE_CANVAS && tmn < WHITE_TARGET) || (cmn >= WHITE_PAPER && tmn < WHITE_PAPER - 8))) continue; m[p] = 1; any++; } const out = []; if (!any) return out; const seen = new Uint8Array(W * H); const q = new Int32Array(any); for (let s = 0; s < W * H; s++) { if (!m[s] || seen[s]) continue; let head = 0, tail = 0, cnt = 0; q[tail++] = s; seen[s] = 1; let x0 = W, y0 = H, x1 = -1, y1 = -1; while (head < tail) { const p = q[head++]; cnt++; const x = p % W, y = (p - x) / W; if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; for (let dy = -1; dy <= 1; dy++) { for (let dx = -1; dx <= 1; dx++) { const xx = x + dx, yy = y + dy; if (xx < 0 || yy < 0 || xx >= W || yy >= H) continue; const pp = yy * W + xx; if (!seen[pp] && m[pp]) { seen[pp] = 1; q[tail++] = pp; } } } } out.push({ x0, y0, x1, y1, n: cnt, pix: Array.prototype.slice.call(q.subarray(0, cnt)) }); } out.sort((a, b) => b.n - a.n); return out;}// 每个白点补一个"方点笔":掩膜严格等于这一块,笔形外扩 1px(保证像素中心一定落在笔形里),// 颜色取目标图这一块的平均色。方框只被掩膜裁到那几个像素上,周围正确的像素不受影响。function addDotStroke(cl, grow, dest) { const tgt = target.data; const g = grow || 0; // 顽固白点:一轮没补掉就把掩膜和笔形再外扩一圈 const x0 = Math.max(0, cl.x0 - g), y0 = Math.max(0, cl.y0 - g); const x1 = Math.min(W - 1, cl.x1 + g), y1 = Math.min(H - 1, cl.y1 + g); const w0 = x1 - x0 + 1, h0 = y1 - y0 + 1; const mask = new Uint8Array(w0 * h0); let cr = 0, cg = 0, cb = 0, cnt = 0; for (const p of cl.pix) { const x = p % W, y = (p - x) / W; if (x < x0 || y < y0 || x > x1 || y > y1) continue; mask[(y - y0) * w0 + (x - x0)] = 1; cr += tgt[p * 4]; cg += tgt[p * 4 + 1]; cb += tgt[p * 4 + 2]; cnt++; } if (g) { // 外扩的那些像素也纳入掩膜,颜色一起取 for (let y = y0; y <= y1; y++) { for (let x = x0; x <= x1; x++) { const k = (y - y0) * w0 + (x - x0); if (mask[k]) continue; const q = (y * W + x) * 4; if (Math.min(tgt[q], tgt[q + 1], tgt[q + 2]) >= WHITE_PAPER) continue; // 原图纯白的地方不收 mask[k] = 1; cr += tgt[q]; cg += tgt[q + 1]; cb += tgt[q + 2]; cnt++; } } } CLIPS.push({ x: x0, y: y0, w: w0, h: h0, mask }); const gx0 = x0 - 1, gy0 = y0 - 1, gx1 = x1 + 2, gy1 = y1 + 2; const my = (gy0 + gy1) / 2; STROKES.push({ rib: [gx0, gy0, gx1, gy0, gx1, gy1, gx0, gy1], tp: [gx0, my, gx1, my], w: Math.max(3, h0 + 2), color: [Math.round(cr / cnt), Math.round(cg / cnt), Math.round(cb / cnt)], pen: 0, c: CLIPS.length - 1, r: -1, ph: 3, ms: 260, }); paintStroke(STROKES.length - 1, 1, dest);}// 成品自检:扫"该有色却是白的"像素并补笔,扫到 0 才收手。// 三条约束:// 1) 不能挡住开播——用户在等的是"开始画",所以自检在回放跑完之后再做;// 2) 不能依赖 setTimeout/await 链——标签页被节流时那种链会长时间不回来,页面看着像死了;// 改由主循环 tick() 分片推进(rAF 和 50ms 看门狗双驱动,播放能跑它就一定能跑);// 3) 不能跟回放抢主线程——早先的写法是在离屏画布上把整幅重画一遍(两万笔 ≈ 26 秒纯 CPU),// 把 240 秒的回放拖成好几分钟;现在等回放画完,直接扫它画出来的那张成品,一笔都不用重画。let selfCheckGen = 0;let checkState = null; // { gen, phase, round, added, left, head, cv, from }function startSelfCheck(head) { // 自检要的是"成品画布",而回放本身就在把成品一笔一笔堆出来 —— 所以先等着,别抢主线程。 // 老做法是在离屏画布上把整幅**重画一遍**(两万笔 ≈ 26 秒纯 CPU),跟回放抢主线程, // 结果 240 秒的回放被拖成好几分钟;回放画完时 painted 就是成品,直接扫它,一笔都不用重画。 checkState = { gen: ++selfCheckGen, phase: 0, i: 0, round: 0, added: 0, left: 0, head: head || '', cv: null, from: STROKES.length }; return checkState;}function stepSelfCheck(gap) { const cs = checkState; if (!cs) { selfChecking = false; return; } if (cs.gen !== selfCheckGen) { checkState = null; selfChecking = false; return; } if (cs.phase === 0) { // 等回放把成品画完 if (!done) return; cs.head = $('stat').innerHTML; // 这时状态行里已经有成品读数了 cs.cv = painted; // 回放画出来的就是成品,直接扫它 cs.phase = 2; cs.from = STROKES.length; selfChecking = true; $('stat').innerHTML = (cs.head || '') + ' · 白点自检中…'; return; } const dots = whiteDots(cs.cv); cs.left = dots.length; if (!cs.left || cs.round >= 8) { finishSelfCheck(); return; } // 扫到 0 个才收手 const grow = cs.round >= 2 ? Math.min(2, cs.round - 1) : 0; // 三轮还没清掉的:掩膜外扩再补 for (const cl of dots) { addDotStroke(cl, grow, cs.cv); cs.added++; } cs.round++;}function finishSelfCheck() { const cs = checkState; if (!cs) return; checkState = null; selfChecking = false; checkMsg = ' · 白点自检 <em>' + (cs.left === 0 ? '0 个' : cs.left + ' 个残留') + '</em>' + (cs.added ? '(补 ' + cs.added + ' 笔)' : ''); if (cs.added) { buildTimeline(); // 补笔已追加在最后:重建时间轴,它们会在收尾阶段被画上 if (done || idx >= STROKES.length) { // 万一已经画完了:直接补到成品上 for (let i = cs.from; i < STROKES.length; i++) paintStroke(i, 1); idx = STROKES.length; done = true; } } $('stat').innerHTML = (cs.head || $('stat').innerHTML) + checkMsg;}// ---------------- 收尾与差异 ----------------function diffVsTarget() { const a = painted.getContext('2d').getImageData(0, 0, W, H).data; const b = target.data; let sum = 0, bad = 0; const tot = W * H * 3; for (let i = 0; i < a.length; i += 4) { for (let k = 0; k < 3; k++) { const d = Math.abs(a[i + k] - b[i + k]); if (d > 8) bad++; sum += d; } } // 整体差异:缩到 1/4 再看一次(忽略逐像素的颗粒/锯齿,更接近肉眼判断) const s = 4, w2 = Math.max(1, Math.round(W / s)), h2 = Math.max(1, Math.round(H / s)); const c1 = document.createElement('canvas'); c1.width = w2; c1.height = h2; const c2 = document.createElement('canvas'); c2.width = w2; c2.height = h2; const x1 = c1.getContext('2d'), x2 = c2.getContext('2d'); const tc = document.createElement('canvas'); tc.width = W; tc.height = H; tc.getContext('2d').putImageData(target, 0, 0); x1.drawImage(painted, 0, 0, w2, h2); x2.drawImage(tc, 0, 0, w2, h2); const d1 = x1.getImageData(0, 0, w2, h2).data, d2 = x2.getImageData(0, 0, w2, h2).data; let s2 = 0, b2 = 0; const t2 = w2 * h2 * 3; for (let i = 0; i < d1.length; i += 4) { for (let k = 0; k < 3; k++) { const d = Math.abs(d1[i + k] - d2[i + k]); if (d > 8) b2++; s2 += d; } } return { mean: sum / tot, badPct: bad / tot * 100, mean4: s2 / t2, bad4: b2 / t2 * 100 };}function finish() { done = true; playing = false; rushing = false; cursor = null; guide = null; airPath = null; $('play').textContent = '重播'; setPhase(4); const d = diffVsTarget(); const dots = whiteDots().reduce((a, c) => a + c.n, 0); $('stat').innerHTML = '画完了 · <em>' + STROKES.length + '</em> 笔 · 整体差异(1/4 缩放)平均 <em>' + d.mean4.toFixed(2) + '/255</em>、可见差异 <em>' + d.bad4.toFixed(2) + '%</em>' + ' · 逐像素细节差异平均 ' + d.mean.toFixed(2) + '、可见 ' + d.badPct.toFixed(2) + '%' + ' · 空白点 <em>' + dots + '</em> 个';}// ---------------- 导出 ----------------function download(blob, name) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);}function stamp() { const d = new Date(), p = (n) => String(n).padStart(2, '0'); return d.getFullYear() + p(d.getMonth() + 1) + p(d.getDate()) + '-' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());}// 成品图function exportPng() { if (!STROKES.length) return; painted.toBlob((b) => { download(b, 'image2strokes-' + stamp() + '.png'); $('expMsg').textContent = '成品图已导出(' + W + '×' + H + ')'; }, 'image/png');}// 视频:把整段过程在 ~24 秒里重画一遍,用 MediaRecorder 录成 WebM。// 推进由"真实时间"驱动(而不是靠每帧增量),所以即使标签页被节流//(rAF 不触发、定时器降到 1Hz),成片的长度和进度也还是对的。async function exportVideo() { if (!STROKES.length) return; const cv = $('cv'); if (typeof MediaRecorder === 'undefined' || !cv.captureStream) { $('expMsg').textContent = '这个浏览器不支持录制,换 Chrome / Edge 试试'; return; } const btn = $('expVideo'), oldTxt = btn.textContent; btn.disabled = true; btn.textContent = '录制中…'; $('expMsg').textContent = '正在快放录制(画面会自动重画一遍)…'; const OUT_SEC = 24; const oldTip = showTip, oldGuide = showGuide, wasPlaying = playing; const mime = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'] .find((m) => MediaRecorder.isTypeSupported(m)) || ''; const rec = new MediaRecorder(cv.captureStream(30), mime ? { mimeType: mime, videoBitsPerSecond: 8000000 } : undefined); const chunks = []; rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); }; const stopped = new Promise((r) => { rec.onstop = () => r(); }); const nextFrame = () => new Promise((r) => { let fired = false; const fin = () => { if (!fired) { fired = true; r(); } }; const h = setTimeout(fin, 500); // rAF 被节流时的兜底 requestAnimationFrame(() => { clearTimeout(h); fin(); }); }); try { showTip = false; showGuide = false; // 录进去的画面不带笔尖和参考线 resetPlayback(); playing = false; // 由下面的循环推进,不走 tick const total = STATS.totalMs; const rate = total / (OUT_SEC * 1000); // 每毫秒真实时间推进多少毫秒的"画" rec.start(); const t0 = performance.now(); for (;;) { const simT = Math.min(total, (performance.now() - t0) * rate); while (idx < STROKES.length && cumMs[idx + 1] <= simT) { paintStroke(idx, 1); idx++; if ((idx & 511) === 0) $('expMsg').textContent = '录制中… ' + idx + ' / ' + STROKES.length + ' 笔'; } if (idx >= STROKES.length) break; const tStart = cumMs[idx] + travels[idx] + beats[idx]; if (simT > tStart) paintStroke(idx, clamp((simT - tStart) / msPaint[idx], 0, 1)); flushMain(); await nextFrame(); if (performance.now() - t0 > OUT_SEC * 4000) { // 机器太慢时别再拖:剩下的笔一次补完,保证成品完整(视频会早一点结束) while (idx < STROKES.length) { paintStroke(idx, 1); idx++; } break; } } flushMain(); await new Promise((r) => setTimeout(r, 600)); // 让最后一帧落进流里 } finally { if (rec.state !== 'inactive') rec.stop(); await stopped; showTip = oldTip; showGuide = oldGuide; playing = wasPlaying; $('tip').checked = oldTip; $('guideChk').checked = oldGuide; btn.disabled = false; btn.textContent = oldTxt; } if (!chunks.length) { $('expMsg').textContent = '录制失败,没拿到数据'; return; } const blob = new Blob(chunks, { type: 'video/webm' }); download(blob, 'image2strokes-' + stamp() + '.webm'); $('expMsg').textContent = '视频已导出:约 ' + OUT_SEC + ' 秒快放 · ' + (blob.size / 1048576).toFixed(1) + ' MB'; finish(); // 回到"画完了"的状态}// 笔触脚本:笔形 + 轨迹 + 纯色 + 裁剪掩膜(PNG),可直接喂给别的绘图工具function exportJson() { if (!STROKES.length) return; const btn = $('expJson'), oldTxt = btn.textContent; btn.disabled = true; btn.textContent = '打包中…'; $('expMsg').textContent = '正在打包裁剪掩膜…'; setTimeout(() => { try { const clips = CLIPS.map((c) => { const cv = document.createElement('canvas'); cv.width = c.w; cv.height = c.h; const x = cv.getContext('2d'); const id = x.createImageData(c.w, c.h); for (let k = 0; k < c.mask.length; k++) { id.data[k * 4] = 255; id.data[k * 4 + 1] = 255; id.data[k * 4 + 2] = 255; id.data[k * 4 + 3] = c.mask[k] ? 255 : 0; } x.putImageData(id, 0, 0); return { x: c.x, y: c.y, w: c.w, h: c.h, mask: cv.toDataURL('image/png') }; }); const data = { format: 'image2strokes/1', W, H, colors: STATS.colors, photo: !!STATS.photo, style: STATS.style, strokes: STROKES.length, note: '每条笔触:rib/tp 是平铺坐标数组([x0,y0,x1,y1,…]),color 是纯色 RGB,' + 'w 是笔宽,ms 是这一笔的时长,pen=1 表示硬笔尖;c 指向 clips 里的裁剪掩膜' + '(PNG:白色不透明=可以落色,透明=裁掉),绘制时把 rib 多边形与掩膜求交再填 color 即可。', strokes: STROKES, clips, }; const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); download(blob, 'image2strokes-' + stamp() + '.json'); $('expMsg').textContent = '笔触已导出:' + (blob.size / 1048576).toFixed(1) + ' MB'; } catch (e) { $('expMsg').textContent = '导出失败:' + e.message; } finally { btn.disabled = false; btn.textContent = oldTxt; } }, 30);}// ---------------- 交互 ----------------function bindUI() { const dz = $('drop'); const openPicker = () => { if (!extracting) $('file').click(); }; /* 原来这个框只写了"点击或拖入",但没有任何 click 处理器——点它没反应,只能靠示例图。 注意 #file 就嵌在框里,它自己的 click 会冒泡回来,必须挡掉,否则选择器被打开两次 */ dz.addEventListener('click', (e) => { if (e.target === $('file')) return; openPicker(); }); dz.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.code === 'Space') { e.preventDefault(); openPicker(); } }); $('file').addEventListener('change', (e) => { const f = e.target.files && e.target.files[0]; if (f) loadFile(f); e.target.value = ''; // 连续选同一张也能重新触发 }); /* 整页都接收拖放:落在选图框外也认;同时拦住浏览器默认行为—— 否则浏览器会把图片当页面打开,正在跑的东西全丢 */ let dragDepth = 0; const mask = $('dropMask'); const hasFile = (e) => e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types || [], 'Files') >= 0; const endDrag = () => { dragDepth = 0; if (mask) mask.classList.remove('on'); dz.classList.remove('over'); }; window.addEventListener('dragenter', (e) => { if (!hasFile(e)) return; e.preventDefault(); dragDepth++; if (mask) mask.classList.add('on'); }); window.addEventListener('dragover', (e) => { if (!hasFile(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); window.addEventListener('dragleave', () => { if (--dragDepth <= 0) endDrag(); }); window.addEventListener('drop', (e) => { if (!hasFile(e)) return; e.preventDefault(); const f = e.dataTransfer.files && e.dataTransfer.files[0]; endDrag(); if (f) loadFile(f); }); dz.addEventListener('dragenter', () => dz.classList.add('over')); dz.addEventListener('dragleave', () => dz.classList.remove('over')); /* 粘贴上传:截完图 Ctrl/Cmd+V 直接进来 */ window.addEventListener('paste', (e) => { const items = e.clipboardData && e.clipboardData.items; if (!items) return; for (let i = 0; i < items.length; i++) { if (items[i].kind === 'file' && items[i].type.indexOf('image/') === 0) { const f = items[i].getAsFile(); if (f) { e.preventDefault(); loadFile(f); return; } } } }); document.querySelectorAll('[data-sample]').forEach((el) => { el.addEventListener('click', () => { if (extracting) { note('上一张还在提炼,等它跑完再换。', 'warn'); return; } const im = new Image(); im.crossOrigin = 'anonymous'; im.onload = () => startExtract(fitImage(im)); im.onerror = () => note('示例图加载失败,检查一下 samples/ 是否齐全。', 'err'); im.src = el.getAttribute('data-sample'); }); }); $('play').addEventListener('click', () => { if (done) { resetPlayback(); return; } playing = !playing; $('play').textContent = playing ? '暂停' : '继续'; }); $('speed').addEventListener('input', (e) => setSpeed(parseFloat(e.target.value))); // 进度条:拖动时只更新读数(拖动本身会连续重画上千笔,会卡);松手/实时轻量跳转 let lastSeekAt = 0; $('seek').addEventListener('pointerdown', () => { draggingSeek = true; }); window.addEventListener('pointerup', () => { draggingSeek = false; }); $('seek').addEventListener('input', (e) => { if (!STROKES.length) return; const sec = parseFloat(e.target.value) / 1000 * (STATS.totalMs / 1000); $('clock').textContent = fmt(sec) + ' / ' + fmt(STATS.totalMs / 1000); const now = performance.now(); if (now - lastSeekAt < 150) return; // 拖动中最多 6~7 次/秒做实际定位 lastSeekAt = now; playing = false; $('play').textContent = '继续'; jumpTo(sec); }); $('seek').addEventListener('change', (e) => { // 松手:精确定位 if (!STROKES.length) return; playing = false; $('play').textContent = '继续'; jumpTo(parseFloat(e.target.value) / 1000 * (STATS.totalMs / 1000)); }); $('toEnd').addEventListener('click', () => { // 看成品:快进到画完 if (!STROKES.length) return; if (done) { resetPlayback(); return; } rushing = true; playing = true; $('play').textContent = '暂停'; $('stat').textContent = '快进到成品…'; }); const jumpPhase = (el) => { // 点/按阶段名 → 跳到该阶段开头 const ph = parseInt(el.getAttribute('data-ph'), 10); if (!STROKES.length || !cumMs) return; let i = 0; while (i < STROKES.length && (STROKES[i].ph | 0) !== ph) i++; if (i >= STROKES.length) return; playing = false; $('play').textContent = '继续'; jumpTo(cumMs[i] / 1000); }; document.querySelectorAll('#phases span').forEach((el) => { el.tabIndex = 0; el.setAttribute('role', 'button'); el.addEventListener('click', () => jumpPhase(el)); el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.code === 'Space') { e.preventDefault(); jumpPhase(el); } }); }); $('cmp').addEventListener('change', () => {}); $('tip').addEventListener('change', (e) => { showTip = e.target.checked; }); $('guideChk').addEventListener('change', (e) => { showGuide = e.target.checked; }); $('expPng').addEventListener('click', exportPng); $('expVideo').addEventListener('click', () => { exportVideo(); }); $('expJson').addEventListener('click', exportJson); const fmtSpeed = (v) => (Math.round(v * 100) / 100) + '×'; const setSpeed = (v) => { speed = Math.max(0.25, Math.min(4, Math.round(v * 4) / 4)); $('speed').value = String(speed); $('speedVal').textContent = fmtSpeed(speed); }; const seekRel = (deltaSec) => { if (!STROKES.length || !cumMs) return; const total = STATS.totalMs / 1000; const cur = (done ? cumMs[STROKES.length] : (cumMs[idx] + acc)) / 1000; playing = false; $('play').textContent = '继续'; jumpTo(Math.max(0, Math.min(total, cur + deltaSec))); }; const TOGGLE = { KeyO: 'cmp', KeyT: 'tip', KeyG: 'guideChk' }; document.addEventListener('keydown', (e) => { if (e.defaultPrevented) return; // 选图框自己处理掉的 Enter/Space 不再抢 const t = e.target; if (t && /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)) return; // 别抢控件自己的按键 if (e.metaKey || e.ctrlKey || e.altKey) return; if (e.code === 'Space') { if (STROKES.length) { e.preventDefault(); $('play').click(); } return; } if (TOGGLE[e.code]) { const el = $(TOGGLE[e.code]); el.checked = !el.checked; el.dispatchEvent(new Event('change')); e.preventDefault(); return; } if (!STROKES.length) return; if (e.code === 'ArrowLeft') { e.preventDefault(); seekRel(e.shiftKey ? -30 : -5); } else if (e.code === 'ArrowRight') { e.preventDefault(); seekRel(e.shiftKey ? 30 : 5); } else if (e.code === 'ArrowUp') { e.preventDefault(); setSpeed(speed + 0.25); } else if (e.code === 'ArrowDown') { e.preventDefault(); setSpeed(speed - 0.25); } else if (e.code === 'Home') { e.preventDefault(); seekRel(-1e9); playing = true; $('play').textContent = '暂停'; } else if (e.code === 'End') { e.preventDefault(); $('toEnd').click(); } });}function loadFile(f) { if (extracting) { note('上一张还在提炼,等它跑完再换。', 'warn'); return; } if (!f.type || f.type.indexOf('image/') !== 0) { note('这不是图片文件——支持 JPG / PNG / WEBP。', 'err'); return; } if (f.size > 25 * 1024 * 1024) { note('图片超过 25MB,先压一下再传(长边反正只会用到 1024px)。', 'err'); return; } const url = URL.createObjectURL(f); const im = new Image(); im.onload = () => { note('', ''); startExtract(fitImage(im)); URL.revokeObjectURL(url); }; im.onerror = () => { note('这张图片读不出来,换一张试试。', 'err'); URL.revokeObjectURL(url); }; im.src = url;}bindUI();schedule();