lsmpp commited on
Commit
3ad81a7
·
verified ·
1 Parent(s): f6465fc

Make example switching reuse preloaded images and sessions instead of re-encoding and downloading

Browse files
.gitattributes CHANGED
@@ -35,3 +35,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  examples/chinese.png filter=lfs diff=lfs merge=lfs -text
37
  examples/english.png filter=lfs diff=lfs merge=lfs -text
 
 
 
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  examples/chinese.png filter=lfs diff=lfs merge=lfs -text
37
  examples/english.png filter=lfs diff=lfs merge=lfs -text
38
+ examples/chinese-preview.webp filter=lfs diff=lfs merge=lfs -text
39
+ examples/english-preview.webp filter=lfs diff=lfs merge=lfs -text
app.py CHANGED
@@ -18,6 +18,7 @@ import urllib.request
18
  import uuid
19
  import zipfile
20
  from concurrent.futures import ThreadPoolExecutor
 
21
  from pathlib import Path
22
 
23
  from fastapi import FastAPI, File, HTTPException, UploadFile
@@ -53,7 +54,23 @@ if os.environ.get("PIXTEXT_GRADIO") == "1":
53
  else:
54
  app = FastAPI(title="PixText Studio", docs_url=None, redoc_url=None)
55
  app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
56
- app.mount("/examples", StaticFiles(directory=ROOT / "examples"), name="examples")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
 
59
  class ExampleRequest(BaseModel):
@@ -109,14 +126,19 @@ def make_session(image: Image.Image, example: str | None = None):
109
  sid = uuid.uuid4().hex
110
  directory = RUNTIME / sid
111
  directory.mkdir()
112
- image.save(directory / "source.png")
113
- image.save(directory / "preview.webp", quality=94, method=4)
 
 
 
 
114
  SESSIONS[sid] = {
115
  "directory": directory, "size": image.size, "example": example,
116
  "touched": time.monotonic(), "job": None,
117
  }
118
  return {"id": sid, "width": image.width, "height": image.height,
119
- "image": f"/api/sessions/{sid}/preview.webp", "example": example}
 
120
 
121
 
122
  def update_job(jid: str, **values):
@@ -320,7 +342,7 @@ def example_session(request: ExampleRequest):
320
  if request.example not in EXAMPLES:
321
  raise HTTPException(404, "Example not found")
322
  with Image.open(ROOT / "examples" / f"{request.example}.png") as image:
323
- session = make_session(image.convert("RGB"), request.example)
324
  return {**session, **example_preset(request.example)}
325
 
326
 
 
18
  import uuid
19
  import zipfile
20
  from concurrent.futures import ThreadPoolExecutor
21
+ from functools import lru_cache
22
  from pathlib import Path
23
 
24
  from fastapi import FastAPI, File, HTTPException, UploadFile
 
54
  else:
55
  app = FastAPI(title="PixText Studio", docs_url=None, redoc_url=None)
56
  app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static")
57
+
58
+ class ExampleFiles(StaticFiles):
59
+ async def get_response(self, path, scope):
60
+ response = await super().get_response(path, scope)
61
+ if path.endswith("-preview.webp") and response.status_code in (200, 304):
62
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
63
+ return response
64
+
65
+
66
+ app.mount("/examples", ExampleFiles(directory=ROOT / "examples"), name="examples")
67
+
68
+
69
+ @lru_cache(maxsize=2)
70
+ def example_preview(example: str):
71
+ path = ROOT / "examples" / f"{example}-preview.webp"
72
+ version = hashlib.sha256(path.read_bytes()).hexdigest()[:12]
73
+ return f"/examples/{path.name}?v={version}"
74
 
75
 
76
  class ExampleRequest(BaseModel):
 
126
  sid = uuid.uuid4().hex
127
  directory = RUNTIME / sid
128
  directory.mkdir()
129
+ if example:
130
+ shutil.copyfile(ROOT / "examples" / f"{example}.png", directory / "source.png")
131
+ shutil.copyfile(ROOT / "examples" / f"{example}-preview.webp", directory / "preview.webp")
132
+ else:
133
+ image.save(directory / "source.png")
134
+ image.save(directory / "preview.webp", quality=94, method=4)
135
  SESSIONS[sid] = {
136
  "directory": directory, "size": image.size, "example": example,
137
  "touched": time.monotonic(), "job": None,
138
  }
139
  return {"id": sid, "width": image.width, "height": image.height,
140
+ "image": example_preview(example) if example else f"/api/sessions/{sid}/preview.webp",
141
+ "example": example}
142
 
143
 
144
  def update_job(jid: str, **values):
 
342
  if request.example not in EXAMPLES:
343
  raise HTTPException(404, "Example not found")
344
  with Image.open(ROOT / "examples" / f"{request.example}.png") as image:
345
+ session = make_session(image, request.example)
346
  return {**session, **example_preset(request.example)}
347
 
348
 
examples/chinese-preview.webp ADDED

Git LFS Details

  • SHA256: dfbb3fb24330572923d74950bb7719016b2504201e6e1edd17b7be1bcbbf6617
  • Pointer size: 131 Bytes
  • Size of remote file: 471 kB
examples/english-preview.webp ADDED

Git LFS Details

  • SHA256: 8a15b66d2fd915746f9ed1e8eae6513b34ccae55958c8dd17a56123988498d24
  • Pointer size: 131 Bytes
  • Size of remote file: 141 kB
static/app.js CHANGED
@@ -6,6 +6,22 @@ let gesture = null;
6
  const clone = (x) => structuredClone(x);
7
  const escape = (s) => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
8
  const endpoint = (name) => `/api/sessions/${state.session.id}/${name}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  async function api(url, body, isForm=false) {
11
  const response = await fetch(url, body === undefined ? {} : {method:'POST', headers:isForm?{}:{'Content-Type':'application/json'}, body:isForm?body:JSON.stringify(body)});
@@ -28,13 +44,13 @@ function changed() {
28
  updateControls();
29
  }
30
  function selected() {return state.regions.find(r=>r.id===state.selected);}
31
- function setBusy(busy) {
32
  state.busy=busy;
33
  document.querySelectorAll('[data-example],[data-model],#uploadButton,#exampleTargets,#resetButton,#drawTool,#undoButton,#targetText,#enableRegion,#deleteRegion,.coords input,.settings input,.region-row input').forEach(e=>e.disabled=busy);
34
  $('regionCanvas').style.pointerEvents=busy?'none':'';
35
  $('jobProgress').hidden=!busy;
36
  $('jobProgress').classList.toggle('waiting',busy);
37
- if(busy){$('jobMessage').textContent='Waiting for the model';$('jobPercent').textContent='';$('progressFill').style.width='0%';}
38
  updateControls();
39
  }
40
  function updateControls() {
@@ -63,7 +79,8 @@ function applySettings(settings) {
63
  if(settings[key]!=null)$(id).value=settings[key];
64
  }
65
  }
66
- async function loadSession(session) {
 
67
  state.session=session;state.regions=clone(session.regions||[]);state.selected=state.regions[0]?.id||null;state.history=[];state.result=null;state.stale=false;state.tool='select';
68
  if(session.settings)applySettings(session.settings);
69
  $('regionResults').hidden=true;$('compareView').disabled=true;
@@ -72,14 +89,18 @@ async function loadSession(session) {
72
  const image=$('sourceImage'); image.setAttribute('href',session.image); image.setAttribute('width',session.width);image.setAttribute('height',session.height);
73
  $('imageSize').textContent=`${session.width} × ${session.height}`;
74
  $('wipe').style.aspectRatio=`${session.width} / ${session.height}`;
75
- state.source=new Image(); state.source.src=session.image;
76
- await state.source.decode();
77
  document.querySelectorAll('[data-example]').forEach(e=>e.classList.toggle('active',e.dataset.example===session.example));
78
  switchView('edit');setTool('select');render();notice();
79
  }
80
  async function loadExample(example) {
81
  if(state.busy)return;
82
- try{setBusy(true);await loadSession(await api('/api/session',{example}));history.replaceState(null,'',location.pathname);}catch(e){notice(e.message);}finally{setBusy(false);}
 
 
 
 
 
83
  }
84
  async function upload(file) {
85
  if(!file||state.busy)return;
@@ -272,6 +293,8 @@ async function openStudio(){
272
  }catch(e){notice('This preview has expired. Choose an example to start a new image.');}
273
  finally{setBusy(false);}
274
  }
 
 
275
  await loadExample('english');
276
  }
277
  openStudio();
 
6
  const clone = (x) => structuredClone(x);
7
  const escape = (s) => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
8
  const endpoint = (name) => `/api/sessions/${state.session.id}/${name}`;
9
+ const exampleCache = new Map();
10
+
11
+ function prepareExample(example) {
12
+ const cached = exampleCache.get(example);
13
+ if(cached && cached.expires > Date.now()) return cached.promise;
14
+ const entry = {expires:Date.now()+45*60*1000};
15
+ entry.promise = (async()=>{
16
+ const session = await api('/api/session',{example});
17
+ const image = new Image(); image.src=session.image;
18
+ await image.decode();
19
+ return {session,image};
20
+ })();
21
+ exampleCache.set(example,entry);
22
+ entry.promise.catch(()=>{if(exampleCache.get(example)===entry)exampleCache.delete(example);});
23
+ return entry.promise;
24
+ }
25
 
26
  async function api(url, body, isForm=false) {
27
  const response = await fetch(url, body === undefined ? {} : {method:'POST', headers:isForm?{}:{'Content-Type':'application/json'}, body:isForm?body:JSON.stringify(body)});
 
44
  updateControls();
45
  }
46
  function selected() {return state.regions.find(r=>r.id===state.selected);}
47
+ function setBusy(busy, message='Waiting for the model') {
48
  state.busy=busy;
49
  document.querySelectorAll('[data-example],[data-model],#uploadButton,#exampleTargets,#resetButton,#drawTool,#undoButton,#targetText,#enableRegion,#deleteRegion,.coords input,.settings input,.region-row input').forEach(e=>e.disabled=busy);
50
  $('regionCanvas').style.pointerEvents=busy?'none':'';
51
  $('jobProgress').hidden=!busy;
52
  $('jobProgress').classList.toggle('waiting',busy);
53
+ if(busy){$('jobMessage').textContent=message;$('jobPercent').textContent='';$('progressFill').style.width='0%';}
54
  updateControls();
55
  }
56
  function updateControls() {
 
79
  if(settings[key]!=null)$(id).value=settings[key];
80
  }
81
  }
82
+ async function loadSession(session, source=null) {
83
+ if(!source){source=new Image();source.src=session.image;await source.decode();}
84
  state.session=session;state.regions=clone(session.regions||[]);state.selected=state.regions[0]?.id||null;state.history=[];state.result=null;state.stale=false;state.tool='select';
85
  if(session.settings)applySettings(session.settings);
86
  $('regionResults').hidden=true;$('compareView').disabled=true;
 
89
  const image=$('sourceImage'); image.setAttribute('href',session.image); image.setAttribute('width',session.width);image.setAttribute('height',session.height);
90
  $('imageSize').textContent=`${session.width} × ${session.height}`;
91
  $('wipe').style.aspectRatio=`${session.width} / ${session.height}`;
92
+ state.source=source;
 
93
  document.querySelectorAll('[data-example]').forEach(e=>e.classList.toggle('active',e.dataset.example===session.example));
94
  switchView('edit');setTool('select');render();notice();
95
  }
96
  async function loadExample(example) {
97
  if(state.busy)return;
98
+ try{
99
+ setBusy(true,'Loading example');
100
+ const prepared=await prepareExample(example);
101
+ await loadSession(clone(prepared.session),prepared.image);
102
+ history.replaceState(null,'',location.pathname);
103
+ }catch(e){notice(e.message);}finally{setBusy(false);}
104
  }
105
  async function upload(file) {
106
  if(!file||state.busy)return;
 
293
  }catch(e){notice('This preview has expired. Choose an example to start a new image.');}
294
  finally{setBusy(false);}
295
  }
296
+ // Prepare both examples together; switching reuses decoded images and untouched presets.
297
+ prepareExample('chinese').catch(()=>{});
298
  await loadExample('english');
299
  }
300
  openStudio();
static/index.html CHANGED
@@ -5,7 +5,9 @@
5
  <meta name="theme-color" content="#faf9f6">
6
  <title>PixText · Text repair studio</title>
7
  <link rel="stylesheet" href="/static/style.css">
8
- <link rel="modulepreload" href="/static/gradio-client.js">
 
 
9
  <script defer src="/static/app.js"></script>
10
  </head>
11
  <body>
 
5
  <meta name="theme-color" content="#faf9f6">
6
  <title>PixText · Text repair studio</title>
7
  <link rel="stylesheet" href="/static/style.css">
8
+ <link rel="preload" as="image" href="/examples/english-preview.webp?v=8a15b66d2fd9">
9
+ <link rel="preload" as="image" href="/examples/chinese-preview.webp?v=dfbb3fb24330">
10
+ <link rel="modulepreload" href="/static/gradio-client.js">
11
  <script defer src="/static/app.js"></script>
12
  </head>
13
  <body>
tests/test_api.py CHANGED
@@ -92,3 +92,25 @@ def test_sessions_and_jobs_expire_together(client, monkeypatch):
92
  new_session(client)
93
  assert not directory.exists()
94
  assert client.get(f"/api/sessions/{sid}/source.png").status_code == 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  new_session(client)
93
  assert not directory.exists()
94
  assert client.get(f"/api/sessions/{sid}/source.png").status_code == 404
95
+
96
+
97
+ @pytest.mark.parametrize('example', ['english', 'chinese'])
98
+ def test_example_selection_reuses_preview_without_reencoding(client, monkeypatch, example):
99
+ def unexpected_encoding(*args, **kwargs):
100
+ raise AssertionError('Example selection is re-encoding images on the request path')
101
+
102
+ with monkeypatch.context() as patch:
103
+ patch.setattr(Image.Image, 'save', unexpected_encoding)
104
+ first = client.post('/api/session', json={'example': example}).json()
105
+ second = client.post('/api/session', json={'example': example}).json()
106
+ assert first['id'] != second['id']
107
+ assert first['image'] == second['image']
108
+ preview = client.get(first['image'])
109
+ assert preview.status_code == 200
110
+ assert 'public' in preview.headers['cache-control']
111
+ assert 'immutable' in preview.headers['cache-control']
112
+ assert client.get(first['image'], headers={'If-None-Match': preview.headers['etag']}).status_code == 304
113
+ assert first['image'] in client.get('/').text
114
+ assert Image.open(io.BytesIO(preview.content)).size == (first['width'], first['height'])
115
+ original = client.get(f"/api/sessions/{first['id']}/source.png")
116
+ assert original.headers['cache-control'] == 'private, no-store'
tests/test_examples.mjs ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict';
2
+ import {readFileSync} from 'node:fs';
3
+ import test from 'node:test';
4
+ import vm from 'node:vm';
5
+
6
+ const script = readFileSync(new URL('../static/app.js', import.meta.url), 'utf8');
7
+ const presets = Object.fromEntries(['english', 'chinese'].map(name => [name,
8
+ JSON.parse(readFileSync(new URL(`../examples/${name}.json`, import.meta.url), 'utf8'))]));
9
+
10
+ // Run the real editor against a small DOM/fetch adapter; no models or browser packages needed.
11
+ function editor() {
12
+ const elements = new Map(), requests = [], decoded = [];
13
+ let nextId = 0, failExample = null;
14
+ const element = id => {
15
+ if(!elements.has(id)) elements.set(id, {value:'', dataset:{}, style:{setProperty(){}},
16
+ classList:{toggle(){},add(){},remove(){}}, setAttribute(){}, addEventListener(){},
17
+ getContext(){return {drawImage(){}};}, children:[], focus(){}});
18
+ return elements.get(id);
19
+ };
20
+ const examples = ['english','chinese'].map(example => ({...element(example),dataset:{example}}));
21
+ const models = ['meanflow','teacher'].map(model => ({...element(model),dataset:{model}}));
22
+ const context = vm.createContext({structuredClone, URLSearchParams, console, setTimeout,
23
+ document:{getElementById:element,addEventListener(){},querySelectorAll(selector){
24
+ if(selector==='[data-example]') return examples;
25
+ if(selector==='[data-model]') return models;
26
+ return [];
27
+ }},
28
+ Image:class {async decode(){decoded.push(this.src);this.complete=true;}},
29
+ location:{pathname:'/',hash:''}, history:{replaceState(){}},
30
+ fetch:async(url,options) => {
31
+ if(url==='/api/config') return {ok:true,json:async()=>({transport:'http'})};
32
+ assert.equal(url,'/api/session');
33
+ const {example}=JSON.parse(options.body);requests.push(example);
34
+ if(example===failExample){failExample=null;throw new Error('Network unavailable');}
35
+ return {ok:true,json:async()=>({id:`session-${++nextId}`,example,width:1024,height:1024,
36
+ image:`/examples/${example}-preview.webp?v=release`,
37
+ regions:presets[example].map((r,i)=>({...r,id:`example-${i}`,enabled:true,origin:'example'})),
38
+ settings:{variant:'meanflow',steps:4,seed:42,guidance:2.6,text_threshold:.5}})};
39
+ }});
40
+ vm.runInContext(script,context);
41
+ const run=code=>vm.runInContext(code,context);
42
+ return {run,requests,decoded,element,failNext(example){failExample=example;}};
43
+ }
44
+
45
+ async function ready(app) {
46
+ await app.run("Promise.all([prepareExample('english'),prepareExample('chinese')])");
47
+ await new Promise(resolve=>setImmediate(resolve));
48
+ }
49
+
50
+ test('switching and restoring example edits reuse prepared sessions and decoded images', async()=>{
51
+ const app=editor();await ready(app);
52
+ assert.equal(app.requests.length,2);
53
+ assert.equal(app.decoded.length,2);
54
+ for(const example of ['chinese','english','chinese','english']) {
55
+ await app.run(`loadExample('${example}')`);
56
+ assert.equal(app.run('state.regions.length'),presets[example].length);
57
+ assert.equal(app.element('guidance').value,2.6);
58
+ assert.equal(app.element('refineButton').disabled,false);
59
+ app.run("state.regions[0].text='edited'; state.regions[0].bbox[0]=0; state.regions[0].enabled=false;");
60
+ }
61
+ await app.run("loadExample('english')");
62
+ assert.equal(app.run('state.regions[0].text'),presets.english[0].text);
63
+ assert.equal(app.run('state.regions[0].bbox[0]'),presets.english[0].bbox[0]);
64
+ assert.equal(app.run('state.regions[0].enabled'),true);
65
+ assert.equal(app.requests.length,2);
66
+ assert.equal(app.decoded.length,2);
67
+ });
68
+
69
+ test('concurrent preparation shares one request and an expired session is renewed',async()=>{
70
+ const app=editor();await ready(app);
71
+ app.run("exampleCache.get('chinese').expires=0");
72
+ await app.run("Promise.all([prepareExample('chinese'),prepareExample('chinese')])");
73
+ assert.equal(app.requests.filter(x=>x==='chinese').length,2);
74
+ });
75
+
76
+ test('failed preparation is retryable without replacing the current editor',async()=>{
77
+ const app=editor();await ready(app);
78
+ await app.run("loadExample('english')");
79
+ const current=app.run('state.session.id');
80
+ app.run("exampleCache.get('chinese').expires=0");app.failNext('chinese');
81
+ await app.run("loadExample('chinese')");
82
+ assert.equal(app.run('state.session.id'),current);
83
+ assert.equal(app.run('state.busy'),false);
84
+ await app.run("loadExample('chinese')");
85
+ assert.equal(app.run('state.regions.length'),13);
86
+ });