File size: 51,338 Bytes
45faa4c
479d5b6
 
 
 
 
23fb8e6
479d5b6
23fb8e6
479d5b6
01d24bf
479d5b6
 
 
01d24bf
23fb8e6
 
479d5b6
 
 
 
c0668cc
479d5b6
c0668cc
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0668cc
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
c0668cc
 
479d5b6
 
 
 
 
45faa4c
479d5b6
 
45faa4c
479d5b6
97ed4cf
 
45faa4c
479d5b6
 
 
 
 
97ed4cf
b082bff
479d5b6
97ed4cf
479d5b6
 
 
 
 
e7589a4
97ed4cf
479d5b6
 
e7589a4
479d5b6
 
97ed4cf
 
479d5b6
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
97ed4cf
 
479d5b6
97ed4cf
479d5b6
97ed4cf
479d5b6
97ed4cf
479d5b6
97ed4cf
479d5b6
97ed4cf
479d5b6
 
 
97ed4cf
 
 
 
479d5b6
 
97ed4cf
23fb8e6
97ed4cf
 
 
 
479d5b6
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
b082bff
479d5b6
 
97ed4cf
 
 
479d5b6
 
 
97ed4cf
 
 
 
 
 
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
b082bff
479d5b6
97ed4cf
479d5b6
 
 
97ed4cf
479d5b6
 
 
97ed4cf
 
479d5b6
 
 
97ed4cf
479d5b6
 
97ed4cf
 
479d5b6
 
 
 
 
 
 
 
 
 
 
b082bff
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b082bff
479d5b6
 
 
b082bff
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b082bff
0a38b03
479d5b6
 
 
 
 
b082bff
97ed4cf
 
479d5b6
 
97ed4cf
479d5b6
97ed4cf
479d5b6
 
97ed4cf
479d5b6
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e7589a4
 
479d5b6
 
97ed4cf
479d5b6
 
97ed4cf
 
 
 
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b082bff
479d5b6
 
97ed4cf
 
 
479d5b6
 
97ed4cf
 
 
0a38b03
b082bff
479d5b6
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
b082bff
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45faa4c
479d5b6
 
 
 
 
 
 
 
 
 
45faa4c
479d5b6
 
 
97ed4cf
479d5b6
 
 
 
 
b082bff
479d5b6
 
b082bff
479d5b6
 
 
97ed4cf
479d5b6
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
97ed4cf
 
479d5b6
 
97ed4cf
 
479d5b6
97ed4cf
 
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
97ed4cf
479d5b6
 
 
 
 
97ed4cf
479d5b6
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
b082bff
479d5b6
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
 
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
 
479d5b6
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
97ed4cf
479d5b6
97ed4cf
479d5b6
 
 
 
97ed4cf
479d5b6
 
97ed4cf
479d5b6
97ed4cf
479d5b6
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
b082bff
479d5b6
 
 
 
 
 
 
 
 
 
b082bff
 
479d5b6
 
 
 
b082bff
479d5b6
 
b082bff
479d5b6
 
b082bff
479d5b6
 
 
 
 
 
 
 
 
 
b082bff
479d5b6
 
 
 
 
 
 
 
45faa4c
 
479d5b6
 
 
 
 
 
97ed4cf
479d5b6
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
b082bff
45faa4c
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
97ed4cf
b082bff
479d5b6
 
 
 
 
 
 
b082bff
479d5b6
 
 
 
 
 
 
 
b082bff
479d5b6
b082bff
479d5b6
 
 
 
 
 
 
 
 
b082bff
45faa4c
479d5b6
 
 
 
 
 
45faa4c
 
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
45faa4c
479d5b6
 
 
 
 
 
 
45faa4c
479d5b6
45faa4c
479d5b6
 
 
 
 
 
 
 
 
 
 
45faa4c
 
479d5b6
 
 
 
 
45faa4c
 
 
 
479d5b6
 
45faa4c
 
 
479d5b6
45faa4c
 
479d5b6
 
 
 
 
45faa4c
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b082bff
45faa4c
479d5b6
 
 
 
 
 
 
 
 
97ed4cf
479d5b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import os
import shutil # Added for directory cleanup
import requests
import io
import time
import re
import random
import tempfile # Added for use in create_clip
import math
import cv2
import numpy as np
import soundfile as sf
import torch
import gradio as gr
import pysrt
from bs4 import BeautifulSoup
from urllib.parse import quote
from PIL import Image, ImageDraw, ImageFont
from gtts import gTTS
from pydub import AudioSegment
from pydub.generators import Sine

# Import moviepy components correctly
try:
    from moviepy.editor import (
        VideoFileClip, AudioFileClip, ImageClip, concatenate_videoclips,
        CompositeVideoClip, TextClip, CompositeAudioClip
    )
    import moviepy.video.fx.all as vfx
    import moviepy.config as mpy_config
    # Set ImageMagick binary (adjust path if necessary for your environment)
    # Check if ImageMagick is available, otherwise TextClip might fail
    try:
        # Attempt to find ImageMagick automatically or use a common path
        # If running locally, ensure ImageMagick is installed and in your PATH
        # If on Hugging Face Spaces, add 'imagemagick' to a packages.txt file
        mpy_config.change_settings({"IMAGEMAGICK_BINARY": "/usr/bin/convert"}) # Common Linux path
        print("ImageMagick path set.")
        # You might need to verify this path works in your specific deployment environment
    except Exception as e:
        print(f"Warning: Could not configure ImageMagick path. TextClip might fail. Error: {e}")
        # Consider adding a fallback or disabling text if ImageMagick is essential and not found
except ImportError:
    print("Error: moviepy library not found. Please install it using 'pip install moviepy'.")
    # Optionally, exit or raise a more specific error if moviepy is critical
    exit() # Exit if moviepy is absolutely required

# Import Kokoro (ensure it's installed)
try:
    from kokoro import KPipeline
    # Initialize Kokoro TTS pipeline
    # Using 'en' as a placeholder, adjust 'a' if it was intentional and valid for Kokoro
    pipeline = KPipeline(lang_code='en')
    print("Kokoro Pipeline Initialized.")
except ImportError:
    print("Warning: Kokoro library not found. TTS generation will rely solely on gTTS.")
    pipeline = None
except Exception as e:
    print(f"Warning: Failed to initialize Kokoro Pipeline. TTS generation will rely solely on gTTS. Error: {e}")
    pipeline = None


# Global Configuration
# --- IMPORTANT: Replace placeholders with your actual keys or use environment variables ---
PEXELS_API_KEY = os.getenv('PEXELS_API_KEY', 'YOUR_PEXELS_API_KEY_HERE') # Use environment variable or replace
OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY', 'YOUR_OPENROUTER_API_KEY_HERE') # Use environment variable or replace
# --- ---

if PEXELS_API_KEY == 'YOUR_PEXELS_API_KEY_HERE' or OPENROUTER_API_KEY == 'YOUR_OPENROUTER_API_KEY_HERE':
    print("\n*** WARNING: API keys are not set. Please set PEXELS_API_KEY and OPENROUTER_API_KEY environment variables or replace the placeholders in the script. ***\n")

OPENROUTER_MODEL = "mistralai/mistral-small-3.1-24b-instruct:free" # Or choose another model
TEMP_FOLDER = "temp_video_processing"
OUTPUT_VIDEO_FILENAME = "final_video.mp4"
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
BACKGROUND_MUSIC_PATH = "background_music.mp3" # Ensure this file exists

# --- Global variables to be set by Gradio input ---
TARGET_RESOLUTION = (1920, 1080) # Default, will be updated
CAPTION_COLOR = "white" # Default, will be updated

# Helper Functions

def generate_script(user_input):
    """Generates a script using the OpenRouter API based on user input."""
    if not OPENROUTER_API_KEY or OPENROUTER_API_KEY == 'YOUR_OPENROUTER_API_KEY_HERE':
        print("Error: OpenRouter API Key not configured.")
        return None

    headers = {
        'Authorization': f'Bearer {OPENROUTER_API_KEY}',
        'HTTP-Referer': 'https://your-app-name.hf.space', # Optional: Replace with your app's URL if deployed
        'X-Title': 'AI Documentary Maker' # Optional
    }
    # Updated prompt based on original instructions
    prompt = f"""Short Documentary Script Generator Instructions:
If I say "use this," just output the script exactly as I gave it.
If I only give topics, generate a script based on them.
If I provide a full script, rewrite it without any changes.
Make everything short, simple, humorous, funny, and act serious but humorous.
Don't say anything off-topic.
Always include a funny statement to subscribe based on the video topic at the end.
Use normal conversational text like a normal person talking, avoid AI phrases, make statements humanized and normal conversational.
Always talk negative views, don't really want any positive statements.
The full script will be one video about one topic, so keep that in mind.

Formatting Rules:
Title in Square Brackets: Each section starts with a one-word title inside [ ] (max two words if necessary). This title will be used as a search term for Pexels footage.
Casual & Funny Narration: Each section has 5-15 words of narration. Keep it natural, funny, and unpredictable (not robotic, poetic, or rhythmic).
No Special Formatting: No bold, italics, or special characters. You are an assistant AI; your task is to create the script. You aren't a chatbot. So, don't write extra text.
Generalized Search Terms: If a term is too specific, make it more general for Pexels search.
Scene-Specific Writing: Each section describes only what should be shown in the video.
Output Only the Script: No extra text, just the script.

Example Output:
[North Korea]
Top 5 unknown facts about North Korea, maybe.
[Invisibility]
North Korea’s internet speed is so fast… it’s basically dial-up from 1998.
[Leadership]
Kim Jong-un once won an election with 100% votes… because who would vote against him?
[Magic]
North Korea discovered unicorns. They're delicious, apparently.
[Warning]
Subscribe now, or Kim Jong-un might send you a strongly worded letter.
[Freedom]
North Korean citizens enjoy unparalleled freedom... to agree with the government.

Now here is the Topic/script: {user_input}
"""
    data = {
        'model': OPENROUTER_MODEL,
        'messages': [{'role': 'user', 'content': prompt}],
        'temperature': 0.5, # Slightly increased for more variety in humor
        'max_tokens': 1000 # Reduced slightly, adjust if scripts get cut off
    }
    try:
        response = requests.post(
            'https://openrouter.ai/api/v1/chat/completions',
            headers=headers,
            json=data,
            timeout=45 # Increased timeout
        )
        response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
        response_data = response.json()
        if 'choices' in response_data and len(response_data['choices']) > 0:
            script_content = response_data['choices'][0]['message']['content']
            # Basic cleaning: remove potential preamble/postamble if the model adds it
            script_content = re.sub(r'^.*?\n*\[', '[', script_content, flags=re.DOTALL) # Remove text before first bracket
            script_content = script_content.strip()
            print(f"Generated Script:\n{script_content}") # Log the script
            return script_content
        else:
            print(f"Error: No choices found in OpenRouter response. Response: {response_data}")
            return None
    except requests.exceptions.RequestException as e:
        print(f"Error calling OpenRouter API: {e}")
        return None
    except Exception as e:
        print(f"An unexpected error occurred during script generation: {e}")
        return None

def parse_script(script_text):
    """Parses the generated script text into structured elements."""
    if not script_text:
        return []
    sections = {}
    current_title = None
    current_text = ""
    try:
        for line in script_text.splitlines():
            line = line.strip()
            if not line: # Skip empty lines
                continue
            match = re.match(r'^\[([^\]]+)\](.*)', line)
            if match:
                # If a title was being processed, save it
                if current_title is not None and current_text:
                    sections[current_title] = current_text.strip()

                current_title = match.group(1).strip()
                current_text = match.group(2).strip()
            elif current_title: # Append to the text of the current title
                current_text += " " + line # Add space between lines

        # Save the last section
        if current_title is not None and current_text:
            sections[current_title] = current_text.strip()

        elements = []
        if not sections:
             print("Warning: Script parsing resulted in no sections.")
             # Maybe try a simpler split if the regex fails?
             lines = [l.strip() for l in script_text.splitlines() if l.strip()]
             if len(lines) >= 2: # Basic fallback: assume first line is title, second is text
                 print("Attempting basic fallback parsing.")
                 title = lines[0].replace('[','').replace(']','')
                 narration = ' '.join(lines[1:])
                 sections[title] = narration

        print(f"Parsed Sections: {sections}") # Log parsed sections

        for title, narration in sections.items():
            if not title or not narration:
                print(f"Skipping empty section: Title='{title}', Narration='{narration}'")
                continue
            # Use title as media prompt
            media_element = {"type": "media", "prompt": title, "effects": "random"} # Use random Ken Burns
            # Calculate rough duration based on words
            words = narration.split()
            # Duration: Base 2s + 0.4s per word, capped at ~10s unless very long
            duration = min(10.0, max(3.0, 2.0 + len(words) * 0.4))
            tts_element = {"type": "tts", "text": narration, "voice": "en", "duration": duration} # Duration is approximate here
            elements.append(media_element)
            elements.append(tts_element)

        if not elements:
             print("Error: No elements created after parsing.")
        return elements
    except Exception as e:
        print(f"Error parsing script: {e}\nScript content was:\n{script_text}")
        return []

def search_pexels(query, api_key, media_type="videos"):
    """Searches Pexels for videos or images."""
    if not api_key or api_key == 'YOUR_PEXELS_API_KEY_HERE':
        print("Error: Pexels API Key not configured.")
        return None

    headers = {'Authorization': api_key}
    base_url = f"https://api.pexels.com/{media_type}/search"
    results = []
    # Search multiple pages for better results
    for page in range(1, 4): # Check first 3 pages
        try:
            params = {"query": query, "per_page": 15, "page": page}
            if media_type == "videos":
                params["orientation"] = "landscape" if TARGET_RESOLUTION[0] > TARGET_RESOLUTION[1] else "portrait"
            else: # images
                 params["orientation"] = "landscape" if TARGET_RESOLUTION[0] > TARGET_RESOLUTION[1] else "portrait"

            response = requests.get(base_url, headers=headers, params=params, timeout=15)
            response.raise_for_status()
            data = response.json()

            if media_type == "videos":
                media_items = data.get("videos", [])
                for item in media_items:
                    video_files = item.get("video_files", [])
                    # Prioritize HD or FHD based on target resolution, fallback to highest available
                    target_quality = "hd" # 1280x720 or 1920x1080
                    if TARGET_RESOLUTION[0] >= 1920 or TARGET_RESOLUTION[1] >= 1920:
                         target_quality = "fhd" # Often not available, but check anyway

                    link = None
                    for file in video_files:
                        # Pexels uses 'hd' for 1920x1080 too sometimes
                        if file.get("quality") == target_quality or file.get("quality") == "hd":
                            link = file.get("link")
                            break
                    if not link and video_files: # Fallback to the first link if specific quality not found
                         link = video_files[0].get("link")

                    if link:
                        results.append(link)

            else: # images
                media_items = data.get("photos", [])
                for item in media_items:
                    # Get original size, resizing happens later
                    link = item.get("src", {}).get("original")
                    if link:
                        results.append(link)

        except requests.exceptions.RequestException as e:
            print(f"Warning: Pexels API request failed for '{query}' (page {page}, {media_type}): {e}")
            # Don't stop searching on a single page failure
            continue
        except Exception as e:
            print(f"Warning: Unexpected error during Pexels search for '{query}': {e}")
            continue

    if results:
        print(f"Found {len(results)} Pexels {media_type} for '{query}'. Choosing one randomly.")
        return random.choice(results)
    else:
        print(f"Warning: No Pexels {media_type} found for query: '{query}'")
        return None

def search_google_images(query):
    """Searches Google Images (use cautiously, scraping can be fragile)."""
    print(f"Attempting Google Image search for: '{query}' (Use with caution)")
    try:
        search_url = f"https://www.google.com/search?q={quote(query)}&tbm=isch&safe=active" # Added safe search
        headers = {"User-Agent": USER_AGENT}
        response = requests.get(search_url, headers=headers, timeout=15)
        response.raise_for_status()
        soup = BeautifulSoup(response.text, "html.parser")

        image_urls = []
        # Google changes its structure often, this might need updates
        # Look for image data embedded in script tags or specific img tags
        # This is a common pattern, but highly unstable
        img_tags = soup.find_all("img")
        for img in img_tags:
            src = img.get("src") or img.get("data-src")
            if src and src.startswith("http") and "gstatic" not in src and "googlelogo" not in src:
                 # Basic check for valid image extensions or base64
                 if re.search(r'\.(jpg|jpeg|png|webp)$', src, re.IGNORECASE) or src.startswith('data:image'):
                     image_urls.append(src)

        # Limit the number of results to avoid processing too many
        image_urls = image_urls[:10] # Consider first 10 potential images

        if image_urls:
            print(f"Found {len(image_urls)} potential Google Images for '{query}'. Choosing one.")
            return random.choice(image_urls)
        else:
            print(f"Warning: No suitable Google Images found for query: '{query}'")
            return None
    except requests.exceptions.RequestException as e:
        print(f"Warning: Google Image search failed for '{query}': {e}")
        return None
    except Exception as e:
        print(f"Warning: Error parsing Google Image search results for '{query}': {e}")
        return None

def download_media(media_url, filename):
    """Downloads media (image or video) from a URL."""
    try:
        headers = {"User-Agent": USER_AGENT} # Use User-Agent for downloads too
        response = requests.get(media_url, headers=headers, stream=True, timeout=30) # Increased timeout for large files
        response.raise_for_status()
        with open(filename, 'wb') as f:
            for chunk in response.iter_content(chunk_size=8192):
                f.write(chunk)
        print(f"Successfully downloaded media to {filename}")

        # Verify image integrity and convert if necessary
        if filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')):
            try:
                img = Image.open(filename)
                img.verify() # Verify that it is, in fact an image
                # Re-open image for conversion check
                img = Image.open(filename)
                if img.mode != 'RGB':
                    print(f"Converting image {filename} to RGB.")
                    img = img.convert('RGB')
                    img.save(filename, "JPEG") # Save as JPEG for compatibility
                img.close()
            except (IOError, SyntaxError, Image.UnidentifiedImageError) as img_e:
                print(f"Warning: Downloaded file {filename} is not a valid image or is corrupted: {img_e}. Removing.")
                os.remove(filename)
                return None
        # Basic video check (can be expanded)
        elif filename.lower().endswith(('.mp4', '.mov', '.avi')):
             if os.path.getsize(filename) < 1024: # Check if file is too small (likely error)
                  print(f"Warning: Downloaded video file {filename} is suspiciously small. Removing.")
                  os.remove(filename)
                  return None

        return filename
    except requests.exceptions.RequestException as e:
        print(f"Error downloading media from {media_url}: {e}")
        if os.path.exists(filename):
            os.remove(filename)
        return None
    except Exception as e:
        print(f"An unexpected error occurred during media download: {e}")
        if os.path.exists(filename):
            os.remove(filename)
        return None

def generate_media(prompt, current_index=0, total_segments=1):
    """Generates media (video or image) based on the prompt."""
    safe_prompt = re.sub(r'[^\w\s-]', '', prompt).strip().replace(' ', '_')
    if not safe_prompt: safe_prompt = f"media_{current_index}" # Fallback filename
    print(f"\n--- Generating Media for Prompt: '{prompt}' ---")

    # --- Strategy ---
    # 1. Try Pexels Video
    # 2. Try Pexels Image
    # 3. If prompt contains 'news' or similar, try Google Image as fallback
    # 4. Use generic Pexels image as last resort

    # 1. Try Pexels Video
    video_url = search_pexels(prompt, PEXELS_API_KEY, media_type="videos")
    if video_url:
        video_file = os.path.join(TEMP_FOLDER, f"{safe_prompt}_video.mp4")
        if download_media(video_url, video_file):
            print(f"Using Pexels video for '{prompt}'")
            return {"path": video_file, "asset_type": "video"}
        else:
            print(f"Failed to download Pexels video for '{prompt}'.")

    # 2. Try Pexels Image
    image_url = search_pexels(prompt, PEXELS_API_KEY, media_type="photos")
    if image_url:
        image_file = os.path.join(TEMP_FOLDER, f"{safe_prompt}_image.jpg")
        if download_media(image_url, image_file):
            print(f"Using Pexels image for '{prompt}'")
            return {"path": image_file, "asset_type": "image"}
        else:
            print(f"Failed to download Pexels image for '{prompt}'.")

    # 3. Try Google Image (especially for specific/newsy terms)
    if "news" in prompt.lower() or "breaking" in prompt.lower() or len(prompt.split()) > 4: # Heuristic for specific terms
        google_image_url = search_google_images(prompt)
        if google_image_url:
            google_image_file = os.path.join(TEMP_FOLDER, f"{safe_prompt}_google_image.jpg")
            if download_media(google_image_url, google_image_file):
                print(f"Using Google image for '{prompt}' as fallback.")
                return {"path": google_image_file, "asset_type": "image"}
            else:
                print(f"Failed to download Google image for '{prompt}'.")

    # 4. Fallback to generic Pexels images
    print(f"Could not find specific media for '{prompt}'. Using generic fallback.")
    fallback_terms = ["abstract", "technology", "texture", "nature", "cityscape"]
    random.shuffle(fallback_terms) # Try different fallbacks
    for term in fallback_terms:
        fallback_url = search_pexels(term, PEXELS_API_KEY, media_type="photos")
        if fallback_url:
            fallback_file = os.path.join(TEMP_FOLDER, f"fallback_{term}_{current_index}.jpg")
            if download_media(fallback_url, fallback_file):
                print(f"Using fallback Pexels image ('{term}')")
                return {"path": fallback_file, "asset_type": "image"}
            else:
                 print(f"Failed to download fallback Pexels image ('{term}')")

    print(f"Error: Failed to generate any media for prompt: '{prompt}'")
    return None # Failed to get any media

def generate_tts(text, voice="en"):
    """Generates Text-to-Speech audio using Kokoro or gTTS."""
    safe_text = re.sub(r'[^\w\s-]', '', text[:15]).strip().replace(' ', '_')
    if not safe_text: safe_text = f"tts_{random.randint(1000, 9999)}"
    file_path = os.path.join(TEMP_FOLDER, f"{safe_text}.wav")

    # Attempt Kokoro first if available
    if pipeline:
        try:
            print(f"Generating TTS with Kokoro for: '{text[:30]}...'")
            # Kokoro specific voice if needed, 'en' might map internally or use a default
            # The original code used 'af_heart' for 'en', let's try that logic
            kokoro_voice = 'af_heart' if voice == 'en' else voice # Adjust if Kokoro has different voice codes
            generator = pipeline(text, voice=kokoro_voice, speed=0.95, split_pattern=r'\n+|[.!?]+') # Adjust speed/split
            audio_segments = [audio for _, _, audio in generator]

            if not audio_segments:
                 raise ValueError("Kokoro returned no audio segments.")

            # Ensure segments are numpy arrays before concatenating
            valid_segments = [seg for seg in audio_segments if isinstance(seg, np.ndarray) and seg.size > 0]

            if not valid_segments:
                 raise ValueError("Kokoro returned empty or invalid audio segments.")

            full_audio = np.concatenate(valid_segments) if len(valid_segments) > 0 else valid_segments[0]

            # Ensure audio is float32 for soundfile
            if full_audio.dtype != np.float32:
                full_audio = full_audio.astype(np.float32)
                # Normalize if needed (Kokoro might output integers)
                max_val = np.max(np.abs(full_audio))
                if max_val > 1.0:
                    full_audio /= max_val

            sf.write(file_path, full_audio, 24000) # Kokoro typically outputs at 24kHz
            print(f"Kokoro TTS generated successfully: {file_path}")
            return file_path
        except Exception as e:
            print(f"Warning: Kokoro TTS failed: {e}. Falling back to gTTS.")
            # Fall through to gTTS

    # Fallback to gTTS
    try:
        print(f"Generating TTS with gTTS for: '{text[:30]}...'")
        tts = gTTS(text=text, lang=voice, slow=False) # Use voice as language code for gTTS
        # Save as mp3 first, then convert
        mp3_path = os.path.join(TEMP_FOLDER, f"tts_{safe_text}.mp3")
        tts.save(mp3_path)
        audio = AudioSegment.from_mp3(mp3_path)
        # Export as WAV for consistency with moviepy
        audio.export(file_path, format="wav")
        os.remove(mp3_path) # Clean up temporary mp3
        print(f"gTTS TTS generated successfully: {file_path}")
        return file_path
    except Exception as e:
        print(f"Error: gTTS also failed: {e}. Generating silence.")
        # Final fallback: generate silence
        try:
            # Estimate duration based on text length (similar to parsing logic)
            words = text.split()
            duration_seconds = min(10.0, max(3.0, 2.0 + len(words) * 0.4))
            samplerate = 24000 # Match Kokoro's typical rate
            num_samples = int(duration_seconds * samplerate)
            silence = np.zeros(num_samples, dtype=np.float32)
            sf.write(file_path, silence, samplerate)
            print(f"Generated silence fallback: {file_path} ({duration_seconds:.1f}s)")
            return file_path
        except Exception as silence_e:
             print(f"Error: Failed even to generate silence: {silence_e}")
             return None # Complete failure

def apply_kenburns_effect(clip, target_resolution, effect_type="random"):
    """Applies a Ken Burns effect (zoom/pan) to an ImageClip."""
    target_w, target_h = target_resolution
    # Ensure clip has dimensions (might be needed if ImageClip wasn't fully initialized)
    if not hasattr(clip, 'w') or not hasattr(clip, 'h') or clip.w == 0 or clip.h == 0:
         print("Warning: Clip dimensions not found for Ken Burns effect. Using target resolution.")
         # Attempt to get frame to determine size, or default
         try:
              frame = clip.get_frame(0)
              clip.w, clip.h = frame.shape[1], frame.shape[0]
         except:
              clip.w, clip.h = target_w, target_h # Fallback

    # Resize image to cover target area while maintaining aspect ratio
    clip_aspect = clip.w / clip.h
    target_aspect = target_w / target_h

    if clip_aspect > target_aspect: # Image is wider than target
        new_height = target_h
        new_width = int(new_height * clip_aspect)
    else: # Image is taller than target
        new_width = target_w
        new_height = int(new_width / clip_aspect)

    # Resize slightly larger than needed for the effect
    base_scale = 1.20 # Zoom factor range
    zoom_width = int(new_width * base_scale)
    zoom_height = int(new_height * base_scale)

    # Use PIL for initial resize - often better quality for large changes
    try:
        pil_img = Image.fromarray(clip.get_frame(0)) # Get frame as PIL image
        resized_pil = pil_img.resize((zoom_width, zoom_height), Image.Resampling.LANCZOS)
        resized_clip = ImageClip(np.array(resized_pil)).set_duration(clip.duration)
        clip = resized_clip # Use the better resized clip
        clip.w, clip.h = zoom_width, zoom_height # Update dimensions
    except Exception as pil_e:
        print(f"Warning: PIL resize failed ({pil_e}). Using moviepy resize.")
        clip = clip.resize(newsize=(zoom_width, zoom_height))


    # Calculate max offsets for panning
    max_offset_x = max(0, clip.w - target_w)
    max_offset_y = max(0, clip.h - target_h)

    # Define effect types
    available_effects = ["zoom-in", "zoom-out", "pan-left", "pan-right", "pan-up", "pan-down", "slow-zoom"]
    if effect_type == "random":
        effect_type = random.choice(available_effects)
    print(f"Applying Ken Burns effect: {effect_type}")

    # Determine start/end zoom and center positions based on effect
    start_zoom, end_zoom = 1.0, 1.0
    start_center_x, start_center_y = clip.w / 2, clip.h / 2
    end_center_x, end_center_y = clip.w / 2, clip.h / 2

    if effect_type == "zoom-in":
        start_zoom = 1.0
        end_zoom = 1 / base_scale # Zoom factor applied to crop size
    elif effect_type == "zoom-out":
        start_zoom = 1 / base_scale
        end_zoom = 1.0
    elif effect_type == "slow-zoom":
         start_zoom = 1.0
         end_zoom = 1 / 1.05 # Very subtle zoom in
    elif effect_type == "pan-left":
        start_center_x = target_w / 2
        end_center_x = clip.w - target_w / 2
        start_center_y = end_center_y = clip.h / 2 # Center vertically
    elif effect_type == "pan-right":
        start_center_x = clip.w - target_w / 2
        end_center_x = target_w / 2
        start_center_y = end_center_y = clip.h / 2
    elif effect_type == "pan-up":
        start_center_y = target_h / 2
        end_center_y = clip.h - target_h / 2
        start_center_x = end_center_x = clip.w / 2 # Center horizontally
    elif effect_type == "pan-down":
        start_center_y = clip.h - target_h / 2
        end_center_y = target_h / 2
        start_center_x = end_center_x = clip.w / 2
    # Add more effects like diagonal pans if desired

    def transform_frame(get_frame, t):
        frame = get_frame(t) # Get the frame from the (potentially PIL-resized) clip
        # Smooth interpolation (ease-in, ease-out)
        ratio = 0.5 - 0.5 * math.cos(math.pi * t / clip.duration) if clip.duration > 0 else 0

        current_zoom = start_zoom + (end_zoom - start_zoom) * ratio
        crop_w = int(target_w / current_zoom)
        crop_h = int(target_h / current_zoom)

        # Ensure crop dimensions are not larger than the frame itself
        crop_w = min(crop_w, clip.w)
        crop_h = min(crop_h, clip.h)

        current_center_x = start_center_x + (end_center_x - start_center_x) * ratio
        current_center_y = start_center_y + (end_center_y - start_center_y) * ratio

        # Clamp center position to avoid cropping outside the image boundaries
        min_center_x = crop_w / 2
        max_center_x = clip.w - crop_w / 2
        min_center_y = crop_h / 2
        max_center_y = clip.h - crop_h / 2

        current_center_x = max(min_center_x, min(current_center_x, max_center_x))
        current_center_y = max(min_center_y, min(current_center_y, max_center_y))

        # Perform the crop using cv2.getRectSubPix for subpixel accuracy
        # Ensure frame is contiguous array for cv2
        if not frame.flags['C_CONTIGUOUS']:
             frame = np.ascontiguousarray(frame)

        try:
            cropped_frame = cv2.getRectSubPix(frame, (crop_w, crop_h), (current_center_x, current_center_y))
            # Resize the cropped area to the final target resolution
            # Using LANCZOS4 for potentially better quality resizing
            final_frame = cv2.resize(cropped_frame, (target_w, target_h), interpolation=cv2.INTER_LANCZOS4)
            return final_frame
        except cv2.error as cv2_err:
             print(f"Error during cv2 operation in Ken Burns: {cv2_err}")
             print(f"Frame shape: {frame.shape}, Crop W/H: {crop_w}/{crop_h}, Center X/Y: {current_center_x}/{current_center_y}")
             # Fallback: return uncropped frame resized? Or black frame?
             return cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_LINEAR) # Fallback resize


    # Apply the transformation function to the clip
    return clip.fl(transform_frame, apply_to=['mask']) # Apply to mask if it exists


def resize_to_fill(clip, target_resolution):
    """Resizes a video clip to fill the target resolution, cropping if necessary."""
    target_w, target_h = target_resolution
    clip_w, clip_h = clip.w, clip.h

    if clip_w == 0 or clip_h == 0:
        print("Warning: Clip has zero dimensions before resize_to_fill. Cannot resize.")
        # Return a black clip of the target size?
        return ColorClip(size=target_resolution, color=(0,0,0), duration=clip.duration)


    clip_aspect = clip_w / clip_h
    target_aspect = target_w / target_h

    if math.isclose(clip_aspect, target_aspect, rel_tol=1e-3):
        # Aspect ratios are close enough, just resize
        print(f"Resizing video clip {clip.filename} to {target_resolution} (aspect match).")
        return clip.resize(newsize=target_resolution)
    elif clip_aspect > target_aspect:
        # Clip is wider than target aspect ratio, resize to target height and crop width
        print(f"Resizing video clip {clip.filename} to height {target_h} and cropping width.")
        clip = clip.resize(height=target_h)
        # Calculate amount to crop from each side
        crop_amount = (clip.w - target_w) / 2
        if crop_amount < 0: # Avoid negative crop
             print("Warning: Negative crop amount calculated in resize_to_fill (width). Resizing only.")
             return clip.resize(newsize=target_resolution)
        return clip.crop(x1=crop_amount, width=target_w)
    else:
        # Clip is taller than target aspect ratio, resize to target width and crop height
        print(f"Resizing video clip {clip.filename} to width {target_w} and cropping height.")
        clip = clip.resize(width=target_w)
        # Calculate amount to crop from top/bottom
        crop_amount = (clip.h - target_h) / 2
        if crop_amount < 0: # Avoid negative crop
             print("Warning: Negative crop amount calculated in resize_to_fill (height). Resizing only.")
             return clip.resize(newsize=target_resolution)
        return clip.crop(y1=crop_amount, height=target_h)


def add_background_music(final_video, bg_music_path=BACKGROUND_MUSIC_PATH, bg_music_volume=0.08):
    """Adds background music to the final video."""
    if not os.path.exists(bg_music_path):
        print(f"Warning: Background music file not found at {bg_music_path}. Skipping.")
        return final_video

    try:
        print("Adding background music...")
        bg_music = AudioFileClip(bg_music_path)

        if final_video.duration is None or final_video.duration <= 0:
             print("Warning: Final video has no duration. Cannot add background music.")
             return final_video
        if bg_music.duration is None or bg_music.duration <= 0:
             print("Warning: Background music has no duration. Skipping.")
             return final_video


        # Loop or trim background music to match video duration
        if bg_music.duration < final_video.duration:
            loops_needed = math.ceil(final_video.duration / bg_music.duration)
            print(f"Looping background music {loops_needed} times.")
            bg_music = concatenate_audioclips([bg_music] * loops_needed)

        # Trim to exact duration
        bg_music = bg_music.subclip(0, final_video.duration)

        # Adjust volume
        bg_music = bg_music.volumex(bg_music_volume)

        # Combine with existing audio (if any)
        video_audio = final_video.audio
        if video_audio:
            # Normalize main audio slightly? Optional.
            # video_audio = video_audio.volumex(1.0) # Keep original volume
            print("Mixing existing audio with background music.")
            mixed_audio = CompositeAudioClip([video_audio, bg_music])
        else:
            print("No existing audio found. Using only background music.")
            mixed_audio = bg_music

        # Set the new audio track
        final_video = final_video.set_audio(mixed_audio)
        print("Background music added successfully.")
        return final_video

    except Exception as e:
        print(f"Error adding background music: {e}")
        # Return the original video without crashing
        return final_video


def create_clip(media_path, asset_type, tts_path, duration=None, effects=None, narration_text=None, segment_index=0):
    """Creates a single video clip segment with media, audio, and optional captions."""
    print(f"\n--- Creating Clip Segment {segment_index} ---")
    print(f"Media: {media_path} ({asset_type})")
    print(f"TTS: {tts_path}")
    print(f"Narration: '{narration_text[:50]}...'")

    try:
        # Validate inputs
        if not media_path or not os.path.exists(media_path):
            print(f"Error: Media path not found or invalid: {media_path}")
            return None
        if not tts_path or not os.path.exists(tts_path):
            print(f"Error: TTS path not found or invalid: {tts_path}")
            # Attempt to use media without audio? Or fail? Let's fail for now.
            return None

        # Load audio first to determine duration
        audio_clip = AudioFileClip(tts_path)
        # Add slight fade out to avoid abrupt cuts
        audio_clip = audio_clip.audio_fadeout(0.2)
        target_duration = audio_clip.duration
        if target_duration is None or target_duration <= 0.1: # Check for valid duration
             print(f"Warning: Audio clip {tts_path} has invalid duration ({target_duration}). Estimating 3 seconds.")
             target_duration = 3.0 # Fallback duration
             # Recreate audio clip with fixed duration if possible? Or just use the duration.
             audio_clip = audio_clip.set_duration(target_duration)


        print(f"Audio Duration: {target_duration:.2f}s")

        # --- Create Video/Image Clip ---
        clip = None
        if asset_type == "video":
            try:
                clip = VideoFileClip(media_path, target_resolution=TARGET_RESOLUTION[::-1]) # Provide target res hint
                # Ensure video has audio track removed initially if we overlay TTS fully
                clip = clip.without_audio()

                # Resize/Crop to fill target resolution
                clip = resize_to_fill(clip, TARGET_RESOLUTION)

                # Loop or cut video to match audio duration
                if clip.duration < target_duration:
                    print(f"Looping video (duration {clip.duration:.2f}s) to match audio.")
                    clip = clip.loop(duration=target_duration)
                else:
                    # Start from a random point if video is longer? Or just take the start?
                    start_time = 0
                    # Optional: random start time if video is much longer
                    # if clip.duration > target_duration + 2:
                    #    start_time = random.uniform(0, clip.duration - target_duration)
                    print(f"Subclipping video from {start_time:.2f}s to {start_time + target_duration:.2f}s.")
                    clip = clip.subclip(start_time, start_time + target_duration)

                # Add fade in/out for smoother transitions
                clip = clip.fadein(0.3).fadeout(0.3)

            except Exception as video_e:
                print(f"Error processing video file {media_path}: {video_e}")
                # Fallback to a black screen?
                clip = ColorClip(size=TARGET_RESOLUTION, color=(0,0,0), duration=target_duration)

        elif asset_type == "image":
            try:
                # Use tempfile for converted image if needed (handled in download now)
                # Load image clip
                clip = ImageClip(media_path).set_duration(target_duration)

                # Apply Ken Burns effect
                clip = apply_kenburns_effect(clip, TARGET_RESOLUTION, effect_type=effects or "random")

                # Fades are good for images too
                clip = clip.fadein(0.3).fadeout(0.3)

            except Exception as img_e:
                 print(f"Error processing image file {media_path}: {img_e}")
                 # Fallback to a grey screen?
                 clip = ColorClip(size=TARGET_RESOLUTION, color=(50,50,50), duration=target_duration)
        else:
            print(f"Error: Unknown asset type '{asset_type}'")
            return None # Unknown type

        # Ensure clip has the correct duration after processing
        clip = clip.set_duration(target_duration)

        # --- Add Captions ---
        subtitle_clips = []
        if narration_text and CAPTION_COLOR != "transparent":
            print("Adding captions...")
            try:
                # Simple word splitting for timing (can be improved with proper SRT/timing info)
                words = narration_text.split()
                words_per_chunk = 5 # Adjust number of words per caption line
                chunks = [' '.join(words[i:i+words_per_chunk]) for i in range(0, len(words), words_per_chunk)]
                if not chunks: chunks = [narration_text] # Handle empty or short text

                chunk_duration = target_duration / len(chunks) if len(chunks) > 0 else target_duration

                # Calculate font size based on resolution (heuristic)
                font_size = int(TARGET_RESOLUTION[1] / 25) # Adjust divisor as needed

                # Position captions towards the bottom
                subtitle_y_position = int(TARGET_RESOLUTION[1] * 0.85) # Lower position

                for i, chunk_text in enumerate(chunks):
                    start_time = i * chunk_duration
                    # Ensure end time doesn't exceed clip duration
                    end_time = min((i + 1) * chunk_duration, target_duration)
                    # Avoid zero-duration captions
                    if end_time <= start_time: end_time = start_time + 0.1

                    # Create TextClip for the chunk
                    # Ensure font is available in the environment (Arial is common, but might need install)
                    # Added stroke for better visibility
                    txt_clip = TextClip(
                        chunk_text,
                        fontsize=font_size,
                        font='Arial-Bold', # Ensure this font is available or choose another like 'Liberation-Sans-Bold'
                        color=CAPTION_COLOR,
                        bg_color='rgba(0, 0, 0, 0.5)', # Slightly darker background
                        method='caption', # Wraps text
                        align='center',
                        stroke_color='black', # Black stroke
                        stroke_width=max(1, font_size // 20), # Stroke width relative to font size
                        size=(TARGET_RESOLUTION[0] * 0.85, None) # Limit width
                    ).set_start(start_time).set_duration(end_time - start_time).set_position(('center', subtitle_y_position))

                    subtitle_clips.append(txt_clip)

                # Composite the main clip with subtitles
                if subtitle_clips:
                    clip = CompositeVideoClip([clip] + subtitle_clips, size=TARGET_RESOLUTION)
                    print(f"Added {len(subtitle_clips)} caption segments.")

            except Exception as caption_e:
                # This often happens if ImageMagick or fonts are missing/misconfigured
                print(f"ERROR: Failed to create captions: {caption_e}")
                print("Check if ImageMagick is installed and configured, and if the font (e.g., Arial-Bold) is available.")
                # Continue without captions if they fail

        # Set the audio track
        clip = clip.set_audio(audio_clip)

        print(f"Clip Segment {segment_index} created successfully.")
        return clip

    except Exception as e:
        print(f"FATAL ERROR creating clip segment {segment_index}: {e}")
        import traceback
        traceback.print_exc() # Print detailed traceback for debugging
        # Return a short, silent black clip to avoid crashing the concatenation
        return ColorClip(size=TARGET_RESOLUTION, color=(0,0,0), duration=1.0).set_audio(None)


# Main Gradio Function
def generate_video(video_concept, resolution_choice, caption_option):
    """The main function called by Gradio to generate the video."""
    print("\n\n--- Starting Video Generation ---")
    print(f"Concept: {video_concept}")
    print(f"Resolution: {resolution_choice}")
    print(f"Captions: {caption_option}")

    global TARGET_RESOLUTION, CAPTION_COLOR
    # Set global config based on input
    if resolution_choice == "Short (9:16)":
        TARGET_RESOLUTION = (1080, 1920)
    else: # Default to Full HD
        TARGET_RESOLUTION = (1920, 1080)
    CAPTION_COLOR = "white" if caption_option == "Yes" else "transparent" # Use "transparent" to disable

    # --- Cleanup and Setup ---
    if os.path.exists(TEMP_FOLDER):
        print(f"Removing existing temp folder: {TEMP_FOLDER}")
        shutil.rmtree(TEMP_FOLDER)
    try:
        os.makedirs(TEMP_FOLDER)
        print(f"Created temp folder: {TEMP_FOLDER}")
    except OSError as e:
         print(f"Error creating temp folder {TEMP_FOLDER}: {e}")
         return f"Error: Could not create temporary directory. Check permissions. {e}" # Return error message to Gradio

    # --- Script Generation ---
    print("Generating script...")
    script = generate_script(video_concept)
    if not script:
        print("Error: Failed to generate script.")
        shutil.rmtree(TEMP_FOLDER) # Clean up
        return "Error: Failed to generate script from AI. Please try a different concept or check API keys." # Return error message

    # --- Script Parsing ---
    print("Parsing script...")
    elements = parse_script(script)
    if not elements:
        print("Error: Failed to parse script into elements.")
        shutil.rmtree(TEMP_FOLDER) # Clean up
        return "Error: Failed to parse the generated script. The script might be malformed." # Return error message

    # Pair media prompts with TTS elements
    paired_elements = []
    if len(elements) >= 2:
        for i in range(0, len(elements), 2):
            if i + 1 < len(elements) and elements[i]['type'] == 'media' and elements[i+1]['type'] == 'tts':
                paired_elements.append((elements[i], elements[i+1]))
            else:
                 print(f"Warning: Skipping mismatched elements at index {i}")

    if not paired_elements:
        print("Error: No valid media/TTS pairs found after parsing.")
        shutil.rmtree(TEMP_FOLDER) # Clean up
        return "Error: Could not find valid [Title]/Narration pairs in the script." # Return error message

    print(f"Found {len(paired_elements)} pairs of media prompts and narrations.")

    # --- Clip Generation Loop ---
    clips = []
    total_segments = len(paired_elements)
    for idx, (media_elem, tts_elem) in enumerate(paired_elements):
        print(f"\nProcessing Segment {idx+1}/{total_segments}: Prompt='{media_elem['prompt']}'")

        # 1. Generate Media (Video/Image)
        media_asset = generate_media(media_elem['prompt'], current_index=idx, total_segments=total_segments)
        if not media_asset or not media_asset.get('path'):
            print(f"Warning: Failed to generate media for '{media_elem['prompt']}'. Skipping segment.")
            # Option: Create a placeholder clip instead of skipping?
            # clips.append(ColorClip(size=TARGET_RESOLUTION, color=(20,0,0), duration=3.0)) # Short red flash?
            continue # Skip this segment

        # 2. Generate TTS
        tts_path = generate_tts(tts_elem['text'], tts_elem['voice'])
        if not tts_path:
            print(f"Warning: Failed to generate TTS for segment {idx}. Skipping segment.")
            # Option: Create clip without audio? Requires adjusting create_clip
            continue # Skip this segment

        # 3. Create MoviePy Clip Segment
        clip = create_clip(
            media_path=media_asset['path'],
            asset_type=media_asset['asset_type'],
            tts_path=tts_path,
            duration=tts_elem['duration'], # Duration hint (create_clip prioritizes actual audio length)
            effects=media_elem.get('effects', 'random'),
            narration_text=tts_elem['text'],
            segment_index=idx
        )

        if clip:
            clips.append(clip)
        else:
            print(f"Warning: Failed to create clip for segment {idx}. Skipping.")
            # Maybe add a fallback black clip here too?

    # --- Final Video Assembly ---
    if not clips:
        print("Error: No clips were successfully created.")
        shutil.rmtree(TEMP_FOLDER) # Clean up
        return "Error: Failed to create any video segments. Check logs for media/TTS/clip creation errors." # Return error message

    print(f"\nConcatenating {len(clips)} video clips...")
    try:
        # Concatenate all the generated clips
        final_video = concatenate_videoclips(clips, method="compose") # 'compose' handles transparency if needed
    except Exception as concat_e:
        print(f"Error during video concatenation: {concat_e}")
        shutil.rmtree(TEMP_FOLDER)
        return f"Error: Failed to combine video segments: {concat_e}"

    # --- Add Background Music ---
    final_video = add_background_music(final_video, bg_music_volume=0.08) # Adjust volume as needed

    # --- Write Output File ---
    print(f"Writing final video to {OUTPUT_VIDEO_FILENAME}...")
    try:
        # Write the final video file
        # Use preset 'medium' or 'slow' for better quality/compression ratio if time allows
        # Use 'libx264' for wide compatibility, 'aac' for audio codec
        # threads=4 can speed up encoding on multi-core CPUs
        final_video.write_videofile(
            OUTPUT_VIDEO_FILENAME,
            codec='libx264',
            audio_codec='aac',
            fps=24, # Standard frame rate
            preset='medium', # 'veryfast', 'fast', 'medium', 'slow', 'veryslow'
            threads=4, # Adjust based on CPU cores
            logger='bar' # Show progress bar
            )
        print("Final video written successfully.")
    except Exception as write_e:
        print(f"Error writing final video file: {write_e}")
        shutil.rmtree(TEMP_FOLDER)
        return f"Error: Failed to write the final video file: {write_e}"
    finally:
        # --- Cleanup ---
        # Close clips to release file handles (important on some OS)
        for clip in clips:
            clip.close()
        if final_video:
             final_video.close()
        if 'bg_music' in locals() and bg_music: # Close bg music if loaded
            bg_music.close()
        if 'audio_clip' in locals() and audio_clip: # Close last audio clip
            audio_clip.close()

        print(f"Cleaning up temporary folder: {TEMP_FOLDER}")
        shutil.rmtree(TEMP_FOLDER)


    print("--- Video Generation Complete ---")
    # Return the path to the generated video for Gradio
    return OUTPUT_VIDEO_FILENAME

# --- Gradio Interface Definition ---
with gr.Blocks(theme=gr.themes.Soft()) as demo:
    gr.Markdown(
        """
        # 🎬 AI Documentary Video Generator 🎥
        Enter a concept or topic, and the AI will generate a short, humorous documentary-style video.
        Configure API keys (Pexels, OpenRouter) and ensure `background_music.mp3` exists before running.
        """
    )
    with gr.Row():
        with gr.Column(scale=2):
            video_concept = gr.Textbox(
                label="Video Concept / Topic / Script",
                placeholder="e.g., 'The secret life of squirrels', 'Why cats secretly judge us', or paste a full script starting with [Title]...",
                lines=4
            )
            with gr.Row():
                 resolution = gr.Dropdown(
                     ["Full HD (16:9)", "Short (9:16)"],
                     label="Resolution",
                     value="Full HD (16:9)"
                 )
                 caption_option = gr.Dropdown(
                     ["Yes", "No"],
                     label="Add Captions",
                     value="Yes"
                 )
            generate_btn = gr.Button("✨ Generate Video ✨", variant="primary")

        with gr.Column(scale=3):
            output_video = gr.Video(label="Generated Video")
            status_message = gr.Textbox(label="Status", interactive=False) # To show errors or progress

    # Connect button click to the main function
    generate_btn.click(
        fn=generate_video,
        inputs=[video_concept, resolution, caption_option],
        outputs=[output_video] # Can also output to status_message if needed
        # Example with status: outputs=[output_video, status_message]
    )

# Launch the Gradio app
if __name__ == "__main__":
    # Check for background music file on startup
    if not os.path.exists(BACKGROUND_MUSIC_PATH):
        print(f"\n*** WARNING: Background music file '{BACKGROUND_MUSIC_PATH}' not found. Background music will be skipped. ***\n")
    demo.launch(debug=True) # debug=True provides more detailed logs