screengraft 0.25.2 → 0.37.0

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
@@ -32,6 +32,9 @@
32
32
  /* A four-step raised ladder, added in the design file to fix two collapsed chip states: hover used to
33
33
  step DOWN to --float and selected-hover had collapsed onto --edge. */
34
34
  --raise:#2b2b30; --raise-mid:#333337; --raise-hi:#3a3a40; --raise-highest:#46464e;
35
+ /* surface/raise-low — the recessed step, under --raise rather than over it.
36
+ It is what a pressed control sits on, and what a disabled one sits on. */
37
+ --raise-low:#1c1c20;
35
38
  --panel:#161618;
36
39
  /* surface/float is a real token (#26262b, the chip hover surface). The
37
40
  canvas overlay is a separate thing and gets its own name. */
@@ -39,6 +42,8 @@
39
42
  --overlay:rgba(17,17,18,.98);
40
43
  --ink:#e9ecf1; --mute:#8f939a; --faint:#8f939ab2;
41
44
  --line:#1d1e20; --edge:#3a3b41; --edge-mid:#494a50; --edge-hi:#6d6f79;
45
+ /* border/edge-low — the dimmest bound, for a control that cannot be used. */
46
+ --edge-low:#2b2c31;
42
47
 
43
48
  /* Accent, per the Button variant matrix (4:14). Primary at REST is the
44
49
  accent at 85%, not solid — solid is the hover state. */
@@ -47,9 +52,12 @@
47
52
  --acc:#f23b0d; --acc-btn-rest:rgba(242,59,13,.85); --acc-press:#bb2b00;
48
53
  --acc-stroke:#ff6833; --acc-ink:#fbf9f9; --acc-soft:rgba(242,59,13,.14);
49
54
  /* Neutral button hover/pressed are explicit surfaces in Figma, not a
50
- brightness filter on the rest state. */
51
- --btn-hover:#333337; --btn-hover-edge:#3f4045;
52
- --btn-press:#1c1c20; --btn-press-edge:#2b2c31;
55
+ brightness filter on the rest state. Re-picked 11 Sep 2026 against the
56
+ polished Button matrix (4:14): hover's border is border/edge-mid, and
57
+ pressed keeps border/edge rather than dimming to edge-low — pressing a
58
+ control must not make it look unavailable. Both had drifted. */
59
+ --btn-hover:#333337; --btn-hover-edge:#494a50;
60
+ --btn-press:#1c1c20; --btn-press-edge:#3a3b41;
53
61
  /* Accent disabled keeps the label READABLE rather than fading the whole
54
62
  button — the Button component in Figma says so explicitly. */
55
63
  --acc-dis:#bb2b00b2; --acc-dis-edge:#ff6833b2; --acc-dis-ink:#fbf9f966;
@@ -162,41 +170,114 @@
162
170
  filter over the rest state — a filter shifts hue and cannot be measured
163
171
  against a token.
164
172
  ------------------------------------------------------------------ */
173
+ /* Md is radius/md, Sm is radius/sm — one rule, both variants. Md had been
174
+ 6px here while Primary/Md was 8px, so the neutral and accent buttons beside
175
+ each other in the top bar were not the same shape. Settled in Figma
176
+ 11 Sep 2026: Md = 8, Sm = 6. */
165
177
  button{font:inherit;color:var(--ink);background:var(--raise);
166
- border:1px solid var(--edge);border-radius:var(--r-sm);
178
+ border:1px solid var(--edge);border-radius:var(--r-md);
167
179
  padding:0 var(--s3);height:28px;font-size:13px;cursor:pointer;box-shadow:var(--hi);
168
180
  transition:var(--t-all), transform var(--t)}
169
181
  button:hover{background:var(--btn-hover);border-color:var(--btn-hover-edge)}
170
182
  button:active{background:var(--btn-press);border-color:var(--btn-press-edge);
171
183
  box-shadow:none;transform:translateY(.5px)}
172
184
  button:focus-visible{outline:2px solid var(--acc);outline-offset:2px}
173
- /* Default disabled keeps the raised surface and dims only the LABEL — the
174
- component description is explicit that opacity is not the mechanism. */
175
- button[disabled]{background:var(--raise);border-color:var(--edge);
185
+ /* Default/Disabled RECESSES: surface/raise-low with border/edge-low, label at
186
+ text/faint x 50%. It used to keep the raised surface and dim only the label.
187
+ The change is deliberate — a control you cannot press should not be sitting
188
+ at the same elevation as one you can — and it means the surface, the border
189
+ and the label all say the same thing rather than only the label.
190
+ The 50% the label layer carries in Figma is deliberately NOT applied, and
191
+ this is a disagreement inside the design file rather than a liberty: the
192
+ Button component's own description says "Disabled keeps its label readable
193
+ rather than using opacity", and the layer does the opposite. Measured, the
194
+ description is also the better outcome — text/faint on the recessed surface
195
+ is **3.37:1**, where the same label with the 50% on top is **1.80:1** and
196
+ what ships today (faint on --raise) is 3.01:1. So the new surface makes the
197
+ label MORE readable than before, and the multiplier would have made it the
198
+ dimmest text in the tool. The description wins until that is
199
+ settled in the file. */
200
+ button[disabled]{background:var(--raise-low);border-color:var(--edge-low);
176
201
  color:var(--faint);cursor:default;font-weight:400;
177
202
  box-shadow:none;transform:none}
178
- button[disabled]:hover{background:var(--raise);border-color:var(--edge)}
203
+ button[disabled]:hover{background:var(--raise-low);border-color:var(--edge-low)}
179
204
  button.sm{height:24px;padding:0 9px;font-size:12px;border-radius:var(--r-sm)}
180
- button.sm[disabled]{color:var(--mute)}
181
205
 
182
206
  /* Primary. The accent means exactly one thing: the next action, so only one
183
207
  of these is lit at a time (see the accent hand-off in the JS). */
184
208
  button.primary{background:var(--acc-btn-rest);color:var(--acc-ink);
185
- border-color:var(--acc-stroke);font-weight:600;
186
- border-radius:var(--r-md);box-shadow:none}
209
+ border-color:var(--acc-stroke);font-weight:600;box-shadow:none}
187
210
  button.primary:hover{background:var(--acc);border-color:var(--acc-stroke)}
188
211
  button.primary:active{background:var(--acc-press);border-color:var(--acc-dis-edge);
189
212
  transform:translateY(.5px)}
190
- button.primary.sm{border-radius:var(--r-sm)}
191
213
  /* An accent-family button that is enabled but is NOT the next action reads
192
214
  as an ordinary neutral control. */
193
215
  button.accent:not(:disabled):not(.primary){
194
216
  background:var(--raise); color:var(--ink); border-color:var(--edge);
195
- font-weight:400; box-shadow:var(--hi); border-radius:var(--r-md)}
217
+ font-weight:400; box-shadow:var(--hi)}
218
+ /* Radius is deliberately NOT restated in these three rules any more. It is the
219
+ base button's, so .sm keeps winning on size — an accent .sm button used to
220
+ be handed --r-md by specificity alone. */
196
221
  button.accent:disabled,button.primary:disabled{
197
222
  background:var(--acc-dis); border-color:var(--acc-dis-edge);
198
- color:var(--acc-dis-ink); font-weight:600;
199
- border-radius:var(--r-md); box-shadow:none; opacity:1}
223
+ color:var(--acc-dis-ink); font-weight:600; box-shadow:none; opacity:1}
224
+
225
+ /* ------------------------------------------------------------------
226
+ Render, while it is rendering: the button IS the progress bar.
227
+ The track is the disabled accent the button
228
+ already wears while it is busy; the fill is the lighter rest accent,
229
+ so the two are the same colour family one step apart and nothing new
230
+ enters the palette.
231
+
232
+ Two background layers rather than a child element: the label on this
233
+ button is written by several different code paths (Save / Render /
234
+ Saving… / a percentage), and a required child span would mean every
235
+ one of them has to maintain it — which is precisely the several-
236
+ writers-disagreeing defect this project has already shipped twice.
237
+ A custom property is one writer.
238
+
239
+ `background-clip: padding-box, border-box` is what makes the fill
240
+ "as high as the button minus the strokes": the fill layer is clipped
241
+ to the padding box, the track fills the border box behind it.
242
+
243
+ The spinner goes. It cannot be told apart from a stall — and in a
244
+ hidden pane it does not animate at all — where a percentage that
245
+ stops moving is a stall you can see.
246
+ ------------------------------------------------------------------
247
+ The name is `--fill`, NOT `--p`, and that is the whole of a regression
248
+ this shipped with in v0.33.0.
249
+
250
+ `@property` registers a custom property GLOBALLY, for every element on the
251
+ page — it is not scoped to the selector near it. `--p` was already in use:
252
+ the range sliders had been setting it to a unitless fraction (0.35) since
253
+ long before this button existed, and reading it back as
254
+ `calc(var(--p,0) * 100%)`.
255
+
256
+ Registering it as a `<percentage>` changed that everywhere at once. A
257
+ unitless 0.35 is invalid against that syntax, so it fell back to the
258
+ registered initial value `0%` — and the `0` fallback in `var(--p,0)` never
259
+ fired either, because a registered property ALWAYS has a value. The track
260
+ gradient computed to `calc(0% * 100%)`, which is invalid, so the whole
261
+ gradient was dropped and the Realism and Corner radius sliders simply
262
+ stopped being orange.
263
+
264
+ The rule: a name passed to @property is a page-wide type declaration.
265
+ Before registering one, check who else already uses that name — this is the
266
+ second time a page-wide fix here has caught unrelated components, after the
267
+ `[hidden]` rule. `ui-audit.js` now asserts the sliders are actually filled
268
+ with the accent, because nothing else in the project could see this.
269
+ ------------------------------------------------------------------ */
270
+ @property --fill { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
271
+ button.primary.progress,
272
+ button.primary.progress:hover,
273
+ button.primary.progress:active{
274
+ background-image:
275
+ linear-gradient(to right, var(--acc-btn-rest) var(--fill, 0%), transparent 0),
276
+ linear-gradient(var(--acc-dis), var(--acc-dis));
277
+ background-clip:padding-box,border-box;
278
+ border-color:var(--acc-dis-edge); color:var(--acc-ink);
279
+ font-weight:600; box-shadow:none; opacity:1;
280
+ transform:none; transition:--fill .45s linear}
200
281
 
201
282
  /* ------------------------------------------------------------------
202
283
  Chip (Figma 5:6). Selection is a raised neutral surface plus a lighter
@@ -235,13 +316,22 @@
235
316
  24px tall, which is exactly why +/- read as a different species from the
236
317
  buttons beside them. Only the corner radii and the shared middle border
237
318
  are special. */
319
+ /* ------------------------------------------------------------------
320
+ `hidden` must actually hide. THREE components have now needed this
321
+ patched one at a time — .cvbar, #outImg/#outVid, and the format
322
+ stepper — because ANY author rule that sets `display` beats the
323
+ browser's own [hidden]{display:none}, whatever its specificity.
324
+ Author styles win over the UA stylesheet by cascade origin, so
325
+ `display:inline-flex` on a class silently switches the attribute off.
326
+ Each instance shipped: the video one as a black panel under the
327
+ result (v0.30.1), this one as a Web/ProRes control sitting beside
328
+ Render for a still, where there is no format to choose.
329
+ `!important` is deliberate — it is the only thing that survives the
330
+ next component that styles `display` without thinking about it.
331
+ ------------------------------------------------------------------ */
332
+ [hidden]{display:none !important}
333
+
238
334
  .stepper{display:inline-flex;align-items:center}
239
- /* A segmented control has to show which segment is on. `.sel` was only ever
240
- styled for the thumbnail picker (.th.sel), so the web/ProRes pair rendered
241
- with no selected state at all — reported 9 Sep 2026. Same step as the
242
- device chips: selected sits on --raise-hi with the brighter border. */
243
- .stepper button.sel{background:var(--raise-hi);border-color:var(--edge-hi);color:var(--ink)}
244
- .stepper button.sel:hover{background:var(--raise-highest);border-color:var(--edge-hi)}
245
335
  .stepper button{border-radius:0}
246
336
  .stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
247
337
  .stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
@@ -250,6 +340,41 @@
250
340
  .stepper button:hover,.stepper button:focus-visible{position:relative;z-index:1}
251
341
  /* The dock's stepper is Sm height but keeps 12px padding (Figma 8:31/8:33). */
252
342
  .dock-step button{padding:0 var(--s3)}
343
+
344
+ /* ------------------------------------------------------------------
345
+ Segmented control (Figma 84:177). NOT a stepper: a stepper is ordinary
346
+ buttons butted together, which is right for -/+ but wrong for a choice —
347
+ two adjacent buttons read as two actions, and the web/ProRes pair had been
348
+ borrowing the chip's selected step to say which one was on.
349
+ This is the component the design file actually specifies: a RECESSED track
350
+ with ONE raised thumb inside it. The selected option is the only thing at
351
+ button elevation, so the control reads as a single switch with a position
352
+ rather than as a pair.
353
+
354
+ Heights: the drawn thumb is 24px, which with 2px of track padding and a 1px
355
+ border makes the control 30px. Its neighbours in the top bar are 28px, and
356
+ a 2px mismatch in that row has already shipped once as a bug (v0.32.0, when
357
+ the stepper was 24px against 28px neighbours), so the thumb is 22px here so the track lands on 28px exactly. Everything else
358
+ — padding, gap, border, both radii — is as drawn.
359
+ ------------------------------------------------------------------ */
360
+ .seg{display:inline-flex;align-items:center;gap:2px;padding:2px;
361
+ background:var(--sunk);border:1px solid var(--edge);
362
+ border-radius:var(--r-md);transition:var(--t-all)}
363
+ .seg:hover{border-color:var(--edge-mid)}
364
+ .seg:active{border-color:var(--edge-mid)}
365
+ /* The track carries the border and the elevation, so a segment is a label
366
+ until it is selected: no border, no shadow, no lift on press. */
367
+ .seg button{height:22px;padding:0 11px;font-size:12px;border-radius:var(--r-sm);
368
+ background:transparent;border:0;box-shadow:none;color:var(--mute);
369
+ font-weight:400}
370
+ .seg button:hover{background:transparent;color:var(--ink)}
371
+ .seg button:active{background:transparent;transform:none}
372
+ /* The thumb. Its three surfaces are the Default button's own, which is the
373
+ point of the component: one raised thing, behaving like every other raised
374
+ thing on the page. */
375
+ .seg button.sel{background:var(--raise);color:var(--ink);font-weight:600}
376
+ .seg button.sel:hover{background:var(--raise-mid)}
377
+ .seg button.sel:active{background:var(--raise-low);transform:none}
253
378
  /* Input chip (Figma 5:14 / 5:20). Surface and shadow are declared here rather
254
379
  than inherited from the base button rule, so the filled state is explicit and
255
380
  the .is-empty override below has something definite to override.
@@ -367,22 +492,27 @@
367
492
  -webkit-backdrop-filter:blur(5px);
368
493
  border-radius:var(--r-lg);box-shadow:var(--shadow)}
369
494
  .cvbar-actions{display:flex;align-items:center;gap:var(--s2);flex-wrap:nowrap}
495
+ /* The scrubber needs a floor, or the bar "fits" by crushing it. Moving the
496
+ format control out stopped the bar overflowing at 742px — and the space it
497
+ freed went to the other controls while the slider collapsed to FOUR pixels,
498
+ measured. The bar may scroll (it has overflow-x:auto by design); the one
499
+ control that is useless when small must not be the one that gives way. */
500
+ #vframe{min-width:140px;flex:1 1 180px}
370
501
  .cvbar{white-space:nowrap}
371
502
  /* `hidden` is a UA display:none, which any explicit display beats — and
372
503
  .cvbar sets display:flex. Without this the bar stays visible while the
373
504
  photo is chosen but the screenshot is not, offering corners to fit
374
505
  nothing. */
375
- .cvbar[hidden]{display:none}
376
- /* Render spinner. A determinate percentage was the first version and it read
377
- as busier than the work felt; a single moving thing says "still going"
378
- without asking to be read. prefers-reduced-motion gets a static ring —
379
- the button's label already carries the meaning. */
380
- .spin{display:inline-block;width:12px;height:12px;margin-left:8px;
381
- vertical-align:-1px;border:2px solid currentColor;border-radius:50%;
382
- border-right-color:transparent;opacity:.9;
383
- animation:spin .7s linear infinite}
384
- @keyframes spin{to{transform:rotate(360deg)}}
385
- @media (prefers-reduced-motion:reduce){ .spin{animation:none} }
506
+ /* The render spinner was HERE, and it is worth writing down why it is not.
507
+ v0.19.0 replaced a determinate percentage with it, reasoning that the
508
+ number "read as busier than the work felt". That judgement was made
509
+ against a percentage that never moved: the poll feeding it had been
510
+ broken since v0.18.0 (it POSTed to a GET-only route and swallowed the
511
+ error), which was only found on 11 Sep. A number that never updates does
512
+ read as noise — but so would any number, and the conclusion did not
513
+ belong to percentages.
514
+ The button is the progress bar now (see button.primary.progress above).
515
+ A DECISION MADE AGAINST A BROKEN MEASUREMENT IS NOT A DECISION. */
386
516
 
387
517
  /* Preview popover: the composite at whatever size fits, on the sunk surface
388
518
  so the image edge is unambiguous against the panel. */
@@ -457,7 +587,28 @@
457
587
 
458
588
  #outPane{display:grid}
459
589
  #outWrap{overflow:auto;background:var(--well);display:grid;box-shadow:inset 0 1px 3px rgba(0,0,0,.5)}
460
- #outImg{display:block;margin:auto}
590
+ #outImg,#outVid{display:block;margin:auto}
591
+ /* The live layer stacks the clip on the photo. `position:relative` on the
592
+ wrapper and `transform-origin:0 0` on the video are what make the matrix3d
593
+ below map the video's own pixel rectangle onto the quad — change either and
594
+ the maths silently refers to a different origin. */
595
+ #liveWrap{position:relative;margin:auto;line-height:0}
596
+ #liveWrap img{display:block;width:100%}
597
+ #liveVid{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}
598
+ /* Emissive, approximated: `screen` is 1-(1-a)(1-b) — the screen's own light
599
+ PLUS the glass under it, which is the same idea as the real blend and the
600
+ reason a true-black UI stops reading as a hole. It is NOT the same maths as
601
+ compose()'s emissive, and the page says so. */
602
+ #liveVid.emissive{mix-blend-mode:screen}
603
+ /* An icon button is square and centres its glyph; everything else about it —
604
+ height, border, the raise ladder — stays whatever .sm already is, so the bar
605
+ keeps one button shape. */
606
+ /* `flex:none` and a min-width, not just a width: the bar is a nowrap flex row
607
+ that overflows at narrow widths, and a flex item with a width will happily
608
+ shrink to its content. Measured at 742px before this: the button was 13px
609
+ wide — the glyph and its border — which is a target nobody can hit. */
610
+ .sm.icon{display:inline-flex;align-items:center;justify-content:center;
611
+ flex:none;width:28px;min-width:28px;padding:0}
461
612
  .empty{color:var(--mute);font-size:12px;padding:24px;text-align:center;align-self:center;justify-self:center;max-width:320px}
462
613
 
463
614
  .rail{min-width:0;overflow:auto;padding:0;display:grid;align-content:start;background:var(--card)}
@@ -752,9 +903,21 @@
752
903
  </div>
753
904
  </div>
754
905
  <span class="spacer"></span>
906
+ <!-- Order is the order of the work: choose the format, render, then send it.
907
+ The format was in the Result pane's clip bar, which is where you judge a
908
+ fit rather than where you decide what gets written — and it was the
909
+ fifth control in a bar that overflows at 742px, squeezing the frame
910
+ scrubber to nothing. It is a render setting, so it sits with Render.
911
+
912
+ The segmented control stays segmented: two mutually exclusive options of
913
+ equal weight, both readable without a click. It carries no accent, so
914
+ the one-accent-on-screen rule is untouched. -->
755
915
  <div class="tb-actions">
756
- <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
916
+ <span class="seg" id="fmt" role="radiogroup" aria-label="Output format" hidden>
917
+ <button class="sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button data-preset="prores" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
918
+ </span>
757
919
  <button id="save" class="accent" disabled>Preview first</button>
920
+ <button id="imp" class="accent" disabled title="Send the saved file to Claude so it appears in the chat">Send to Claude</button>
758
921
  </div>
759
922
  </header>
760
923
 
@@ -807,20 +970,37 @@
807
970
  <span class="spacer"></span>
808
971
  <span class="sm" style="color:var(--mute)">shares zoom &amp; pan</span>
809
972
  </div>
810
- <div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden></div>
973
+ <div id="outWrap"><span class="empty" id="outEmpty">The composite appears here and updates as you drag.</span><img id="outImg" alt="" hidden><video id="outVid" hidden loop muted playsinline controls></video><!--
974
+ The live layer: the PHOTOGRAPH with the source clip warped onto the
975
+ fitted quad by the browser. Not the composite — the composite already
976
+ has a frame burned into it, so playing over that would show two screens.
977
+ --><div id="liveWrap" hidden><img id="liveBg" alt=""><video id="liveVid" loop muted playsinline></video></div></div>
811
978
  <!-- Video controls. Hidden entirely for a still source: a scrubber over a
812
979
  screenshot is a control that can only confuse. The fit itself is
813
980
  unchanged — you match the edges on one frame and every frame gets the
814
981
  same geometry, because the photo does not move. -->
815
982
  <div class="cvbar" id="vidbar" hidden>
983
+ <!-- What is left in this bar is only what the RESULT pane is for:
984
+ which moment of the clip you are looking at, and the two ways of
985
+ looking at it. The output format left for the top bar, beside
986
+ Render, because deciding how a file is written is not judging a
987
+ fit — and as the fifth control here it was squeezing the scrubber
988
+ to nothing at 742px. -->
816
989
  <span class="sm" style="color:var(--mute)">Fit on frame</span>
817
990
  <span class="cvbar-actions">
818
991
  <input type="range" id="vframe" min="0" max="0" value="0" step="1"
819
992
  aria-label="Which frame of the clip to match the edges on">
820
993
  <span class="sm" id="vframeLbl" style="color:var(--mute);min-width:9ch">0</span>
821
- <span class="stepper" role="radiogroup" aria-label="Output format">
822
- <button class="sm sel" data-preset="web" role="radio" aria-checked="true" title="H.264 at CRF 16 — near-visually-lossless, plays anywhere.">Web</button><button class="sm" data-preset="prores" role="radio" aria-checked="false" title="ProRes 422 HQ — bigger file, no chroma subsampling. For a case study or further editing.">ProRes</button>
823
- </span>
994
+ <!-- Viewing controls sit with the scrubber, which is the other viewing
995
+ control; the format stepper is about the OUTPUT and stays at the
996
+ end. Icon-only, so each carries an aria-label — a title alone
997
+ names it for a mouse and not for a screen reader. -->
998
+ <button class="sm icon" id="playlive" aria-label="Play the clip on the photo"
999
+ title="Play the clip on the photo, right now. The browser applies the same four corners and corner radius, and approximates emissive with screen blending; the colour grade and grain are not in this view.">
1000
+ <svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" focusable="false"><path d="M3 1.5 10 6 3 10.5Z" fill="currentColor"/></svg>
1001
+ </button>
1002
+ <button class="sm" id="playclip"
1003
+ title="Composite a few seconds from the fitted frame through the real pipeline and play that. Slower — it is a render — but it is the only view with the grade, the grain and the true emissive blend.">Preview</button>
824
1004
  </span>
825
1005
  </div>
826
1006
  </section>
@@ -1140,7 +1320,12 @@ const api = async (p, body, raw) => {
1140
1320
  const j = await r.json(); if (!r.ok) throw new Error(j.error || r.statusText); return j;
1141
1321
  };
1142
1322
  const fileURL = p => '/file?path=' + encodeURIComponent(p);
1143
- const st = {photo:null, shot:null, corners:null, frac:0, type:null, preset:null, measured:null, presets:[]};
1323
+ const st = {photo:null, shot:null, corners:null, frac:0, type:null, preset:null, measured:null,
1324
+ // Where the quad on screen came from: 'detected' | 'remembered' | 'restored'
1325
+ // | 'default'. The tool's contract is that it never presents a guess as a
1326
+ // fact, and each of those is a different strength of claim, so the status
1327
+ // line has to be able to say which one is on the canvas.
1328
+ remembered:null, quadFrom:null, presets:[]};
1144
1329
  const remember = (k,v) => { try{ localStorage.setItem('sg.'+k, v); }catch(e){} };
1145
1330
  const recall = (k,d) => { try{ const v = localStorage.getItem('sg.'+k); return v===null?d:v; }catch(e){ return d; } };
1146
1331
 
@@ -1192,6 +1377,68 @@ function casedText(c, text, x, y, color){
1192
1377
  c.restore();
1193
1378
  }
1194
1379
 
1380
+ /* ===== a saved fit, dropped back in =====================================
1381
+ The corners are the expensive part of this job and they belong to the
1382
+ photograph, so a fit written beside a mockup is worth carrying between runs:
1383
+ the same scene with next week's UI, a re-export the automatic memory cannot
1384
+ recognise (it keys on pixels, and a re-export changes them), or someone
1385
+ else's machine entirely.
1386
+
1387
+ No file dialog is involved and none is needed. The file sits in the folder
1388
+ the user already chose, Finder finds it, and it arrives here the same way a
1389
+ photograph does. The drop is on the DOCUMENT rather than a zone, because a
1390
+ fit is not a third source — it is an instruction about the photo already
1391
+ loaded, and hunting for the right rectangle to aim at would be worse than
1392
+ pasting a path.
1393
+
1394
+ The page reads the bytes and the SERVER judges them: whether this is the
1395
+ photograph the fit was made for is a question only the side holding the
1396
+ pixels can answer. */
1397
+ async function loadFitFile(file){
1398
+ const s = $('#detSt');
1399
+ try {
1400
+ const text = await file.text();
1401
+ s.className = 'status busy'; s.textContent = 'Reading that fit…';
1402
+ const r = await api('/api/fit', {fit: JSON.parse(text)});
1403
+ st.corners = r.corners.map(c => c.slice());
1404
+ st.measured = null;
1405
+ st.quadFrom = 'file';
1406
+ if (r.device) setType(r.device);
1407
+ else if (!st.type) setType('phone');
1408
+ if (r.radius_frac != null) setFrac(r.radius_frac, 'from the fit you loaded');
1409
+ else applyPreset();
1410
+ // 'exact' and 'same-size' are the cases where the corners are used as they
1411
+ // were saved; 'scaled' and 'reshaped' moved them, and 'reshaped' means the
1412
+ // photograph is a different SHAPE, which is where a plausible-looking wrong
1413
+ // answer comes from. Only the first is stated as a fact.
1414
+ s.className = 'status ' + (r.match === 'exact' ? 'ok' : 'warn');
1415
+ s.textContent = r.match === 'exact' ? `Fit loaded from ${file.name}`
1416
+ : r.match === 'same-size' ? 'Fit loaded — same size, different pixels'
1417
+ : r.match === 'reshaped' ? 'Fit loaded and STRETCHED — check every corner'
1418
+ : 'Fit loaded and scaled — check a corner';
1419
+ s.title = r.message || '';
1420
+ toast(r.match === 'exact' ? 'ok' : 'info', r.message || 'Fit loaded.',
1421
+ { ms: 9000, dismissible: true });
1422
+ pick = {kind:'edge', i:0};
1423
+ draw(); drawStrip(); autoPreview();
1424
+ } catch (e) {
1425
+ s.className = 'status warn'; s.textContent = 'That fit could not be used';
1426
+ s.title = e.message || '';
1427
+ toast('err', 'Could not load that fit: ' + (e.message || e));
1428
+ }
1429
+ }
1430
+ // Capture phase, so the per-role drop zones never see a fit file and try to
1431
+ // decode it as an image.
1432
+ addEventListener('dragover', e => {
1433
+ if ([...(e.dataTransfer?.items || [])].some(i => i.kind === 'file')) e.preventDefault();
1434
+ }, true);
1435
+ addEventListener('drop', e => {
1436
+ const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
1437
+ if (!f || !/\.json$/i.test(f.name)) return; // not ours: let the zones have it
1438
+ e.preventDefault(); e.stopPropagation();
1439
+ loadFitFile(f);
1440
+ }, true);
1441
+
1195
1442
  /* ===== pickers (popovers over the canvas) ===== */
1196
1443
  async function loadRecent(){
1197
1444
  const {items} = await api('/api/recent?days=14&limit=40');
@@ -1222,7 +1469,11 @@ function setChosen(role, path, size, el, meta){
1222
1469
  chip.querySelector('.nm').textContent = `${path.split('/').pop()} · ${size[0]}×${size[1]}`
1223
1470
  + (meta && meta.video ? ` · ${meta.frames} frames @ ${Math.round(meta.fps)}fps` : '');
1224
1471
  $('#sw'+n).style.background = `center/cover no-repeat url("${fileURL(thumb)}")`;
1225
- if (role==='photo'){ st.photo = path; st.corners = null; }
1472
+ // A fit the server recognised from this photograph's own pixels. Held
1473
+ // rather than applied here: the quad only means anything once there is a
1474
+ // screenshot to put inside it, which is what maybeStart() waits for.
1475
+ if (role==='photo'){ st.photo = path; st.corners = null;
1476
+ st.remembered = (meta && meta.remembered) || null; }
1226
1477
  else { st.shot = path; st.video = meta && meta.video ? meta : null; syncVideoUI(); }
1227
1478
  closePop();
1228
1479
  maybeStart();
@@ -1389,12 +1640,36 @@ async function maybeStart(){
1389
1640
  // something. No quad yet — there is nothing to fit into it.
1390
1641
  draw();
1391
1642
  $('#cvbar').hidden = true;
1392
- $('#detSt').className = 'status';
1393
- $('#detSt').textContent = 'Now choose the screenshot to place on the screen.';
1643
+ $('#detSt').className = 'status' + (st.remembered ? ' ok' : '');
1644
+ // Naming it here is the whole point: this is the first moment the page
1645
+ // knows, and a feature nobody notices is a feature that does not exist.
1646
+ $('#detSt').textContent = st.remembered
1647
+ ? 'Your saved fit for this photo will be used — now choose the screenshot.'
1648
+ : 'Now choose the screenshot to place on the screen.';
1394
1649
  return;
1395
1650
  }
1396
1651
  $('#cvbar').hidden = false;
1397
- if (!st.corners) detect(); else { draw(); drawStrip(); }
1652
+ if (st.corners){
1653
+ draw(); drawStrip();
1654
+ // A reload arrives here with the session's corners already in hand, and
1655
+ // the only thing on the status line is the "now choose the screenshot"
1656
+ // text from the photo-only step above — a message about a step already
1657
+ // done, and on this path it also claimed the SAVED fit was in use when
1658
+ // the corners on screen are the ones from this session. Every other route
1659
+ // to a quad states itself at the moment it acts; this one had nobody.
1660
+ if (st.quadFrom === 'restored'){
1661
+ const s = $('#detSt'); s.className = 'status ok';
1662
+ s.textContent = 'Corners restored from this session — drag any edge to adjust';
1663
+ // The pill is ~200px at 742px wide and every status this page sets is
1664
+ // longer than that (measured: 278-426px against 197-207 available), so
1665
+ // the full sentence lives on hover and the first three words have to
1666
+ // carry the meaning on their own. Same shape as the abstain message.
1667
+ s.title = 'These are the corners this session already had — a reload does not '
1668
+ + 'throw away work. Press Re-detect or Reset to start over.';
1669
+ }
1670
+ }
1671
+ else if (st.remembered) applyRemembered();
1672
+ else detect();
1398
1673
  };
1399
1674
  img.src = fileURL(st.photo);
1400
1675
  }
@@ -1561,7 +1836,7 @@ async function pointAt(x, y){
1561
1836
  try {
1562
1837
  const r = await api('/api/detect', {click: [x, y]});
1563
1838
  if (r.found){
1564
- st.corners = r.corners; st.measured = r.corner_radius;
1839
+ st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
1565
1840
  s.className = 'status ok';
1566
1841
  s.textContent = `Screen found from your click (${r.method})`;
1567
1842
  if (!st.type) setType(r.type_guess || 'phone');
@@ -1591,7 +1866,7 @@ async function detect(){
1591
1866
  const s = $('#detSt'); s.className='status busy'; s.textContent = 'Detecting…';
1592
1867
  const r = await api('/api/detect', {});
1593
1868
  if (r.found){
1594
- st.corners = r.corners; st.measured = r.corner_radius;
1869
+ st.corners = r.corners; st.measured = r.corner_radius; st.quadFrom = 'detected';
1595
1870
  const corroborated = r.confidence === 'corroborated';
1596
1871
  s.className = 'status ' + (corroborated ? 'ok' : 'warn');
1597
1872
  s.textContent = corroborated ? `Both detectors agree (${r.method})`
@@ -1600,7 +1875,7 @@ async function detect(){
1600
1875
  if (r.corner_radius && r.corner_radius.confident){ setFrac(r.corner_radius.frac_of_width, 'measured from the photo'); }
1601
1876
  else { applyPreset(); $('#radSt').textContent = 'Radius not measurable here — using the device preset.'; }
1602
1877
  } else {
1603
- st.corners = defaultQuad();
1878
+ st.corners = defaultQuad(); st.quadFrom = 'default';
1604
1879
  s.className='status warn';
1605
1880
  // Naming the control is the whole fix. The page knows the detector just
1606
1881
  // failed, which is exactly the moment "Point at screen" is worth doing —
@@ -1617,6 +1892,40 @@ async function detect(){
1617
1892
  draw(); drawStrip();
1618
1893
  autoPreview(); // if the compare pane is open, fill it straight away
1619
1894
  }
1895
+ /* The third source of a starting quad, beside detection and the default
1896
+ rectangle — and the strongest of the three, so it says so.
1897
+
1898
+ It is not a guess being dressed up: the key is a hash of the photograph's
1899
+ decoded pixels, so a hit means THIS image, not a similar one, and the corners
1900
+ are the ones that produced a saved composite. Every correction path still
1901
+ applies — Re-detect, Reset and dragging all work exactly as before. */
1902
+ function applyRemembered(){
1903
+ const r = st.remembered;
1904
+ st.corners = r.corners.map(c => c.slice());
1905
+ st.measured = null; // nothing was measured on this run
1906
+ st.quadFrom = 'remembered';
1907
+ if (r.device) setType(r.device);
1908
+ else if (!st.type) setType('phone');
1909
+ // setType rebuilds the preset list but does not touch the radius, so the
1910
+ // remembered fraction is applied after it and survives.
1911
+ if (r.radius_frac != null) setFrac(r.radius_frac, 'remembered from your last fit');
1912
+ else applyPreset();
1913
+ const s = $('#detSt'); s.className = 'status ok';
1914
+ s.textContent = 'Your saved fit for this photo' + (r.saved ? ' — ' + savedWhen(r.saved) : '');
1915
+ s.title = 'These four corners come from a composite you saved from this exact '
1916
+ + 'photograph. Drag any edge to adjust, or press Re-detect to start over.';
1917
+ pick = {kind:'edge', i:0};
1918
+ draw(); drawStrip(); autoPreview();
1919
+ }
1920
+ /* Dates on this page are relative: "3 days ago" answers "is this still the fit I
1921
+ mean?" without the reader doing arithmetic against today. */
1922
+ function savedWhen(t){
1923
+ const days = Math.floor((Date.now()/1000 - t) / 86400);
1924
+ if (days <= 0) return 'saved today';
1925
+ if (days === 1) return 'saved yesterday';
1926
+ if (days < 30) return `saved ${days} days ago`;
1927
+ return 'saved ' + new Date(t*1000).toLocaleDateString();
1928
+ }
1620
1929
  // One definition of "a sane starting position", used by the abstention path and
1621
1930
  // by Reset. Deliberately a plain centred rectangle: it claims nothing about
1622
1931
  // where the screen is, and every handle is on the picture where it can be
@@ -1628,7 +1937,7 @@ function defaultQuad(){
1628
1937
  $('#redetect').onclick = detect;
1629
1938
  $('#resetquad').onclick = () => {
1630
1939
  if (!img.naturalWidth) return;
1631
- st.corners = defaultQuad();
1940
+ st.corners = defaultQuad(); st.quadFrom = 'default';
1632
1941
  const s = $('#detSt'); s.className='status warn';
1633
1942
  s.textContent = 'Edges reset — place them on the screen';
1634
1943
  s.title = '';
@@ -1638,6 +1947,10 @@ $('#resetquad').onclick = () => {
1638
1947
  };
1639
1948
 
1640
1949
  function draw(){
1950
+ // The clip follows the quad while it is being dragged: the live layer IS the
1951
+ // fit, so leaving it on the old corners would make the two panes disagree
1952
+ // mid-drag — which is the one thing the side-by-side exists to prevent.
1953
+ if (!$('#liveWrap').hidden) placeLiveVideo();
1641
1954
  ctx.clearRect(0,0,cv.width,cv.height);
1642
1955
  if (!img.naturalWidth) return;
1643
1956
  ctx.drawImage(img, 0,0, cv.width, cv.height);
@@ -1996,10 +2309,18 @@ function setType(t){
1996
2309
  st.type = t;
1997
2310
  document.querySelectorAll('#types .chip').forEach(c => c.classList.toggle('on', c.dataset.t===t));
1998
2311
  const sel = $('#preset'); sel.innerHTML='';
1999
- st.presets.filter(p=>p.type===t).forEach(p => { const o=document.createElement('option'); o.value=p.id; o.textContent=`${p.label} (${(p.frac*100).toFixed(1)}%)`; sel.appendChild(o); });
2312
+ st.presets.filter(p=>p.type===t).forEach(p => { const o=document.createElement('option'); o.value=p.id;
2313
+ o.textContent=`${p.label} (${(p.frac*100).toFixed(1)}%)`;
2314
+ sel.appendChild(o); });
2000
2315
  st.preset = sel.value;
2001
2316
  }
2002
- function applyPreset(){ const p = st.presets.find(p=>p.id===$('#preset').value); if (p) setFrac(p.frac, 'preset: '+p.label); }
2317
+ /* The caption gets the preset's FULL membership, not the short label the
2318
+ dropdown shows. The label is cut to what a closed <select> can display, and a
2319
+ `title` on an <option> is not reliably rendered in a native macOS popup — so
2320
+ the caption is the only place the exact model list can actually be read.
2321
+ It says where the number came from too (radius pt over width pt), because a
2322
+ preset is a claim about a device and a claim should show its working. */
2323
+ function applyPreset(){ const p = st.presets.find(p=>p.id===$('#preset').value); if (p) setFrac(p.frac, 'preset: '+(p.full || p.label)); }
2003
2324
  function setFrac(f, why){ st.frac = f; $('#radius').value = f; syncSlider();
2004
2325
  $('#radSt').textContent = `${(f*100).toFixed(1)}% of screen width — ${why}`; markStale(); }
2005
2326
  $('#preset').onchange = () => { applyPreset(); autoPreview(); };
@@ -2019,6 +2340,16 @@ function setRadiusOn(on){
2019
2340
  setSwitch($('#radBtn'), on);
2020
2341
  }
2021
2342
  $('#radBtn').onclick = () => { setRadiusOn(!radiusOn); autoPreview(); };
2343
+ /* Corner smoothing follows the SELECTED PRESET, not the radius. A measured
2344
+ radius is still a radius on that device, and the device is what has a shape:
2345
+ an iPhone's corners are a continuous curve whether or not we measured how big
2346
+ they are. So this reads the preset dropdown even when st.measured is in use.
2347
+ Returns 0 for Android, the square entries, and anything with no preset at
2348
+ all — 0 is a circular arc, which is what everything did before. */
2349
+ function smoothingValue(){
2350
+ const p = st.presets.find(p => p.id === $('#preset').value);
2351
+ return (p && p.smoothing) || 0;
2352
+ }
2022
2353
  function syncSlider(){ const r=$('#radius'); r.style.setProperty('--p', (r.value - r.min)/(r.max - r.min)); }
2023
2354
 
2024
2355
  /* ===== preview / compare / save ===== */
@@ -2048,9 +2379,23 @@ function markStale(){
2048
2379
  $('#imp').classList.remove('primary');
2049
2380
  }
2050
2381
  function syncOut(){
2051
- const im = $('#outImg');
2052
- if (!im.hidden && outNat){
2053
- im.style.width = Math.round(img.naturalWidth * scale) + 'px';
2382
+ // The live layer is a photo plus a transformed video, so it re-places rather
2383
+ // than being resized: the transform is computed FROM the zoom, and a stale
2384
+ // matrix puts the clip somewhere the fit is not.
2385
+ if (!$('#liveWrap').hidden){
2386
+ placeLiveVideo();
2387
+ const a = $('#scroller'), b = $('#outWrap');
2388
+ b.scrollLeft = a.scrollLeft; b.scrollTop = a.scrollTop;
2389
+ return;
2390
+ }
2391
+ // Whichever of the two is showing gets the SAME treatment: the pane exists to
2392
+ // be compared with the fit beside it, and a clip that ignored the zoom would
2393
+ // break the pairing the moment you looked closely at a corner. The proxy is
2394
+ // smaller than the photo, so it is sized by the PHOTO's width — the composite
2395
+ // covers the same frame either way.
2396
+ const el = $('#outImg').hidden ? ($('#outVid').hidden ? null : $('#outVid')) : $('#outImg');
2397
+ if (el && (outNat || el.id === 'outVid')){
2398
+ el.style.width = Math.round(img.naturalWidth * scale) + 'px';
2054
2399
  const a = $('#scroller'), b = $('#outWrap');
2055
2400
  b.scrollLeft = a.scrollLeft; b.scrollTop = a.scrollTop;
2056
2401
  }
@@ -2062,24 +2407,261 @@ function autoPreview(){
2062
2407
  // Judge while you fit: with the compare pane open the composite re-renders
2063
2408
  // after each correction (compose() measures 0.06s server-side), so Preview
2064
2409
  // stops being a round trip.
2410
+ //
2411
+ // The live layer answers to the same settings — emissive and the corner
2412
+ // radius both change what it draws — and it is free to update, so it does it
2413
+ // immediately rather than on the debounce.
2414
+ if (!$('#liveWrap').hidden) placeLiveVideo();
2065
2415
  clearTimeout(autoTimer);
2066
2416
  autoTimer = setTimeout(renderPreview, 350);
2067
2417
  }
2418
+ /* The Result pane holds either the still composite or a played clip, never
2419
+ both. Several writers disagreeing about which is visible is a defect this
2420
+ project has already shipped once: one function, and everything else calls
2421
+ it. */
2422
+ function showResult(what){
2423
+ $('#outImg').hidden = what !== 'still';
2424
+ const v = $('#outVid');
2425
+ if (what !== 'clip' && !v.hidden){ try { v.pause(); } catch(e){} }
2426
+ v.hidden = what !== 'clip';
2427
+ const live = $('#liveWrap');
2428
+ if (what !== 'live' && !live.hidden){ try { $('#liveVid').pause(); } catch(e){} }
2429
+ live.hidden = what !== 'live';
2430
+ $('#outEmpty').style.display = (what === 'empty') ? '' : 'none';
2431
+ }
2432
+
2433
+ /* ===== live playback: the clip on the photo, warped by the browser =========
2434
+
2435
+ The fit is a homography, and a homography is exactly what CSS `matrix3d`
2436
+ applies — so the browser can put a moving video on the device's screen with
2437
+ no compositing at all. Instant, where the rendered proxy costs seconds.
2438
+
2439
+ What it can and cannot show, stated because the page states it too:
2440
+ geometry — exact, the same four corners the render uses
2441
+ corner radius — applied in the video's own pixel space, before the warp,
2442
+ which is where compose() applies it
2443
+ emissive — APPROXIMATED with `screen` blending. Same idea (the
2444
+ screen's light plus the glass beneath it), not the same
2445
+ arithmetic as compose()'s emissive.
2446
+ grade, grain — not applied. Those are per-frame Python and are the reason
2447
+ the rendered preview costs what it costs.
2448
+
2449
+ So this answers "is it in the right place and does the motion read", and the
2450
+ rendered preview answers "does it look real". Two questions, two controls. */
2451
+
2452
+ /* Solve the 8 unknowns of the projective transform taking the video's own
2453
+ rectangle to the quad. Plain Gaussian elimination on an 8x8 — no library, and
2454
+ deterministic, which matters because everything else here is. */
2455
+ function solveHomography(src, dst){
2456
+ const A = [], b = [];
2457
+ for (let i = 0; i < 4; i++){
2458
+ const [x, y] = src[i], [u, v] = dst[i];
2459
+ A.push([x, y, 1, 0, 0, 0, -u * x, -u * y]); b.push(u);
2460
+ A.push([0, 0, 0, x, y, 1, -v * x, -v * y]); b.push(v);
2461
+ }
2462
+ for (let c = 0; c < 8; c++){
2463
+ let p = c;
2464
+ for (let r = c + 1; r < 8; r++) if (Math.abs(A[r][c]) > Math.abs(A[p][c])) p = r;
2465
+ if (Math.abs(A[p][c]) < 1e-12) return null; // degenerate quad
2466
+ [A[c], A[p]] = [A[p], A[c]]; [b[c], b[p]] = [b[p], b[c]];
2467
+ for (let r = 0; r < 8; r++){
2468
+ if (r === c) continue;
2469
+ const f = A[r][c] / A[c][c];
2470
+ if (!f) continue;
2471
+ for (let k = c; k < 8; k++) A[r][k] -= f * A[c][k];
2472
+ b[r] -= f * b[c];
2473
+ }
2474
+ }
2475
+ const h = b.map((v, i) => v / A[i][i]);
2476
+ return [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1];
2477
+ }
2478
+
2479
+ /* Place the clip on the quad at the pane's current zoom. Called on every zoom,
2480
+ pan and corner move, so it does arithmetic and touches two style properties —
2481
+ nothing that reads layout back. */
2482
+ function placeLiveVideo(){
2483
+ const wrap = $('#liveWrap'), v = $('#liveVid');
2484
+ if (wrap.hidden || !st.corners || !v.videoWidth) return;
2485
+ const W = v.videoWidth, H = v.videoHeight;
2486
+ const dst = st.corners.map(([x, y]) => [x * scale, y * scale]);
2487
+ const h = solveHomography([[0, 0], [W, 0], [W, H], [0, H]], dst);
2488
+ if (!h) return;
2489
+ v.style.width = W + 'px'; v.style.height = H + 'px';
2490
+ // Column-major, and the third row/column is identity: this is a 2D projective
2491
+ // transform expressed in a 4x4, not a 3D rotation.
2492
+ v.style.transform = `matrix3d(${h[0]},${h[3]},0,${h[6]},`
2493
+ + `${h[1]},${h[4]},0,${h[7]},`
2494
+ + `0,0,1,0,`
2495
+ + `${h[2]},${h[5]},0,${h[8]})`;
2496
+ // The radius is a fraction of the SCREENSHOT's width and is applied before the
2497
+ // warp — same as compose(), which rounds the screenshot and then warps it.
2498
+ v.style.borderRadius = (radiusValue() * W) + 'px';
2499
+ v.classList.toggle('emissive', emisValue() !== null);
2500
+ const bg = $('#liveBg');
2501
+ bg.style.width = Math.round(img.naturalWidth * scale) + 'px';
2502
+ wrap.style.width = bg.style.width;
2503
+ }
2504
+
2505
+ /* Play and Stop are the same control, because they are the same question asked
2506
+ twice: is it running. Two buttons would leave one of them meaningless at all
2507
+ times. */
2508
+ const PLAY_ICON = '<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" focusable="false"><path d="M3 1.5 10 6 3 10.5Z" fill="currentColor"/></svg>';
2509
+ const STOP_ICON = '<svg viewBox="0 0 12 12" width="11" height="11" aria-hidden="true" focusable="false"><rect x="2.5" y="2.5" width="7" height="7" rx="1" fill="currentColor"/></svg>';
2510
+ function setPlayIcon(playing){
2511
+ const b = $('#playlive');
2512
+ b.innerHTML = playing ? STOP_ICON : PLAY_ICON;
2513
+ // The icon is the only label this button has, so the accessible name has to
2514
+ // carry the state as well — a screen reader on a square that still says
2515
+ // "Play" is being told the opposite of what is happening.
2516
+ b.setAttribute('aria-label', playing ? 'Stop and return to the fitted frame'
2517
+ : 'Play the clip on the photo');
2518
+ }
2519
+
2520
+ /* Stopping returns to the FITTED frame, not to wherever playback happened to
2521
+ be. That frame is the one the edges were matched against and the one the
2522
+ light match is bound from, so it is the only frame the fit is actually a
2523
+ statement about — leaving a random one on screen would invite judging the
2524
+ quad against something the render never uses. */
2525
+ function stopLive(){
2526
+ const v = $('#liveVid');
2527
+ try { v.pause(); } catch(e){}
2528
+ const fps = st.video && st.video.fps;
2529
+ if (fps) v.currentTime = (+$('#vframe').value || 0) / fps;
2530
+ setPlayIcon(false);
2531
+ const s = $('#outSt');
2532
+ s.textContent = `Stopped on frame ${+$('#vframe').value || 0} — the frame the edges are matched against`;
2533
+ }
2534
+
2535
+ async function playLive(){
2536
+ if (!(st.photo && st.shot && st.corners && st.video)) return;
2537
+ if (!$('#liveWrap').hidden && !$('#liveVid').paused) return stopLive();
2538
+ const v = $('#liveVid'), s = $('#outSt');
2539
+ $('#liveBg').src = fileURL(st.photo);
2540
+ if (!v.src || v.dataset.src !== st.shot){
2541
+ v.dataset.src = st.shot;
2542
+ v.src = fileURL(st.shot);
2543
+ }
2544
+ showResult('live');
2545
+ await new Promise(r => {
2546
+ if (v.videoWidth) return r();
2547
+ v.onloadedmetadata = r;
2548
+ setTimeout(r, 4000); // never hang the page on a bad decode
2549
+ });
2550
+ placeLiveVideo();
2551
+ v.currentTime = st.video && st.video.fps ? (+$('#vframe').value / st.video.fps) : 0;
2552
+ v.play().catch(() => {}); // a blocked autoplay is not an error
2553
+ setPlayIcon(true);
2554
+ // The video can also end, pause or be stopped by its own controls, and the
2555
+ // icon has to follow that rather than only the button press. One writer for
2556
+ // the icon, driven by the element's real state.
2557
+ v.onpause = () => setPlayIcon(false);
2558
+ v.onplay = () => setPlayIcon(true);
2559
+ // Never claim more than it is. The honesty contract applies to a preview as
2560
+ // much as to a detection: this is geometry plus a blend mode, and a person
2561
+ // judging colour from it would be judging the wrong thing.
2562
+ s.textContent = 'Playing live — geometry'
2563
+ + (emisValue() !== null ? ' + emissive (approximated)' : '')
2564
+ + ', no grade or grain. Use Render preview for the real look.';
2565
+ s.title = 'The browser applies the same four corners and the corner radius, and '
2566
+ + 'approximates emissive with screen blending. The colour grade and the '
2567
+ + 'grain are computed per frame in Python and are not in this view.';
2568
+ }
2569
+
2570
+
2571
+
2572
+
2573
+ /* Watch the composite move before committing to a full render.
2574
+
2575
+ A proxy, not a shortcut: the same pipeline at PREVIEW_WIDTH, so the grade,
2576
+ grain and emissive blend are all applied and what is watched is what will
2577
+ render. The alternative — a <video> under a CSS perspective transform — would
2578
+ show the geometry moving and none of the three, which is to say none of what
2579
+ this is for. Measured on a 21s clip: 18s at 720px against 57s full size. */
2580
+ let building = false;
2581
+ async function playClip(){
2582
+ const btn = $('#playclip'), s = $('#outSt');
2583
+ btn.disabled = true; btn.textContent = 'Building…';
2584
+ // Dragging an edge during a build re-renders the still, and renderPreview
2585
+ // owns this same status line — so without this the frame counter is wiped by
2586
+ // a preview the user did not ask for, and the wait goes silent again.
2587
+ building = true;
2588
+ try{
2589
+ await api('/api/preview_video', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(),
2590
+ device: st.type, grade: gradeValue(),
2591
+ reflection: emisValue(), fit_frame: +$('#vframe').value});
2592
+ }catch(e){
2593
+ building = false;
2594
+ btn.disabled = false; btn.textContent = 'Play';
2595
+ toast('err', 'Could not build the preview: ' + e.message); return;
2596
+ }
2597
+ const tick = setInterval(async () => {
2598
+ // fetch, NOT api(): api's third argument is `raw` (headers + body for an
2599
+ // upload), not options — so `{method:'GET'}` made this a POST with a null
2600
+ // body, /api/render_status is GET-only, and every poll threw "no such
2601
+ // route" into a catch that swallows it. Shipped since v0.18.0: a render
2602
+ // that FINISHED left the button on "Rendering…" for ever, with no toast and
2603
+ // Send to Claude never enabled. It looks exactly like a hung encode, which
2604
+ // is why it was reported as "no progress" rather than as a broken poll.
2605
+ let d; try { d = await (await fetch('/api/render_status')).json(); }
2606
+ catch(e){ return; }
2607
+ if (d.state === 'running'){
2608
+ // The wait is real — tens of seconds on a long clip — so it counts frames
2609
+ // rather than spinning. A number that stops moving is a stall you can see;
2610
+ // a spinner is not, and in a hidden pane it does not even animate.
2611
+ s.textContent = d.total ? `Building preview — ${d.done} / ${d.total} frames`
2612
+ : 'Building preview…';
2613
+ } else if (d.state === 'done'){
2614
+ clearInterval(tick); building = false;
2615
+ btn.disabled = false; btn.textContent = 'Play';
2616
+ const v = $('#outVid');
2617
+ v.src = `${fileURL(d.output)}&t=${Date.now()}`;
2618
+ showResult('clip'); syncOut();
2619
+ v.play().catch(()=>{}); // a blocked autoplay is not an error
2620
+ // Say it is a SEGMENT. A preview that silently showed six seconds of a
2621
+ // forty-second clip would look like a broken render.
2622
+ const secs = d.info && d.info.fps ? (d.done / d.info.fps) : 0;
2623
+ const at = d.info && d.info.start_frame ? ` from frame ${d.info.start_frame}` : '';
2624
+ s.textContent = `Preview · ${secs ? secs.toFixed(1) + 's' : d.done + ' frames'}${at}`
2625
+ + ` at ${(d.info && d.info.output_size || []).join('×')} — drag an edge for the still`;
2626
+ } else if (d.state === 'error'){
2627
+ clearInterval(tick); building = false;
2628
+ btn.disabled = false; btn.textContent = 'Play';
2629
+ s.textContent = 'Preview failed';
2630
+ toast('err', 'Preview failed: ' + (d.message || 'unknown'));
2631
+ }
2632
+ }, 900);
2633
+ }
2634
+ $('#playclip').onclick = playClip;
2635
+ $('#playlive').onclick = playLive;
2636
+
2068
2637
  async function renderPreview(){
2069
2638
  if (!(st.photo && st.shot && st.corners)) return;
2070
- const s = $('#outSt'); s.textContent = 'Rendering…';
2639
+ syncPlayable();
2640
+ const s = $('#outSt');
2641
+ // The status line describes what the pane is SHOWING. The still keeps
2642
+ // rendering behind a clip that is playing — that is deliberate, so switching
2643
+ // back is instant — but it must not narrate over it. Twice now a status has
2644
+ // been overwritten by a preview nobody was looking at.
2645
+ const say = t => { if (!building && $('#liveWrap').hidden) s.textContent = t; };
2646
+ say('Rendering…');
2071
2647
  try{
2072
- const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
2648
+ const r = await api('/api/preview', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
2073
2649
  const im = $('#outImg');
2074
- im.onload = () => { outNat = im.naturalWidth; im.hidden = false; $('#outEmpty').style.display='none'; syncOut(); };
2650
+ im.onload = () => {
2651
+ outNat = im.naturalWidth;
2652
+ // autoPreview runs on every drag, and it must not yank the pane away from
2653
+ // a clip that is playing. The still is still rendered and waiting behind.
2654
+ if ($('#liveWrap').hidden) showResult('still');
2655
+ syncOut();
2656
+ };
2075
2657
  im.src = `${fileURL(r.path)}&t=${Date.now()}`;
2076
- s.textContent = `radius ${r.radius_px}px on the screenshot`;
2658
+ say(`radius ${r.radius_px}px on the screenshot`);
2077
2659
  const b = $('#save'); b.disabled = false; b.textContent = saveLabel();
2078
2660
  b.classList.add('primary'); // Save is now the next action
2079
2661
  if (!previewHinted){ previewHinted = true;
2080
2662
  toast('info', 'Check the corners in Result, then <b>Save</b> — Save renders at full resolution.');
2081
2663
  }
2082
- }catch(e){ s.textContent = e.message; }
2664
+ }catch(e){ say(e.message); }
2083
2665
  }
2084
2666
  function prettyDir(p){
2085
2667
  // ~ for home, and show the last two segments of a long path: the interesting
@@ -2106,11 +2688,30 @@ document.querySelectorAll('[data-preset]').forEach(btn => {
2106
2688
  is shown on, and what the primary button does. Everything else — the edge
2107
2689
  matching, the loupe, the compare view, the realism rail — is untouched,
2108
2690
  because the geometry does not care whether the pixels move. */
2691
+ function syncPlayable(){
2692
+ const ok = !!(st.video && st.corners);
2693
+ for (const id of ['#playclip', '#playlive']){
2694
+ const b = $(id);
2695
+ if (b) b.disabled = !ok;
2696
+ }
2697
+ // Dragging a corner while the clip plays should move the clip, not leave it
2698
+ // behind on the old quad.
2699
+ if (!$('#liveWrap').hidden) placeLiveVideo();
2700
+ }
2109
2701
  function syncVideoUI(){
2110
2702
  const v = st.video;
2111
2703
  $('#vidbar').hidden = !v;
2704
+ // A still has no format to choose, so the control is not there at all — the
2705
+ // same rule the clip bar already follows. It lives in the top bar now, so it
2706
+ // has to be told; it used to be inside the bar being hidden.
2707
+ $('#fmt').hidden = !v;
2708
+ syncPlayable();
2112
2709
  const b = $('#save');
2113
2710
  if (v && v.ffmpeg === false){
2711
+ // Live playback needs no ffmpeg — it is the browser drawing. Only the
2712
+ // rendered preview does.
2713
+ $('#playclip').disabled = true;
2714
+ $('#playclip').title = 'A rendered preview means compositing, which needs ffmpeg.';
2114
2715
  // Say it now, not after the fit. The fitting still works — it is only the
2115
2716
  // encode that cannot run — so the page stays usable and the message names
2116
2717
  // the one thing to do.
@@ -2134,6 +2735,13 @@ $('#vframe').onchange = async () => {
2134
2735
  // uses, so what you match the edges against is the frame you chose.
2135
2736
  try{
2136
2737
  await api('/api/frame', {index: +$('#vframe').value});
2738
+ // If the clip is on screen and paused, the scrubber is choosing what you
2739
+ // are looking at — so move it there too, or the number and the picture
2740
+ // disagree.
2741
+ const lv = $('#liveVid');
2742
+ if (!$('#liveWrap').hidden && lv.paused && st.video && st.video.fps){
2743
+ lv.currentTime = (+$('#vframe').value || 0) / st.video.fps;
2744
+ }
2137
2745
  // Only the composite changes. The chip keeps frame 0 on purpose — at that
2138
2746
  // size one frame looks like any other, so updating it would be movement
2139
2747
  // without meaning.
@@ -2141,32 +2749,69 @@ $('#vframe').onchange = async () => {
2141
2749
  }catch(e){ toast('err', 'Could not read that frame: ' + e.message); }
2142
2750
  };
2143
2751
 
2752
+ /* One writer for the render button's progress state, so the class and the
2753
+ custom property can never disagree about whether a render is running. */
2754
+ function setRenderProgress(pct){
2755
+ const b = $('#save');
2756
+ if (pct === null){
2757
+ b.classList.remove('progress');
2758
+ b.style.removeProperty('--fill');
2759
+ b.removeAttribute('aria-busy');
2760
+ b.removeAttribute('role'); b.removeAttribute('aria-valuenow');
2761
+ return;
2762
+ }
2763
+ const p = Math.max(0, Math.min(100, pct));
2764
+ b.classList.add('progress');
2765
+ b.style.setProperty('--fill', p.toFixed(1) + '%');
2766
+ // The bar is a colour change, which a screen reader cannot see. The label
2767
+ // carries the number and the element says it is a progress bar while it is
2768
+ // one — otherwise this is an animation that only sighted users can read.
2769
+ b.setAttribute('aria-busy', 'true');
2770
+ b.setAttribute('role', 'progressbar');
2771
+ b.setAttribute('aria-valuenow', String(Math.round(p)));
2772
+ b.textContent = `Rendering ${Math.round(p)}%`;
2773
+ }
2774
+
2144
2775
  async function renderVideo(){
2145
2776
  const b = $('#save');
2146
2777
  b.disabled = true;
2147
- b.innerHTML = 'Rendering<span class="spin" aria-hidden="true"></span>';
2778
+ setRenderProgress(0);
2148
2779
  try{
2149
- await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), device: st.type,
2780
+ await api('/api/render', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type,
2150
2781
  grade: gradeValue(), reflection: emisValue(), preset, fit_frame: +$('#vframe').value});
2151
- }catch(e){ toast('err','Could not start the render: '+e.message); b.disabled=false; b.textContent = saveLabel(); return; }
2782
+ }catch(e){
2783
+ setRenderProgress(null);
2784
+ toast('err','Could not start the render: '+e.message);
2785
+ b.disabled=false; b.textContent = saveLabel(); return;
2786
+ }
2152
2787
  // Poll rather than hold a request open: a clip is hundreds of frames and a
2153
2788
  // browser would time the request out long before the render finished.
2154
2789
  const tick = setInterval(async () => {
2155
- let d; try { d = await api('/api/render_status', null, {method:'GET'}); }
2790
+ // fetch, NOT api(): api's third argument is `raw` (headers + body for an
2791
+ // upload), not options — so `{method:'GET'}` made this a POST with a null
2792
+ // body, /api/render_status is GET-only, and every poll threw "no such
2793
+ // route" into a catch that swallows it. Shipped since v0.18.0: a render
2794
+ // that FINISHED left the button on "Rendering…" for ever, with no toast and
2795
+ // Send to Claude never enabled. It looks exactly like a hung encode, which
2796
+ // is why it was reported as "no progress" rather than as a broken poll.
2797
+ let d; try { d = await (await fetch('/api/render_status')).json(); }
2156
2798
  catch(e){ return; }
2157
2799
  if (d.state === 'running'){
2158
- // The button says what it is doing; the frame counter beside the
2159
- // scrubber carries the numbers, so the primary control stays calm.
2160
- b.innerHTML = 'Rendering<span class="spin" aria-hidden="true"></span>';
2800
+ // The button IS the progress bar now. A spinner cannot be told apart
2801
+ // from a stall; a percentage that stops moving can, and the fill gives
2802
+ // the same information at a glance.
2803
+ setRenderProgress(d.total ? (100 * d.done / d.total) : 0);
2161
2804
  if (d.total) $('#vframeLbl').textContent = `${d.done} / ${d.total} frames`;
2162
2805
  } else if (d.state === 'done'){
2163
2806
  clearInterval(tick);
2807
+ setRenderProgress(null);
2164
2808
  b.textContent = saveLabel(); b.disabled = false; b.classList.remove('primary');
2165
2809
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
2166
2810
  toast('ok', `Rendered <code>${(d.output||'').split('/').pop()}</code> · ${d.done} frames to <code>${prettyDir(st.outDir||'')}</code>`);
2167
2811
  const imp = $('#imp'); imp.disabled = false; imp.textContent = 'Send to Claude'; imp.classList.add('primary');
2168
2812
  } else if (d.state === 'error'){
2169
2813
  clearInterval(tick);
2814
+ setRenderProgress(null);
2170
2815
  b.textContent = saveLabel(); b.disabled = false;
2171
2816
  $('#vframeLbl').textContent = `${$('#vframe').value} / ${$('#vframe').max}`;
2172
2817
  toast('err', 'Render failed: ' + (d.message || 'unknown'));
@@ -2178,12 +2823,17 @@ $('#save').onclick = async () => {
2178
2823
  if (st.video) return renderVideo();
2179
2824
  const b = $('#save'); b.textContent = 'Saving…'; b.disabled = true;
2180
2825
  try{
2181
- const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
2826
+ const r = await api('/api/save', {corners: st.corners, radius_frac: radiusValue(), smoothing: smoothingValue(), device: st.type, grade: gradeValue(), reflection: emisValue()});
2182
2827
  b.textContent = saveLabel();
2183
2828
  // The real destination, not a hardcoded one: --out-dir means saves usually
2184
2829
  // land in the project folder now, and telling the user "~/Desktop" when
2185
2830
  // they aren't there is how you lose a file.
2186
- toast('ok', `Saved <code>${r.output.split('/').pop()}</code> to <code>${prettyDir(st.outDir || '')}</code>`);
2831
+ // Naming the fit file here is the whole reason it is discoverable: it is
2832
+ // written silently beside the mockup, and a file nobody knows about is a
2833
+ // file nobody drags back in.
2834
+ toast('ok', `Saved <code>${r.output.split('/').pop()}</code> to <code>${prettyDir(st.outDir || '')}</code>`
2835
+ + (r.fit_file ? ` · the fit is beside it as <code>${r.fit_file.split('/').pop()}</code> — drop that back on this page to reuse these corners` : ''),
2836
+ { ms: 9000, dismissible: true });
2187
2837
  const imp = $('#imp');
2188
2838
  imp.disabled = false; imp.textContent = 'Send to Claude';
2189
2839
  // Hand the accent on: the file exists, so sending it is what is next.
@@ -2266,7 +2916,6 @@ $('#imp').onclick = async () => {
2266
2916
  // The server keeps the session; the page used to start blank on reload,
2267
2917
  // silently discarding a photo, a screenshot and any corner work.
2268
2918
  if (s.photo || s.screenshot){
2269
- if (s.corners) st.corners = s.corners;
2270
2919
  if (s.radius_frac) st.frac = s.radius_frac;
2271
2920
  if (s.device) st.type = s.device;
2272
2921
  for (const [role, path] of [['photo', s.photo], ['screenshot', s.screenshot]]){
@@ -2274,6 +2923,13 @@ $('#imp').onclick = async () => {
2274
2923
  try { const r = await api('/api/use', {role, path}); setChosen(role, r.path, r.size, null, r); }
2275
2924
  catch(e){ /* file moved since: leave that slot empty */ }
2276
2925
  }
2926
+ // AFTER the loop, not before it: choosing a photo clears the quad, because
2927
+ // in every other case the new photo has nothing to do with the old corners.
2928
+ // On the restore path it is the SAME photo, so the assignment above was
2929
+ // being wiped by the /api/use it preceded, and a reload silently re-detected
2930
+ // over hand-placed edges. maybeStart's fit runs on the image's load event,
2931
+ // which cannot fire before this line, so the value is in place by then.
2932
+ if (s.corners){ st.corners = s.corners; st.quadFrom = 'restored'; }
2277
2933
  // Populate the radius dropdown on the restore path too. detect() only
2278
2934
  // calls setType when st.type is unset, so a restored session used to come
2279
2935
  // back with an empty preset list and a zeroed slider.