diff --git a/README.md b/README.md index 009e817..30a3d39 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,15 @@ modern conveniences belong in the workflow, not the output. See ## Run ```sh -python3 -m http.server 8777 # from this directory -# open http://127.0.0.1:8777 +python3 serve.py # from this directory, then open 127.0.0.1:8777 ``` +Use `serve.py`, not `python3 -m http.server`. The latter sends `Last-Modified` +and browsers cache ES modules on it hard enough that a reload serves a stale +`js/app.js` against a fresh `index.html` — new knobs appear in the markup, nothing +wires them, no error is raised, and the symptom reads as "the feature does not +work". `serve.py` is the same server with `no-store`. + Static files and ES modules — no build step, no dependencies beyond MediaPipe's wasm, which is fetched from a CDN on first use. @@ -267,6 +272,40 @@ egg by construction, and no landmark precision fixes that. Hence the photo. **Save frame 4x** writes the current registered composite as a 1280×800 PNG to draw on. +## Paint — background cels + +**A sketch.** It exists to test whether the aesthetic holds when a human draws +the background instead of the tracker deriving it, and it is meant to be +replaced by a real paint surface with onion skin and undo. It is one dependency- +free module, `js/paint.js`, so throwing it away is a delete rather than surgery. + +Cels are drawn on the frames that get their own drawing and **hold until the +next one** — the same rule the plate follows, and literally the same lookup. You +can scrub anywhere and keep drawing on the cel you can see; the header says +which one you are editing and how far it holds. + +- **pen** — click to place vertices, click the green box on the first one (or + Enter / double-click) to close. +- **edit** — click a shape to select, drag a vertex or the whole shape, + Shift-click an edge to insert a vertex, Alt-click one to + remove it, Del to delete the layer. +- **Layers** stack Photoshop-style, front at the top, with per-layer colour, + show/hide and reorder. +- **Drag a frame** from the strip onto the canvas to seed this cel from that + one, every layer, as a deep copy. **Copy previous** does the same for the + previous kept frame, which is the case you reach for constantly. + +Two rules are enforced rather than left to discipline. Colours are **palette +indices**, so you cannot pick one that is not in the ramp — sampling colour from +the source is the one move `docs/design.md` says is irrecoverable. And vertices +**snap to the 320×200 grid**, because on a hard-edged indexed rasteriser a shape +nudged by 0.4px moves an edge by a whole pixel or not at all depending on where +it lands, which shimmers instead of holding. + +Drawings autosave to `localStorage` per take name. They are the only thing in +the tool a person made by hand; everything else regenerates. They are not in the +`.take` export yet. + ## Two kinds of sparseness Sparseness has two unrelated causes, and conflating them was the original design diff --git a/index.html b/index.html index 9df6db8..579e043 100644 --- a/index.html +++ b/index.html @@ -52,6 +52,19 @@ .sw { display:flex; align-items:center; gap:5px; color:var(--dim); font-size:11px; } .sw input { width:26px; height:20px; padding:0; border:1px solid var(--line); background:none; } .legend { color:var(--dim); font-size:11px; margin-top:6px; } + /* paint: a sketch, see js/paint.js */ + #cv-paint { border:1px solid var(--line); cursor:crosshair; touch-action:none; } + #cv-paint.drop { border-color:var(--ok); } + #paintlayers { flex:0 0 280px; max-height:600px; overflow:auto; } + .lay { display:flex; align-items:center; gap:5px; padding:3px 4px; border-radius:3px; + border:1px solid transparent; cursor:pointer; } + .lay.sel { border-color:var(--accent); background:#1c2130; } + .lay i { width:12px; height:12px; border-radius:2px; border:1px solid #0006; flex:0 0 auto; } + .lay b { flex:1; font-weight:400; color:var(--dim); font-size:11px; } + .lay.sel b { color:var(--fg); } + .lay select { font:inherit; font-size:10px; background:#1c2130; color:var(--dim); + border:1px solid var(--line); border-radius:2px; padding:1px 2px; max-width:88px; } + .lay button { padding:0 5px; font-size:11px; line-height:18px; } kbd { background:#1c2130; border:1px solid var(--line); border-radius:2px; padding:0 4px; color:var(--fg); font-size:11px; } @@ -229,6 +242,36 @@ +
+

paint — background cels, drawn on the frames that get their own drawing

+
+ + + + + +
+
+ +
+
+
+
+ pen click to place vertices · click the green box on the first one, or + Enter / double-click, to close · Esc cancels · + Backspace drops the last point
+ edit click a shape to select · drag a vertex or the shape itself · + Shift-click an edge inserts a vertex · Alt-click a vertex + removes it · Del deletes the layer
+ drag a frame from the strip onto the canvas to copy its drawing, every + layer. Vertices snap to the 320×200 grid, and colours are palette indices — + you cannot pick one that is not in the ramp. +
+
+

drawings needed — registered reference per kept frame, with the range it holds

diff --git a/js/app.js b/js/app.js index 266ca86..ea4eb46 100644 --- a/js/app.js +++ b/js/app.js @@ -11,6 +11,7 @@ import { extractTeeth } from './interior.js'; import { applySim, offsetRing } from './mathutil.js'; import { writeTake } from './take.js'; import { synthDense } from './synth.js'; +import { PaintUI, drawCel, cloneCel, newCel } from './paint.js'; const RW = 320, RH = 200, ZOOM = 2, THUMB = 92; @@ -53,6 +54,7 @@ const state = { teeth: null, // resolved per-frame {show, t} after knobs eyes: null, // resolved per-frame lid rings, shut flags, iris discs brows: null, // resolved per-frame brow rings after quantised raise + cels: new Map(), // kept frame -> hand-painted background layers eyeSig: null, // raw eye measurement, kept for the gaze readout }; @@ -510,7 +512,14 @@ function renderFrame(f, mode = plateMode()) { PALETTE.map((p) => p.hex)); } else { r.clear(IDX.bg); - if (mode === 'oval' || mode === 'oval+photo') r.fillPoly(state.plates[pf], IDX.base); + } + // Painted cels sit BEHIND the face and hold on the same frames the plate + // does - pf is already "the most recent kept frame at or before f", which is + // exactly the rule the user draws against: a cel holds until the next frame + // that has its own drawing. + drawCel(r, state.cels.get(pf), (i) => i); + if (mode !== 'posterize' && (mode === 'oval' || mode === 'oval+photo')) { + r.fillPoly(state.plates[pf], IDX.base); } // Eyes run on the CLOCK, not on the mouth lead. The lead is a lip-sync @@ -641,6 +650,7 @@ function drawAll() { drawStrip(); drawWorksheet(); drawReadout(); + drawPaint(); } function drawReadout() { @@ -885,6 +895,8 @@ function drawStrip() { const tag = document.createElement('span'); tag.textContent = f; cell.append(cv, tag); + cell.draggable = true; + cell.ondragstart = (ev) => ev.dataTransfer.setData('text/plain', String(f)); cell.onclick = (ev) => { seekTo(f); if (ev.shiftKey) toggle(f); @@ -1056,6 +1068,7 @@ async function runFrames() { el('scrub').max = dense.length - 1; state.frame = 0; labelExposure(); + loadCels(); rebuild(true); const dur = (dense.length / state.fps).toFixed(2); status(`${images.length} frames · ${images[0].naturalWidth}x${images[0].naturalHeight} · ` + @@ -1078,6 +1091,7 @@ function runSynthetic() { el('scrub').max = 71; state.frame = 0; labelExposure(); + loadCels(); rebuild(true); status('synthetic — exercises everything below detection', 'ok'); } @@ -1254,6 +1268,92 @@ PALETTE.forEach((p) => { el('palette').append(sw); }); +/* ---------- paint ---------- */ + +// The cel being edited is the one ON SCREEN, which is the most recent kept +// frame at or before the playhead. You can scrub anywhere and keep drawing on +// the cel you can see, rather than having to land exactly on a kept frame. +const celFrame = () => (state.dense ? plateIndex(state.frame) : 0); + +const paint = new PaintUI({ + canvas: el('cv-paint'), list: el('paintlayers'), info: el('paintinfo'), + zoom: 3, RW, RH, palette: PALETTE, + getCel: () => (state.dense ? (state.cels.get(celFrame()) || []) : []), + setCel: (cel) => { if (state.dense) state.cels.set(celFrame(), cel); }, + celAt: (f) => (state.dense ? state.cels.get(heldFrame(keptSorted(), f)) : null), + backing: (cv, z) => { + if (!state.dense) { + cv.width = RW * z; cv.height = RH * z; + const g = cv.getContext('2d'); + g.fillStyle = '#0d0f16'; g.fillRect(0, 0, cv.width, cv.height); + return; + } + compositeRender(cv, state.frame, z); + }, + onChange: () => { saveCels(); drawPanes(); drawStrip(); drawWorksheet(); }, +}); + +el('paintTool').addEventListener('change', () => { + paint.tool = el('paintTool').value; + paint.draft = null; + paint.render(); +}); +el('paintColor').addEventListener('change', () => { paint.color = +el('paintColor').value; }); +el('btn-celclear').onclick = () => { + if (!state.dense) return; + state.cels.set(celFrame(), newCel()); + paint.sel = -1; + paint.commit(); +}; +el('btn-celprev').onclick = () => { + // Copy the previous kept frame's finished drawing onto this one. The same + // thing the drag-and-drop does, for the case you reach for constantly. + if (!state.dense) return; + const kept = keptSorted(), here = celFrame(); + const prev = kept.filter((k) => k < here).pop(); + if (prev === undefined) { paint.say('no earlier drawing to copy'); return; } + const src = state.cels.get(prev); + if (!src || !src.length) { paint.say(`f${prev} has nothing to copy`); return; } + state.cels.set(here, cloneCel(src)); + paint.sel = -1; + paint.commit(); + paint.say(`copied f${prev} — ${src.length} layers`); +}; + +PALETTE.forEach((p, i) => { + const o = document.createElement('option'); + o.value = i; o.textContent = p.name; + el('paintColor').append(o); +}); +el('paintColor').value = 1; +paint.color = 1; + +// Drawings are the only thing here a person made by hand, so losing them to a +// reload would be the worst failure in the tool. Everything else regenerates. +const celKey = () => `arthur.cels.${el('takename').value || 'line_01'}`; +function saveCels() { + try { + localStorage.setItem(celKey(), JSON.stringify([...state.cels])); + } catch { /* private window, quota - not worth failing a brush stroke over */ } +} +function loadCels() { + state.cels = new Map(); + try { + const raw = localStorage.getItem(celKey()); + if (raw) state.cels = new Map(JSON.parse(raw).map(([k, v]) => [+k, v])); + } catch { /* corrupt or absent: start empty */ } +} + +function drawPaint() { + if (!state.dense) { paint.render(); return; } + const kept = keptSorted(), cf = celFrame(); + const until = (kept[kept.indexOf(cf) + 1] ?? state.dense.length) - 1; + el('paintframe').textContent = + `drawing cel f${cf}` + (until > cf ? ` — holds to f${until}` : '') + + (state.frame !== cf ? ` (playhead f${state.frame})` : ''); + paint.render(); +} + // #synth / #frames autorun, so the tool can be driven headlessly for smoke tests // and deep-linked. Detection needs WebGL; the synthetic path does not. window.addEventListener('error', (e) => { diff --git a/js/paint.js b/js/paint.js new file mode 100644 index 0000000..b50648a --- /dev/null +++ b/js/paint.js @@ -0,0 +1,345 @@ +// Vector cel painting: flat polygons on the frames that get their own drawing. +// +// THIS IS A SKETCH. It exists to test whether the aesthetic holds when a human +// draws the background rather than the tracker deriving it, and it is expected +// to be replaced by a real paint surface with onion skin, undo and a proper +// tool model. Deliberately kept to one module with no dependencies on the rest +// of the pipeline so that throwing it away is a delete rather than a surgery. +// +// The data model is the part worth keeping: +// +// cel = Layer[] index 0 is the BACK of the stack, last is the front +// Layer = { name, color, pts, hidden } +// +// `color` is a PALETTE INDEX, never an RGB value - the one rule from +// docs/design.md that a paint tool could most easily break. You cannot pick a +// colour here that is not already in the take's ramp. +// +// Points are integers in 320x200 raster space. Snapping is not a convenience: +// sub-pixel vertices on a hard-edged indexed rasteriser move an edge by a whole +// pixel or not at all depending on where the polygon happens to land, so a +// shape nudged by 0.4px shimmers instead of holding still. + +export const newCel = () => []; + +export const cloneCel = (cel) => + (cel || []).map((l) => ({ + name: l.name, color: l.color, hidden: !!l.hidden, + pts: l.pts.map((p) => ({ x: p.x, y: p.y })), + })); + +// Fill every visible layer back to front. Same rasteriser the derived parts +// use, so a painted shape and a traced one cannot look different. +export function drawCel(raster, cel, idxOf) { + if (!cel) return; + for (const l of cel) { + if (l.hidden || l.pts.length < 3) continue; + raster.fillPoly(l.pts, idxOf(l.color)); + } +} + +const pointInPoly = (pts, x, y) => { + let inside = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + if ((pts[i].y > y) !== (pts[j].y > y) && + x < ((pts[j].x - pts[i].x) * (y - pts[i].y)) / (pts[j].y - pts[i].y) + pts[i].x) inside = !inside; + } + return inside; +}; + +// Distance from p to segment ab, and where along it the foot falls. +function segDist(p, a, b) { + const vx = b.x - a.x, vy = b.y - a.y; + const len = vx * vx + vy * vy; + const t = len ? Math.max(0, Math.min(1, ((p.x - a.x) * vx + (p.y - a.y) * vy) / len)) : 0; + const cx = a.x + t * vx, cy = a.y + t * vy; + return { d: Math.hypot(p.x - cx, p.y - cy), t }; +} + +export class PaintUI { + constructor(opts) { + this.canvas = opts.canvas; // where the cel is drawn and edited + this.list = opts.list; // layer stack DOM host + this.info = opts.info; // status line + this.zoom = opts.zoom || 3; + this.RW = opts.RW; this.RH = opts.RH; + this.palette = opts.palette; // [{name,hex}], index is the colour + this.getCel = opts.getCel; // () => Layer[] for the frame being edited + this.setCel = opts.setCel; // (Layer[]) => void + this.celAt = opts.celAt; // (frame) => Layer[] for drag-to-seed + this.backing = opts.backing; // (canvas, zoom) => void, paints what is under + this.onChange = opts.onChange; // commit + redraw everything else + + this.tool = 'pen'; + this.color = 1; + this.sel = -1; // selected layer index + this.draft = null; // in-progress pen path + this.drag = null; + + this.bind(); + } + + /* ---- geometry ---- */ + + at(ev) { + const r = this.canvas.getBoundingClientRect(); + // Snap to the raster grid. See the note at the top of the file. + return { + x: Math.round(((ev.clientX - r.left) / r.width) * this.RW), + y: Math.round(((ev.clientY - r.top) / r.height) * this.RH), + }; + } + + hit(p) { + const cel = this.getCel(); + const near = 3; + // Vertices of the SELECTED layer win over everything, so a vertex sitting + // under another shape stays grabbable instead of selecting the shape on top. + if (this.sel >= 0 && cel[this.sel]) { + const pts = cel[this.sel].pts; + for (let i = 0; i < pts.length; i++) { + if (Math.hypot(pts[i].x - p.x, pts[i].y - p.y) <= near) return { layer: this.sel, vertex: i }; + } + for (let i = 0; i < pts.length; i++) { + const s = segDist(p, pts[i], pts[(i + 1) % pts.length]); + if (s.d <= near / 2) return { layer: this.sel, edge: i }; + } + } + // Then shapes, front to back, so clicking picks what you can see. + for (let i = cel.length - 1; i >= 0; i--) { + if (!cel[i].hidden && cel[i].pts.length >= 3 && pointInPoly(cel[i].pts, p.x, p.y)) { + return { layer: i }; + } + } + return null; + } + + /* ---- editing ---- */ + + commit() { this.onChange(); this.render(); } + + addLayer(pts) { + const cel = this.getCel().slice(); + cel.push({ name: `shape ${cel.length + 1}`, color: this.color, hidden: false, pts }); + this.setCel(cel); + this.sel = cel.length - 1; + this.commit(); + } + + mutate(fn) { + const cel = cloneCel(this.getCel()); + fn(cel); + this.setCel(cel); + this.commit(); + } + + move(from, to) { + if (to < 0 || to >= this.getCel().length) return; + this.mutate((cel) => { cel.splice(to, 0, cel.splice(from, 1)[0]); }); + this.sel = to; + this.render(); + } + + bind() { + const c = this.canvas; + + c.addEventListener('mousedown', (ev) => { + ev.preventDefault(); + const p = this.at(ev); + + if (this.tool === 'pen') { + if (!this.draft) { this.draft = [p]; this.render(); return; } + const first = this.draft[0]; + // Closing on the first point is how you finish; three points minimum, + // because a two-point "polygon" fills nothing and looks like a bug. + if (this.draft.length >= 3 && Math.hypot(first.x - p.x, first.y - p.y) <= 3) { + const pts = this.draft; this.draft = null; this.addLayer(pts); return; + } + this.draft.push(p); this.render(); return; + } + + const h = this.hit(p); + if (!h) { this.sel = -1; this.render(); return; } + this.sel = h.layer; + + if (h.vertex !== undefined) { + if (ev.altKey) { + // Never below a triangle. + if (this.getCel()[h.layer].pts.length > 3) { + this.mutate((cel) => cel[h.layer].pts.splice(h.vertex, 1)); + } + return; + } + this.drag = { kind: 'vertex', layer: h.layer, vertex: h.vertex }; + this.render(); return; + } + if (h.edge !== undefined && ev.shiftKey) { + this.mutate((cel) => cel[h.layer].pts.splice(h.edge + 1, 0, { x: p.x, y: p.y })); + this.drag = { kind: 'vertex', layer: h.layer, vertex: h.edge + 1 }; + return; + } + this.drag = { kind: 'shape', layer: h.layer, from: p }; + this.render(); + }); + + window.addEventListener('mousemove', (ev) => { + if (!this.drag) return; + const p = this.at(ev); + const d = this.drag; + const cel = this.getCel(); + if (d.kind === 'vertex') { + const v = cel[d.layer].pts[d.vertex]; + if (v.x === p.x && v.y === p.y) return; + v.x = p.x; v.y = p.y; + } else { + const dx = p.x - d.from.x, dy = p.y - d.from.y; + if (!dx && !dy) return; + for (const q of cel[d.layer].pts) { q.x += dx; q.y += dy; } + d.from = p; + } + this.render(); + }); + + window.addEventListener('mouseup', () => { + if (!this.drag) return; + this.drag = null; + this.commit(); + }); + + c.addEventListener('dblclick', (ev) => { + ev.preventDefault(); + if (this.tool === 'pen' && this.draft && this.draft.length >= 3) { + const pts = this.draft; this.draft = null; this.addLayer(pts); + } + }); + + // Seed from another frame: drag a strip thumbnail onto the canvas and take + // a copy of whatever that frame is showing, every layer. Copy, never + // reference - two cels sharing a layer object would edit each other and the + // reason would be invisible. + c.addEventListener('dragover', (ev) => { ev.preventDefault(); c.classList.add('drop'); }); + c.addEventListener('dragleave', () => c.classList.remove('drop')); + c.addEventListener('drop', (ev) => { + ev.preventDefault(); + c.classList.remove('drop'); + const f = +ev.dataTransfer.getData('text/plain'); + if (!Number.isFinite(f)) return; + const src = this.celAt(f); + if (!src || !src.length) { this.say(`f${f} has nothing to copy`); return; } + this.setCel(cloneCel(src)); + this.sel = -1; + this.commit(); + this.say(`seeded from f${f} — ${src.length} layers`); + }); + + window.addEventListener('keydown', (ev) => { + if (ev.target.tagName === 'INPUT' || ev.target.tagName === 'SELECT') return; + if (!this.focused) return; + if (ev.key === 'Escape' && this.draft) { this.draft = null; this.render(); ev.preventDefault(); } + else if (ev.key === 'Enter' && this.draft && this.draft.length >= 3) { + const pts = this.draft; this.draft = null; this.addLayer(pts); ev.preventDefault(); + } else if (ev.key === 'Backspace' && this.draft) { + this.draft.pop(); if (!this.draft.length) this.draft = null; + this.render(); ev.preventDefault(); + } else if ((ev.key === 'Delete' || ev.key === 'Backspace') && this.sel >= 0) { + this.mutate((cel) => cel.splice(this.sel, 1)); + this.sel = -1; ev.preventDefault(); + } + }); + + // The paint canvas takes the keyboard only while the pointer is over it, so + // the main window's frame stepping keeps working everywhere else. + c.addEventListener('mouseenter', () => { this.focused = true; }); + c.addEventListener('mouseleave', () => { this.focused = false; }); + } + + say(msg) { if (this.info) this.info.textContent = msg; } + + /* ---- drawing ---- */ + + render() { + const z = this.zoom, c = this.canvas; + this.backing(c, z); // the composited frame, cels included + const g = c.getContext('2d'); + + const cel = this.getCel(); + g.lineWidth = 1; + cel.forEach((l, i) => { + if (l.pts.length < 2) return; + const on = i === this.sel; + // Unselected outlines stay faint: they are there so you can find a shape + // to click, not so you can read them. + g.strokeStyle = l.hidden ? '#f8717166' : (on ? '#fbbf24' : '#ffffff33'); + g.beginPath(); + l.pts.forEach((p, k) => (k ? g.lineTo(p.x * z, p.y * z) : g.moveTo(p.x * z, p.y * z))); + g.closePath(); g.stroke(); + if (!on) return; + g.fillStyle = '#fbbf24'; + for (const p of l.pts) g.fillRect(p.x * z - 2, p.y * z - 2, 5, 5); + }); + + if (this.draft) { + g.strokeStyle = '#4ade80'; + g.beginPath(); + this.draft.forEach((p, k) => (k ? g.lineTo(p.x * z, p.y * z) : g.moveTo(p.x * z, p.y * z))); + g.stroke(); + g.fillStyle = '#4ade80'; + for (const p of this.draft) g.fillRect(p.x * z - 2, p.y * z - 2, 5, 5); + // The closing target, so "click here to finish" is visible rather than + // something you have to know. + if (this.draft.length >= 3) { + g.strokeStyle = '#4ade80'; + g.strokeRect(this.draft[0].x * z - 4, this.draft[0].y * z - 4, 9, 9); + } + } + this.renderList(); + } + + renderList() { + const cel = this.getCel(); + this.list.innerHTML = ''; + // Top of the list is the FRONT of the stack, the way a layers panel reads. + for (let i = cel.length - 1; i >= 0; i--) { + const l = cel[i]; + const row = document.createElement('div'); + row.className = 'lay' + (i === this.sel ? ' sel' : ''); + + const sw = document.createElement('select'); + this.palette.forEach((p, k) => { + const o = document.createElement('option'); + o.value = k; o.textContent = p.name; + sw.append(o); + }); + sw.value = l.color; + sw.onchange = () => this.mutate((cc) => { cc[i].color = +sw.value; }); + + const chip = document.createElement('i'); + chip.style.background = this.palette[l.color] ? this.palette[l.color].hex : '#f0f'; + + const nm = document.createElement('b'); + nm.textContent = l.name; + nm.onclick = () => { this.sel = i; this.render(); }; + + const btn = (txt, title, fn) => { + const b = document.createElement('button'); + b.textContent = txt; b.title = title; + b.onclick = (e) => { e.stopPropagation(); fn(); }; + return b; + }; + + row.append(chip, sw, nm, + btn('↑', 'forward', () => this.move(i, i + 1)), + btn('↓', 'back', () => this.move(i, i - 1)), + btn(l.hidden ? '◻' : '◼', 'show/hide', () => this.mutate((cc) => { cc[i].hidden = !cc[i].hidden; })), + btn('✕', 'delete', () => { this.mutate((cc) => cc.splice(i, 1)); this.sel = -1; })); + row.onclick = () => { this.sel = i; this.render(); }; + this.list.append(row); + } + if (!cel.length) { + const e = document.createElement('div'); + e.className = 'legend'; + e.textContent = 'no layers — draw with the pen, or drag a frame here to copy its drawing'; + this.list.append(e); + } + } +} diff --git a/serve.py b/serve.py new file mode 100755 index 0000000..36139d3 --- /dev/null +++ b/serve.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Dev server that refuses to cache anything. + +`python3 -m http.server` sends Last-Modified, and browsers cache ES modules on +it hard enough that a reload can serve a stale js/app.js against a fresh +index.html. That failure is silent and points nowhere near its cause: the new +knobs appear in the markup, nothing wires them, no error is raised, and the +symptom reads as "the feature you just added does not work". +""" +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer + + +class NoCache(SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header('Cache-Control', 'no-store, must-revalidate') + self.send_header('Expires', '0') + super().end_headers() + + def log_message(self, fmt, *args): + if '304' not in fmt % args: + super().log_message(fmt, *args) + + +if __name__ == '__main__': + port = int(sys.argv[1]) if len(sys.argv) > 1 else 8777 + print(f'http://127.0.0.1:{port} (no-store)') + ThreadingHTTPServer(('127.0.0.1', port), NoCache).serve_forever()