forked from kamilstanuch/Autocrop-vertical
-
Notifications
You must be signed in to change notification settings - Fork 1k
Expand file tree
/
Copy pathapp.py
More file actions
6149 lines (5309 loc) · 261 KB
/
Copy pathapp.py
File metadata and controls
6149 lines (5309 loc) · 261 KB
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
import os
import llm_backend
import re
import sys
import uuid
import subprocess
import threading
import json
import shutil
import glob
import hashlib
import hmac
import time
import zipfile
import math
import itertools
import functools
import asyncio
import signal
import socket
from datetime import datetime, timezone, timedelta
from dotenv import load_dotenv
from typing import Any, Dict, Optional, List
from contextlib import asynccontextmanager
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, Header, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from starlette.background import BackgroundTask
from pydantic import BaseModel
from s3_uploader import upload_job_artifacts, list_all_clips, upload_actor_to_s3, list_actor_gallery, upload_video_to_gallery, list_video_gallery
import recut
import layout_ranges
load_dotenv()
# Constants
UPLOAD_DIR = "uploads"
OUTPUT_DIR = "output"
os.makedirs(UPLOAD_DIR, exist_ok=True)
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Configuration
# Default to 1 if not set, but user can set higher for powerful servers
MAX_CONCURRENT_JOBS = int(os.environ.get("MAX_CONCURRENT_JOBS", "5"))
MAX_FILE_SIZE_MB = 2048 # 2GB limit
# How TikTok receives our uploads. MEDIA_UPLOAD lands the video in the user's
# TikTok drafts so they finish the post inside TikTok's own editor; DIRECT_POST
# publishes straight to their feed, which is Upload-Post's default.
#
# Drafts are the safer default for an automated pipeline: nothing reaches an
# audience without the account owner seeing it first, and TikTok's own editor is
# where covers, sounds and hashtags actually get chosen. The UI must say so —
# a user who expects a published post and finds a draft will read it as a bug.
TIKTOK_POST_MODE = os.environ.get("TIKTOK_POST_MODE", "MEDIA_UPLOAD").strip()
# Ceiling for the working directory once it lives on a persistent volume: the
# age-based sweep alone can't stop a burst of long videos from filling the disk.
# 0 disables the cap.
OUTPUT_MAX_GB = int(os.environ.get("OUTPUT_MAX_GB", "25"))
# Same idea for source uploads, which are the biggest single files on disk.
UPLOADS_MAX_GB = int(os.environ.get("UPLOADS_MAX_GB", "15"))
# Pre-flight quality gate: warn before processing a YouTube source below this
# height (0 disables). Only applies to URLs; uploads are whatever the user gave.
QUALITY_GATE_MIN_HEIGHT = int(os.environ.get("QUALITY_GATE_MIN_HEIGHT", "720"))
# Reject sources shorter than this before starting (0 disables). A 24s YouTube
# Short cannot yield 15-60s clips: Gemini returns nothing, the job burns
# managed minutes and dies with "no usable clips" (prod 20-ago: 3 of 5 recent
# failures were exactly this, one user retrying the same 24s video).
MIN_SOURCE_SECONDS = int(os.environ.get("MIN_SOURCE_SECONDS", "45"))
QUALITY_PROBE_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "quality_probe.py")
DISABLE_YOUTUBE_URL = os.environ.get("DISABLE_YOUTUBE_URL", "false").lower() in ("1", "true", "yes")
# Every log line in this module is emoji-prefixed, and a Windows console is
# cp1252 by default. _recover_jobs_from_disk() prints one during startup, so
# without this the server dies before it ever listens:
#
# UnicodeEncodeError: 'charmap' codec can't encode characters in position 0-1
# ERROR: Application startup failed. Exiting.
#
# subtitles._configure_stdio solved this for the transcription path; the server
# needs it too, and needs it before the first print.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# ---- Cloud billing (paid / managed-keys) integration --------------------------
# All paid-mode code lives in the optional `cloud/` package and is imported ONLY
# when BILLING_ENABLED is set. With the flag off, the app behaves exactly as the
# self-hosted BYOK app does today (no extra dependencies required).
BILLING_ENABLED = os.environ.get("BILLING_ENABLED", "").lower() in ("1", "true", "yes")
# Job/file retention (issue #46). Self-host defaults to 24h: the 1h sweep kept
# deleting finished projects under users who never touched their env, and the
# OUTPUT_MAX_GB / UPLOADS_MAX_GB caps below already bound the disk. Cloud keeps
# the tight default because clips are archived to R2 as soon as a job finishes.
JOB_RETENTION_SECONDS = int(
os.environ.get("JOB_RETENTION_SECONDS", "3600" if BILLING_ENABLED else "86400")
)
# The retained download of a URL job (--keep-original) is the one artifact that
# is a full copy of someone else's video rather than something we made, so it
# can be aged out ahead of the clips it produced. Defaults to the job clock,
# i.e. no change: dropping it earlier costs the clip editor, whose rerender,
# reframe, scenes and EDL endpoints all read that file and answer 409 once it
# is gone. Lower it only if you would rather lose in-session re-edits than
# keep the original around. Uploads are deliberately untouched: that file is
# the user's own content, which they attested to owning.
SOURCE_RETENTION_SECONDS = int(
os.environ.get("SOURCE_RETENTION_SECONDS", str(JOB_RETENTION_SECONDS))
)
# Force full pipeline logs to the client even under billing (local debugging).
DEBUG_LOGS = os.environ.get("DEBUG_LOGS", "").lower() in ("1", "true", "yes")
if BILLING_ENABLED:
import cloud
from cloud import managed_keys, metering as _metering, config as _cloud_config, alerts as _alerts
from cloud.auth import get_current_user_optional
else:
cloud = None
managed_keys = None
_metering = None
_cloud_config = None
_alerts = None
async def get_current_user_optional(request: Request):
# No-op dependency in self-host mode: every request is anonymous / BYOK.
return None
async def _user_from_request(request: Request):
"""Load the authenticated cloud user (or None). Cheap indexed lookup."""
return await get_current_user_optional(request)
async def resolve_gemini(request: Request) -> Optional[str]:
"""Resolve the Gemini API key for a request.
Cloud (hosted) is PAID-ONLY: there is no BYOK for the core pipeline, so the
``X-Gemini-Key`` header is ignored — an entitled user (active plan or trial)
gets the managed server key, everyone else gets ``None`` (→ 402, start trial).
Self-host keeps BYOK: header wins, else the env fallback.
"""
if BILLING_ENABLED:
user = await _user_from_request(request)
if managed_keys.has_active_entitlement(user):
return managed_keys.gemini_key()
return None
header = request.headers.get("X-Gemini-Key")
if header:
return header
return os.environ.get("GEMINI_API_KEY")
async def resolve_upload_post(request: Request, body_key: Optional[str] = None):
"""Resolve the Upload-Post key and the profile to post as.
Returns ``(api_key, forced_profile_username_or_None)``. Cloud is paid-only:
an entitled user gets the managed key + their own forced profile (body key /
user_id ignored); a non-entitled user gets ``(None, None)``. Self-host keeps
BYOK: header, then body key, then env.
"""
if BILLING_ENABLED:
user = await _user_from_request(request)
if managed_keys.has_active_entitlement(user):
profile = await cloud.social_profiles.ensure_profile(user)
return managed_keys.upload_post_key(), profile
return None, None
header = request.headers.get("X-Upload-Post-Key")
key = header or body_key or os.environ.get("UPLOAD_POST_API_KEY")
return key, None
def resolve_post_profile(forced_profile: Optional[str], client_profile: Optional[str]) -> str:
"""The Upload-Post profile to act as, for posting/scheduling/analytics.
Fails closed on purpose. Every call site used to read
``forced_profile or client_profile``, which quietly honours whatever
profile the *client* asked for if the server ever failed to resolve its
own — one refactor of ``resolve_upload_post`` away from letting a cloud
user schedule into someone else's connected accounts. In cloud mode the
client value is never consulted: either the server knows the caller's
profile or the request is refused.
"""
if BILLING_ENABLED:
if not forced_profile:
raise HTTPException(
status_code=503,
detail="Could not resolve your social profile. Please try again.")
return forced_profile
# Self-host: no user model, the caller owns the Upload-Post account whose
# key resolved above, so it picks its own profile.
profile = forced_profile or client_profile
if not profile:
raise HTTPException(status_code=400, detail="Missing Upload-Post user profile")
return profile
def gemini_missing_error():
"""The right 4xx when no Gemini key could be resolved.
402 for a signed-in-but-not-entitled cloud user (needs a plan); 400 otherwise
(BYOK header simply missing).
"""
if BILLING_ENABLED:
return HTTPException(status_code=402, detail={
"error": "no_plan",
"message": "This action needs an active plan. Choose a plan or add your own API key.",
})
return HTTPException(status_code=400, detail="Missing X-Gemini-Key header")
# Probe rate limiter. In-memory, resets on restart by design — the hard monthly
# quota lives in the metering ledger; this only stops someone hammering the
# proxy with metadata probes.
_probe_times: dict = {} # user_id -> [monotonic timestamps]
PROBES_PER_HOUR = 15
# Out-of-minutes upsell email: at most one per user per day (a client may
# retry the same 402 many times).
_last_quota_email: dict = {}
_QUOTA_EMAIL_COOLDOWN = 24 * 3600
def _maybe_send_quota_email(user):
if user is None or user.plan != "free" or not user.email:
return
now = time.monotonic()
last = _last_quota_email.get(str(user.id))
if last is not None and now - last < _QUOTA_EMAIL_COOLDOWN:
return
_last_quota_email[str(user.id)] = now
from cloud.emails import send_out_of_minutes_email
upgrade_url = f"{_cloud_config.settings.frontend_url}/#/pricing"
asyncio.create_task(send_out_of_minutes_email(user.email, upgrade_url))
def _check_probe_rate(user_id):
now = time.monotonic()
times = _probe_times.setdefault(str(user_id), [])
times[:] = [t for t in times if now - t < 3600]
if len(times) >= PROBES_PER_HOUR:
raise HTTPException(status_code=429,
detail="Too many requests this hour. Please slow down.")
times.append(now)
async def reserve_process_minutes(request, url, input_path, job_id):
"""Meter a managed /api/process request.
Returns (user_id, priority, reservation_id, plan).
BYOK / self-host requests don't consume minutes (priority 2, no reservation).
For a managed (entitled, no BYOK header) request this probes the input
duration, enforces the per-user concurrent-job limit, and reserves minutes —
raising 402 (quota) or 429 (too many jobs) as needed.
NOTE: in cloud mode ``resolve_gemini`` ignores ``X-Gemini-Key`` (paid-only,
no BYOK), so we must NOT skip metering just because that header is present —
otherwise a client could send a dummy header and run unlimited managed jobs
on the operator's key for free. Only skip metering when billing is off.
"""
if not BILLING_ENABLED:
return None, 2, None, None
user = await _user_from_request(request)
if not managed_keys.has_active_entitlement(user):
return None, 2, None, None # shouldn't happen (resolve_gemini would have 402'd)
priority = _cloud_config.PLAN_PRIORITY.get(user.plan, 1)
# Per-user simultaneous job cap.
limit = _cloud_config.PLAN_JOB_LIMIT.get(user.plan, 2)
active = sum(1 for j in jobs.values()
if j.get('user_id') == user.id and j.get('status') in ('queued', 'processing'))
if active >= limit:
raise HTTPException(status_code=429,
detail="You already have the maximum number of jobs running. Please wait.")
# Out of minutes -> 402 before probing. The probe is a real yt-dlp metadata
# fetch through the download proxies, and every job costs at least one
# minute, so a user at zero can be turned away without spending bandwidth on
# a duration we are about to reject anyway (4 of 12 submissions in the
# 21-aug-2026 sample were quota 402s that had already paid for their probe).
balance = await _metering.get_balance(user.id)
if balance["remaining"] < 1:
_maybe_send_quota_email(user)
raise HTTPException(status_code=402, detail={
"error": "quota_exceeded",
"minutes_required": 1,
"minutes_remaining": balance["remaining"],
})
# Probe rate limit: probing costs a (cheap) proxied metadata call. The
# 20-minute monthly quota is the real bound on free usage; there is no daily
# job cap.
_check_probe_rate(user.id)
# Probe input duration (blocking → run in a thread). When today's paid
# traffic is over budget, the probe (and below, the job itself) runs
# without the per-GB proxy: statics or nothing.
try:
from cloud import proxy_ledger as _pl
paid_allowed = not await _pl.budget_exceeded()
except Exception:
pass
loop = asyncio.get_event_loop()
try:
if url:
minutes = await loop.run_in_executor(
None, functools.partial(_metering.probe_url_minutes, url,
allow_paid=paid_allowed))
else:
minutes = await loop.run_in_executor(None, _metering.probe_file_minutes, input_path)
except Exception:
raise HTTPException(status_code=400,
detail="Could not determine the video duration. Try a different source.")
finally:
# A probe that had to reach the paid proxy leaves an event behind;
# record it (DB row + Telegram) whether or not the probe succeeded.
try:
from cloud import proxy_ledger as _pl
await _pl.drain_probe_events()
except Exception:
pass
minutes = max(1, math.ceil(minutes))
try:
reservation_id = await _metering.reserve_minutes(user.id, minutes, job_id)
except _metering.QuotaExceeded as e:
_maybe_send_quota_email(user)
raise HTTPException(status_code=402, detail={
"error": "quota_exceeded",
"minutes_required": e.required,
"minutes_remaining": e.remaining,
})
return user.id, priority, reservation_id, user.plan
async def reserve_managed_action(request, minutes, job_id, job_type):
"""Reserve quota for a synchronous managed action (e.g. thumbnail image gen).
Returns a reservation_id to commit/release around the work, or None for
BYOK / self-host. Raises 402 when the user is out of minutes.
"""
if not BILLING_ENABLED:
return None
if minutes <= 0:
# Free action (e.g. burning captions). Skip the ledger entirely rather
# than writing a 0-minute row on every call — the endpoint's own
# entitlement gate is what bounds it.
return None
user = await _user_from_request(request)
if not managed_keys.has_active_entitlement(user):
return None # BYOK header path (self-host) — not metered
try:
return await _metering.reserve_minutes(user.id, minutes, job_id, job_type)
except _metering.QuotaExceeded as e:
_maybe_send_quota_email(user)
raise HTTPException(status_code=402, detail={
"error": "quota_exceeded",
"minutes_required": e.required,
"minutes_remaining": e.remaining,
})
async def require_managed_entitlement(request):
"""Gate a managed compute endpoint that doesn't resolve a Gemini key itself.
Some endpoints (subtitle/hook FFmpeg re-encodes, render proxy, the thumbnail
upload that kicks off a YouTube download + Whisper) do expensive server work
without ever calling ``resolve_gemini``, so nothing was stopping an anonymous
or non-entitled caller from driving unbounded compute in cloud mode. In cloud
mode this rejects them with 402; it's a no-op for self-host (BILLING off).
"""
if not BILLING_ENABLED:
return None
user = await _user_from_request(request)
if not managed_keys.has_active_entitlement(user):
raise gemini_missing_error()
return user
async def _owner_id(request):
"""The authenticated cloud user's id to stamp on a new job/session, or None
for self-host / BYOK / anonymous (BILLING off → nothing to scope)."""
if not BILLING_ENABLED:
return None
user = await _user_from_request(request)
return user.id if user else None
async def _assert_job_owner(request, record):
"""Cloud multi-tenant guard: reject unless the caller owns this in-memory
job/session record.
No-op for self-host (BILLING off) and for records with no owner stamped
(BYOK / self-host jobs never set ``user_id``). Returns 404 rather than 403 so
a non-owner can't even confirm the id exists. UUID ids already make these
stores hard to enumerate; this closes the gap for a shared/leaked id.
"""
if not BILLING_ENABLED:
return
owner = record.get("user_id") if isinstance(record, dict) else None
if owner is None:
return
user = await _user_from_request(request)
# Compare as strings: live jobs store a uuid.UUID, but jobs recovered from
# the .owner sidecar store its string form — UUID != str is always True.
if user is None or str(user.id) != str(owner):
raise HTTPException(status_code=404, detail="Not found")
# Application State
# PriorityQueue holds (priority, seq, job_id). Lower priority dispatches first:
# pro=0, starter/creator=1, BYOK/anonymous/self-host=2. The seq counter keeps
# FIFO order within a priority and makes the tuples always comparable. With
# BILLING disabled every job enqueues at priority 2 → plain FIFO as before.
job_queue = asyncio.PriorityQueue()
_job_seq = itertools.count()
jobs: Dict[str, Dict] = {}
thumbnail_sessions: Dict[str, Dict] = {}
publish_jobs: Dict[str, Dict] = {} # {publish_id: {status, result, error}}
# Semester to limit concurrency to MAX_CONCURRENT_JOBS
concurrency_semaphore = asyncio.Semaphore(MAX_CONCURRENT_JOBS)
def _enqueue_job(job_id: str, priority: int = 2):
job_queue.put_nowait((priority, next(_job_seq), job_id))
def _relocate_root_job_artifacts(job_id: str, job_output_dir: str) -> bool:
"""
Backward-compat rescue:
If main.py accidentally wrote metadata/clips into OUTPUT_DIR root (e.g. output/<jobid>_...),
move them into output/<job_id>/ so the API can find and serve them.
"""
try:
os.makedirs(job_output_dir, exist_ok=True)
root = OUTPUT_DIR
pattern = os.path.join(root, f"{job_id}_*_metadata.json")
meta_candidates = sorted(glob.glob(pattern), key=lambda p: os.path.getmtime(p), reverse=True)
if not meta_candidates:
return False
# Move the newest metadata and its associated clips.
metadata_path = meta_candidates[0]
base_name = os.path.basename(metadata_path).replace("_metadata.json", "")
# Move metadata
dest_metadata = os.path.join(job_output_dir, os.path.basename(metadata_path))
if os.path.abspath(metadata_path) != os.path.abspath(dest_metadata):
shutil.move(metadata_path, dest_metadata)
# Move any clips that match the same base_name into the job folder
clip_pattern = os.path.join(root, f"{base_name}_clip_*.mp4")
for clip_path in glob.glob(clip_pattern):
dest_clip = os.path.join(job_output_dir, os.path.basename(clip_path))
if os.path.abspath(clip_path) != os.path.abspath(dest_clip):
shutil.move(clip_path, dest_clip)
# Also move any temp_ clips that might remain
temp_clip_pattern = os.path.join(root, f"temp_{base_name}_clip_*.mp4")
for clip_path in glob.glob(temp_clip_pattern):
dest_clip = os.path.join(job_output_dir, os.path.basename(clip_path))
if os.path.abspath(clip_path) != os.path.abspath(dest_clip):
shutil.move(clip_path, dest_clip)
return True
except Exception:
return False
def _canonical_clip_file(output_dir, base_name, index):
"""The file to serve for clip ``index``, preferring a derived version.
The pipeline writes the clean reframe as ``<base>_clip_<n>.mp4`` and any
post-processing (auto-captions, /api/subtitle re-styles, and clip-editor
recuts) as ``subtitled_<ts>_<clean>.mp4`` / ``recut_<ts>_<clean>.mp4``,
keeping the original for re-styling. Every place that rebuilds the
canonical name from disk — restore after a restart, the R2 upload, the
download bundle — must therefore resolve to the newest derived file, or
clips silently lose their captions (or their recut) on a redeploy.
"""
clean = f"{base_name}_clip_{index + 1}.mp4"
try:
# subtitled_*_{clean} also matches subtitled_<ts>_recut_<ts>_{clean}
# and subtitled_<ts>_hooked_<ts>_{clean}, i.e. captioned recuts and
# captioned hooks; the bare recut_/hooked_/hook_ patterns cover
# derivations that shipped uncaptioned (hook_ is the legacy manual-
# hook prefix, kept so old jobs still resolve).
derived = (glob.glob(os.path.join(output_dir, f"subtitled_*_{clean}"))
+ glob.glob(os.path.join(output_dir, f"recut_*_{clean}"))
+ glob.glob(os.path.join(output_dir, f"hooked_*_{clean}"))
+ glob.glob(os.path.join(output_dir, f"hook_{clean}")))
except Exception:
derived = []
if not derived:
return clean
# Highest timestamp wins — that's the most recent styling.
return os.path.basename(max(derived, key=os.path.getmtime))
def _strip_burned_captions(output_dir, filename):
"""Walk ``subtitled_<ts>_`` prefixes back to the file without burned captions.
Returns the name unchanged when there is nothing to strip (or when the
underlying file is gone, e.g. a library restore that only kept the current
version).
"""
while True:
m = re.match(r'^subtitled_\d+_(.+)$', filename)
if not m or not os.path.exists(os.path.join(output_dir, m.group(1))):
return filename
filename = m.group(1)
def _strip_burned_hook(output_dir, filename):
"""Walk ``hooked_<ts>_`` (and legacy ``hook_``) prefixes back to the file
without a burned hook. Same fail-safe contract as _strip_burned_captions:
the name is returned unchanged when there is nothing to strip or the
underlying file is gone."""
while True:
m = re.match(r'^(?:hooked_\d+_|hook_)(.+)$', filename)
if not m or not os.path.exists(os.path.join(output_dir, m.group(1))):
return filename
filename = m.group(1)
def _reapply_captions(job_id, clip_index, video_path):
"""Re-burn the default captions onto a freshly derived file.
Captions must always be the LAST layer. Editing or hooking a clip that
already had them burned in produced `edited_subtitled_<...>`, and the next
subtitle pass then stacked a second caption layer on top of the first —
visibly doubled and unreadable in real user clips (26-jul-2026). So the
derivation runs on the clean file and captions go back on afterwards.
Returns the captioned path, or None if there was nothing to caption.
"""
try:
meta_files = glob.glob(os.path.join(OUTPUT_DIR, job_id, "*_metadata.json"))
if not meta_files:
return None
with open(meta_files[0], 'r') as f:
data = json.load(f)
transcript = data.get('transcript')
clips = data.get('shorts', [])
if not transcript or clip_index >= len(clips):
return None
clip = clips[clip_index]
import main as _main
# A recut clip is a concatenation of source segments, so the flat
# start..end window is wrong for it — caption against the clip-relative
# remapped transcript instead (same trick /api/subtitle uses).
recipe_segments = (clip.get('recipe') or {}).get('segments')
if recipe_segments:
v_transcript = recut.virtual_transcript(transcript, recipe_segments)
return _main.auto_caption_clip(
video_path, v_transcript, 0.0,
recut.total_duration(recipe_segments))
return _main.auto_caption_clip(video_path, transcript,
clip['start'], clip['end'])
except Exception as e:
print(f"⚠️ Could not re-apply captions to {video_path}: {e}")
return None
def _recover_jobs_from_disk():
"""Rebuild completed jobs from OUTPUT_DIR after a restart (issue #46 / #18).
Jobs live in memory, so a restart used to orphan finished clips that are
still on disk: the frontend restores the job_id from localStorage but every
endpoint answers 404 "Job not found". Rebuild a minimal completed record
for each job directory that has a metadata JSON.
"""
recovered = 0
try:
entries = os.listdir(OUTPUT_DIR)
except FileNotFoundError:
return
for job_id in entries:
job_path = os.path.join(OUTPUT_DIR, job_id)
if not os.path.isdir(job_path) or job_id in jobs:
continue
json_files = glob.glob(os.path.join(job_path, "*_metadata.json"))
if not json_files:
continue
try:
with open(json_files[0], 'r') as f:
data = json.load(f)
base_name = os.path.basename(json_files[0]).replace('_metadata.json', '')
clips = data.get('shorts', [])
for i, clip in enumerate(clips):
if not clip.get('video_url'):
clip['video_url'] = (
f"/videos/{job_id}/"
f"{_canonical_clip_file(job_path, base_name, i)}")
owner = None
owner_path = os.path.join(job_path, ".owner")
if os.path.exists(owner_path):
with open(owner_path) as f:
raw = f.read().strip()
owner = int(raw) if raw.isdigit() else (raw or None)
jobs[job_id] = {
'status': 'completed',
'logs': ["♻️ Job recovered from disk after server restart."],
'output_dir': job_path,
'user_id': owner,
'result': {'clips': clips, 'cost_analysis': data.get('cost_analysis')},
}
recovered += 1
except Exception as e:
print(f"⚠️ Could not recover job {job_id}: {e}")
if recovered:
print(f"♻️ Recovered {recovered} completed job(s) from disk.")
# --- Mid-flight job resume (survive a redeploy without losing work) ----------
# A job lives only in memory, so killing the container mid-processing used to
# lose it: the user's clip just stops. We persist a tiny manifest per job and,
# on startup, re-enqueue any that were interrupted — the user sees it resume
# instead of vanish. Bounded by MAX_RESUME_ATTEMPTS so a video that reliably
# crashes the worker can't crashloop the service.
_RESUME_FILE = ".resume.json"
MAX_RESUME_ATTEMPTS = 2
# --- Deploy handover (two instances, one disk) -------------------------------
# Coolify starts the NEW container before it stops the old one (rolling
# update), and both see the same OUTPUT_DIR. Without coordination the new one
# re-enqueued, at startup, the very jobs the old one was still rendering —
# and the old one had 30 s to live anyway. Now:
# * every instance stamps OUTPUT_DIR/.instance with its id at startup; an
# instance that sees another id there knows it is the OLD one and DRAINS:
# it finishes what it is running, starts nothing new, and leaves queued
# manifests for the new instance to pick up;
# * a running job writes a heartbeat into its manifest every few seconds,
# so the new instance skips manifests that are alive elsewhere and resumes
# only the stale ones (an instance killed mid-job stops heartbeating);
# * SIGTERM (docker stop) also drains, up to DRAIN_TIMEOUT_SECONDS — keep it
# under the Coolify stop grace period — before letting uvicorn exit.
INSTANCE_ID = os.environ.get("INSTANCE_ID") or socket.gethostname()
_INSTANCE_MARKER = ".instance"
HEARTBEAT_EVERY = 10 # seconds between manifest heartbeats
HEARTBEAT_STALE_AFTER = 60 # no heartbeat for this long = nobody has it
RESUME_SCAN_INTERVAL = 30 # seconds between looks for stale manifests
HANDOVER_CHECK_INTERVAL = 5 # seconds between looks at the marker
DRAIN_TIMEOUT_SECONDS = int(os.environ.get("DRAIN_TIMEOUT_SECONDS", "840"))
# After the jobs are drained, keep SERVING this long with /health/ready at 503
# before closing the socket: the proxy only drops a container once its Docker
# healthcheck has failed interval*retries times (15 s with the Coolify
# settings), and closing the socket earlier sends that many seconds of
# requests to a dead port. Measured 2026-08-25: ~60 s of alternating 502/200
# per deploy with retries=12 and no grace at all.
PROXY_DRAIN_SECONDS = float(os.environ.get("PROXY_DRAIN_SECONDS", "20"))
# Once uvicorn has the signal it closes within --timeout-graceful-shutdown
# (15 s), but the interpreter then waits for non-daemon threads, and a
# request cancelled mid-flight can leave an executor thread stuck in a
# network probe (yt-dlp) for as long as that takes. Seen 2026-08-25: "Finished
# server process" printed, container alive until the 900 s SIGKILL, deploy
# stuck in "Removing old containers". So the process is ended outright a
# little after uvicorn was told to stop. Jobs are already drained by then.
HARD_EXIT_SECONDS = float(os.environ.get("HARD_EXIT_SECONDS", "30"))
_draining = False
_stopping = False # SIGTERM received: report not-ready so the
# proxy stops routing here before the
# listening socket closes
_running_jobs: set = set() # job ids with a live subprocess here
def _manifest_path(job_id):
return os.path.join(OUTPUT_DIR, job_id, _RESUME_FILE)
def _read_manifest(job_id):
try:
with open(_manifest_path(job_id)) as f:
return json.load(f)
except FileNotFoundError:
return None
except Exception as e:
print(f"⚠️ Bad resume manifest for {job_id}: {e}")
return None
def _touch_manifest(job_id, now=None):
"""Stamp 'this instance is running it, as of now' into the manifest."""
m = _read_manifest(job_id)
if m is None:
return
m["heartbeat"] = now if now is not None else time.time()
m["instance"] = INSTANCE_ID
try:
with open(_manifest_path(job_id), "w") as f:
json.dump(m, f)
except Exception as e:
print(f"⚠️ Could not heartbeat manifest for {job_id}: {e}")
def _manifest_busy_elsewhere(m, now=None):
"""True when ANOTHER instance heartbeated this job recently. Our own id
with a fresh heartbeat is a job WE were running before a restart of this
same container (same hostname) — that one must be resumed, not skipped."""
now = time.time() if now is None else now
return (m.get("instance") not in (None, INSTANCE_ID)
and now - float(m.get("heartbeat") or 0) < HEARTBEAT_STALE_AFTER)
def _write_instance_marker():
try:
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(os.path.join(OUTPUT_DIR, _INSTANCE_MARKER), "w") as f:
f.write(INSTANCE_ID)
except Exception as e:
print(f"⚠️ Could not write instance marker: {e}")
def _read_instance_marker():
try:
with open(os.path.join(OUTPUT_DIR, _INSTANCE_MARKER)) as f:
return f.read().strip()
except Exception:
return None
def _begin_drain(reason):
global _draining
if not _draining:
_draining = True
print(f"⏸️ Draining ({reason}): finishing {len(_running_jobs)} running job(s), "
f"starting none.")
def _check_instance_marker():
"""A different id in the marker means a newer instance is up: drain."""
other = _read_instance_marker()
if other and other != INSTANCE_ID:
_begin_drain(f"newer instance {other} is up")
return True
return False
async def _handover_watch():
while not _draining:
await asyncio.sleep(HANDOVER_CHECK_INTERVAL)
_check_instance_marker()
async def _resume_scan():
"""Keep looking for manifests nobody is running (the old instance left
them queued, or was killed at the end of its grace period)."""
while True:
await asyncio.sleep(RESUME_SCAN_INTERVAL)
if _draining:
continue
try:
_resume_interrupted_jobs()
except Exception as e:
print(f"⚠️ Resume scan failed: {e}")
async def _drain_then_exit(previous_handler, timeout=None, proxy_grace=None,
hard_exit_after=None):
"""Wait for running jobs (bounded), let the proxy notice we are not ready,
then hand the signal to uvicorn."""
timeout = DRAIN_TIMEOUT_SECONDS if timeout is None else timeout
proxy_grace = PROXY_DRAIN_SECONDS if proxy_grace is None else proxy_grace
hard_exit_after = HARD_EXIT_SECONDS if hard_exit_after is None else hard_exit_after
deadline = time.time() + timeout
while _running_jobs and time.time() < deadline:
await asyncio.sleep(1)
if _running_jobs:
print(f"⏱️ Drain timeout after {timeout}s with {len(_running_jobs)} job(s) still "
f"running — they will resume on the next instance.")
else:
print("✅ Drained: no running jobs.")
if proxy_grace > 0:
print(f"⏳ Serving {proxy_grace:.0f}s more while the proxy drops this instance.")
await asyncio.sleep(proxy_grace)
print("👋 Shutting down.")
if hard_exit_after > 0:
import threading
threading.Timer(hard_exit_after, _hard_exit).start()
if callable(previous_handler):
previous_handler(signal.SIGTERM, None)
else:
os._exit(0)
def _hard_exit():
print(f"⛔ Still alive {HARD_EXIT_SECONDS:.0f}s after the stop signal (a thread "
f"is hanging) — exiting now.", flush=True)
os._exit(0)
def _install_drain_signal_handler():
"""Replace uvicorn's SIGTERM handler with drain-first. uvicorn's own
handler closes the listening socket at once, which would make Traefik
send half the traffic to a refused port for the whole drain."""
previous = signal.getsignal(signal.SIGTERM)
loop = asyncio.get_running_loop()
def on_sigterm():
global _stopping
_stopping = True
_begin_drain("SIGTERM")
asyncio.ensure_future(_drain_then_exit(previous))
try:
loop.add_signal_handler(signal.SIGTERM, on_sigterm)
except (NotImplementedError, RuntimeError, ValueError) as e:
print(f"⚠️ Drain-on-SIGTERM unavailable ({e}); jobs will resume on restart instead.")
def _write_resume_manifest(job_id, cmd, priority, user_id, reservation_id, watermark,
webhook_url=None, webhook_secret=None, base_url=None):
try:
path = os.path.join(OUTPUT_DIR, job_id, _RESUME_FILE)
with open(path, "w") as f:
json.dump({
"cmd": cmd, "priority": priority,
"user_id": None if user_id is None else str(user_id),
"reservation_id": reservation_id,
"watermark": bool(watermark), "attempts": 0,
# The caller's webhook must survive a redeploy: a pipeline that
# relies on the callback would otherwise hang forever on a job
# that resumed fine. The secret is the caller's own HMAC value,
# stored next to their video on the same disk — not a server
# credential (those are rebuilt from os.environ on resume).
"webhook_url": webhook_url,
"webhook_secret": webhook_secret,
"base_url": base_url,
}, f)
except Exception as e:
print(f"⚠️ Could not write resume manifest for {job_id}: {e}")
def _clear_resume_manifest(job_id):
"""Drop the manifest once a job reaches a terminal state, so it is never
re-run on a later restart. Only an interrupted (still-running) job keeps it."""
try:
os.remove(os.path.join(OUTPUT_DIR, job_id, _RESUME_FILE))
except FileNotFoundError:
pass
except Exception as e:
print(f"⚠️ Could not clear resume manifest for {job_id}: {e}")
def _resume_interrupted_jobs() -> set:
"""Re-enqueue jobs that were mid-processing when the server last stopped.
Runs after _recover_jobs_from_disk: a job whose clips already finished has a
metadata JSON and is recovered as 'completed', so we only resume manifests
with no metadata yet (analysis never finished). Also called periodically
(_resume_scan): a manifest another instance is heartbeating is left alone
until that heartbeat goes stale, and a job this instance already holds is
never enqueued twice.
Returns the set of reservation ids for every manifest still on disk —
resumed here or alive on the other instance — so the caller can keep them
out of the orphaned-reservation refund. Does NO DB work — the DB engine
isn't up yet at this point in startup. A poison job (too many attempts) is
simply not resumed; its reservation is then refunded as a normal orphan.
"""
keep_reservations: set = set()
try:
entries = os.listdir(OUTPUT_DIR)
except FileNotFoundError:
return keep_reservations
resumed = 0
for job_id in entries:
job_path = os.path.join(OUTPUT_DIR, job_id)
manifest_path = os.path.join(job_path, _RESUME_FILE)
if not os.path.isfile(manifest_path):
continue
if glob.glob(os.path.join(job_path, "*_metadata.json")):
# Finished after all — recovered as completed already.
_clear_resume_manifest(job_id)
continue
try:
with open(manifest_path) as f:
m = json.load(f)
except Exception as e:
print(f"⚠️ Bad resume manifest for {job_id}: {e}")
continue
if m.get("reservation_id"):
keep_reservations.add(str(m["reservation_id"]))
if job_id in jobs:
continue # already ours (queued, running or recovered)
if _manifest_busy_elsewhere(m):
continue # the other instance is on it; we take over if it goes stale
attempts = int(m.get("attempts", 0)) + 1
user_id = m.get("user_id")
reservation_id = m.get("reservation_id")
if attempts > MAX_RESUME_ATTEMPTS:
# Poison job: don't resume. Leaving its reservation out of the keep
# set lets the orphan sweep refund it, and the user can retry by hand.
print(f"🛑 Job {job_id} exceeded {MAX_RESUME_ATTEMPTS} resume attempts — giving up.")
_clear_resume_manifest(job_id)
if reservation_id:
keep_reservations.discard(str(reservation_id)) # let the sweep refund it
continue
# Rebuild env from scratch — the manifest holds no secrets. Managed
# (cloud) jobs get the server key; self-host falls back to its env key.
env = os.environ.copy()
try:
from cloud import proxy_ledger as _pl
if BILLING_ENABLED and _pl.budget_exceeded_sync():
env.pop("PROXY_URL", None) # daily paid-proxy budget hit
except Exception:
pass
if BILLING_ENABLED and user_id is not None:
try:
env["GEMINI_API_KEY"] = managed_keys.gemini_key()
except Exception:
pass
if m.get("watermark"):
env["WATERMARK"] = "1"
else:
env.pop("WATERMARK", None)
m["attempts"] = attempts
try:
with open(manifest_path, "w") as f:
json.dump(m, f)
except Exception:
pass
jobs[job_id] = {
'status': 'queued',
'logs': [f"♻️ Resuming your video after a server update (attempt {attempts})."],
'cmd': m.get("cmd"),
'env': env,
'output_dir': job_path,
'user_id': None if user_id is None else user_id,
'reservation_id': reservation_id,
'watermark': bool(m.get("watermark")),
'webhook_url': m.get("webhook_url"),
'webhook_secret': m.get("webhook_secret"),
'base_url': m.get("base_url"),
}
_enqueue_job(job_id, int(m.get("priority", 2)))
resumed += 1
if resumed:
print(f"♻️ Re-enqueued {resumed} interrupted job(s) after restart.")
return keep_reservations
def _dir_size(path: str) -> int:
total = 0
for root, _dirs, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total
def _enforce_uploads_size_cap():
"""Delete the oldest source uploads while UPLOAD_DIR is over UPLOADS_MAX_GB.
Sources are only needed while a job runs (and for the preview afterwards),
but they're the biggest files on disk — up to MAX_FILE_SIZE_MB each.
"""
cap = UPLOADS_MAX_GB * 1024 ** 3
if cap <= 0:
return
used = _dir_size(UPLOAD_DIR)
if used <= cap:
return
files = []
for name in os.listdir(UPLOAD_DIR):
p = os.path.join(UPLOAD_DIR, name)
if os.path.isfile(p):
try:
files.append((os.path.getmtime(p), p, os.path.getsize(p)))
except OSError:
pass
files.sort()
print(f"🧹 Uploads at {used / 1024**3:.1f} GB (cap {UPLOADS_MAX_GB} GB) — trimming.")
for _mtime, path, size in files:
if used <= cap:
break
try:
os.remove(path)
used -= size
print(f"🧹 Size cap: removed upload {os.path.basename(path)}")
except OSError:
pass
def _enforce_output_size_cap():
"""Delete the oldest job dirs while OUTPUT_DIR is over OUTPUT_MAX_GB."""
cap = OUTPUT_MAX_GB * 1024 ** 3
if cap <= 0:
return
used = _dir_size(OUTPUT_DIR)