File size: 68,787 Bytes
da72558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
"""Flowframes on Spaces: RIFE frame interpolation that runs on CPU or GPU.

One code path, one model file. The execution provider is chosen at startup
(TensorRT, else CUDA, else CPU) and whatever was actually selected is printed in
the header, because a missing CUDA library drops ORT to CPU silently and a Space
running 50x slower looks identical to one that is not.

Work is cut into segments sized from a measured per-frame rate. On ZeroGPU each
segment is its own GPU call, so the 120 s cap bounds a segment rather than the
job and clip length stops being a limit. On CPU the same seams are checkpoints,
so cancelling keeps the minutes already spent.
"""

import os
import pathlib
import re
import shutil
import sys
import tempfile
import threading
import time
import uuid

# Gradio copies every file it serves into GRADIO_TEMP_DIR and never prunes it,
# so each delivered output existed twice and only one copy was ever cleaned.
# Point that cache somewhere sweepable. Must happen before gradio is imported.
SCRATCH = pathlib.Path(tempfile.gettempdir()) / "flowframes"
OUTDIR = SCRATCH / "out"
CACHEDIR = SCRATCH / "gradio"
for _d in (OUTDIR, CACHEDIR):
    _d.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("GRADIO_TEMP_DIR", str(CACHEDIR))
CACHEDIR = pathlib.Path(os.environ["GRADIO_TEMP_DIR"])   # setdefault may not have won

import gradio as gr

import rife_core as R

try:
    import spaces
    GPU = spaces.GPU
except Exception:                       # noqa: BLE001
    def GPU(*_a, **_k):
        return lambda f: f

# The import above is NOT the test. `spaces` is in requirements.txt and installs
# anywhere; off ZeroGPU its decorator simply no-ops, so `import spaces` succeeded
# on cpu-basic too and every ZeroGPU branch was taken there: BUDGET_S 90 s
# instead of 900, segments ten times smaller than intended, and a header
# announcing a GPU. Only the environment knows. Read from the live container:
#   SPACES_ZERO_GPU='1'  ZEROGPU_V2='true'  SPACES_ZERO_DEVICE_API_URL=...
ZEROGPU = bool(os.environ.get("SPACES_ZERO_GPU"))

HERE = pathlib.Path(__file__).parent
LOG_LINES = 14
CANCELLED: set[str] = set()      # session hashes that asked to stop
LOCK = threading.Lock()
OWNER: str | None = None         # session hash of the job currently holding LOCK

# Built on CPU at import: on ZeroGPU there is no CUDA device until a @spaces.GPU
# call is running, so a session created here could never see one.
BACKEND = R.OnnxBackend(prefer_gpu=False)
_GPU_BE = None
# (height, width, device) -> (seconds per frame, backend name, build seconds).
# Survives between jobs because it lives in the parent, not in a @spaces.GPU fork.
_RATE_CACHE: dict[tuple[int, int, str], tuple[float, str, float]] = {}
# Set when a GPU call fails on quota, cleared when one next succeeds. Module
# level because the header is rendered per page load by visitors who are not
# running a job, and a Space whose daily quota is gone was still announcing
# "GPU (ZeroGPU), about 39s" while every job crawled along on CPU.
GPU_DEAD = False
# Whether NVENC actually encodes in a GPU fork. False until the first fork
# reports back, because the parent process has no GPU and cannot find out.
NVENC_OK = False


def worker() -> R.Backend:
    """The backend to actually infer with. Inside a GPU call this upgrades to
    CUDA or TensorRT once, then stays."""
    global _GPU_BE
    if not ZEROGPU:
        return BACKEND
    if _GPU_BE is None:
        t0 = time.time()
        _GPU_BE = R.OnnxBackend(prefer_gpu=True, trt_cache=str(HERE / ".trt"))
        print(f"[ff] BUILT gpu worker {_GPU_BE.name} in {time.time()-t0:.0f}s "
              f"pid={os.getpid()}", flush=True)
    else:
        print(f"[ff] REUSED gpu worker pid={os.getpid()}", flush=True)
    return _GPU_BE
# Reference rate at 720p so the header can quote a real number rather than a table.
REF_RATE = R.measure_rate(BACKEND, 720, 1280)
# One GPU call must finish inside its ZeroGPU budget; CPU has no such cap but
# still benefits from the checkpoint seams.
#
# 60, not 90, and not the 75 I first reasoned my way to. plan_gaps charges only
# the inference term, so a segment costs more than it plans for. Measured on this
# Space against a hard 120 s @spaces.GPU window:
#
#   852x480 f2   1.416x, 1.456x        360x640 f2   1.393x, 1.496x
#   852x480 f4   1.566x   <-- worst
#
# I predicted f4 would be BELOW f2, on the theory that a fixed per-gap overhead
# is diluted by three inferences instead of one. It came in highest. The overhead
# is 0.0235 s/gap at f2 and 0.0957 at f4 - four times larger, not fixed - and per
# generated frame it is superlinear too, so there is no model here, only a worst
# case. At the worst observed 1.566x: 90 plans 141 s (killed), 75 plans 117 s
# (2% of headroom, which is not headroom), 60 plans 94 s.
#
# The cost of being conservative is refunded by the re-cut below, which grows the
# plan back when a segment proves cheaper than budgeted. CPU stays 900: no window
# to overrun, and bigger segments mean fewer ffmpeg restarts.
BUDGET_S = 60.0 if ZEROGPU else 900.0
print(f"[ff] provider={BACKEND.provider} rate720p={REF_RATE:.3f}s/frame "
      f"budget={BUDGET_S:.0f}s zerogpu={ZEROGPU}", flush=True)
# Kept: if HF ever renames the marker, this line is how it gets noticed.
print("[ff] env " + " ".join(sorted(k for k in os.environ if "ZERO" in k.upper())),
      flush=True)
# On GPU the plan-check ratio says 40-66% of a segment is decode, emit and x264
# on two vCPUs rather than the model, so whether ffmpeg can offload any of that
# is the largest open speed question. It depends on the image's ffmpeg build,
# which nothing else here can see.
R.report_ffmpeg_hw()
# Whether the thread count came from the cgroup or from a failed read matters:
# one is the container's real allowance, the other is the host's count and means
# ORT is oversubscribing the two vCPUs a Space is supposed to have.
print(f"[rife] threads={R.CPUS} from: {R.cgroup_report()}", flush=True)
# One-shot: is the 12-thread cap right for THIS container? The sweep behind it
# measured 12 against 32 on a dev box, and the ZeroGPU container allows 16, which
# was never tested. Both measured in the same boot so the comparison is not
# across two containers with different noise. Costs a few seconds once, against a
# 1h startup budget, and the CPU path is what users hit when quota runs out.
if ZEROGPU and R.container_cpus() > R.CPUS:
    try:
        _alt = R.OnnxBackend(prefer_gpu=False, threads=R.container_cpus())
        _r_alt = R.measure_rate(_alt, 720, 1280)
        del _alt
        print(f"[rife] thread sweep at 720p: {R.CPUS} threads {REF_RATE:.3f}s/frame, "
              f"{R.container_cpus()} threads {_r_alt:.3f}s/frame -> "
              f"{'raise the cap' if _r_alt < REF_RATE * 0.95 else 'cap is right'}",
              flush=True)
    except Exception as _e:                                  # noqa: BLE001
        print(f"[rife] thread sweep failed ({type(_e).__name__})", flush=True)


def hms(s: float) -> str:
    s = int(s)
    return f"{s//3600}:{(s%3600)//60:02d}:{s%60:02d}" if s >= 3600 else f"{s//60}:{s%60:02d}"


def plain(seconds: float) -> str:
    """Wall clock the way a person says it, not seconds-per-frame."""
    if seconds < 90:
        return f"{int(seconds)}s"
    if seconds < 5400:
        return f"{seconds/60:.0f} min"
    return f"{seconds/3600:.1f} h"


# Measured at 1280x720 on the ZeroGPU device (RTX PRO 6000 Blackwell MIG 2g.48gb),
# fp32 CUDA, by running a real job through this Space. Used only as the a-priori
# number in the header before any job has run here, because there is no GPU at
# import time to measure on. Quoted at 720p directly rather than scaled up from a
# smaller measurement, so the header carries a number and not an extrapolation.
GPU_REF_RATE_720P = 0.130


def measured() -> tuple[float, str, int, int] | None:
    """The rate this Space actually runs at, with the device that produced it.

    One snapshot and one preference order, so the name and the number in the
    header always describe the same device. Two independent lookups over this
    dict used to disagree: the estimate preferred a GPU entry while the name took
    whatever was inserted first, which on a Space that ran on CPU before the
    quota reset printed a CPU backend beside a GPU rate.

    The snapshot also matters: demo.load runs outside the queue on every page
    load, so a job in another session can insert here mid-iteration.
    """
    snap = list(_RATE_CACHE.items())
    devices = ("cpu",) if GPU_DEAD else ("gpu", "cpu")
    # 1280x720 first, because "10s 720p clip" is the phrase a person can act on
    # and the default Max height produces exactly that key on any 16:9 source,
    # which is most video. Failing that, quote whatever WAS measured and name its
    # size: a number that is true about an odd resolution beats a familiar one
    # restated from a measurement taken somewhere else.
    for exact in (True, False):
        for want in devices:
            for (h, w, dev), (rate, name, _b) in snap:
                if dev == want and (not exact or (h, w) == (720, 1280)):
                    return rate, name, h, w
    return None


def header() -> str:
    """A 10 s 30fps clip at 2x is 300 generated frames.

    The measured rate is quoted at the resolution it was measured at, never
    restated at 720p. Scaling by pixel count looked exact from 640x360 (0.032
    measured, 0.033 predicted) and was 15% out from 852x480 (0.067 measured
    predicts 45 s; the directly measured 720p answer is 39 s), because a small
    frame leaves the GPU idle. Naming the resolution costs nothing and means the
    number is always something that was actually observed.
    """
    hit = measured()
    if hit:
        rate, name, h, w = hit
        size = "720p" if (h, w) == (720, 1280) else f"{w}x{h}"
        cost = f"about {plain(rate * 300)} for a 10s {size} clip at 2x"
    elif ZEROGPU:
        name = "GPU (ZeroGPU)"
        cost = f"about {plain(GPU_REF_RATE_720P * 300)} for a 10s 720p clip at 2x"
    else:
        name = BACKEND.name
        cost = f"about {plain(REF_RATE * 300)} for a 10s 720p clip at 2x"
    return (f"[flowframes](https://github.com/n00mkrad/flowframes): "
            f"RIFE 4.9 frame interpolation · **{name}** · **{cost}** · "
            f"streams two frames at a time, so length costs time and not memory")


class Log:
    """Ring buffer with elapsed-time prefixes.

    Every yield re-sends the whole joined string over SSE, so an unbounded log
    grows each frame. Elapsed rather than wall clock because on a long job the
    delta between two lines is the only ETA instrument the viewer has.
    """

    def __init__(self, cap: int = 400):
        self.t0 = time.time()
        self.lines: list[str] = []
        self.cap = cap

    def __call__(self, msg: str) -> str:
        self.lines.append(f"[+{hms(time.time() - self.t0)}] {msg}")
        if len(self.lines) > self.cap:
            del self.lines[: self.cap // 2]
        return "\n".join(self.lines[-LOG_LINES:])

    @property
    def text(self) -> str:
        return "\n".join(self.lines[-LOG_LINES:])


def stop(request: gr.Request):
    """Cancel only the caller's own job.

    This handler runs outside the queue, so a second visitor waiting behind the
    first used to be able to kill the first visitor's run by pressing Stop, and
    anyone could POST the endpoint directly. Cancellation is keyed on the session
    that owns the running job.
    """
    me = getattr(request, "session_hash", None) or f"anon-{uuid.uuid4()}"
    if OWNER is not None and me != OWNER:
        return "That job belongs to another visitor. Yours has not started yet."
    CANCELLED.add(me)
    return "Stopping at the next frame boundary. The finished part is still saved."


def _segment_duration(src, dst, start, gaps, factor, clip, crf, write_tail,
                      me, tw, th) -> int:
    """Ask for what this particular segment needs, not for the ceiling.

    Takes the same arguments as the call it sizes, which is how a `duration`
    callable is invoked. Reuses the planner's own arithmetic and the measured
    `_OVERHEAD`, so a segment cut for a 60s budget asks for roughly 60s rather
    than the 120s cap, and a one-gap segment asks for seconds.

    Under-asking is not free: ZeroGPU kills a call that outlives its declaration.
    That lands in the existing `is_timeout_error` path, which halves the segment
    and retries up to four times, so it degrades into a slower job rather than a
    failed one. Hence the 1.4x on top of an overhead figure that already carries
    the measured 1.40-1.66 correction.
    """
    # A measurement of THIS shape beats the pooled mean. _PER_GAP holds the wall
    # cost per gap of the last full segment at this (h, w, factor), which is the
    # same quantity the re-cut sizes `gaps` from - so the declaration and the
    # plan agree by construction instead of drifting apart with the mean.
    seen = _PER_GAP.get((th, tw, int(factor), "gpu"))
    if seen:
        # 1.35, not 1.4: it gives every segment the same headroom the re-cut
        # already assumes is enough. REAL_BUDGET_S is 0.75 of the cap, so a 25%
        # swing - the deadband's own definition of ordinary - still lands under
        # 119. A short tail segment then gets the same margin as a full one,
        # instead of whatever the pooled mean happened to imply for it.
        return max(15, min(GPU_MAX_S, int(gaps * seen * 1.35) + 5))
    hit = _RATE_CACHE.get((th, tw, "gpu"))
    if not hit:
        return GPU_MAX_S                 # nothing measured yet, keep the ceiling
    est = gaps * hit[0] * max(1, int(factor) - 1) * _OVERHEAD["gpu"]
    return max(15, min(GPU_MAX_S, int(est * 1.4) + 5))


@GPU(duration=_segment_duration)
def _segment_gpu(src, dst, start, gaps, factor, clip, crf, write_tail, me, tw, th):
    """One segment, start to finish, inside a single GPU allocation.

    Returns (frames, seconds_inside). The caller times this call from the parent,
    which means its clock also covers the ZeroGPU allocation queue - 45s on a
    session's first call. That number is not the cost of the work, and using it
    as one shrank the next segment ~2x and permanently skewed the overhead mean.
    """
    t0 = time.perf_counter()
    n = R.interpolate_segment(src, dst, start, gaps, factor, worker(), clip,
                              crf=crf, cancel=lambda: me in CANCELLED,
                              write_tail=write_tail, out_w=tw, out_h=th,
                              hw_encode=NVENC_OK)
    return n, time.perf_counter() - t0


def _segment_cpu(src, dst, start, gaps, factor, clip, crf, write_tail, me, tw, th):
    """The same work without a GPU allocation, for when ZeroGPU quota is gone.

    Same (frames, seconds_inside) shape as the GPU worker so the caller does not
    branch on which one ran.
    """
    t0 = time.perf_counter()
    n = R.interpolate_segment(src, dst, start, gaps, factor, BACKEND, clip,
                              crf=crf, cancel=lambda: me in CANCELLED,
                              write_tail=write_tail, out_w=tw, out_h=th)
    return n, time.perf_counter() - t0


# Two different failures used to share one bucket. "GPU task aborted" is what a
# segment that overran its 120 s window looks like, and calling that quota
# exhaustion moved the whole rest of the job to CPU, told the user the day's
# budget was gone, and never mentioned the real fault. A timeout means the
# segment was too big; only the quota messages mean there is no GPU left today.
QUOTA_HINTS = ("exceeded your zerogpu", "zerogpu runs limit", "proxy token",
               "no gpu available", "gpu quota")
TIMEOUT_HINTS = ("gpu task aborted", "illegal duration", "exceeded the maximum",
                 "timeout")


def is_quota_error(e: BaseException) -> bool:
    return any(h in str(e).lower() for h in QUOTA_HINTS)


def is_timeout_error(e: BaseException) -> bool:
    return any(h in str(e).lower() for h in TIMEOUT_HINTS)


def cpu_rate(h: int, w: int) -> tuple[float, str, float]:
    """Same caching for the CPU path. Measuring costs a few seconds of the two
    vCPUs a job is about to need."""
    key = (h, w, "cpu")
    if key not in _RATE_CACHE:
        _RATE_CACHE[key] = (R.measure_rate(BACKEND, h, w), BACKEND.name, 0.0)
    return _RATE_CACHE[key]


def gpu_rate(h: int, w: int) -> tuple[float, str, float]:
    """Cached in the parent process, which survives between jobs even though each
    @spaces.GPU call runs in a fork. ZeroGPU meters runs rather than seconds, so a
    90 s measurement costs the same as a 90 s segment; paying it on every job was
    spending a fifth of a short job's budget to re-learn a constant."""
    global NVENC_OK
    key = (h, w, "gpu")
    if key not in _RATE_CACHE:
        rate, name, build, nvenc = _gpu_rate(h, w)
        # The cache stays a 3-tuple. measured() unpacks it as (rate, name, _b),
        # and an element-count mismatch here has already cost one debugging
        # session, so the new field rides beside the cache rather than inside it.
        _RATE_CACHE[key] = (rate, name, build)
        NVENC_OK = nvenc
        print(f"[ff] nvenc usable in the GPU fork: {nvenc}", flush=True)
    return _RATE_CACHE[key]


# measure_rate times three samples. At keep-source 4K that is 3 x ~1.1s, plus a
# session build the logs show landing in under a second once the CUDA libs are
# preloaded. 90s was reserving a minute and a half of quota to spend four
# seconds, on the one call whose queue wait the user actually sits through.
@GPU(duration=30)
def _gpu_rate(h: int, w: int) -> tuple[float, str, float]:
    """Measure the per-frame rate where the work will actually happen.

    Measuring outside a GPU call times the CPU session, and the segment planner
    would then size GPU segments from a CPU number, making them far too small.
    """
    t0 = time.time()
    be = worker()                       # first call in this worker builds the session
    build = time.time() - t0
    rate = R.measure_rate(be, h, w)
    # Probed here because this fork HAS a GPU, and returned rather than cached,
    # because a global set inside a fork dies with it. Riding the rate call keeps
    # it free: a separate @spaces.GPU probe would burn a metered run on one bool.
    # Printed, not returned: this goes to the container log, and adding a fifth
    # element to the tuple is how the 3-vs-2 unpack bug happened once already.
    # FIXED 720p, not the job's resolution. This runs inside _gpu_rate, which
    # declares duration=30; profiling four extra inferences at a keep-source 4K
    # would add ~5-8s plus a second session build to a probe that already spends
    # ~4s measuring, and a probe that outlives its declared duration is KILLED -
    # which would drop the whole job to CPU. The op MIX is what decides between
    # CUDA graphs and IO binding, and that does not need the job's own size.
    print(f"[ff] profile 1280x720: {R.profile_ops(720, 1280)}", flush=True)
    return rate, be.name, build, R.nvenc_works()


def example_run(video, factor, max_h, crf):
    """Examples cache one value per output, so drain the generator and hand back
    only the video and the final log. Routed through run() so a cached example is
    the same code path the button takes."""
    class _Anon:
        session_hash = "example"
    vid_out, log_out = None, ""
    for v, lg, *_ in run(video, factor, max_h, crf, _Anon()):
        vid_out, log_out = v or vid_out, lg
    if vid_out is None:
        # Raising keeps Gradio from writing the failure into the cache. Returning
        # None here cached a null video permanently, so the example row stayed
        # broken for every later visitor with nothing to explain why.
        raise gr.Error(f"Example produced no video. {log_out.splitlines()[-1:]}")
    return vid_out, log_out


# 50 GB of non-persistent container disk, shared with the model, the upload and
# every segment. Four is room for several 4K jobs and nowhere near the ceiling.
DISK_BUDGET = 4 * 1024 ** 3
SWEEP_MIN_AGE = 3600          # never touch anything younger than an hour
SWEEP_KEEP = 3                # ...nor the newest few, whatever the budget says
ABANDONED_AGE = 6 * 3600      # work dirs left by a kill, not by the finally


VIDEO_NAME = re.compile(r"[\w.\-]+\.(?:mp4|webm|mkv|mov)")


def pinned_names() -> set[str]:
    """Filenames the lazily cached example row points at.

    Gradio postprocesses the example's output through save_file_to_cache, so the
    video is copied into GRADIO_TEMP_DIR and the cached_examples CSV stores that
    path. The row is written once and never rewritten, so its mtime never moves
    and it becomes the OLDEST file in the cache: precisely the first thing an
    oldest-first sweep takes. Losing it breaks the example permanently, for
    everyone, with nothing in the log to say why.

    Matching on the bare filename over-protects at worst, which is the right way
    to be wrong here.
    """
    names: set[str] = set()
    try:
        for csv in pathlib.Path(".gradio").rglob("*.csv"):
            names.update(VIDEO_NAME.findall(
                csv.read_text(encoding="utf-8", errors="replace")))
    except OSError:
        pass
    return names


def sweep_outputs(budget: int = DISK_BUDGET, protect: str | None = None) -> None:
    """Hold finished outputs and Gradio's copies of them under a byte budget.

    Counting files is the wrong unit: three 4K outputs are far more disk than
    thirty 480p ones. Nothing pruned either directory before, so disk-full was
    the expected end state of a busy day, and the first thing that fails when the
    disk is full is the mkdtemp at the top of the next job.

    Four things must survive regardless of the budget. `protect` is the source
    video of the job doing the sweeping, pinned by resolved path: GRADIO_TEMP_DIR
    is also Gradio's UPLOAD folder, and the age floor does NOT cover it. That
    claim used to sit here and was wrong - SWEEP_MIN_AGE is one hour while this
    code permits a six hour job, so an upload that sat for ninety minutes before
    the user pressed Interpolate could be unlinked two lines before
    `R.probe(video)` read it, and the failure surfaced as "ffmpeg cannot open
    this file as a video", blaming the user's file. The cached example is pinned
    by name. And the newest few are always kept, because a budget smaller than
    one job's output would otherwise delete its own input.
    """
    now = time.time()
    pinned = pinned_names()
    safe = None
    if protect:
        try:
            safe = os.path.realpath(protect)
        except OSError:
            safe = None
    files = []
    for root in (OUTDIR, CACHEDIR):
        for f in root.rglob("*"):
            try:
                if f.is_file():
                    st = f.stat()
                    files.append((st.st_mtime, st.st_size, f))
            except OSError:
                pass
    total = sum(sz for _, sz, _ in files)
    files.sort()                                  # oldest first
    keep = {f for _, _, f in files[-SWEEP_KEEP:]}
    for mt, sz, f in files:
        if total <= budget:
            break
        if f in keep or f.name in pinned or now - mt < SWEEP_MIN_AGE:
            continue
        if safe:
            try:
                if os.path.realpath(f) == safe:
                    continue
            except OSError:
                pass
        try:
            f.unlink()
            total -= sz
        except OSError:
            pass      # an open handle on Windows; still counted, so the next
                      # candidate is tried rather than the entry being forgotten

    # Work directories are removed by run()'s finally on every normal path, but
    # not after a hard kill: an OOM, a container restart, a ZeroGPU teardown.
    # Those hold a whole segment set, gigabytes for a 4K job, and were invisible
    # to the accounting above, so the budget could report itself satisfied while
    # the disk filled anyway.
    for d in SCRATCH.glob("ff_*"):
        try:
            if d.is_dir() and now - d.stat().st_mtime > ABANDONED_AGE:
                shutil.rmtree(d, ignore_errors=True)
        except OSError:
            pass


def preflight(video):
    """Reject before the buttons swap, never after."""
    if video is None:
        raise gr.Error("Upload a video first, or click the example row below.")
    return BUSY


# The heights the Max height control actually offers, descending. Advice about
# that control reads the same list, so a suggestion can never name a value the
# dropdown does not have - which it did: at 480p it proposed 240, and at 360p it
# claimed 480 was "the smallest step" when 480 IS the smallest step.
HEIGHTS = (1080, 720, 480)


def next_lower_height(th: int) -> int | None:
    """The largest offered height below the current one, or None at the floor."""
    return next((h for h in HEIGHTS if h < th), None)


# What the plan does NOT charge, seeded from this Space's own `plan-check` lines
# and then re-fitted from every full segment that runs. plan_gaps budgets only
# inference; a real segment also decodes, emits factor-1 frames per gap and runs
# x264. Seeds are the medians of what was actually logged:
#   gpu  1.400 1.417 1.456 1.566 1.663
#   cpu  1.050 1.187 1.192 1.204 1.230
# Not a model: a constant would be wrong, because the ratio moves with device and
# resolution - an earlier additive attempt fitted three points and was falsified
# by a fourth. This is a running mean of measured ratios and converges on
# whatever this container actually does.
# The gpu seed moved 1.46 -> 2.10 when the model went to opset 18 and Resize
# stopped falling back to the CPU provider. Inference went from 0.105 to 0.021
# s/frame, so ffmpeg - decode, emit, x264, none of which got faster - became the
# dominant share of a segment and the ratio rose accordingly. Measured 2.120 and
# 2.111 on two consecutive 179-gap 720p segments.
#
# Leaving it at 1.46 was not merely inaccurate: _segment_duration falls back to
# `gaps * rate * overhead * 1.4` when no _PER_GAP measurement exists, so the
# FIRST segment of a long clip would have declared ~85s for ~89s of work and been
# killed by ZeroGPU. Making the model faster broke the duration declaration.
_OVERHEAD = {"gpu": 2.10, "cpu": 1.19}
_OVERHEAD_N = {"gpu": 5, "cpu": 5}

# Wall seconds per gap of the last FULL segment at each (h, w, factor, device).
# Set in the parent after a segment returns, read in the parent by the duration
# callable, so no fork boundary is crossed. Empty until a segment completes,
# which is why the reconstruction below it still has to exist.
#
# The DEVICE belongs in the key. The ratio moves with resolution and factor - the
# note in plan_gaps is explicit about that - but the per-gap cost itself moves
# with the hardware by roughly 4x, so a CPU-measured value would size a GPU
# segment at four times its true length and hand it a duration to match.
_PER_GAP: dict[tuple[int, int, int, str], float] = {}

# The wait the estimate always ignored. A ZeroGPU allocation queues before it
# forks: measured 45-46s for the first call of a session, then 1-2s per later
# call. On a short clip that first wait is the LARGEST term - a real job logged
# "estimate 0:26", did 31s of work and took 1:17 wall clock, and the 46s nobody
# was accounting for is the whole difference.
# Seeded from a COLD container, where the first allocation of a session queued
# 45-46s. That is the worst case, not the normal one: a warm Space allocates in
# ~1s, and a browser run whose button promised 59s finished in 22 - 39 phantom
# seconds, all of them this constant. Now that `took - spent` measures the queue
# directly, it is a running mean like _OVERHEAD rather than a guess.
GPU_QUEUE_S = 45.0
_QUEUE_N = 1
GPU_ACQUIRE_S = 1.5

# ZeroGPU's per-call ceiling. Declaring above a tier's cap does not run slower,
# it is refused outright with "ZeroGPU illegal duration", so this is a hard limit
# rather than a target and every duration is clamped to it.
GPU_MAX_S = 119

# What the RE-CUT aims a segment at, in wall-clock seconds.
#
# BUDGET_S is not this. plan_gaps charges it against measure_rate, which times
# one bare inference, so BUDGET_S = 60 means 60s of INFERENCE - about 88s of wall
# time once decode, emit and x264 are added. The re-cut divides a wall-clock
# measurement, so it needs a wall-clock budget or it silently re-plans every job
# down by the overhead factor: want = gaps_per / 1.46, which trips the 0.8
# deadband on the first segment of every GPU job and then settles there. That put
# segments at 60s inside a 119s window and cost 1.46x more GPU runs than
# intended, reported in the log as ordinary adaptive behaviour.
#
# 0.75 of the cap restores the planner's own intent (~88s) with 30s of headroom.
# Safe because real_per_gap is a measurement of the segment that just ran, not
# the 1.566x worst case that forced BUDGET_S to half the cap - and an overrun
# still lands in the halve-and-retry path at the cost of one run.
REAL_BUDGET_S = 0.75 * GPU_MAX_S if ZEROGPU else 900.0


def note_queue(seconds: float) -> None:
    """Fold one observed allocation wait into the running mean.

    Bounded below at 0 because a clock skew or a cancelled segment can make the
    subtraction negative, and above at 120 because anything larger is not a queue
    - it is a stall the estimate should not learn from.
    """
    global GPU_QUEUE_S, _QUEUE_N
    if not 0.0 <= seconds <= 120.0:
        return
    _QUEUE_N = min(_QUEUE_N + 1, 20)
    GPU_QUEUE_S += (seconds - GPU_QUEUE_S) / _QUEUE_N


def note_overhead(dev: str, ratio: float) -> None:
    """Fold one full segment's measured/assumed ratio into the running mean."""
    if not 0.2 < ratio < 20:                 # a cancelled or starved segment
        return
    n = _OVERHEAD_N[dev] = min(_OVERHEAD_N[dev] + 1, 40)
    _OVERHEAD[dev] += (ratio - _OVERHEAD[dev]) / n


# How far pixel-linear scaling may be trusted before the estimate stops being a
# single number. It was 15% out at 852x480, which is fine, and 31x out at
# 3840x2160, which is not. Two is deliberately conservative: past it, say a
# range rather than a number nobody can stand behind.
TRUST_RATIO = 2.0


def _rate_for(h: int, w: int, dev: str | None = None
              ) -> tuple[float, bool, float] | None:
    """Per-frame rate at this size, whether it was measured AT this size, and how
    far the answer had to be extrapolated (target pixels / measured pixels)."""
    if dev is None:
        dev = "gpu" if (ZEROGPU and not GPU_DEAD) else "cpu"
    hit = _RATE_CACHE.get((h, w, dev))
    if hit:
        return hit[0], True, 1.0
    # Fall back to pixel-scaling from the nearest measurement on the same device.
    # That model was measured 15% out at 852x480, which is why it only ever feeds
    # a rounded label carrying a "~", and never the segment planner.
    same = [(hh, ww, v[0]) for (hh, ww, d), v in list(_RATE_CACHE.items())
            if d == dev]
    if not same:
        # Cold container: _RATE_CACHE is empty until a job runs, and seeding it
        # here would be a bug - gpu_rate/cpu_rate treat a present key as measured
        # and would plan the whole job off an a-priori number. So fall back the
        # way header() already does, without writing anything back. REF_RATE was
        # measured at import on THIS container; GPU_REF_RATE_720P is the only
        # a-priori constant in the estimate, and it is replaced the moment a real
        # GPU rate lands.
        same = [(720, 1280, GPU_REF_RATE_720P if dev == "gpu" else REF_RATE)]
    hh, ww, r = min(same, key=lambda t: abs(t[0] * t[1] - h * w))
    ratio = (h * w) / (hh * ww)
    return r * ratio, False, ratio


def eta(clip: R.Clip, factor: int, max_h: int) -> tuple[float, float] | None:
    """Wall clock from click to result, as (likely, worst) seconds.

    The log's `estimate` line has always been rate x frames, so it promised less
    than the user waits: it left out the per-segment overhead the plan
    under-charges, and the ZeroGPU allocation queue entirely.

    The two numbers differ only where the rate had to be extrapolated well past
    anything measured on this container. A 3840x2160 job was quoted 13 min and
    ran 3.5 h: the label scaled the measured 720p GPU rate up by pixel count
    while the job itself fell back to the CPU, whose 4K cost the planner had
    right all along. Past TRUST_RATIO the CPU price is therefore the honest
    upper bound.
    """
    tw, th = scale_for(clip, int(max_h))
    dev = "gpu" if (ZEROGPU and not GPU_DEAD) else "cpu"
    hit = _rate_for(th, tw, dev)
    if hit is None:
        return None
    rate, _exact, ratio = hit

    def total_for(r: float, d: str) -> float:
        t = r * clip.frames * (int(factor) - 1) * _OVERHEAD[d]
        if d == "gpu":
            gaps_per = max(1, R.plan_gaps(clip, int(factor), r, BUDGET_S))
            segs = max(1, -(-(clip.frames - 1) // gaps_per))
            t += segs * GPU_ACQUIRE_S
            if (th, tw, "gpu") not in _RATE_CACHE:
                t += GPU_QUEUE_S      # the cold probe the user waits through
        return t

    likely = total_for(rate, dev)
    if ratio <= TRUST_RATIO or dev != "gpu":
        return likely, likely
    slow = _rate_for(th, tw, "cpu")
    worst = total_for(slow[0], "cpu") if slow else likely
    return likely, max(likely, worst)


_ETA_PROBE: dict[str, R.Clip] = {}


def button_label(video, factor, max_h):
    """Put the cost in the control that starts it, recomputed as inputs change.

    Never raises into the UI and never blocks it: a probe failure or an unknown
    rate just leaves the plain label, because a button that cannot be clicked is
    worse than a button with no number on it.
    """
    if not video:
        return gr.update(value="Interpolate")
    try:
        clip = _ETA_PROBE.get(video)
        if clip is None:
            if len(_ETA_PROBE) > 32:
                _ETA_PROBE.clear()
            clip = _ETA_PROBE[video] = R.probe(video)
        pair = eta(clip, factor, max_h)
    except Exception:                                        # noqa: BLE001
        return gr.update(value="Interpolate")
    if not pair or not pair[0]:
        return gr.update(value="Interpolate")
    likely, worst = pair
    if worst > likely * 1.5:
        # Never a lone "~13 min" for a size nothing was measured at. The spread
        # IS the message: it says the height cap is the lever worth moving.
        return gr.update(value=f"Interpolate ({plain(likely)} - {plain(worst)})")
    return gr.update(value=f"Interpolate (~{plain(likely)})")


def scale_for(clip: R.Clip, max_h: int) -> tuple[int, int]:
    """Both dimensions are forced even unconditionally: yuv420p subsamples chroma
    2x2 and x264 refuses an odd side, so a 1920x1038 or phone-cropped source used
    to die on the first frame written even though nothing else about it was hard."""
    if max_h <= 0 or clip.height <= max_h:
        return clip.width // 2 * 2, clip.height // 2 * 2
    w = int(round(clip.width * max_h / clip.height)) // 2 * 2
    return w, max_h // 2 * 2


def run(video: str, factor: int, max_h: int, crf: float,
        request: gr.Request, progress=gr.Progress()):
    """Interpolate a video to a higher frame rate with RIFE.

    Args:
        video: Path to the source clip. Any container ffmpeg can read.
        factor: Frame rate multiplier, 2 doubles the frame rate and 4 quadruples it.
        max_h: Cap the output height, 0 keeps the source resolution. Lowering this is
            the single biggest lever on runtime, since cost scales with pixels.
        crf: x264 quality, lower is better and larger. 18 is visually lossless.
    """
    global OWNER, GPU_DEAD
    log = Log()
    me = getattr(request, "session_hash", None) or f"anon-{uuid.uuid4()}"
    if not LOCK.acquire(blocking=False):
        raise gr.Error("A job is already running. Two vCPUs, one job. "
                       "Press Stop to cancel it.")
    CANCELLED.discard(me)
    OWNER = me
    closing = False
    stopped = ""                     # why the run ended early, if it did
    out_path = None
    parts: list[str] = []
    work = None
    try:
        # Before the mkdtemp, because mkdtemp is the first thing that fails on a
        # full disk. Inside the try, not before it: failing between acquire() and
        # try: held the lock with nothing to release it, wedging every later job.
        sweep_outputs(protect=video)
        work = tempfile.mkdtemp(prefix="ff_", dir=str(SCRATCH))
        clip = R.probe(video)
        tw, th = scale_for(clip, int(max_h))
        src = video

        yield None, log(f"source {clip.width}x{clip.height} {clip.fps:.2f}fps "
                        f"{clip.frames} frames {clip.duration:.1f}s"), *BUSY

        if (tw, th) != (clip.width, clip.height):
            yield None, log(f"scaling to {tw}x{th} inside each segment decoder, "
                            f"no pre-transcode"), *BUSY

        # Estimate from a measured rate at this resolution, before committing.
        # Start from what the last job learned: a cached GPU rate never raises,
        # so an optimistic retry costs a failed segment and a re-plan per job.
        gpu_dead = GPU_DEAD
        if ZEROGPU:
            try:
                rate, be_name, build_s = gpu_rate(th, tw)
                # Clear what the LAST job learned. Without this, GPU_DEAD from an
                # earlier quota-out job survived a successful probe: gpu_rate
                # returned a GPU rate and name, the header and the estimate
                # quoted them, and then every segment ran on _segment_cpu because
                # gpu_dead was still True. Seen live on 2026-09-06 - "GPU CUDA:
                # 0.053s/frame ... estimate 0:09" against a measured 57.4s, with
                # the plan-check line labelling its own segment "cpu".
                #
                # The comment above this block already described the intended
                # behaviour ("an optimistic retry costs a failed segment and a
                # re-plan per job"); the retry just never happened. A cached rate
                # does not re-prove the quota, so this is deliberately
                # optimistic: the segment loop catches the quota error, sets the
                # flag and re-cuts the rest for CPU. One failed segment is the
                # price of not pinning the Space to CPU until it restarts, and it
                # keeps the one invariant that matters - the rate that plans the
                # job is the rate of the device that runs it.
                gpu_dead = GPU_DEAD = False
            except Exception as e:                           # noqa: BLE001
                if not (is_quota_error(e) or is_timeout_error(e)):
                    raise
                gpu_dead = GPU_DEAD = True
                # str(e), not type(e).__name__: ZeroGPU raises gradio's own
                # Error, so the class name rendered as the literal "no usable
                # GPU (Error)" - true, and carrying none of the quota message
                # that tells the user when the GPU comes back.
                why = " ".join(str(e).split())[:110] or type(e).__name__
                yield None, log(f"no usable GPU ({why}), "
                                f"running on CPU"), *BUSY
                rate, be_name, build_s = cpu_rate(th, tw)
        else:
            rate, be_name, build_s = cpu_rate(th, tw)
        new_frames = clip.frames * (int(factor) - 1)
        est = rate * new_frames
        if build_s > 1:
            yield None, log(f"{be_name} session built in {build_s:.0f}s"), *BUSY
        yield None, log(f"{be_name}: {rate:.3f}s/frame here, "
                        f"{new_frames} frames to generate, estimate {hms(est)}"), *BUSY

        if est > 6 * 3600:
            # BACKEND is always the CPU session, built at import with
            # prefer_gpu=False, so testing BACKEND.on_gpu here named the CPU
            # ceiling on GPU hardware every time. Ask the flag that knows.
            fits = int(clip.duration * (6 * 3600) / est)
            raise gr.Error(
                f"Estimated {hms(est)} at {tw}x{th}, past the 6 hour ceiling"
                f"{' and well past a ZeroGPU day' if ZEROGPU else ''}. "
                f"Lower 'Max height' to {min(720, max(240, th // 2))}, which is "
                f"about 4x faster, or trim the clip to under "
                f"{hms(max(1, fits))} at this size.")

        # Plan segment 0 from a MEASUREMENT when this container has already run
        # this shape on this device. plan_gaps charges BUDGET_S against a bare
        # inference rate, so it deliberately mis-sizes the first segment and the
        # re-cut then fires once to correct it - on every repeat job, forever.
        #
        # It also mis-states the plan LENGTH, and `len(plan) > 30` below is what
        # gates the "you will run out of ZeroGPU quota, lower your resolution"
        # warning. At a clean overhead of 1.26 a job that really needs 30 calls
        # was announced as 35, so the user was told to halve their height for a
        # limit they were not going to hit.
        dev0 = "cpu" if gpu_dead else "gpu"
        seen0 = _PER_GAP.get((th, tw, int(factor), dev0))
        if seen0:
            gaps_per = max(1, int(REAL_BUDGET_S / seen0))
            budget_note = f"{REAL_BUDGET_S:.0f}s measured budget"
        else:
            # Charge the overhead here too. plan_gaps bills against measure_rate,
            # which times bare inference, so BUDGET_S seconds of inference is
            # BUDGET_S * overhead of WALL time - and the cap is on wall time.
            #
            # That gap widened when the model got five times faster: ffmpeg did
            # not, so the ratio went 1.46 -> 1.85 and the same BUDGET_S = 60 went
            # from 87.6s (74% of the 119s cap, the intent) to 111.0s (93%). The
            # first segment of every job was being planned with 7% headroom, and
            # the re-cut that would fix it only runs AFTER that segment lands.
            #
            # Billing rate * overhead against REAL_BUDGET_S puts the first
            # segment on the same 75%-of-cap target the re-cut aims for, and it
            # stays correct automatically as the ratio moves.
            gaps_per = R.plan_gaps(clip, int(factor), rate * _OVERHEAD[dev0],
                                   REAL_BUDGET_S)
            budget_note = f"{REAL_BUDGET_S:.0f}s wall budget"
        plan = R.segment_bounds(clip, gaps_per)
        total_gaps = max(1, clip.frames - 1)
        yield None, log(f"{len(plan)} segment(s) of up to {gaps_per} gaps "
                        f"({gaps_per / clip.fps:.1f}s), inside the "
                        f"{budget_note}"), *BUSY

        # On ZeroGPU each segment is a separate allocation and the daily budget
        # is counted in RUNS, so segment count - not estimated hours - is what
        # decides whether a job can finish on the GPU. The 6 hour ceiling above
        # is checked against the GPU estimate, so a 4K five minute clip at source
        # resolution sails through it at ~2.8h and then needs ~165 allocations:
        # quota dies partway, the CPU fallback takes over at roughly 6 s/frame,
        # and the user who was shown "2:45" is waiting most of a day. Say so
        # before any of it starts, with the number, rather than letting them find
        # out hours in.
        if ZEROGPU and not gpu_dead and len(plan) > 30:
            # The remedy has to be an action the user can actually take. A
            # suggestion to cap height at the height they are already using is
            # not advice, and 240 is the floor of the dropdown - below that the
            # only lever left is a shorter clip. Cost scales with pixels, so
            # halving the height is ~4x fewer calls; say the number either way.
            lower = next_lower_height(th)
            if lower:
                # Cost scales with pixels, so the saving is the area ratio.
                saved = max(1, int(len(plan) * (lower / th) ** 2))
                remedy = (f"Capping 'Max height' at {lower} (from {th}) would "
                          f"cut it to about {saved} calls.")
            else:
                fits = clip.duration * 30 / len(plan)
                # Reached when no offered cap is BELOW the current height -
                # either it is already the lowest option, or the source is
                # smaller than every option. Saying "the control's lowest" would
                # be wrong in the second case, so say what is true in both.
                remedy = (f"no lower cap is available for a {th}p source, so the "
                          f"lever left is length: about "
                          f"{hms(max(1, int(fits)))} of this clip fits in "
                          f"30 calls.")
            yield None, log(
                f"heads up: {len(plan)} GPU calls needed, and ZeroGPU meters a "
                f"daily number of them. This will likely run out partway and "
                f"finish on CPU, which is far slower. " + remedy), *BUSY

        done = 0
        cursor = 0                       # gaps already rendered
        i = 0
        shrinks = 0                      # GPU-window overruns absorbed so far
        while cursor < total_gaps:
            if me in CANCELLED:
                # Set `stopped` like every other early exit. Without it the
                # summary read "done: 121 frames" on a job that expected 599 -
                # the word done, and a number with nothing to compare it to.
                # Cancel is the most frequently taken of the early exits and was
                # the only one not marking the result partial.
                stopped = "cancelled at your request"
                yield None, log("cancelled, keeping what finished"), *BUSY
                break
            if i >= len(plan):
                # Never silent. cursor advancing without a segment to consume it
                # is a planner bug, and the two guards below cover the ways it
                # has actually happened; this catches any third.
                stopped = (f"the plan ran out at {cursor} of {total_gaps} gaps")
                yield None, log(f"stopped early: {stopped}"), *BUSY
                break
            t0, gaps, is_last = plan[i]
            part = os.path.join(work, f"seg{i:04d}.mp4")
            t_seg = time.perf_counter()
            try:
                worker_fn = _segment_cpu if gpu_dead else _segment_gpu
                n, inside = worker_fn(src, part, t0, gaps, int(factor), clip,
                                      int(crf), is_last, me, tw, th)
            except Exception as e:                           # noqa: BLE001
                # ZEROGPU gates this: only there is there a window to overrun,
                # and TIMEOUT_HINTS is broad enough to match ordinary ffmpeg
                # errors that retrying cannot help.
                overran = ZEROGPU and not gpu_dead and is_timeout_error(e)
                if overran and shrinks < 4 and gaps_per > 1:
                    # The segment was too big for its GPU window, not the day's
                    # budget. measure_rate times one bare inference while a real
                    # segment also runs factor-1 encodes and emits per gap, and
                    # at 4x that overhead is where a 90 s plan meets a 120 s cap.
                    # Halve and retry the same range rather than abandoning the
                    # GPU for the rest of the job.
                    shrinks += 1
                    gaps_per = max(1, gaps_per // 2)
                    plan = plan[:i] + R.segment_bounds(clip, gaps_per, cursor)
                    yield None, log(f"segment overran its GPU window, re-cut to "
                                    f"{gaps_per} gaps and retrying "
                                    f"({len(plan) - i} segments left)"), *BUSY
                    continue
                if gpu_dead or not (overran or is_quota_error(e)):
                    raise
                # Two ways to get here. Quota exhausted, or a single gap that
                # cannot fit the GPU window even after shrinking - which used to
                # kill the job outright, the one case where the GPU truly cannot
                # proceed being the one case that refused the existing fallback.
                why = ("one gap will not fit the GPU window" if overran
                       else "ZeroGPU quota exhausted")
                #
                # Re-cutting the remainder is the part that matters. These
                # segments were sized from a GPU rate, so one of them is roughly
                # six times more work than the CPU budget allows: a segment
                # yields once, at the end, so an over-sized one freezes progress,
                # gives Stop that much latency, and stops the seams being
                # checkpoints at all. Caching the GPU rate made this the common
                # path, because a cached rate never raises the quota error that
                # used to be caught before any planning happened.
                gpu_dead = GPU_DEAD = True
                rate, be_name, _ = cpu_rate(th, tw)
                gaps_per = R.plan_gaps(clip, int(factor), rate, BUDGET_S)
                plan = plan[:i] + R.segment_bounds(clip, gaps_per, cursor)
                left = rate * (total_gaps - cursor) * max(1, int(factor) - 1)
                yield None, log(f"{why}. Finishing on {be_name} at "
                                f"{rate:.3f}s/frame: re-cut the rest into "
                                f"{len(plan) - i} segments, about {hms(left)} to "
                                f"go. Frames so far are kept."), *BUSY
                continue
            # plan_gaps budgets rate*(factor-1) per gap from measure_rate, which
            # times one bare inference. A real segment also runs `factor` emit
            # passes and `factor` x264 frames per gap. That overhead is roughly
            # fixed per gap while the inference term scales with the device, so
            # the plan under-charges least on CPU and most on a fast GPU - which
            # is exactly where the 120 s cap lives. Logged rather than guessed,
            # so any real job on any device reports its own correction factor.
            took = time.perf_counter() - t_seg      # wall, includes the queue
            spent = inside                          # what the work actually cost
            assumed = gaps * rate * max(1, int(factor) - 1)
            if assumed > 0:
                # Only a segment that rendered its whole assignment describes the
                # overhead. A cancelled or starved one returns early and logs
                # ratios like 0.399x, which would drag the mean toward nonsense.
                if n // max(1, int(factor)) >= gaps:
                    note_overhead("gpu" if (ZEROGPU and not gpu_dead) else "cpu",
                                  spent / assumed)
                    if ZEROGPU and not gpu_dead:
                        note_queue(took - spent)
                    if gaps > 0:
                        _PER_GAP[(th, tw, int(factor),
                                  "cpu" if gpu_dead else "gpu")] = spent / gaps
                print(f"[ff] plan-check seg{i} {gaps} gaps f{factor} {tw}x{th} "
                      f"{'gpu' if (ZEROGPU and not gpu_dead) else 'cpu'}: "
                      f"assumed {assumed:.2f}s "
                      f"measured {spent:.2f}s ratio {spent / assumed:.3f}x "
                      f"(wall {took:.2f}s, queue {took - spent:.2f}s)",
                      flush=True)
            if not gpu_dead:
                GPU_DEAD = False          # a GPU segment landed, quota is back
            if n:                         # a cancelled segment writes no file
                parts.append(part)
            done += n
            # A segment cancelled part-way returns fewer frames than its gap
            # assignment. Crediting the whole assignment made the last line of a
            # cancelled job overstate progress: 60 of 179 gaps reported against
            # 156 frames, which at 4x is 39 gaps of real work. Each gap writes
            # `factor` frames, so the frames are the honest count.
            got_gaps = min(gaps, n // max(1, int(factor)))
            cursor += got_gaps
            i += 1
            if got_gaps < gaps and me not in CANCELLED:
                # The decoder handed back fewer frames than this segment covers,
                # and nobody cancelled: the source is shorter than its header
                # claims. On CPU nothing re-cuts the plan, so cursor stopped
                # short of total_gaps while i walked past the last entry, and the
                # next pass raised "IndexError: list index out of range" - which
                # is what the live Space showed for a truncated upload while this
                # machine's ffmpeg raised the clean starved-decoder error before
                # ever reaching here. Same file, two decoders, one path untested.
                stopped = (f"source ended early: this segment covers {gaps} gaps "
                           f"but the decoder supplied {got_gaps}. The file is "
                           f"probably truncated or corrupt - its header claims "
                           f"{clip.frames} frames.")
                yield None, log(f"stopped early: {stopped}"), *BUSY
                break

            # Re-cut from what this segment ACTUALLY cost, not from a constant.
            # The plan charges only inference, so it is optimistic by a fixed
            # per-gap amount that is a bigger fraction the faster the device is -
            # measured 1.456x at 852x480 on the GPU against 1.03x on CPU. That
            # ratio cannot be bounded ahead of time: a smaller frame makes
            # inference cheaper without making ffmpeg cheaper, so it climbs as
            # resolution drops, and four measurements were neither constant nor
            # pixel-linear. So do not model it - measure it here and shrink the
            # rest of the plan if the segment that just ran says the next one
            # would not fit the window. Costs nothing when the plan is right.
            # Every device, not just the GPU. The gate used to read
            # `ZEROGPU and not gpu_dead`, on the reasoning that only a GPU has a
            # window to overrun - but a segment 5x its plan is a problem on any
            # device, because a segment yields once at the END. A 2290x1708 job
            # planned at 59s a segment took 318s and showed the user a frozen log
            # for 5m18s with no progress line, no ETA and 5 minutes of Stop
            # latency (2026-09-06). The 120 s cap is why the GPU MUST re-cut; the
            # seams being checkpoints at all is why everything else should.
            #
            # Cheap when the plan is right: the measured CPU under-charge is
            # 1.05-1.23x, which lands inside the 20% deadband and re-cuts nothing.
            if gaps > 0 and cursor < total_gaps:
                real_per_gap = spent / gaps
                # Both directions. Shrinking keeps a segment inside the window;
                # growing refunds a conservative BUDGET_S when the plan turns out
                # pessimistic, so starting safe does not cost a GPU run per
                # segment for the whole job. real_per_gap already contains the
                # under-charge, so this is a measurement and not a correction
                # factor. The 20% deadband stops one noisy segment churning the
                # plan, and BUDGET_S sits at half the hard cap, so even a segment
                # twice as slow as the one just measured still lands inside it.
                want = max(1, int(REAL_BUDGET_S / real_per_gap))
                if want < gaps_per * 0.8 or want > gaps_per * 1.25:
                    grew = want > gaps_per
                    gaps_per = want
                    plan = plan[:i] + R.segment_bounds(clip, gaps_per, cursor)
                    yield None, log(f"segment cost {real_per_gap:.3f}s/gap against "
                                    f"{rate * max(1, int(factor) - 1):.3f}s planned; "
                                    f"{'widened' if grew else 're-cut'} the rest into "
                                    f"{len(plan) - i} segments"), *BUSY
            progress(cursor / total_gaps, desc=f"{cursor}/{total_gaps} gaps")
            yield None, log(f"[{cursor}/{total_gaps} gaps] {n} frames, "
                            f"{done} total"), *BUSY
    except GeneratorExit:
        # Tab closed or SSE torn down. Yielding from finally here would raise
        # "generator ignored GeneratorExit" and block teardown while ffmpeg muxes.
        closing = True
        CANCELLED.add(me)
        raise
    except gr.Error as e:
        # Mirror the generic handler below. This clause exists so a validation
        # error raised BEFORE any work reaches the user as a clean toast, and
        # with no parts that is still exactly what happens. But ZeroGPU itself
        # raises gr.Error, so a mid-job GPU failure whose text matches neither
        # QUOTA_HINTS nor TIMEOUT_HINTS lands here with segments already on disk:
        # the finally delivered them labelled "done", for a job that produced a
        # fraction of its frames, with the log replaced by an Error badge.
        if not parts:
            raise
        stopped = f"{type(e).__name__}: {e}"
        yield None, log(f"stopped early: {stopped}"), *IDLE
    except Exception as e:                                   # noqa: BLE001
        if parts:
            # Segments landed, so there IS a result. Raising here would hand
            # Gradio an errored generator, and verified in Chrome on 2026-09-06
            # against truncated.mp4 that means: the video the finally yields
            # survives and stays downloadable, but the log component is replaced
            # by a bare "Error" badge. The user gets a silently truncated clip
            # with the one line explaining it wiped off the screen. Record the
            # reason and let the finally deliver both.
            stopped = f"{type(e).__name__}: {e}"
            yield None, log(f"stopped early: {stopped}"), *IDLE
        else:
            yield None, log(f"failed: {type(e).__name__}: {e}"), *IDLE
            raise
    finally:
        # Package from finally so cancel, error and success all hand back the
        # frames that were actually produced. The flag is re-derived here because
        # a GeneratorExit raised at the yield inside `except Exception` is a
        # sibling of the GeneratorExit handler and never reaches it.
        closing = closing or sys.exc_info()[0] is GeneratorExit
        try:
            if closing:
                # Do not YIELD - that raises "generator ignored GeneratorExit"
                # and blocks teardown. But keeping is a separate decision from
                # yielding, and the inner finally is about to rmtree `work` with
                # every finished segment inside it. Concat into OUTDIR so the
                # result survives the cleanup and is recoverable from the log,
                # instead of a dropped SSE costing a 40 minute job outright.
                if parts:
                    try:
                        fd, sv = tempfile.mkstemp(suffix=".mp4", dir=str(OUTDIR))
                        os.close(fd)
                        R.concat(parts, sv)
                        print(f"[ff] stream closed mid-job; kept {len(parts)} "
                              f"segments at {sv}", flush=True)
                    except Exception as e:                   # noqa: BLE001
                        # Best effort on a path that is already unwinding.
                        print(f"[ff] stream closed mid-job and the salvage "
                              f"failed: {type(e).__name__}: {e}", flush=True)
            elif parts:
                # Guarded for the same reason the probe below is: describing OR
                # packaging the result must never be able to discard it. A raise
                # from concat, mkstemp or the copy escapes this finally so
                # nothing is yielded, REPLACES the exception already in flight -
                # overwriting a segment failure already recorded in `stopped` -
                # and still reaches the inner finally that deletes `work`. The
                # parts would be destroyed by cleanup for a delivery that never
                # happened. Falling back to the largest single segment keeps most
                # of the job rather than none of it.
                out_path = None
                try:
                    joined = os.path.join(work, "joined.mp4")
                    R.concat(parts, joined)
                    fd, out_path = tempfile.mkstemp(suffix=".mp4", dir=str(OUTDIR))
                    os.close(fd)
                    if not R.mux_audio(joined, video, out_path):
                        shutil.copy(joined, out_path)
                except Exception as e:                       # noqa: BLE001
                    stopped = stopped or f"{type(e).__name__}: {e}"
                    big = max(parts, key=lambda p: os.path.getsize(p)
                              if os.path.exists(p) else 0)
                    try:
                        fd, out_path = tempfile.mkstemp(suffix=".mp4",
                                                        dir=str(OUTDIR))
                        os.close(fd)
                        shutil.copy(big, out_path)
                        log(f"could not join the segments ({e}); handing back the "
                            f"longest one instead")
                    except Exception:                        # noqa: BLE001
                        out_path = None
                if out_path is None:
                    # No `return` here: a return inside a finally DISCARDS an
                    # exception that is still propagating. An else instead, so
                    # the summary below cannot run on a None path and probe it.
                    yield None, log("the frames were rendered but could not be "
                                    "packaged; nothing to hand back"), *IDLE
                else:
                    # Describing the result must never be able to discard it.
                    # probe raises when it cannot establish a frame count, and
                    # this call happens after the work is finished and written.
                    head = "partial" if stopped else "done"
                    try:
                        got = R.probe(out_path)
                        summary = (f"{head}: {got.width}x{got.height} "
                                   f"{got.fps:.2f}fps {got.frames} frames")
                        if stopped:
                            summary += (f" of the "
                                        f"{(clip.frames - 1) * int(factor) + 1} "
                                        f"expected - the download below is the "
                                        f"part that finished")
                    except Exception as e:                   # noqa: BLE001
                        summary = (f"{head}, {done} frames written "
                                   f"({type(e).__name__})")
                    yield out_path, log(summary), *IDLE
            else:
                yield None, log("no frames produced"), *IDLE
        finally:
            if work:
                shutil.rmtree(work, ignore_errors=True)
            OWNER = None
            LOCK.release()


BUSY = (gr.update(visible=False), gr.update(visible=True))
IDLE = (gr.update(visible=True), gr.update(visible=False))

CSS = """
footer{display:none}
.gradio-container{padding:4px !important;max-width:100% !important}
.form{gap:2px !important}
#hdr{max-height:34px}
#hdr p{margin:0 !important;font-size:.82em;opacity:.75}
#log textarea{font-family:ui-monospace,monospace;font-size:.78em}
#vid video{max-height:190px}
:where(button,input,textarea,select,[role="button"],[tabindex]):focus-visible{
  outline:2px solid var(--color-accent) !important;outline-offset:2px}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{
  animation-duration:.01ms !important;transition-duration:.01ms !important}}
"""

with gr.Blocks(title="Flowframes", fill_width=True) as demo:
    hdr = gr.Markdown(header(), elem_id="hdr")
    with gr.Row():
        with gr.Column(scale=4):
            vid = gr.Video(label="Source clip", elem_id="vid", height=190)
            go = gr.Button("Interpolate", variant="primary")
            halt = gr.Button("Stop", variant="stop", visible=False)
            # label with show_label=False: hidden visually, still the control's
            # accessible name. Without it this is the one element on the page a
            # screen reader announces as just "Textbox", and it is the element
            # carrying every progress line, the ETA and every error message.
            status = gr.Textbox(lines=8, max_lines=LOG_LINES + 2, autoscroll=True,
                                label="Progress log", show_label=False,
                                container=False, elem_id="log")
        with gr.Column(scale=2):
            factor = gr.Radio([2, 4], value=2, label="Frame rate multiplier",
                              info="4x generates three frames per gap, so it costs "
                                   "three times 2x.")
            max_h = gr.Dropdown(
                [("Keep source resolution", 0)]
                + [(f"Cap at {h}p" + (" (fastest)" if h == 720 else ""), h)
                   for h in HEIGHTS],
                value=720, label="Max height",
                info="Cost scales with pixels. Halving height is roughly 4x faster.")
            with gr.Accordion("Advanced", open=False):
                crf = gr.Slider(12, 28, value=18, step=1, label="x264 CRF",
                                info="Lower is better quality and a larger file.")
        with gr.Column(scale=4):
            out = gr.Video(label="Result", height=190, interactive=False)
            dl = gr.DownloadButton("Download result", size="sm", interactive=False)

    # Registered before the work handler and outside the queue, so the swap lands
    # immediately rather than waiting behind a job that may run for hours.
    # Validation lives in the swap handler, so a rejected click never swaps the
    # buttons. Raising after the swap stranded the UI showing Stop forever.
    # The trailing .then restores the buttons on paths that never reach run()'s
    # final yield: the lock-busy gr.Error is raised before the try, so the swap
    # that preflight already made would otherwise leave the page showing Stop
    # forever. .then runs on failure too, which .success would not.
    go.click(preflight, inputs=[vid], outputs=[go, halt], queue=False).then(
        run, [vid, factor, max_h, crf], [out, status, go, halt]).then(
        lambda: IDLE, outputs=[go, halt], queue=False)
    out.change(lambda v: gr.update(value=v, interactive=v is not None),
               inputs=out, outputs=dl, queue=False)
    # queue=False so the label never waits behind a running job, which under
    # concurrency_limit=1 would mean the number arrives after the work finishes.
    for _c in (vid, factor, max_h):
        _c.change(button_label, [vid, factor, max_h], [go], queue=False)
    halt.click(stop, outputs=status, queue=False)
    # Recomputed per page load, so the first real job replaces the reference
    # estimate with the rate measured on the hardware actually serving this Space.
    demo.load(header, outputs=[hdr], queue=False)

    ex = HERE / "examples" / "sample.mp4"
    if ex.exists():
        # Cached so the first visitor is not asked to supply a video before they
        # can see anything work. Lazy, not eager: eager runs the whole pipeline
        # before the Space serves its first request, and on ZeroGPU that would
        # also spend quota on every rebuild. Cached after the first click, then
        # instant for everyone.
        gr.Examples(examples=[[str(ex), 2, 480, 20]],
                    inputs=[vid, factor, max_h, crf],
                    outputs=[out, status],
                    fn=example_run,
                    cache_examples=True, cache_mode="lazy",
                    label="Example (cached after first run)")

if __name__ == "__main__":
    demo.queue(max_size=4, default_concurrency_limit=1).launch(css=CSS, show_error=True)