-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathintro_builder.py
More file actions
3417 lines (3129 loc) · 169 KB
/
Copy pathintro_builder.py
File metadata and controls
3417 lines (3129 loc) · 169 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
#!/usr/bin/env python3
"""
Intro Builder v1.0
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, colorchooser, font as tkfont
import subprocess
import threading
import os
import sys
import re
import shutil
import json
import base64
import queue
from pathlib import Path
# ──────────────────────────────────────────────
# App version and configuration
# ──────────────────────────────────────────────
APP_VERSION = "1.0"
NICE_LEVEL = None # overridden at startup by nice=n command-line argument
# ──────────────────────────────────────────────
# Dark theme
# ──────────────────────────────────────────────
BG = "#0e0e12"
SURFACE = "#18181f"
CARD = "#22222d"
BORDER = "#2e2e3a"
ACCENT = "#e05a2b"
ACCENT2 = "#f08040"
TEXT = "#e8e8f0"
SUBTEXT = "#8888aa"
SUCCESS = "#3ecf74"
WARNING = "#f0b040"
DANGER = "#e05050"
ENTRY_BG = "#1a1a24"
FONT_UI = ("Courier New", 10)
FONT_LABEL = ("Courier New", 9)
FONT_TITLE = ("Courier New", 17, "bold")
FONT_SECTION = ("Courier New", 10, "bold")
FONT_MONO = ("Courier New", 9)
# ──────────────────────────────────────────────
# Helper widgets
# ──────────────────────────────────────────────
def styled_frame(parent, **kw):
kw.setdefault("bg", CARD)
kw.setdefault("relief", "flat")
return tk.Frame(parent, **kw)
def styled_label(parent, text, size=9, bold=False, color=TEXT, **kw):
weight = "bold" if bold else "normal"
kw.setdefault("bg", parent.cget("bg"))
return tk.Label(parent, text=text,
font=("Courier New", size, weight),
fg=color, **kw)
def styled_entry(parent, textvariable=None, width=30, **kw):
return tk.Entry(parent, textvariable=textvariable, width=width,
bg=ENTRY_BG, fg=TEXT, insertbackground=ACCENT,
relief="flat", font=FONT_UI,
highlightbackground=BORDER, highlightcolor=ACCENT,
highlightthickness=1, **kw)
def styled_button(parent, text, command, accent=False, danger=False,
small=False, **kw):
fg = BG if accent else TEXT
bg = ACCENT if accent else (DANGER if danger else BORDER)
size = 8 if small else 10
b = tk.Button(parent, text=text, command=command,
bg=bg, fg=fg, activebackground=ACCENT2,
activeforeground=BG, relief="flat",
font=("Courier New", size, "bold"),
cursor="hand2", padx=10, pady=4, **kw)
b.bind("<Enter>", lambda e, _bg=bg: b.config(
bg=ACCENT2 if (accent or not danger) else "#ff6060"))
b.bind("<Leave>", lambda e, _bg=bg: b.config(bg=_bg))
return b
def styled_spinbox(parent, var, lo, hi, width=7, step=1, **kw):
return tk.Spinbox(parent, from_=lo, to=hi, increment=step,
textvariable=var, width=width,
bg=ENTRY_BG, fg=TEXT, buttonbackground=BORDER,
relief="flat", font=FONT_UI,
insertbackground=ACCENT,
highlightbackground=BORDER, highlightcolor=ACCENT,
highlightthickness=1, **kw)
def section_header(parent, title):
frame = tk.Frame(parent, bg=BG)
frame.pack(fill="x", pady=(14, 3))
bar = tk.Frame(frame, bg=ACCENT, width=3, height=18)
bar.pack(side="left", padx=(0, 8))
bar.pack_propagate(False)
tk.Label(frame, text=title, font=FONT_SECTION,
fg=ACCENT, bg=BG).pack(side="left")
def hline(parent):
tk.Frame(parent, bg=BORDER, height=1).pack(fill="x", pady=6)
def _short_path(full_path, max_len=25):
"""Truncate path to max_len, showing filename and parent dir."""
if not full_path:
return "—"
p = Path(full_path)
name = p.name
if len(name) <= max_len:
return name
parent = p.parent.name
if len(parent) + len(name) + 1 <= max_len:
return f"{parent}/{name}"
return "…" + name[-(max_len-2):]
def _safe_getext(path):
try:
return path.lower().rsplit(".", 1)[-1]
except Exception:
return ""
# ──────────────────────────────────────────────
# FilePicker
# ──────────────────────────────────────────────
class FilePicker(tk.Frame):
def __init__(self, parent, label, filetypes, initial_dir=None, **kw):
super().__init__(parent, bg=CARD, **kw)
self.var = tk.StringVar()
self._ft = filetypes
self._id = initial_dir
styled_label(self, label, color=SUBTEXT).pack(anchor="w")
row = tk.Frame(self, bg=CARD); row.pack(fill="x", pady=(2, 0))
# Browse button right-aligned; entry fills remaining space uniformly
browse_btn = styled_button(row, " BROWSE", self._browse, small=True)
browse_btn.pack(side="right", padx=(6, 0))
self.entry = styled_entry(row, textvariable=self.var, width=10)
self.entry.pack(side="left", fill="x", expand=True)
def _browse(self):
p = filedialog.askopenfilename(
initialdir=self._id or str(Path.home()),
filetypes=self._ft)
if p: self.var.set(p)
def get(self): return self.var.get().strip()
def set(self, v): self.var.set(v)
# ──────────────────────────────────────────────
# EffectToggle
# ──────────────────────────────────────────────
class EffectToggle(tk.Frame):
def __init__(self, parent, label, desc="", default=True, **kw):
super().__init__(parent, bg=CARD, **kw)
self.var = tk.BooleanVar(value=default)
inner = tk.Frame(self, bg=CARD, padx=8, pady=0)
inner.pack(fill="x")
tk.Checkbutton(inner, variable=self.var, bg=CARD,
activebackground=CARD, selectcolor=ENTRY_BG,
fg=ACCENT, relief="flat", cursor="hand2"
).pack(side="left")
col = tk.Frame(inner, bg=CARD); col.pack(side="left")
tk.Label(col, text=label, font=("Courier New", 9, "bold"),
fg=TEXT, bg=CARD).pack(anchor="w")
if desc:
tk.Label(col, text=desc, font=("Courier New", 8),
fg=SUBTEXT, bg=CARD).pack(anchor="w")
def get(self): return self.var.get()
# ──────────────────────────────────────────────
# SliderRow — label + scale + value readout
# ──────────────────────────────────────────────
class SliderRow(tk.Frame):
def __init__(self, parent, label, var, lo, hi,
resolution=0.1, fmt="{:.1f}", length=180, **kw):
super().__init__(parent, bg=CARD, **kw)
self.var = var; self.fmt = fmt
top = tk.Frame(self, bg=CARD); top.pack(fill="x")
styled_label(top, label, color=SUBTEXT, size=8).pack(side="left")
self.val_lbl = styled_label(top, self._fv(), color=ACCENT, size=8)
self.val_lbl.pack(side="right")
tk.Scale(self, from_=lo, to=hi, resolution=resolution,
orient="horizontal", variable=var,
length=length, showvalue=False,
bg=CARD, fg=TEXT, troughcolor=ENTRY_BG,
highlightbackground=CARD, activebackground=ACCENT,
sliderlength=14,
command=lambda _: self.val_lbl.config(text=self._fv())
).pack(fill="x")
def _fv(self): return self.fmt.format(self.var.get())
# ──────────────────────────────────────────────
# ColourButton — swatch + choose
# ──────────────────────────────────────────────
class ColourButton(tk.Frame):
"""Combo: entry/combobox + colour-chooser swatch button."""
def __init__(self, parent, var, presets=None, width=10, **kw):
super().__init__(parent, bg=CARD, **kw)
self.var = var
self.swatch = tk.Label(self, width=2, bg=self._safe_colour(),
relief="flat", cursor="hand2")
self.swatch.pack(side="left", padx=(0, 3))
self.swatch.bind("<Button-1>", self._choose)
values = presets or []
self.combo = ttk.Combobox(self, textvariable=var,
values=values, width=width)
self.combo.pack(side="left")
var.trace_add("write", lambda *_: self._refresh())
def _safe_colour(self):
c = self.var.get().strip()
try:
self.winfo_rgb(c); return c
except Exception:
return "#888888"
def _refresh(self):
self.swatch.config(bg=self._safe_colour())
def _choose(self, _=None):
current = self._safe_colour()
result = colorchooser.askcolor(color=current,
title="Choose colour")
if result and result[1]:
self.var.set(result[1])
# ──────────────────────────────────────────────
# Main Application
# ──────────────────────────────────────────────
class IntroBuilderApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("Intro Builder v1.0")
self.configure(bg=BG)
self.resizable(True, True)
self.minsize(860, 820)
self.base_dir = Path(__file__).parent.resolve()
self.audio_dir = self.base_dir / "audio"
self.bg_dir = self.base_dir / "backgrounds"
# Create companion folders if they don't exist yet
for _d in (self.audio_dir, self.bg_dir):
try:
_d.mkdir(parents=False, exist_ok=True)
except Exception:
pass
self.tmp_bg = "/tmp/prm_bg.mp4"
self.tmp_audio = "/tmp/prm_audio.m4a"
self.tmp_audio_pcm = "/tmp/prm_audio_pcm.wav" # safe intermediate
self.tmp_audio_filtered = "/tmp/prm_audio_fx.m4a"
self._output_file = None
self._ui_queue = queue.Queue()
self._build_ui()
self._apply_ttk_style()
self._poll_ui_queue()
def _is_animated_gif(self,path):
if not path or not path.lower().endswith(".gif"): return False
try:
pr=subprocess.run(["ffprobe","-v","error","-select_streams","v:0","-show_entries","stream=nb_frames","-of","default=nokey=1:noprint_wrappers=1",path],capture_output=True,text=True,timeout=8)
nb=pr.stdout.strip()
return int(nb)>1 if nb.isdigit() else True
except Exception: return True
def _codec_map(self,fmt,preset,crf):
if fmt=="mp4_h264_aac":
vcodec=["-c:v","libx264","-preset",preset,"-crf",str(crf),"-pix_fmt","yuv420p","-movflags","+faststart"]; acodec=["-c:a","aac","-b:a","192k"]
elif fmt=="mp4_h265_aac":
vcodec=["-c:v","libx265","-preset",preset,"-crf",str(max(18,crf+4)),"-tag:v","hvc1","-pix_fmt","yuv420p","-movflags","+faststart"]; acodec=["-c:a","aac","-b:a","192k"]
elif fmt=="webm_vp9_opus":
vcodec=["-c:v","libvpx-vp9","-b:v","0","-crf",str(min(40,max(20,crf)))]; acodec=["-c:a","libopus","-b:a","160k"]
elif fmt=="mov_prores":
vcodec=["-c:v","prores_ks","-profile:v","3","-pix_fmt","yuv422p10le"]; acodec=["-c:a","pcm_s16le"]
elif fmt=="mkv_h265_aac":
vcodec=["-c:v","libx265","-preset",preset,"-crf",str(max(18,crf+4))]; acodec=["-c:a","aac","-b:a","192k"]
else:
vcodec=["-c:v","libx264","-preset",preset,"-crf",str(crf),"-pix_fmt","yuv420p","-movflags","+faststart"]; acodec=["-c:a","aac","-b:a","192k"]
return vcodec,acodec
# ── UI scaffold ───────────────────────────────────────────────────
def _poll_ui_queue(self):
"""Drain pending UI updates on the main thread every 40 ms."""
try:
while True:
self._ui_queue.get_nowait()()
except queue.Empty:
pass
self.after(40, self._poll_ui_queue)
def _ui(self, fn):
"""Queue fn() for execution on the main thread (thread-safe)."""
self._ui_queue.put(fn)
def _make_scrollable_tab(self, nb, title):
"""Return (inner_frame, canvas) for a scrollable notebook tab."""
outer = tk.Frame(nb, bg=BG)
nb.add(outer, text=f" {title} ")
canvas = tk.Canvas(outer, bg=BG, highlightthickness=0, bd=0)
vsb = ttk.Scrollbar(outer, orient="vertical", command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)
vsb.pack(side="right", fill="y")
canvas.pack(side="left", fill="both", expand=True)
inner = tk.Frame(canvas, bg=BG)
win_id = canvas.create_window((0, 0), window=inner, anchor="nw")
inner.bind("<Configure>",
lambda e, c=canvas: c.configure(scrollregion=c.bbox("all")))
canvas.bind("<Configure>",
lambda e, c=canvas, w=win_id: c.itemconfig(w, width=e.width))
def _mw(event, c=canvas):
if event.num == 4: c.yview_scroll(-1, "units")
elif event.num == 5: c.yview_scroll(1, "units")
else: c.yview_scroll(-1*(event.delta//120), "units")
canvas.bind("<Enter>",
lambda e, c=canvas, fn=_mw: (
c.bind_all("<MouseWheel>", fn),
c.bind_all("<Button-4>", fn),
c.bind_all("<Button-5>", fn)))
canvas.bind("<Leave>",
lambda e, c=canvas: (
c.unbind_all("<MouseWheel>"),
c.unbind_all("<Button-4>"),
c.unbind_all("<Button-5>")))
return inner, canvas
def _build_ui(self):
# ── Title bar ─────────────────────────────────────────────────
tb = tk.Frame(self, bg=BG); tb.pack(fill="x", padx=20, pady=(16, 0))
tk.Label(tb, text="[>]", font=("Courier New", 20, "bold"),
fg=ACCENT, bg=BG).pack(side="left", padx=(0, 10))
tk.Label(tb, text="INTRO BUILDER",
font=FONT_TITLE, fg=TEXT, bg=BG).pack(side="left")
tk.Label(tb, text=f"v{APP_VERSION}", font=("Courier New", 8),
fg=SUBTEXT, bg=BG).pack(side="left", padx=8, pady=(5, 0))
styled_button(tb, "[ LOAD SETTINGS FROM FILE ]",
self._load_settings_from_file, small=True
).pack(side="right")
tk.Frame(self, bg=BORDER, height=1).pack(fill="x", padx=20, pady=(8, 0))
# ── Main content area: left (tabs) + right (preview) ──────────────
main_area = tk.Frame(self, bg=BG)
main_area.pack(fill="both", expand=True, padx=6, pady=(4, 0))
main_area.columnconfigure(0, weight=1) # left (tabs) expands
main_area.columnconfigure(1, weight=0) # right (preview) fixed width
main_area.rowconfigure(0, weight=1) # tabs+preview row expands
# ── Notebook tabs (left side) ──────────────────────────────────
nb = ttk.Notebook(main_area)
nb.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
self._notebook = nb
t1, c1 = self._make_scrollable_tab(nb, "Sources")
t2, c2 = self._make_scrollable_tab(nb, "Text & Overlays")
t3, c3 = self._make_scrollable_tab(nb, "Video")
t4, c4 = self._make_scrollable_tab(nb, "Colour")
t5, c5 = self._make_scrollable_tab(nb, "Audio")
t6, c6 = self._make_scrollable_tab(nb, "Duration & Output")
self._tab_canvases = [c1, c2, c3, c4, c5, c6]
# ── Preview panel (right side) — built after form so widgets exist ─
self._build_preview_panel(main_area)
# ── Build form controls ────────────────────────────────────────
self._build_form(t1, t2, t3, t4, t5, t6)
# ── Connect preview traces (after form is built) ────────────────
self._connect_preview_traces()
self._update_preview_info()
self._sync_scrubber_range()
# Graphic overlay position vars (used by preview handles)
self.graphic_cx_var = tk.IntVar(value=960)
self.graphic_cy_var = tk.IntVar(value=540)
self.graphic_w_var = tk.IntVar(value=1920)
self.graphic_h_var = tk.IntVar(value=1080)
# ── Permanent log panel (always visible below tabs) ────────────
tk.Frame(self, bg=BORDER, height=1).pack(fill="x")
self._build_log_panel()
tk.Frame(self, bg=BORDER, height=1).pack(fill="x")
self._build_bottom_bar()
def _build_preview_panel(self, parent):
"""Build the interactive preview panel on the right side."""
PW, PH = 340, 192 # canvas pixel size (16:9)
self._prev_w, self._prev_h = PW, PH
prev_frame = tk.Frame(parent, bg=CARD, width=360)
prev_frame.grid(row=0, column=1, sticky="ns", padx=(0, 0), pady=0)
prev_frame.pack_propagate(False)
# Header
phdr = tk.Frame(prev_frame, bg=CARD)
phdr.pack(fill="x", padx=10, pady=(10, 4))
tk.Label(phdr, text="PREVIEW", font=("Courier New", 10, "bold"),
fg=ACCENT, bg=CARD).pack(side="left")
self._prev_time_lbl = tk.Label(phdr, text="t=0.0s",
font=("Courier New", 8), fg=SUBTEXT, bg=CARD)
self._prev_time_lbl.pack(side="right")
# Canvas
self._preview_canvas = tk.Canvas(
prev_frame, width=PW, height=PH,
bg="#111111", highlightthickness=1, highlightbackground=BORDER,
cursor="crosshair")
self._preview_canvas.pack(padx=10, pady=(0, 4))
# Frame-time scrubber
scrub_row = tk.Frame(prev_frame, bg=CARD)
scrub_row.pack(fill="x", padx=10, pady=(0, 2))
styled_label(scrub_row, "Frame:", color=SUBTEXT, size=8).pack(side="left")
self._scrub_var = tk.DoubleVar(value=0.0)
self._scrub_scale = tk.Scale(
scrub_row, from_=0.0, to=8.0, resolution=0.1,
orient="horizontal", variable=self._scrub_var,
showvalue=False, length=230,
bg=CARD, fg=TEXT, troughcolor=ENTRY_BG,
highlightbackground=CARD, activebackground=ACCENT, sliderlength=12,
command=self._on_scrub)
self._scrub_scale.pack(side="left", fill="x", expand=True)
# Lock aspect ratio checkbox for fill image handle
lock_row = tk.Frame(prev_frame, bg=CARD)
lock_row.pack(fill="x", padx=10, pady=(0, 4))
self._lock_ar_var = tk.BooleanVar(value=False)
tk.Checkbutton(lock_row, variable=self._lock_ar_var,
bg=CARD, fg=ACCENT, selectcolor=ENTRY_BG,
activebackground=CARD, cursor="hand2"
).pack(side="left")
styled_label(lock_row, "Lock overlay image aspect ratio",
color=SUBTEXT, size=8).pack(side="left")
# Refresh button
tk.Button(prev_frame, text="↻ Refresh Preview", bg=SURFACE, fg=ACCENT,
font=("Courier New", 8), relief="flat", bd=0,
activebackground=CARD, activeforeground=ACCENT,
command=self._refresh_preview
).pack(fill="x", padx=10, pady=(0, 4))
# Divider
tk.Frame(prev_frame, bg=BORDER, height=1).pack(fill="x", pady=(2, 6))
# Info panel
info_frame = tk.Frame(prev_frame, bg=CARD)
info_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
self._preview_info = {}
for key, label in [
("bg_file", "Background"),
("audio_file", "Audio"),
("duration", "Duration"),
("out_name", "Output"),
("out_format", "Format"),
("text", "Text"),
]:
row = tk.Frame(info_frame, bg=CARD); row.pack(fill="x", pady=2)
tk.Label(row, text=f"{label}:", font=("Courier New", 8),
fg=SUBTEXT, bg=CARD, width=10, anchor="w").pack(side="left")
lbl = tk.Label(row, text="—", font=("Courier New", 8),
fg=TEXT, bg=CARD)
lbl.pack(side="left", fill="x", expand=True)
self._preview_info[key] = lbl
# Graphic overlay handle state (canvas coords in preview space)
self._handle_ids = [] # canvas item ids for handles
self._handle_drag = {} # drag start state
self._gfx_rect = [20, 20, PW-20, PH-20] # [x0,y0,x1,y1] in canvas px
self._gfx_img_id = None
# Bind handle interaction
self._preview_canvas.bind("<ButtonPress-1>", self._on_handle_press)
self._preview_canvas.bind("<B1-Motion>", self._on_handle_drag)
self._preview_canvas.bind("<ButtonRelease-1>", self._on_handle_release)
# Internal state
self._preview_pending = False
self._preview_after = None
# ── Scrubber ──────────────────────────────────────────────────────
def _on_scrub(self, val):
t = float(val)
self._prev_time_lbl.config(text=f"t={t:.1f}s")
# Debounce: cancel pending refresh and re-schedule
if self._preview_after:
self.after_cancel(self._preview_after)
self._preview_after = self.after(400, self._refresh_preview)
def _sync_scrubber_range(self, *_):
"""Keep scrubber max in sync with duration."""
try:
dur = float(self.duration_var.get())
self._scrub_scale.config(to=max(0.1, dur))
except Exception:
pass
# ── Graphic handles ───────────────────────────────────────────────
_HANDLE_SIZE = 7 # half-size of corner handle square
def _draw_handles(self):
"""Draw corner + edge handles on the canvas around _gfx_rect."""
c = self._preview_canvas
hs = self._HANDLE_SIZE
c.delete("handle")
x0, y0, x1, y1 = self._gfx_rect
# Draw bounding box
c.create_rectangle(x0, y0, x1, y1, outline=ACCENT,
width=1, dash=(4, 3), tags="handle")
# 8 handles: corners + edge midpoints
mx, my = (x0+x1)//2, (y0+y1)//2
positions = {
"nw": (x0, y0), "n": (mx, y0), "ne": (x1, y0),
"w": (x0, my), "e": (x1, my),
"sw": (x0, y1), "s": (mx, y1), "se": (x1, y1),
}
for tag, (hx, hy) in positions.items():
c.create_rectangle(hx-hs, hy-hs, hx+hs, hy+hs,
fill=ACCENT, outline="white",
tags=("handle", f"hdl_{tag}"))
def _hit_handle(self, x, y):
"""Return handle tag at (x,y) or None."""
hs = self._HANDLE_SIZE + 2
x0, y0, x1, y1 = self._gfx_rect
mx, my = (x0+x1)//2, (y0+y1)//2
positions = {
"nw": (x0, y0), "n": (mx, y0), "ne": (x1, y0),
"w": (x0, my), "e": (x1, my),
"sw": (x0, y1), "s": (mx, y1), "se": (x1, y1),
}
for tag, (hx, hy) in positions.items():
if abs(x - hx) <= hs and abs(y - hy) <= hs:
return tag
# Check interior drag (move whole rect)
if x0 < x < x1 and y0 < y < y1:
return "move"
return None
def _on_handle_press(self, event):
hit = self._hit_handle(event.x, event.y)
if hit:
self._handle_drag = {
"hit": hit, "sx": event.x, "sy": event.y,
"rect": list(self._gfx_rect)
}
else:
self._handle_drag = {}
def _on_handle_drag(self, event):
d = self._handle_drag
if not d:
return
dx = event.x - d["sx"]
dy = event.y - d["sy"]
x0, y0, x1, y1 = d["rect"]
hit = d["hit"]
MIN = 20
lock = getattr(self, "_lock_ar_var", None) and self._lock_ar_var.get()
orig_w = max(1, x1 - x0)
orig_h = max(1, y1 - y0)
ar = orig_w / orig_h # original aspect ratio
if hit == "move":
w, h = x1 - x0, y1 - y0
self._gfx_rect = [x0+dx, y0+dy, x0+dx+w, y0+dy+h]
else:
nx0, ny0, nx1, ny1 = x0, y0, x1, y1
if "n" in hit: ny0 = min(y1 - MIN, y0 + dy)
if "s" in hit: ny1 = max(y0 + MIN, y1 + dy)
if "w" in hit: nx0 = min(x1 - MIN, x0 + dx)
if "e" in hit: nx1 = max(x0 + MIN, x1 + dx)
if lock:
nw = nx1 - nx0
nh = ny1 - ny0
# Decide which axis drove the resize
if hit in ("n", "s"):
# height driven — adjust width
nw = nh * ar
cx = (nx0 + nx1) / 2
nx0 = cx - nw / 2
nx1 = cx + nw / 2
elif hit in ("w", "e"):
# width driven — adjust height
nh = nw / ar
cy = (ny0 + ny1) / 2
ny0 = cy - nh / 2
ny1 = cy + nh / 2
else:
# corner — use larger delta
if abs(dx) >= abs(dy):
nh = nw / ar
cy = (ny0 + ny1) / 2
ny0 = cy - nh / 2
ny1 = cy + nh / 2
else:
nw = nh * ar
cx = (nx0 + nx1) / 2
nx0 = cx - nw / 2
nx1 = cx + nw / 2
self._gfx_rect = [nx0, ny0, nx1, ny1]
self._draw_handles()
self._write_back_handles()
# Debounced re-render
if hasattr(self, "_preview_after") and self._preview_after:
self.after_cancel(self._preview_after)
self._preview_after = self.after(300, self._refresh_preview)
def _on_handle_release(self, event):
self._handle_drag = {}
def _write_back_handles(self):
"""Convert canvas rect to 1920x1080 coords and write to form vars."""
PW, PH = self._prev_w, self._prev_h
x0, y0, x1, y1 = self._gfx_rect
sx = 1920 / PW
sy = 1080 / PH
# Centre in full-res coords
cx = int(((x0+x1)/2) * sx)
cy = int(((y0+y1)/2) * sy)
w = int((x1-x0) * sx)
h = int((y1-y0) * sy)
if hasattr(self, "graphic_cx_var"):
self.graphic_cx_var.set(cx)
if hasattr(self, "graphic_cy_var"):
self.graphic_cy_var.set(cy)
if hasattr(self, "graphic_w_var"):
self.graphic_w_var.set(w)
if hasattr(self, "graphic_h_var"):
self.graphic_h_var.set(h)
def _rect_from_graphic_vars(self):
"""Compute canvas rect from graphic position vars."""
PW, PH = self._prev_w, self._prev_h
sx, sy = PW / 1920, PH / 1080
if hasattr(self, "graphic_cx_var"):
cx = self.graphic_cx_var.get() * sx
cy = self.graphic_cy_var.get() * sy
w = self.graphic_w_var.get() * sx
h = self.graphic_h_var.get() * sy
self._gfx_rect = [cx-w/2, cy-h/2, cx+w/2, cy+h/2]
# ── Preview generation (no PIL) ───────────────────────────────────
def _connect_preview_traces(self):
self.bg_picker.var.trace_add("write", lambda *_: self._update_preview_info())
self.audio_picker.var.trace_add("write", lambda *_: self._update_preview_info())
self.duration_var.trace_add("write", lambda *_: (
self._update_preview_info(), self._sync_scrubber_range()))
self.out_name_var.trace_add("write", lambda *_: self._update_preview_info())
self.out_format_var.trace_add("write", lambda *_: self._update_preview_info())
if hasattr(self, "_text_widget"):
self._text_widget.bind("<KeyRelease>", lambda *_: self._update_preview_info())
def _update_preview_info(self):
info = {
"bg_file": _short_path(self.bg_picker.get(), 25),
"audio_file": _short_path(self.audio_picker.get(), 25),
"duration": f"{self.duration_var.get()}s",
"out_name": self.out_name_var.get() or "intro_output",
"out_format": self.out_format_var.get() or "mp4_h264_aac",
"text": (self.text_var.get() or "").split('\n')[0][:30],
}
for key, val in info.items():
if key in self._preview_info:
self._preview_info[key].config(text=val or "—")
def _refresh_preview(self):
"""Kick off a background preview render."""
import threading as _thr
_thr.Thread(target=self._gen_preview_frame, daemon=True).start()
def _gen_preview_frame(self):
"""Render a preview frame via ffmpeg. Main graphic is placed within handle rect."""
try:
bg = self.bg_picker.get().strip()
if not bg:
self._log("Preview: no background selected", SUBTEXT)
return
t = float(self._scrub_var.get())
PW, PH = self._prev_w, self._prev_h
import subprocess as _sp, tempfile as _tmp, os as _os
# Handle rect in canvas coords
x0, y0, x1, y1 = [int(v) for v in self._gfx_rect]
gw = max(1, x1 - x0) # graphic width in canvas px
gh = max(1, y1 - y0) # graphic height in canvas px
gx = x0 # overlay x in canvas px
gy = y0 # overlay y in canvas px
tmpdir = _tmp.mkdtemp()
try:
fill = (self.fill_image_picker.get().strip()
if hasattr(self, "fill_image_picker") else "")
use_fill = (fill and
getattr(self, "preserve_ar_var", None) and
self.preserve_ar_var.get() and
getattr(self, "pad_mode_var", None) and
self.pad_mode_var.get() == "image")
bg_ext = _safe_getext(bg)
bg_gif = bg_ext == "gif"
bg_img = bg_ext in ("jpg","jpeg","png","bmp","webp","tiff","gif")
if bg_gif:
bg_flags = ["-ignore_loop", "0", "-i", bg]
elif bg_img:
bg_flags = ["-loop", "1", "-i", bg]
else:
bg_flags = ["-ss", str(t), "-i", bg]
inter_file = _os.path.join(tmpdir, "inter.png")
if use_fill:
fill_ext = _safe_getext(fill)
fill_gif = fill_ext == "gif"
fill_img = fill_ext in ("jpg","jpeg","png","bmp","webp","tiff","gif")
if fill_gif:
fill_flags = ["-ignore_loop", "0", "-i", fill]
elif fill_img:
fill_flags = ["-loop", "1", "-i", fill]
else:
# Video fill — seek to 3s for a representative still frame
fill_flags = ["-ss", "3", "-i", fill]
# Apply chromakey to fill if active
ck_active = (getattr(self, "ck_active", None)
and self.ck_active.get())
if ck_active:
ck_col = (getattr(self, "ck_color_var",
type("_",(),{"get":lambda s:"black"})()).get()
.strip() or "black")
ck_tol = float(getattr(self, "ck_tolerance_var",
type("_",(),{"get":lambda s:0.05})()).get())
ck_blend = float(getattr(self, "ck_blend_var",
type("_",(),{"get":lambda s:0.15})()).get())
fill_proc = (f"format=rgba,colorkey={ck_col}:{ck_tol:.3f}:{ck_blend:.3f},"
f"erosion,"
f"scale={gw}:{gh}:force_original_aspect_ratio=decrease")
else:
fill_proc = f"scale={gw}:{gh}:force_original_aspect_ratio=decrease"
# input 0 = bg (covers full canvas as background)
# input 1 = fill (chromakeyed, scaled to handle rect, overlaid on top)
filt = (
f"[0:v]scale={PW}:{PH}:force_original_aspect_ratio=increase,"
f"crop={PW}:{PH}[bg];"
f"[1:v]{fill_proc}[overlay];"
f"[bg][overlay]overlay={gx}:{gy}[v]"
)
cmd = (["ffmpeg", "-y"] + bg_flags + fill_flags +
["-filter_complex", filt, "-map", "[v]",
"-vframes", "1", inter_file])
else:
# No fill — just scale bg to canvas
cmd = (["ffmpeg", "-y"] + bg_flags +
["-vf", f"scale={PW}:{PH}", "-vframes", "1", inter_file])
r = _sp.run(cmd, capture_output=True, timeout=10)
if r.returncode != 0 or not _os.path.exists(inter_file):
err = r.stderr.decode(errors="replace")[-300:]
self._log(f"Preview failed: {err}", DANGER)
return
# PNG → GIF for tk.PhotoImage (no PIL)
gif_file = _os.path.join(tmpdir, "prev.gif")
gif_cmd = ["ffmpeg", "-y", "-i", inter_file,
"-filter_complex",
"split[a][b];[a]palettegen=stats_mode=single[p];[b][p]paletteuse",
gif_file]
r2 = _sp.run(gif_cmd, capture_output=True, timeout=10)
load_file = gif_file if (r2.returncode == 0 and
_os.path.exists(gif_file)) else inter_file
self._tmpdir_ref = tmpdir # keep alive for tk to read
self.after(0, self._show_preview_file, load_file)
except Exception:
import traceback; traceback.print_exc()
except Exception as e:
self._log(f"Preview error: {e}", DANGER)
def _show_preview_file(self, path):
"""Load image file into canvas (main thread). No PIL needed."""
try:
import os as _os
if not _os.path.exists(path):
return
photo = tk.PhotoImage(file=path)
pw, ph = photo.width(), photo.height()
PW, PH = self._prev_w, self._prev_h
if pw > PW or ph > PH:
s = max(1, max(round(pw/PW), round(ph/PH)))
photo = photo.subsample(s, s)
self._show_preview(photo)
except Exception as e:
self._log(f"Preview display error: {e}", DANGER)
def _show_preview(self, photo):
"""Display photo on canvas then redraw handles on top."""
c = self._preview_canvas
c.delete("bg_img")
PW, PH = self._prev_w, self._prev_h
c.create_image(PW//2, PH//2, image=photo, anchor="center", tags="bg_img")
c._photo_ref = photo # prevent GC
# Lower bg_img below handles
c.tag_lower("bg_img")
self._draw_handles()
# ── Form (split across tab parent frames) ────────────────────
def _build_form(self, t1, t2, t3, t4, t5, t6):
pad = dict(padx=20)
# ═ SOURCE FILES ══════════════════════════════════════════════
section_header(t1, "SOURCE FILES")
fc = styled_frame(t1, padx=14, pady=12)
fc.pack(fill="x", **pad, pady=(0, 4))
self.bg_picker = FilePicker(
fc, "Background (video or image: .mp4 .mov .avi .mkv .jpg .png .gif)",
[("Media", "*.mp4 *.mov *.avi *.mkv *.webm *.jpg *.jpeg *.png *.gif"),
("All", "*")],
initial_dir=str(self.bg_dir) if self.bg_dir.exists() else None)
self.bg_picker.pack(fill="x", pady=(0, 4))
# BG Start / End — shown only when a video file is selected
self._bg_range_frame = tk.Frame(fc, bg=CARD)
_bgr_inner = tk.Frame(self._bg_range_frame, bg=CARD)
_bgr_inner.pack(fill="x")
styled_label(_bgr_inner, "BG Start (s):", color=SUBTEXT
).pack(side="left", padx=(0, 6))
self.seek_var = tk.DoubleVar(value=0.0)
styled_spinbox(_bgr_inner, self.seek_var, 0.0, 3600.0,
width=7, step=0.5).pack(side="left", padx=(0, 24))
styled_label(_bgr_inner, "BG End (s):", color=SUBTEXT
).pack(side="left", padx=(0, 6))
self.bg_end_var = tk.DoubleVar(value=0.0)
_bge_sub = tk.Frame(_bgr_inner, bg=CARD); _bge_sub.pack(side="left")
styled_spinbox(_bge_sub, self.bg_end_var, 0.0, 3600.0,
width=7, step=0.5).pack(side="left")
styled_label(_bge_sub, " (0 = full)", color=SUBTEXT, size=8
).pack(side="left")
# Hidden by default; _on_bg_changed shows it for video files
# Aspect ratio preserve option (for graphic/image files)
ar_row = tk.Frame(fc, bg=CARD); ar_row.pack(fill="x", pady=(0, 2))
self._ar_row_ref = ar_row
self.preserve_ar_var = tk.BooleanVar(value=True)
tk.Checkbutton(ar_row, variable=self.preserve_ar_var,
bg=CARD, fg=ACCENT, selectcolor=ENTRY_BG,
activebackground=CARD, cursor="hand2"
).pack(side="left")
tk.Label(ar_row, text="Preserve aspect ratio",
font=("Courier New", 9, "bold"), fg=TEXT, bg=CARD
).pack(side="left")
# Pad mode — radio buttons + colour picker / fill image picker
# Entire sub-section is stored in _ar_sub so it can be shown/hidden
self._ar_sub = tk.Frame(fc, bg=CARD, padx=26)
# Row 1: radio buttons
pad_mode_row = tk.Frame(self._ar_sub, bg=CARD)
pad_mode_row.pack(fill="x", pady=(0, 3))
self.pad_mode_var = tk.StringVar(value="colour")
styled_label(pad_mode_row, "Padding fill:", color=SUBTEXT
).pack(side="left", padx=(0, 8))
tk.Radiobutton(pad_mode_row, text="Flat colour",
variable=self.pad_mode_var, value="colour",
bg=CARD, fg=TEXT, selectcolor=ENTRY_BG,
activebackground=CARD, activeforeground=ACCENT,
font=FONT_LABEL, cursor="hand2"
).pack(side="left", padx=(0, 12))
tk.Radiobutton(pad_mode_row, text="Overlay",
variable=self.pad_mode_var, value="image",
bg=CARD, fg=TEXT, selectcolor=ENTRY_BG,
activebackground=CARD, activeforeground=ACCENT,
font=FONT_LABEL, cursor="hand2"
).pack(side="left")
# Row 2a: colour picker (shown in "colour" mode)
self._pad_col_row = tk.Frame(self._ar_sub, bg=CARD)
self._pad_col_row.pack(fill="x", pady=(0, 2))
self._pad_col_lbl = styled_label(self._pad_col_row, "Colour:",
color=SUBTEXT)
self._pad_col_lbl.pack(side="left", padx=(0, 6))
self.pad_color_var = tk.StringVar(value="black")
self._pad_col_btn = ColourButton(self._pad_col_row, self.pad_color_var,
presets=["black", "white", "#111111", "#222222",
"gray", "#001020", "#200010"],
width=8)
self._pad_col_btn.pack(side="left")
# Row 2b: fill image picker (shown in "image" mode)
self.fill_image_picker = FilePicker(
self._ar_sub,
"Overlay image / Video (scaled to cover 1920×1080; main graphic centred on top)",
[("Images", "*.jpg *.jpeg *.png *.bmp *.webp *.tiff *.tif *.gif"),
("Video", "*.mp4 *.mov *.avi *.mkv *.webm *.m4v *.flv *.wmv"),
("All", "*")],
initial_dir=str(self.bg_dir) if self.bg_dir.exists() else None)
def _on_pad_mode(*_):
if self.pad_mode_var.get() == "colour":
self._pad_col_row.pack(fill="x", pady=(0, 2))
self.fill_image_picker.pack_forget()
else:
self._pad_col_row.pack_forget()
self.fill_image_picker.pack(fill="x", pady=(0, 2))
self.pad_mode_var.trace_add("write", _on_pad_mode)
self.fill_image_picker.var.trace_add("write", self._update_ck_visibility)
def _on_preserve_ar(*_):
if self.preserve_ar_var.get():
self._ar_sub.pack(fill="x", pady=(0, 4))
else:
self._ar_sub.pack_forget()
self.preserve_ar_var.trace_add("write", _on_preserve_ar)
# Show sub-section initially (preserve_ar defaults to True)
self._ar_sub.pack(fill="x", pady=(0, 4))
# ── Chromakey colour picker ──────────────────────────────────────
tk.Frame(fc, bg=BORDER, height=1).pack(fill="x", pady=(4, 4))
# Shown only when the background is a graphic with no alpha channel.
# The selected colour is treated as transparent via ffmpeg colorkey.
self._ck_frame = tk.Frame(fc, bg=CARD, padx=8, pady=6)
# Not packed at construction — shown when a graphic source is selected
self.ck_active = tk.BooleanVar(value=False) # tracks whether CK is in use
ck_hdr = tk.Frame(self._ck_frame, bg=CARD)
ck_hdr.pack(fill="x", pady=(0, 4))
tk.Label(ck_hdr,
text="\u26a0 No alpha channel detected \u2014 choose a transparency colour",
font=("Courier New", 8, "bold"), fg=WARNING, bg=CARD
).pack(side="left")
ck_row = tk.Frame(self._ck_frame, bg=CARD)
ck_row.pack(fill="x")
styled_label(ck_row, "Treat as transparent:", color=SUBTEXT
).pack(side="left", padx=(0, 8))
_CK_PRESETS = [
"black", "white", "green", "red", "blue",
"#00FF00", "#0000FF", "#FF00FF", "#00FFFF",
"cyan", "magenta",
]
self.ck_color_var = tk.StringVar(value="black")
ColourButton(ck_row, self.ck_color_var,
presets=_CK_PRESETS, width=12
).pack(side="left", padx=(0, 16))
# Second row: tolerance + blend sliders
ck_sliders = tk.Frame(self._ck_frame, bg=CARD)
ck_sliders.pack(fill="x", pady=(4, 0))
styled_label(ck_sliders, "Tolerance:", color=SUBTEXT
).pack(side="left", padx=(0, 6))
self.ck_tolerance_var = tk.DoubleVar(value=0.05)
self._ck_tol_lbl = styled_label(ck_sliders, "0.05", color=ACCENT, size=8)
tk.Scale(ck_sliders, from_=0.01, to=0.40, resolution=0.01,
orient="horizontal", variable=self.ck_tolerance_var,
length=100, showvalue=False,
bg=CARD, fg=TEXT, troughcolor=ENTRY_BG, sliderlength=14,
command=lambda v: self._ck_tol_lbl.config(text=f"{float(v):.2f}")
).pack(side="left", padx=(0, 4))
self._ck_tol_lbl.pack(side="left")
styled_label(ck_sliders, " Blend:", color=SUBTEXT
).pack(side="left", padx=(10, 6))
self.ck_blend_var = tk.DoubleVar(value=0.15)
self._ck_blend_lbl = styled_label(ck_sliders, "0.15", color=ACCENT, size=8)
tk.Scale(ck_sliders, from_=0.0, to=0.50, resolution=0.01,
orient="horizontal", variable=self.ck_blend_var,
length=100, showvalue=False,
bg=CARD, fg=TEXT, troughcolor=ENTRY_BG, sliderlength=14,
command=lambda v: self._ck_blend_lbl.config(text=f"{float(v):.2f}")
).pack(side="left", padx=(0, 4))
self._ck_blend_lbl.pack(side="left")
# Audio mode — order: background | provided | silent
styled_label(fc, "Audio Source", color=SUBTEXT).pack(anchor="w")
self.audio_source_var = tk.StringVar(value="background")
amode_row = tk.Frame(fc, bg=CARD); amode_row.pack(fill="x", padx=8, pady=2)
self._rb_bg = tk.Radiobutton(amode_row,
text="Original background track", value="background",
variable=self.audio_source_var,
bg=CARD, fg=TEXT, selectcolor=ENTRY_BG,
activebackground=CARD, activeforeground=ACCENT,
font=FONT_LABEL, cursor="hand2")
self._rb_bg.pack(side="left", padx=(0, 16))
self._rb_prov = tk.Radiobutton(amode_row,
text="Provided track", value="provided",
variable=self.audio_source_var,