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/README.md +33 -6
- package/package.json +5 -2
- package/scripts/grade.py +53 -26
- package/scripts/preflight.py +48 -0
- package/scripts/requirements.txt +1 -0
- package/scripts/ui.py +146 -5
- package/scripts/warp.py +288 -71
- package/skills/inject-screenshot/SKILL.md +27 -10
- package/ui/index.html +138 -78
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
|
-
|
|
281
|
-
|
|
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:
|
|
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 & pan</span>
|
|
729
749
|
</div>
|
|
730
|
-
<div id="outWrap"><span class="empty" id="outEmpty">
|
|
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>←↑→↓</kbd> nudge 1px · <kbd>⇧</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
|
|
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; }
|
|
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
|
-
|
|
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
|
-
|
|
1890
|
-
|
|
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 (
|
|
1973
|
+
if (st.corners) renderPreview();
|
|
1914
1974
|
}
|
|
1915
1975
|
if (!st.photo) openPop(1);
|
|
1916
1976
|
window.addEventListener('resize', () => {
|