screengraft 0.17.0 → 0.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/index.html CHANGED
@@ -277,8 +277,24 @@
277
277
  /* 304px wrapped "desktop" onto a second line. The rail is sized to the
278
278
  content it must hold — four device chips in one row — rather than to a
279
279
  round number. */
280
- .stage{display:grid;grid-template-columns:minmax(0,1fr) 344px;min-height:0;overflow:hidden}
281
- .stage.compare{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px}
280
+ /* Side by side is the layout, not a mode. Judging a corner means seeing the
281
+ same corner in the fit and in the composite at the same zoom, so a version
282
+ of this page where that is optional is a version where the check is
283
+ optional. Two equal panes plus the rail. */
284
+ .stage{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) 344px;
285
+ min-height:0;overflow:hidden}
286
+ /* Below ~1100px two panes and a 344px rail no longer fit, and the page starts
287
+ scrolling sideways — which the UI audit catches as a hard failure. Stack the
288
+ panes instead of dropping one: losing the comparison is the one thing this
289
+ layout exists to prevent, so the axis gives way before the pairing does.
290
+ (Found by the audit after compare stopped being optional, at 742px.) */
291
+ @media (max-width:1100px){
292
+ .stage{grid-template-columns:minmax(0,1fr) 344px;
293
+ grid-template-rows:minmax(0,1fr) minmax(0,1fr)}
294
+ .stage > .pane:first-of-type{grid-column:1;grid-row:1}
295
+ .stage > #outPane{grid-column:1;grid-row:2;border-top:1px solid var(--line)}
296
+ .stage > .rail{grid-column:2;grid-row:1 / span 2}
297
+ }
282
298
  #types{flex-wrap:nowrap}
283
299
  .pane{min-width:0;min-height:0;display:grid;grid-template-rows:auto minmax(0,1fr);
284
300
  position:relative; /* the frame the corner bar is anchored to */
@@ -327,13 +343,19 @@
327
343
  photo is chosen but the screenshot is not, offering corners to fit
328
344
  nothing. */
329
345
  .cvbar[hidden]{display:none}
346
+ /* Render spinner. A determinate percentage was the first version and it read
347
+ as busier than the work felt; a single moving thing says "still going"
348
+ without asking to be read. prefers-reduced-motion gets a static ring —
349
+ the button's label already carries the meaning. */
350
+ .spin{display:inline-block;width:12px;height:12px;margin-left:8px;
351
+ vertical-align:-1px;border:2px solid currentColor;border-radius:50%;
352
+ border-right-color:transparent;opacity:.9;
353
+ animation:spin .7s linear infinite}
354
+ @keyframes spin{to{transform:rotate(360deg)}}
355
+ @media (prefers-reduced-motion:reduce){ .spin{animation:none} }
330
356
 
331
357
  /* Preview popover: the composite at whatever size fits, on the sunk surface
332
358
  so the image edge is unambiguous against the panel. */
333
- #previewWrap{margin-top:10px;background:var(--well);border-radius:var(--r-md);
334
- box-shadow:inset 0 1px 3px rgba(0,0,0,.5);display:grid;
335
- place-items:center;min-height:200px;max-height:70vh;overflow:auto}
336
- #previewImg{display:block;max-width:100%;height:auto}
337
359
 
338
360
  /* Switch — the full 8-variant matrix from Figma 17:61
339
361
  (State Rest/Hover/Pressed/Disabled x Selected No/Yes).
@@ -403,8 +425,7 @@
403
425
  button.switch:focus-visible{outline:none}
404
426
  button.switch:focus-visible .sw-track{box-shadow:0 0 0 3px var(--acc-soft);border-color:var(--acc)}
405
427
 
406
- #outPane{display:none}
407
- .stage.compare #outPane{display:grid}
428
+ #outPane{display:grid}
408
429
  #outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
409
430
  #outImg{display:block;margin:auto}
410
431
  .empty{color:var(--mute);font-size:12px;padding:24px;text-align:center;align-self:center;justify-self:center;max-width:320px}
@@ -674,7 +695,6 @@
674
695
  </div>
675
696
  <span class="spacer"></span>
676
697
  <div class="tb-actions">
677
- <button id="preview" hidden>Preview</button>
678
698
  <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
679
699
  <button id="save" class="accent" disabled>Preview first</button>
680
700
  </div>
@@ -727,7 +747,22 @@
727
747
  <span class="spacer"></span>
728
748
  <span class="sm" style="color:var(--mute)">shares zoom &amp; pan</span>
729
749
  </div>
730
- <div id="outWrap"><span class="empty" id="outEmpty">Press Preview — or turn on Compare view and this updates as you drag.</span><img id="outImg" alt="" hidden></div>
750
+ <div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden></div>
751
+ <!-- Video controls. Hidden entirely for a still source: a scrubber over a
752
+ screenshot is a control that can only confuse. The fit itself is
753
+ unchanged — you match the edges on one frame and every frame gets the
754
+ same geometry, because the photo does not move. -->
755
+ <div class="cvbar" id="vidbar" hidden>
756
+ <span class="sm" style="color:var(--mute)">Fit on frame</span>
757
+ <span class="cvbar-actions">
758
+ <input type="range" id="vframe" min="0" max="0" value="0" step="1"
759
+ aria-label="Which frame of the clip to match the edges on">
760
+ <span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
761
+ <span class="stepper" role="group" aria-label="Output format">
762
+ <button class="sm sel" data-preset="web" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
763
+ </span>
764
+ </span>
765
+ </div>
731
766
  </section>
732
767
 
733
768
  <aside class="rail">
@@ -779,17 +814,6 @@
779
814
  </button>
780
815
  </div>
781
816
  </div>
782
- <div class="sect" id="secCompare" data-on="1">
783
- <div class="sect-top">
784
- <div class="sect-left">
785
- <h4>Compare view</h4>
786
- <button class="info" type="button" aria-label="About compare view"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path fill="currentColor" d="M8.36 1.01C12.06 1.2 15 4.26 15 8l-.01.36C14.8 12.06 11.74 15 8 15l-.36-.01C4.06 14.81 1.19 11.94 1.01 8.36L1 8c0-3.87 3.13-7 7-7l.36.01ZM8 2C4.69 2 2 4.69 2 8c0 3.31 2.69 6 6 6 3.31 0 6-2.69 6-6 0-3.31-2.69-6-6-6Zm.5 8l.5 0v1L7 11v-1h.5V7H7V6h1.5v4ZM7.9 4c.33 0 .6.27.6.6a.6.6 0 1 1-1.21 0c0-.33.28-.6.61-.6Z"/></svg><span class="tip" role="tooltip">Shows the fit and the composite side by side, sharing zoom and pan, so a corner can be judged against the same corner.</span></button>
787
- </div>
788
- <button class="switch" id="cmpBtn" role="switch" aria-checked="true" aria-label="Compare view">
789
- <span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
790
- </button>
791
- </div>
792
- </div>
793
817
  <div class="sect" id="secKeys" data-on="1">
794
818
  <div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
795
819
  <p class="hint"><kbd>&larr;&uarr;&rarr;&darr;</kbd> nudge 1px · <kbd>&#8679;</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
@@ -825,12 +849,6 @@
825
849
  <div class="scrim" id="scrim"></div>
826
850
 
827
851
 
828
- <div class="pop" id="previewPop" style="width:min(900px,calc(100vw - 24px))">
829
- <h3>Preview</h3>
830
- <p class="sub" id="previewSt">Rendering…</p>
831
- <div id="previewWrap"><img id="previewImg" alt="" hidden></div>
832
- </div>
833
-
834
852
  <div class="pop" id="pop1">
835
853
  <h3>Photo of the device</h3>
836
854
  <p class="sub">Recent images from Desktop and Downloads — click one. Or browse, drop a file, or paste a path.</p>
@@ -853,7 +871,7 @@
853
871
  <div class="pv" id="pv2"><span class="empty">Select a recent image</span></div>
854
872
  </div>
855
873
  <div class="drop" id="drop2">Drop a screenshot here or click to browse</div>
856
- <input type="file" id="file2" accept="image/*" hidden>
874
+ <input type="file" id="file2" accept="image/*,video/mp4,video/quicktime,video/webm,.mp4,.mov,.m4v,.webm" hidden>
857
875
  </div>
858
876
 
859
877
  <script>
@@ -1105,26 +1123,28 @@ async function loadRecent(){
1105
1123
  }
1106
1124
  }
1107
1125
  async function choose(role, path, el){
1108
- try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el); }
1126
+ try{ const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, el, r); }
1109
1127
  catch(e){ alert(e.message); }
1110
1128
  }
1111
- function setChosen(role, path, size, el){
1129
+ function setChosen(role, path, size, el, meta){
1112
1130
  const n = role==='photo'?1:2;
1113
1131
  document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
1114
1132
  if (el) el.classList.add('sel');
1115
1133
  $('#pv'+n).innerHTML = `<img src="${fileURL(path)}" alt="">`;
1116
1134
  const chip = $('#chip'+n);
1117
1135
  chip.classList.remove('is-empty');
1118
- chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`;
1136
+ chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1137
+ + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1119
1138
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(path)}")`;
1120
- if (role==='photo'){ st.photo = path; st.corners = null; } else st.shot = path;
1139
+ if (role==='photo'){ st.photo = path; st.corners = null; }
1140
+ else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
1121
1141
  closePop();
1122
1142
  maybeStart();
1123
1143
  }
1124
1144
  async function upload(role, file){
1125
1145
  const buf = await file.arrayBuffer();
1126
1146
  const r = await api('/api/upload', null, {headers:{'X-Filename':encodeURIComponent(file.name),'X-Role':role}, body:buf});
1127
- setChosen(role, r.path, r.size, null);
1147
+ setChosen(role, r.path, r.size, null, r);
1128
1148
  }
1129
1149
  for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
1130
1150
  const drop = $('#drop'+n), file = $('#file'+n);
@@ -1236,7 +1256,7 @@ $('#figmaBtn').onclick = async () => {
1236
1256
  try {
1237
1257
  const r = await api('/api/job/adopt', {});
1238
1258
  stop('ok', 'Exported');
1239
- setChosen('screenshot', r.path, r.size, null);
1259
+ setChosen('screenshot', r.path, r.size, null, r);
1240
1260
  } catch (e) { stop('err', 'Exported, but the file could not be read: ' + e.message); }
1241
1261
  },
1242
1262
  onError: (j) => stop('err', 'Export failed: ' + (j.message || '')),
@@ -1746,33 +1766,12 @@ function syncOut(){
1746
1766
  }
1747
1767
  }
1748
1768
  $('#scroller').addEventListener('scroll', () => {
1749
- if ($('#stage').classList.contains('compare')) syncOut();
1769
+ syncOut();
1750
1770
  });
1751
- /* Compare is a switch now, not a chip. Its truth lives in aria-checked so the
1752
- control is a real switch to assistive tech, and the label text follows it —
1753
- a switch that says OFF while being on is worse than no label. */
1754
- function setCompareUI(on){
1755
- setSwitch($('#cmpBtn'), on);
1756
- // No text label in the component (17:61) — aria-checked carries the state.
1757
- // With compare on the result pane renders continuously, so a Preview button
1758
- // would offer something already on screen. It belongs to the off state.
1759
- $('#preview').hidden = on;
1760
- }
1761
- $('#cmpBtn').onclick = () => {
1762
- const on = $('#stage').classList.toggle('compare');
1763
- setCompareUI(on);
1764
- remember('compare', on ? '1' : '0');
1765
- if (img.naturalWidth){
1766
- const wasFit = Math.abs(scale-fitScale)<1e-6;
1767
- computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
1768
- }
1769
- if (on && $('#outImg').hidden) renderPreview(); else if (on) syncOut();
1770
- };
1771
1771
  function autoPreview(){
1772
1772
  // Judge while you fit: with the compare pane open the composite re-renders
1773
1773
  // after each correction (compose() measures 0.06s server-side), so Preview
1774
1774
  // stops being a round trip.
1775
- if (!$('#stage').classList.contains('compare')) return;
1776
1775
  clearTimeout(autoTimer);
1777
1776
  autoTimer = setTimeout(renderPreview, 350);
1778
1777
  }
@@ -1785,30 +1784,13 @@ async function renderPreview(){
1785
1784
  im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
1786
1785
  im.src = `${fileURL(r.path)}&t=${Date.now()}`;
1787
1786
  s.textContent = `radius ${r.radius_px}px on the screenshot`;
1788
- const b = $('#save'); b.disabled = false; b.textContent = 'Save';
1787
+ const b = $('#save'); b.disabled = false; b.textContent = st.video ? 'Render' : 'Save';
1789
1788
  b.classList.add('primary'); // Save is now the next action
1790
1789
  if (!previewHinted){ previewHinted = true;
1791
1790
  toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
1792
1791
  }
1793
1792
  }catch(e){ s.textContent = e.message; }
1794
1793
  }
1795
- /* With compare off there is no result pane to draw into, so the composite
1796
- opens in a popup over the canvas — the same surface the pickers use, so it
1797
- is one modal idiom rather than two. */
1798
- async function openPreviewPop(){
1799
- const pop = $('#previewPop');
1800
- const img = $('#previewImg'), st_ = $('#previewSt');
1801
- openPopAt(pop, $('#preview').getBoundingClientRect());
1802
- st_.textContent = 'Rendering…'; img.hidden = true;
1803
- try{
1804
- const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
1805
- img.onload = () => { img.hidden = false; };
1806
- img.src = `${fileURL(r.path)}&t=${Date.now()}`;
1807
- st_.textContent = `radius ${r.radius_px}px on the screenshot`;
1808
- const b = $('#save'); b.disabled = false; b.textContent = 'Save'; b.classList.add('primary');
1809
- }catch(e){ st_.textContent = e.message; }
1810
- }
1811
- $('#preview').onclick = openPreviewPop;
1812
1794
  function prettyDir(p){
1813
1795
  // ~ for home, and show the last two segments of a long path: the interesting
1814
1796
  // part of a long output path is the end, not the volume.
@@ -1818,7 +1800,85 @@ function prettyDir(p){
1818
1800
  return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : d;
1819
1801
  }
1820
1802
 
1803
+ let preset = 'web';
1804
+ document.querySelectorAll('[data-preset]').forEach(btn => {
1805
+ btn.onclick = () => {
1806
+ preset = btn.dataset.preset;
1807
+ document.querySelectorAll('[data-preset]').forEach(o => o.classList.toggle('sel', o === btn));
1808
+ };
1809
+ });
1810
+
1811
+ /* A video source changes exactly two things in this page: which frame the fit
1812
+ is shown on, and what the primary button does. Everything else — the edge
1813
+ matching, the loupe, the compare view, the realism rail — is untouched,
1814
+ because the geometry does not care whether the pixels move. */
1815
+ function syncVideoUI(){
1816
+ const v = st.video;
1817
+ $('#vidbar').hidden = !v;
1818
+ const b = $('#save');
1819
+ if (v && v.ffmpeg === false){
1820
+ // Say it now, not after the fit. The fitting still works — it is only the
1821
+ // encode that cannot run — so the page stays usable and the message names
1822
+ // the one thing to do.
1823
+ toast('err', 'Video needs <b>ffmpeg</b>, which is not installed yet. '
1824
+ + 'Ask Claude to install it (about 25 MB, into screengraft\'s own '
1825
+ + 'environment — nothing system-wide), then reload this page.');
1826
+ }
1827
+ if (v){
1828
+ const sl = $('#vframe');
1829
+ sl.max = Math.max(0, (v.frames || 1) - 1);
1830
+ sl.value = 0;
1831
+ $('#vframeLbl').textContent = `0 / ${Math.max(0,(v.frames||1)-1)}`;
1832
+ if (b.textContent === 'Save') b.textContent = 'Render';
1833
+ }
1834
+ }
1835
+ $('#vframe').oninput = () => { $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`; };
1836
+ $('#vframe').onchange = async () => {
1837
+ // Swapping the poster frame re-runs preview through the same path a still
1838
+ // uses, so what you match the edges against is the frame you chose.
1839
+ try{
1840
+ const r = await api('/api/frame', {index: +$('#vframe').value});
1841
+ $('#pv2').innerHTML = `<img src="${fileURL(r.path)}" alt="">`;
1842
+ $('#sw2').style.background = `center/cover no-repeat url("${fileURL(r.path)}")`;
1843
+ autoPreview();
1844
+ }catch(e){ toast('err', 'Could not read that frame: ' + e.message); }
1845
+ };
1846
+
1847
+ async function renderVideo(){
1848
+ const b = $('#save');
1849
+ b.disabled = true;
1850
+ b.innerHTML = 'Rendering<span class="spin" aria-hidden="true"></span>';
1851
+ try{
1852
+ await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
1853
+ grade: gradeValue(), preset, fit_frame: +$('#vframe').value});
1854
+ }catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent='Render'; return; }
1855
+ // Poll rather than hold a request open: a clip is hundreds of frames and a
1856
+ // browser would time the request out long before the render finished.
1857
+ const tick = setInterval(async () => {
1858
+ let d; try { d = await api('/api/render_status', null, {method:'GET'}); }
1859
+ catch(e){ return; }
1860
+ if (d.state === 'running'){
1861
+ // The button says what it is doing; the frame counter beside the
1862
+ // scrubber carries the numbers, so the primary control stays calm.
1863
+ b.innerHTML = 'Rendering<span class="spin" aria-hidden="true"></span>';
1864
+ if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
1865
+ } else if (d.state === 'done'){
1866
+ clearInterval(tick);
1867
+ b.textContent = 'Render'; b.disabled = false; b.classList.remove('primary');
1868
+ $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1869
+ toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
1870
+ const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
1871
+ } else if (d.state === 'error'){
1872
+ clearInterval(tick);
1873
+ b.textContent = 'Render'; b.disabled = false;
1874
+ $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
1875
+ toast('err', 'Render failed: ' + (d.message || 'unknown'));
1876
+ }
1877
+ }, 900);
1878
+ }
1879
+
1821
1880
  $('#save').onclick = async () => {
1881
+ if (st.video) return renderVideo();
1822
1882
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
1823
1883
  try{
1824
1884
  const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
@@ -1886,8 +1946,8 @@ $('#imp').onclick = async () => {
1886
1946
  // Compare is the DEFAULT view: judging a fit means comparing it with the
1887
1947
  // photo, so the side-by-side is the normal state and turning it off is the
1888
1948
  // deliberate act. recall() only overrides it if the user chose otherwise.
1889
- if (recall('compare','1') === '1'){ $('#stage').classList.add('compare'); setCompareUI(true); }
1890
- else setCompareUI(false);
1949
+ // Nothing to restore: the side-by-side is the layout now, so there is no
1950
+ // remembered preference and no state where the check is unavailable.
1891
1951
  await loadRecent();
1892
1952
  // A session restored after a reload may already have a saved composite, in
1893
1953
  // which case Import is still meaningful — leaving it disabled would strand
@@ -1901,7 +1961,7 @@ $('#imp').onclick = async () => {
1901
1961
  if (s.device) st.type = s.device;
1902
1962
  for (const [role, path] of [['photo', s.photo], ['screenshot', s.screenshot]]){
1903
1963
  if (!path) continue;
1904
- try { const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, null); }
1964
+ try { const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, null, r); }
1905
1965
  catch(e){ /* file moved since: leave that slot empty */ }
1906
1966
  }
1907
1967
  // Populate the radius dropdown on the restore path too. detect() only
@@ -1910,7 +1970,7 @@ $('#imp').onclick = async () => {
1910
1970
  if (st.type) setType(st.type);
1911
1971
  if (s.radius_frac) setFrac(s.radius_frac, 'restored from this session');
1912
1972
  else applyPreset();
1913
- if ($('#stage').classList.contains('compare') && st.corners) renderPreview();
1973
+ if (st.corners) renderPreview();
1914
1974
  }
1915
1975
  if (!st.photo) openPop(1);
1916
1976
  window.addEventListener('resize', () => {