screengraft 0.13.1 → 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 +34 -9
- package/package.json +5 -2
- package/scripts/detect.py +289 -30
- package/scripts/grade.py +53 -26
- package/scripts/preflight.py +48 -0
- package/scripts/requirements.txt +1 -0
- package/scripts/ui.py +159 -6
- package/scripts/warp.py +288 -71
- package/skills/inject-screenshot/SKILL.md +27 -10
- package/ui/index.html +163 -81
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>
|
|
@@ -714,6 +734,7 @@
|
|
|
714
734
|
<span class="cvbar-actions">
|
|
715
735
|
<span class="stepper"><button class="sm" data-jump="0">TL</button><button class="sm" data-jump="1">TR</button><button class="sm" data-jump="2">BR</button><button class="sm" data-jump="3">BL</button></span>
|
|
716
736
|
<button class="sm" id="redetect">Re-detect</button>
|
|
737
|
+
<button class="sm" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button>
|
|
717
738
|
</span>
|
|
718
739
|
</div>
|
|
719
740
|
</section>
|
|
@@ -726,7 +747,22 @@
|
|
|
726
747
|
<span class="spacer"></span>
|
|
727
748
|
<span class="sm" style="color:var(--mute)">shares zoom & pan</span>
|
|
728
749
|
</div>
|
|
729
|
-
<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>
|
|
730
766
|
</section>
|
|
731
767
|
|
|
732
768
|
<aside class="rail">
|
|
@@ -778,17 +814,6 @@
|
|
|
778
814
|
</button>
|
|
779
815
|
</div>
|
|
780
816
|
</div>
|
|
781
|
-
<div class="sect" id="secCompare" data-on="1">
|
|
782
|
-
<div class="sect-top">
|
|
783
|
-
<div class="sect-left">
|
|
784
|
-
<h4>Compare view</h4>
|
|
785
|
-
<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>
|
|
786
|
-
</div>
|
|
787
|
-
<button class="switch" id="cmpBtn" role="switch" aria-checked="true" aria-label="Compare view">
|
|
788
|
-
<span class="sw-track"><span class="sw-handle"></span><span class="sw-grip"></span></span>
|
|
789
|
-
</button>
|
|
790
|
-
</div>
|
|
791
|
-
</div>
|
|
792
817
|
<div class="sect" id="secKeys" data-on="1">
|
|
793
818
|
<div class="sect-top"><div class="sect-left"><h4>Keys</h4></div></div>
|
|
794
819
|
<p class="hint"><kbd>←↑→↓</kbd> nudge 1px · <kbd>⇧</kbd>+arrow 10px · <kbd>Tab</kbd> next edge</p>
|
|
@@ -824,12 +849,6 @@
|
|
|
824
849
|
<div class="scrim" id="scrim"></div>
|
|
825
850
|
|
|
826
851
|
|
|
827
|
-
<div class="pop" id="previewPop" style="width:min(900px,calc(100vw - 24px))">
|
|
828
|
-
<h3>Preview</h3>
|
|
829
|
-
<p class="sub" id="previewSt">Rendering…</p>
|
|
830
|
-
<div id="previewWrap"><img id="previewImg" alt="" hidden></div>
|
|
831
|
-
</div>
|
|
832
|
-
|
|
833
852
|
<div class="pop" id="pop1">
|
|
834
853
|
<h3>Photo of the device</h3>
|
|
835
854
|
<p class="sub">Recent images from Desktop and Downloads — click one. Or browse, drop a file, or paste a path.</p>
|
|
@@ -852,7 +871,7 @@
|
|
|
852
871
|
<div class="pv" id="pv2"><span class="empty">Select a recent image</span></div>
|
|
853
872
|
</div>
|
|
854
873
|
<div class="drop" id="drop2">Drop a screenshot here or click to browse</div>
|
|
855
|
-
<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>
|
|
856
875
|
</div>
|
|
857
876
|
|
|
858
877
|
<script>
|
|
@@ -1104,26 +1123,28 @@ async function loadRecent(){
|
|
|
1104
1123
|
}
|
|
1105
1124
|
}
|
|
1106
1125
|
async function choose(role, path, el){
|
|
1107
|
-
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); }
|
|
1108
1127
|
catch(e){ alert(e.message); }
|
|
1109
1128
|
}
|
|
1110
|
-
function setChosen(role, path, size, el){
|
|
1129
|
+
function setChosen(role, path, size, el, meta){
|
|
1111
1130
|
const n = role==='photo'?1:2;
|
|
1112
1131
|
document.querySelectorAll('#recent'+n+' .th').forEach(x=>x.classList.remove('sel'));
|
|
1113
1132
|
if (el) el.classList.add('sel');
|
|
1114
1133
|
$('#pv'+n).innerHTML = `<img src="${fileURL(path)}" alt="">`;
|
|
1115
1134
|
const chip = $('#chip'+n);
|
|
1116
1135
|
chip.classList.remove('is-empty');
|
|
1117
|
-
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` : '');
|
|
1118
1138
|
$('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(path)}")`;
|
|
1119
|
-
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(); }
|
|
1120
1141
|
closePop();
|
|
1121
1142
|
maybeStart();
|
|
1122
1143
|
}
|
|
1123
1144
|
async function upload(role, file){
|
|
1124
1145
|
const buf = await file.arrayBuffer();
|
|
1125
1146
|
const r = await api('/api/upload', null, {headers:{'X-Filename':encodeURIComponent(file.name),'X-Role':role}, body:buf});
|
|
1126
|
-
setChosen(role, r.path, r.size, null);
|
|
1147
|
+
setChosen(role, r.path, r.size, null, r);
|
|
1127
1148
|
}
|
|
1128
1149
|
for (const [n, role] of [[1,'photo'],[2,'screenshot']]){
|
|
1129
1150
|
const drop = $('#drop'+n), file = $('#file'+n);
|
|
@@ -1235,7 +1256,7 @@ $('#figmaBtn').onclick = async () => {
|
|
|
1235
1256
|
try {
|
|
1236
1257
|
const r = await api('/api/job/adopt', {});
|
|
1237
1258
|
stop('ok', 'Exported');
|
|
1238
|
-
setChosen('screenshot', r.path, r.size, null);
|
|
1259
|
+
setChosen('screenshot', r.path, r.size, null, r);
|
|
1239
1260
|
} catch (e) { stop('err', 'Exported, but the file could not be read: ' + e.message); }
|
|
1240
1261
|
},
|
|
1241
1262
|
onError: (j) => stop('err', 'Export failed: ' + (j.message || '')),
|
|
@@ -1336,16 +1357,37 @@ async function detect(){
|
|
|
1336
1357
|
if (r.corner_radius && r.corner_radius.confident){ setFrac(r.corner_radius.frac_of_width, 'measured from the photo'); }
|
|
1337
1358
|
else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
|
|
1338
1359
|
} else {
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
s.
|
|
1360
|
+
st.corners = defaultQuad();
|
|
1361
|
+
s.className='status warn';
|
|
1362
|
+
s.textContent = r.abstained ? 'Detector abstained — place the edges by hand'
|
|
1363
|
+
: 'No screen found — place the edges by hand';
|
|
1364
|
+
// The full reason is long and belongs on hover, not in a 40-character chip.
|
|
1365
|
+
s.title = r.message || '';
|
|
1342
1366
|
if (!st.type) setType('phone'); applyPreset();
|
|
1343
1367
|
}
|
|
1344
1368
|
pick = {kind:'edge', i:0};
|
|
1345
1369
|
draw(); drawStrip();
|
|
1346
1370
|
autoPreview(); // if the compare pane is open, fill it straight away
|
|
1347
1371
|
}
|
|
1372
|
+
// One definition of "a sane starting position", used by the abstention path and
|
|
1373
|
+
// by Reset. Deliberately a plain centred rectangle: it claims nothing about
|
|
1374
|
+
// where the screen is, and every handle is on the picture where it can be
|
|
1375
|
+
// grabbed — which is the property the detector's off-canvas quad lacked.
|
|
1376
|
+
function defaultQuad(){
|
|
1377
|
+
const w = img.naturalWidth, h = img.naturalHeight;
|
|
1378
|
+
return [[w*.3,h*.2],[w*.7,h*.2],[w*.7,h*.8],[w*.3,h*.8]];
|
|
1379
|
+
}
|
|
1348
1380
|
$('#redetect').onclick = detect;
|
|
1381
|
+
$('#resetquad').onclick = () => {
|
|
1382
|
+
if (!img.naturalWidth) return;
|
|
1383
|
+
st.corners = defaultQuad();
|
|
1384
|
+
const s = $('#detSt'); s.className='status warn';
|
|
1385
|
+
s.textContent = 'Edges reset — place them on the screen';
|
|
1386
|
+
s.title = '';
|
|
1387
|
+
pick = {kind:'edge', i:0};
|
|
1388
|
+
setCanvasZoom(scale);
|
|
1389
|
+
draw(); drawStrip(); autoPreview();
|
|
1390
|
+
};
|
|
1349
1391
|
|
|
1350
1392
|
function draw(){
|
|
1351
1393
|
ctx.clearRect(0,0,cv.width,cv.height);
|
|
@@ -1724,33 +1766,12 @@ function syncOut(){
|
|
|
1724
1766
|
}
|
|
1725
1767
|
}
|
|
1726
1768
|
$('#scroller').addEventListener('scroll', () => {
|
|
1727
|
-
|
|
1769
|
+
syncOut();
|
|
1728
1770
|
});
|
|
1729
|
-
/* Compare is a switch now, not a chip. Its truth lives in aria-checked so the
|
|
1730
|
-
control is a real switch to assistive tech, and the label text follows it —
|
|
1731
|
-
a switch that says OFF while being on is worse than no label. */
|
|
1732
|
-
function setCompareUI(on){
|
|
1733
|
-
setSwitch($('#cmpBtn'), on);
|
|
1734
|
-
// No text label in the component (17:61) — aria-checked carries the state.
|
|
1735
|
-
// With compare on the result pane renders continuously, so a Preview button
|
|
1736
|
-
// would offer something already on screen. It belongs to the off state.
|
|
1737
|
-
$('#preview').hidden = on;
|
|
1738
|
-
}
|
|
1739
|
-
$('#cmpBtn').onclick = () => {
|
|
1740
|
-
const on = $('#stage').classList.toggle('compare');
|
|
1741
|
-
setCompareUI(on);
|
|
1742
|
-
remember('compare', on ? '1' : '0');
|
|
1743
|
-
if (img.naturalWidth){
|
|
1744
|
-
const wasFit = Math.abs(scale-fitScale)<1e-6;
|
|
1745
|
-
computeFit(); setCanvasZoom(wasFit ? fitScale : scale);
|
|
1746
|
-
}
|
|
1747
|
-
if (on && $('#outImg').hidden) renderPreview(); else if (on) syncOut();
|
|
1748
|
-
};
|
|
1749
1771
|
function autoPreview(){
|
|
1750
1772
|
// Judge while you fit: with the compare pane open the composite re-renders
|
|
1751
1773
|
// after each correction (compose() measures 0.06s server-side), so Preview
|
|
1752
1774
|
// stops being a round trip.
|
|
1753
|
-
if (!$('#stage').classList.contains('compare')) return;
|
|
1754
1775
|
clearTimeout(autoTimer);
|
|
1755
1776
|
autoTimer = setTimeout(renderPreview, 350);
|
|
1756
1777
|
}
|
|
@@ -1763,30 +1784,13 @@ async function renderPreview(){
|
|
|
1763
1784
|
im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
|
|
1764
1785
|
im.src = `${fileURL(r.path)}&t=${Date.now()}`;
|
|
1765
1786
|
s.textContent = `radius ${r.radius_px}px on the screenshot`;
|
|
1766
|
-
const b = $('#save'); b.disabled = false; b.textContent = 'Save';
|
|
1787
|
+
const b = $('#save'); b.disabled = false; b.textContent = st.video ? 'Render' : 'Save';
|
|
1767
1788
|
b.classList.add('primary'); // Save is now the next action
|
|
1768
1789
|
if (!previewHinted){ previewHinted = true;
|
|
1769
1790
|
toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
|
|
1770
1791
|
}
|
|
1771
1792
|
}catch(e){ s.textContent = e.message; }
|
|
1772
1793
|
}
|
|
1773
|
-
/* With compare off there is no result pane to draw into, so the composite
|
|
1774
|
-
opens in a popup over the canvas — the same surface the pickers use, so it
|
|
1775
|
-
is one modal idiom rather than two. */
|
|
1776
|
-
async function openPreviewPop(){
|
|
1777
|
-
const pop = $('#previewPop');
|
|
1778
|
-
const img = $('#previewImg'), st_ = $('#previewSt');
|
|
1779
|
-
openPopAt(pop, $('#preview').getBoundingClientRect());
|
|
1780
|
-
st_.textContent = 'Rendering…'; img.hidden = true;
|
|
1781
|
-
try{
|
|
1782
|
-
const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
|
|
1783
|
-
img.onload = () => { img.hidden = false; };
|
|
1784
|
-
img.src = `${fileURL(r.path)}&t=${Date.now()}`;
|
|
1785
|
-
st_.textContent = `radius ${r.radius_px}px on the screenshot`;
|
|
1786
|
-
const b = $('#save'); b.disabled = false; b.textContent = 'Save'; b.classList.add('primary');
|
|
1787
|
-
}catch(e){ st_.textContent = e.message; }
|
|
1788
|
-
}
|
|
1789
|
-
$('#preview').onclick = openPreviewPop;
|
|
1790
1794
|
function prettyDir(p){
|
|
1791
1795
|
// ~ for home, and show the last two segments of a long path: the interesting
|
|
1792
1796
|
// part of a long output path is the end, not the volume.
|
|
@@ -1796,7 +1800,85 @@ function prettyDir(p){
|
|
|
1796
1800
|
return seg.length > 4 ? '…/' + seg.slice(-2).join('/') : d;
|
|
1797
1801
|
}
|
|
1798
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
|
+
|
|
1799
1880
|
$('#save').onclick = async () => {
|
|
1881
|
+
if (st.video) return renderVideo();
|
|
1800
1882
|
const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
|
|
1801
1883
|
try{
|
|
1802
1884
|
const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue()});
|
|
@@ -1864,8 +1946,8 @@ $('#imp').onclick = async () => {
|
|
|
1864
1946
|
// Compare is the DEFAULT view: judging a fit means comparing it with the
|
|
1865
1947
|
// photo, so the side-by-side is the normal state and turning it off is the
|
|
1866
1948
|
// deliberate act. recall() only overrides it if the user chose otherwise.
|
|
1867
|
-
|
|
1868
|
-
|
|
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.
|
|
1869
1951
|
await loadRecent();
|
|
1870
1952
|
// A session restored after a reload may already have a saved composite, in
|
|
1871
1953
|
// which case Import is still meaningful — leaving it disabled would strand
|
|
@@ -1879,7 +1961,7 @@ $('#imp').onclick = async () => {
|
|
|
1879
1961
|
if (s.device) st.type = s.device;
|
|
1880
1962
|
for (const [role, path] of [['photo', s.photo], ['screenshot', s.screenshot]]){
|
|
1881
1963
|
if (!path) continue;
|
|
1882
|
-
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); }
|
|
1883
1965
|
catch(e){ /* file moved since: leave that slot empty */ }
|
|
1884
1966
|
}
|
|
1885
1967
|
// Populate the radius dropdown on the restore path too. detect() only
|
|
@@ -1888,7 +1970,7 @@ $('#imp').onclick = async () => {
|
|
|
1888
1970
|
if (st.type) setType(st.type);
|
|
1889
1971
|
if (s.radius_frac) setFrac(s.radius_frac, 'restored from this session');
|
|
1890
1972
|
else applyPreset();
|
|
1891
|
-
if (
|
|
1973
|
+
if (st.corners) renderPreview();
|
|
1892
1974
|
}
|
|
1893
1975
|
if (!st.photo) openPop(1);
|
|
1894
1976
|
window.addEventListener('resize', () => {
|