-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathvideo_to_script2.py
More file actions
1911 lines (1588 loc) · 77.1 KB
/
Copy pathvideo_to_script2.py
File metadata and controls
1911 lines (1588 loc) · 77.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import argparse
import os
import time
import json
import re
import subprocess
from google import genai
import cv2 # For getting video duration
from scenedetect import detect, AdaptiveDetector
# 1. Configure command line arguments (removed --input_script)
def parse_arguments():
parser = argparse.ArgumentParser(description="Gemini Video Scene Labeling Tool")
# --input_script removed because the model now extracts characters from video itself
parser.add_argument("--video", required=True, help="Path to the video file")
parser.add_argument("--transcript", required=True, help="Path to Whisper output JSON file")
parser.add_argument("--output", required=True, help="Path to save the final script JSON")
return parser.parse_args()
# 2. Helper function: Extract JSON
def extract_json_from_response(text):
"""
Extract JSON content from Gemini response
Supports multiple formats:
1. ```json ... ``` code blocks
2. ``` ... ``` code blocks
3. Explanatory text followed by JSON
4. Pure JSON text
"""
# Method 1: Extract ```json ... ``` code blocks
match = re.search(r'```json\s*(.*?)\s*```', text, re.DOTALL)
if match:
try:
json.loads(match.group(1))
return match.group(1)
except json.JSONDecodeError:
pass # Continue trying other methods
# Method 2: Extract ``` ... ``` code blocks
match = re.search(r'```\s*(.*?)\s*```', text, re.DOTALL)
if match:
try:
json.loads(match.group(1))
return match.group(1)
except json.JSONDecodeError:
pass # Continue trying other methods
# Method 3: Find content between first { and last }
# Handle cases with explanatory text before JSON
first_brace = text.find('{')
last_brace = text.rfind('}')
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
extracted = text[first_brace:last_brace + 1]
# Verify extracted content is valid JSON
try:
json.loads(extracted)
return extracted
except json.JSONDecodeError:
pass # Continue trying other methods
# Method 4: Try to fix common JSON format issues
# For example: double braces, extra commas, comments, etc.
try:
cleaned_text = text
# 4.1 Fix double braces issue {{ → {
# Only replace at JSON start and end to avoid breaking string content
cleaned_text = cleaned_text.strip()
if cleaned_text.startswith('{{') and cleaned_text.endswith('}}'):
# Remove {{ from start and }} from end
cleaned_text = '{' + cleaned_text[2:-2] + '}'
elif cleaned_text.startswith('{{{') and cleaned_text.endswith('}}}'):
# Handle triple braces case
cleaned_text = '{{' + cleaned_text[3:-3] + '}}'
# 4.2 Fix illegal number formats (leading zeros issue)
# Convert 00.00, 01.5 etc. to 0.0, 1.5
# Match number format after colon (may have leading zeros)
def fix_leading_zeros(match):
number = match.group(1)
# Remove leading zeros, keep decimal point
if '.' in number:
# 00.00 -> 0.00, 01.5 -> 1.5
integer_part = number.split('.')[0]
decimal_part = number.split('.')[1]
# Remove leading zeros from integer part
integer_part = integer_part.lstrip('0') or '0'
return f': {integer_part}.{decimal_part}'
else:
# Integer
number_fixed = str(int(number))
return f': {number_fixed}'
# Match pattern: colon followed by number (may have leading zeros)
cleaned_text = re.sub(r':\s*(0\d+\.?\d*)', fix_leading_zeros, cleaned_text)
# 4.3 Remove possible comments
lines = cleaned_text.split('\n')
json_lines = []
in_string = False
for line in lines:
# Simple comment removal logic
if '//' in line and not in_string:
line = line.split('//')[0]
json_lines.append(line)
in_string = '"' in line
cleaned_text = '\n'.join(json_lines)
# Try to parse directly
json.loads(cleaned_text)
return cleaned_text
except:
pass
# Method 5: If all else fails, return original text
return text
# 3. Helper function: Scene detection
def detect_scenes(video_path):
"""
Detect scene transition points in video
Return list of scenes, each containing (start_time, end_time)
"""
print(f"--> Detecting scenes in video...")
# Pass video path directly, don't use open_video()
# detect() function handles video opening automatically, avoiding VideoStreamCv2 type issues
scene_list = detect(video_path, AdaptiveDetector())
# Convert to more usable format
scenes = []
for i, scene in enumerate(scene_list):
start_time = scene[0].get_seconds()
end_time = scene[1].get_seconds()
duration = end_time - start_time
scenes.append({
"scene_index": i + 1,
"start_time": start_time,
"end_time": end_time,
"duration": duration
})
print(f" Scene {i+1}: {start_time:.2f}s - {end_time:.2f}s (duration: {duration:.2f}s)")
print(f" ✅ Detected {len(scenes)} scenes")
return scenes
def merge_short_scenes(scenes, target_duration=8.0, min_duration=4.0):
"""
Merge scenes that are too short into adjacent scenes to make average duration close to target_duration
Args:
scenes: Original scene list
target_duration: Target average duration (seconds), default 8 seconds
min_duration: Minimum duration threshold, scenes shorter than this will be considered for merging
Returns:
Merged scene list
"""
print(f"\n--> Merging short scenes (target: {target_duration}s, min: {min_duration}s)...")
if not scenes:
return scenes
merged_scenes = []
i = 0
while i < len(scenes):
current_scene = scenes[i]
current_duration = current_scene["duration"]
# If current scene is too short, try merging with next scene
if current_duration < min_duration and i + 1 < len(scenes):
next_scene = scenes[i + 1]
merged_duration = current_duration + next_scene["duration"]
# Only merge if merged duration doesn't exceed target_duration * 1.5
if merged_duration <= target_duration * 1.5:
# Merge two scenes
merged = {
"scene_index": current_scene["scene_index"],
"start_time": current_scene["start_time"],
"end_time": next_scene["end_time"],
"duration": merged_duration,
"merged_from": [
current_scene["scene_index"],
next_scene["scene_index"]
]
}
merged_scenes.append(merged)
print(f" Merged scenes {current_scene['scene_index']} & {next_scene['scene_index']} "
f"({current_duration:.2f}s + {next_scene['duration']:.2f}s → {merged_duration:.2f}s)")
i += 2 # Skip the next scene that was merged
continue
# Don't merge, add directly
merged_scenes.append(current_scene)
i += 1
# Statistics
original_count = len(scenes)
merged_count = len(merged_scenes)
reduction_rate = (1 - merged_count / original_count) * 100
original_avg = sum(s["duration"] for s in scenes) / original_count
merged_avg = sum(s["duration"] for s in merged_scenes) / merged_count
print(f" ✅ Scenes: {original_count} → {merged_count} (reduced by {reduction_rate:.1f}%)")
print(f" Original avg duration: {original_avg:.2f}s")
print(f" Merged avg duration: {merged_avg:.2f}s")
print(f" API calls saved: {original_count - merged_count}")
return merged_scenes
# 4. Helper function: Read Whisper JSON and convert to text
def get_video_duration(video_path):
"""Get total video duration (seconds)"""
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise ValueError(f"Cannot open video: {video_path}")
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = frame_count / fps if fps > 0 else 0
cap.release()
return duration
def detect_video_aspect_ratio(video_path):
"""
Use ffprobe to detect video aspect ratio, simplified to landscape/portrait determination
Args:
video_path: Video file path
Returns:
Dictionary containing video aspect ratio information:
{
"width": Width,
"height": Height,
"aspect_ratio": "16:9" or "9:16" (only these two)
"ratio_decimal": Aspect ratio in decimal form
}
"""
try:
# Use ffprobe to get video stream information
cmd = [
'ffprobe',
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'json',
video_path
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
# Get width and height
width = int(data['streams'][0]['width'])
height = int(data['streams'][0]['height'])
# Determine landscape or portrait
# Landscape: width >= height (including square)
# Portrait: width < height
is_landscape = width >= height
if is_landscape:
# Landscape → 16:9 (1920x1080)
target_width = 1920
target_height = 1080
aspect_ratio = "16:9"
ratio_decimal = 1.777778
orientation = "Landscape"
else:
# Portrait → 9:16 (1080x1920)
target_width = 1080
target_height = 1920
aspect_ratio = "9:16"
ratio_decimal = 0.5625
orientation = "Portrait"
video_info = {
"width": target_width,
"height": target_height,
"aspect_ratio": aspect_ratio,
"ratio_decimal": ratio_decimal
}
print(f"\n{'='*60}")
print(f"--> Detected video aspect ratio information:")
print(f"{'='*60}")
print(f" Original resolution: {width}x{height}")
print(f" Video orientation: {orientation}")
print(f" Generated ratio: {aspect_ratio}")
print(f" Target resolution: {target_width}x{target_height}")
print(f"{'='*60}")
return video_info
except subprocess.CalledProcessError as e:
print(f"⚠️ ffprobe execution failed: {e}")
print(f" Using default ratio 16:9")
return {
"width": 1920,
"height": 1080,
"aspect_ratio": "16:9",
"ratio_decimal": 1.777778
}
except Exception as e:
print(f"⚠️ Failed to detect video aspect ratio: {e}")
print(f" Using default ratio 16:9")
return {
"width": 1920,
"height": 1080,
"aspect_ratio": "16:9",
"ratio_decimal": 1.777778
}
def filter_transcript_by_time(transcript_text, start_time, end_time):
"""Filter dialogue by time range"""
lines = transcript_text.strip().split('\n')
filtered_lines = []
for line in lines:
# Extract timestamp, e.g., "[0.00s -> 2.50s] Text"
match = re.match(r'\[(\d+\.?\d*)s\s*->\s*(\d+\.?\d*)s\]\s*(.*)', line)
if match:
line_start = float(match.group(1))
line_end = float(match.group(2))
text = match.group(3)
# If this time segment overlaps with current segment
if line_start < end_time and line_end > start_time:
filtered_lines.append(line)
return '\n'.join(filtered_lines) if filtered_lines else "No dialogue in this segment"
def load_and_format_whisper(json_path):
"""
Load Whisper-transcribed Chinese text
Note: Using --task transcribe, output is original Chinese text
Translation will be intelligently processed in subsequent steps using video context
"""
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
formatted_text = ""
# Compatible with Whisper native JSON format
segments = data.get('text', '')
segments_list = data.get('segments', [])
if not segments_list and 'text' in data:
return data['text']
for seg in segments_list:
start = seg.get('start', 0)
end = seg.get('end', 0)
text = seg.get('text', '').strip()
formatted_text += f"[{start:.2f}s -> {end:.2f}s] {text}\n"
return formatted_text
except Exception as e:
print(f"Warning: Could not parse Whisper JSON ({e}). Using raw file content.")
with open(json_path, 'r', encoding='utf-8') as f:
return f.read()
def smart_translate_transcript(client, myfile, chinese_transcript):
"""
Intelligently translate Chinese transcript text, handling omitted subjects
Analyze through video context to correctly infer Chinese sentences with omitted subjects
For example: "去吃饭" -> "He/She/I goes to eat" (determined based on characters in video)
"""
print(f"\n{'='*60}")
print(f"--> Smart Translation with Video Context")
print(f"{'='*60}")
prompt = f"""
You are translating Chinese dialogue from a video to English, with CRITICAL attention to context.
--- CHINESE TRANSCRIPT (WITH TIMESTAMPS) ---
{chinese_transcript}
--- YOUR TASK ---
Watch the video and translate each Chinese dialogue segment to English.
**CRITICAL RULE FOR CHINESE OMISSION SUBJECTS**:
Chinese frequently omits subjects (e.g., "去吃饭" = "[go] eat [rice]", "很高兴" = "[very] happy").
When translating to English, you MUST infer the correct subject based on:
1. **VISUAL CONTEXT**: Who is speaking in the video at that timestamp?
2. **CONVERSATION FLOW**: What was discussed before and after?
3. **CHARACTER IDENTITY**: Is the speaker the protagonist, supporting character, narrator?
4. **ACTION CONTEXT**: What are they doing in the scene?
**SUBJECT INFERENCE GUIDELINES**:
- If a male character is speaking: use "he"
- If a female character is speaking: use "she"
- If the speaker is referring to themselves: use "I"
- If speaking to someone: use "you"
- If describing others: use their names or "they"
- Preserve the original tone and emotion
**EXAMPLES**:
Chinese: "[0.5s -> 2.3s] 去吃饭吧"
Context: Female character speaking to male character
Translation: "[0.5s -> 2.3s] Let's go eat."
Chinese: "[2.5s -> 4.1s] 好的,没问题"
Context: Male character responding
Translation: "[2.5s -> 4.1s] Okay, no problem."
Chinese: "[5.0s -> 7.2s] 很高兴见到你"
Context: Woman speaking to another person
Translation: "[5.0s -> 7.2s] Very happy to meet you."
**OUTPUT FORMAT**:
Return the translated transcript in the SAME format as input:
[timestamp_start -> timestamp_end] English translation
Maintain ALL timestamps exactly as they appear in the Chinese transcript.
"""
print(" Sending translation request with video context...")
try:
response = client.models.generate_content(
model="gemini-3-pro-preview",
contents=[myfile, prompt]
)
if not response.text:
print(" ⚠️ No response for translation")
return chinese_transcript # Return original text
translated_text = response.text.strip()
print(f" ✅ Translation completed")
return translated_text
except Exception as e:
print(f" ⚠️ Translation failed: {e}")
print(f" Using original Chinese transcript")
return chinese_transcript # Return original text on error
def extract_character_names(client, myfile, translated_transcript, character_roster):
"""
Extract character names from translated transcript text and video
Establish mapping from character_id to real names
For example: @character_01 -> "Emma"
"""
print(f"\n{'='*60}")
print(f"--> Extracting Character Names from Dialogue")
print(f"{'='*60}")
prompt = f"""
You are analyzing a video's dialogue to extract ALL CHARACTER NAMES, TITLES, AND FORMS OF ADDRESS mentioned or used.
--- TRANSLATED TRANSCRIPT (ENGLISH) ---
{translated_transcript}
--- GLOBAL CHARACTER ROSTER ---
{json.dumps(character_roster, indent=2, ensure_ascii=False)}
--- YOUR TASK ---
Watch the video and extract ALL names, titles, and forms of address for each character from the dialogue.
**PRIORITY SYSTEM FOR CHARACTER NAMES**:
1. **NEVER REPLACE** names that came from on-screen labels (source: "on_screen_label")
2. **ADD NEW NAMES** from dialogue to the existing name collections
3. **CATEGORIZE** each name you find into the correct category
**INSTRUCTIONS**:
**A. Extract ALL Forms of Address from Dialogue**
For each character in the roster, find EVERY way they are referred to in dialogue:
1. **Direct Address**: When someone speaks TO them
- "Hey, Emma, come here!" → "Emma" (alias)
- "Ms. Smith, wait!" → "Ms. Smith" (title)
- "Mom, can you help?" → "Mom" (familial)
2. **References**: When someone speaks ABOUT them
- "Where did Sarah go?" → "Sarah" (alias)
- "The Doctor will see you now" → "Doctor" (role/title)
- "Your father is waiting" → "father" (familial reference)
3. **Self-Introduction**: When they introduce themselves
- "Hi, I'm Michael Chen" → "Michael Chen" (primary_name)
- "I'm Dr. Smith" → "Dr. Smith" (title)
4. **Third-Person References**: When referred to in third person
- "Tell the Detective I'm ready" → "Detective" (role/title)
**B. Categorize Each Name You Find**
For each name you extract, determine which category it belongs to:
- **primary_name**: Full formal name (e.g., "Emma Smith", "Michael Chen")
- **aliases**: Nicknames, first names only, shortened versions (e.g., "Emma", "Em", "Emmy", "Mike")
- **titles**: Formal titles and honorifics (e.g., "Mr.", "Ms.", "Dr.", "Professor", "Detective", "Officer", "Mr. Smith", "Dr. Chen")
- **roles**: Professional or story roles (e.g., "Doctor", "Teacher", "Protagonist", "Antagonist", "Detective")
- **familial**: Family-based names (e.g., "Mom", "Dad", "Mother", "Father", "Sarah's Mom", "John's Father")
**C. Add to Existing Collections**
- Check what names each character ALREADY has (from on-screen labels)
- ONLY ADD new names from dialogue - DO NOT replace existing names
- If a name from dialogue already exists in a category, don't add it again
- Track the SOURCE as "dialogue" for all names extracted from dialogue
**OUTPUT FORMAT**:
Return a JSON object that ADDS TO the existing name collections:
{{
"character_name_updates": {{
"@character_01": {{
"names_to_add": {{
"primary_name": null, // null means don't change existing
"aliases": ["Em", "Emmy"], // Add these if not already present
"aliases_sources": {{"Em": "dialogue", "Emmy": "dialogue"}},
"titles": ["Ms. Smith"], // Add these if not already present
"titles_sources": {{"Ms. Smith": "dialogue"}},
"roles": [], // No new roles from dialogue
"roles_sources": {{}},
"familial": ["Mom", "Sarah's Mom"], // Add these
"familial_sources": {{"Mom": "dialogue", "Sarah's Mom": "dialogue"}}
}},
"evidence": "Found in dialogue: 'Em' at [15.3s], 'Emmy' at [45.2s], 'Ms. Smith' at [67.8s], 'Mom' at [89.1s]"
}},
"@character_02": {{
"names_to_add": {{
"primary_name": "Michael Chen", // Set this if it was missing
"aliases": ["Mike"],
"aliases_sources": {{"Mike": "dialogue"}},
"titles": ["Dr. Chen", "Professor"],
"titles_sources": {{"Dr. Chen": "dialogue", "Professor": "dialogue"}},
"roles": [],
"roles_sources": {{}},
"familial": []
}},
"evidence": "Self-introduced as 'Michael Chen' at [25.8s], called 'Mike' at [90.2s], referred to as 'Dr. Chen' and 'Professor' in dialogue"
}}
}}
}}
**CRITICAL RULES**:
- **NEVER REPLACE** names that came from on-screen labels (source: "on_screen_label")
- **ONLY ADD** new names from dialogue - preserve existing names
- **EXTRACT EVERY FORM OF ADDRESS** - not just proper names, but titles, roles, and familial terms
- **CATEGORIZE CORRECTLY** - pay attention to the context to determine the right category
- **TRACK EVIDENCE** - list where in the dialogue each name was found
**CRITICAL OUTPUT RULES (MUST FOLLOW)**:
1. Output ONLY the JSON object above - nothing else
2. DO NOT include any explanations or text outside the JSON
3. Your response must start with '{{' and end with '}}'
4. NO markdown code blocks
5. Directly output the raw JSON only
**IMPORTANT**:
- Only use names that are ACTUALLY SPOKEN in the dialogue
- Do NOT invent names
- If uncertain, use descriptive placeholders
- Be precise about which character ID gets which name
"""
print(" Extracting character names from dialogue and video...")
try:
response = client.models.generate_content(
model="gemini-3-pro-preview",
contents=[myfile, prompt]
)
if not response.text:
print(" ⚠️ No response for name extraction")
return {}
cleaned_json = extract_json_from_response(response.text)
try:
name_updates_data = json.loads(cleaned_json)
character_name_updates = name_updates_data.get("character_name_updates", {})
print(f" ✅ Extracted name updates for {len(character_name_updates)} characters")
for char_id, update_info in character_name_updates.items():
names_to_add = update_info.get("names_to_add", {})
evidence = update_info.get("evidence", "")
print(f" {char_id}:")
if names_to_add.get("aliases"):
print(f" aliases: {names_to_add['aliases']}")
if names_to_add.get("titles"):
print(f" titles: {names_to_add['titles']}")
if names_to_add.get("familial"):
print(f" familial: {names_to_add['familial']}")
print(f" evidence: {evidence[:80]}...")
return character_name_updates
except json.JSONDecodeError as e:
print(f" ⚠️ Failed to parse name updates JSON: {e}")
return {}
except Exception as e:
print(f" ❌ Error extracting character names: {e}")
return {}
def identify_all_characters(client, myfile, full_whisper_text, video_duration):
"""
Phase 1: Global Character Identification
Analyze the entire video to identify all characters and create profiles
"""
print(f"\n{'='*60}")
print(f"--> PHASE 1: Global Character Identification")
print(f"{'='*60}")
prompt = f"""
You are analyzing an entire video to identify ALL unique characters that appear throughout.
--- AUDIO TRANSCRIPT (FULL VIDEO) ---
The complete speech transcript:
{full_whisper_text}
--- YOUR TASK ---
Watch the entire video and create a comprehensive character roster. For each unique person you see:
1. Assign them a UNIQUE ID in the format @character_XX (XX is 01, 02, 03, etc.)
2. **EXTRACT ALL CHARACTER NAMES AND TITLES** (CRITICAL - HIGHEST PRIORITY):
**A. From On-Screen Text Labels (FIRST APPEARANCE)**
- When a character FIRST APPEARS in the video, look carefully for text labels/overlays near their face
- Common formats: "Name", "Name - Role", "Name: Description", or similar text annotations
- These on-screen text labels are the MOST RELIABLE source
- Extract ALL information from on-screen text:
- Primary name (full name if available)
- Titles (Dr., Mr., Ms., Professor, Detective, etc.)
- Roles (Protagonist, Antagonist, Doctor, Teacher, etc.)
**B. Categorize the Names**
For each character, collect names in these categories:
- **primary_name**: Main/full name (e.g., "Emma Smith", "Michael Chen")
- **aliases**: Nicknames, shortened versions, first name only (e.g., "Em", "Emmy", "Mike")
- **titles**: Formal titles and honorifics (e.g., "Mr. Smith", "Dr. Chen", "Professor", "Detective")
- **roles**: Character roles in story or profession (e.g., "Protagonist", "Doctor", "Teacher")
- **familial**: Family-based names (e.g., "Mom", "Dad", "Sarah's Mom")
**C. Track Sources**
For EVERY name you collect, record where it came from:
- "on_screen_label" - From text overlay in video
- "dialogue" - From dialogue (if name is mentioned in this stage)
- "id_fallback" - No name found
**Example**:
If you see on-screen text "Emma Smith - Protagonist", and later in dialogue hear "Em", "Ms. Smith", and "Mom":
- primary_name: "Emma Smith" (source: on_screen_label)
- aliases: ["Emma", "Em"] (sources: Emma=on_screen_label, Em=dialogue)
- titles: ["Ms. Smith"] (source: dialogue)
- roles: ["Protagonist"] (source: on_screen_label)
- familial: ["Mom"] (source: dialogue)
3. Describe their identifying traits using the STRICT criteria below:
- **Physical Attributes**: Gender, approximate age, body type, skin tone
- **Hair**: Color, length, style (including changes throughout the video)
- **Face**: Facial hair, glasses, distinctive marks, scars
- **Clothing**: Document different outfits worn in different scenes (colors, patterns, style, layers)
- **Accessories**: Hats, jewelry, bags, glasses
- **Distinctive Features**: Any unique trait that separates them from others
4. List the scenes/time ranges where each character appears and what they wear in each
--- IMPORTANT RULES ---
- Assign IDs based on FIRST APPEARANCE in the video
- The same person must keep the SAME ID throughout the entire video, **EVEN IF THEIR CLOTHING CHANGES**
- Different people must have DIFFERENT IDs
- Use PERMANENT physical traits (face, hair, body) as primary identifiers
- Use CLOTHING as secondary identifier (document changes, but don't let it create a new ID)
- If a person changes clothes but has the same face/body/hair, use the SAME ID
- Only count REAL characters (ignore background extras)
- **PAY SPECIAL ATTENTION**: When a character first appears, PAUSE and look for text labels around them
--- OUTPUT FORMAT ---
Return a JSON object:
{{
"characters": [
{{
"character_id": "@character_01",
"names": {{
"primary_name": "Emma Smith",
"primary_source": "on_screen_label",
"aliases": ["Emma", "Em", "Emmy"],
"aliases_sources": {{"Emma": "on_screen_label", "Em": "dialogue", "Emmy": "dialogue"}},
"titles": ["Ms. Smith", "Mrs. Smith"],
"titles_sources": {{"Ms. Smith": "dialogue", "Mrs. Smith": "dialogue"}},
"roles": ["Protagonist", "Detective"],
"roles_sources": {{"Protagonist": "on_screen_label", "Detective": "dialogue"}},
"familial": ["Mom", "Sarah's Mom"],
"familial_sources": {{"Mom": "dialogue", "Sarah's Mom": "dialogue"}}
}},
"physical_attributes": "Gender, age, body type, skin tone",
"hair": "Color, length, style",
"face": "Facial features, glasses, marks",
"clothing_variations": [
{{"scene": "Scene 1", "description": "Light blue business suit, white blouse"}},
{{"scene": "Scene 5", "description": "Black leather jacket, skirt"}}
],
"accessories": "Glasses, jewelry, etc.",
"distinctive_features": "Unique identifying traits",
"first_appearance": "0.0s",
"scenes": ["Scene 1", "Scene 3", "Scene 5"]
}},
{{
"character_id": "@character_02",
"names": {{
"primary_name": "Michael",
"primary_source": "on_screen_label",
"aliases": ["Mike", "Mikey"],
"aliases_sources": {{"Mike": "dialogue", "Mikey": "dialogue"}},
"titles": ["Dr. Chen", "Professor Chen"],
"titles_sources": {{"Dr. Chen": "on_screen_label", "Professor Chen": "dialogue"}},
"roles": ["Supporting Character", "Doctor"],
"roles_sources": {{"Supporting Character": "on_screen_label", "Doctor": "dialogue"}},
"familial": [],
"familial_sources": {{}}
}},
"physical_attributes": "Male, 30s, athletic build",
"hair": "Platinum blonde, medium length, styled",
"face": "Clean-shaven, no glasses",
"clothing_variations": [
{{"scene": "Scene 1", "description": "Patterned grey jacket, white t-shirt"}},
{{"scene": "Scene 2", "description": "White suit, glasses"}}
],
"accessories": "None",
"distinctive_features": "Platinum blonde hair is key identifier",
"first_appearance": "0.0s",
"scenes": ["Scene 1", "Scene 2", "Scene 4"]
}}
]
}}
**IMPORTANT NOTES ON CHARACTER NAMES**:
**names object structure:**
- **primary_name**: The character's main/full name (e.g., "Emma Smith", "Michael Chen")
- **primary_source**: Where the primary name came from ("on_screen_label" / "dialogue" / "id_fallback")
- **aliases**: Alternative names the character is called (nicknames, shortened versions, first name only)
- Examples: "Em", "Emmy", "Mike", "Mikey"
- **titles**: Formal titles and honorifics
- Examples: "Mr. Smith", "Ms. Smith", "Dr. Chen", "Professor Chen", "Detective", "Officer"
- **roles**: Character roles in the story or profession
- Examples: "Protagonist", "Antagonist", "Doctor", "Teacher", "Detective"
- **familial**: Family-based names
- Examples: "Mom", "Dad", "Sarah's Mom", "John's Father"
***_sources fields**: For each name category, track where each name came from:
- "on_screen_label" - From text overlay in video
- "dialogue" - From dialogue (spoken by someone)
- "id_fallback" - No name found
**IF NO ON-SCREEN TEXT FOUND**: Set primary_name to empty string "", primary_source to "id_fallback", but keep collecting names from dialogue in the next stage
**CRITICAL OUTPUT RULES (MUST FOLLOW)**:
1. Output ONLY the JSON object above - nothing else
2. DO NOT include any explanations or text outside the JSON
3. Your response must start with '{{' and end with '}}'
4. NO markdown code blocks
5. Directly output the raw JSON only
"""
print(" Sending character identification request to Gemini...")
try:
response = client.models.generate_content(
model="gemini-3-pro-preview",
contents=[myfile, prompt]
)
if not response.text:
print(" ⚠️ No response for character identification")
return {}
cleaned_json = extract_json_from_response(response.text)
try:
char_data = json.loads(cleaned_json)
print(f" ✅ Identified {len(char_data.get('characters', []))} unique characters")
for char in char_data.get('characters', []):
print(f" - {char['character_id']}: {char.get('description', 'N/A')[:50]}...")
return char_data
except json.JSONDecodeError as e:
print(f" ⚠️ Failed to parse character JSON: {e}")
return {}
except Exception as e:
print(f" ❌ Error identifying characters: {e}")
return {}
def detect_major_scenes(client, myfile, video_duration):
"""
Phase 2: Major Scene Detection
Identify major scene changes in video (environment/location changes)
"""
print(f"\n{'='*60}")
print(f"--> PHASE 2: Major Scene Detection")
print(f"{'='*60}")
prompt = f"""
You are analyzing a video to identify MAJOR SCENES (locations/environments).
--- YOUR TASK ---
Watch the entire video and identify all major scenes where the environment/setting changes.
A "MAJOR SCENE" is defined by:
- LOCATION CHANGE: Moving to a different place (e.g., office → home → restaurant)
- LIGHTING CHANGE: Significant shift in lighting style (e.g., daylight → indoor artificial)
- SETTING CHANGE: Different background environment
NOT a major scene:
- Camera angle changes within the same room
- Different shot sizes of the same location
- Minor camera movements
For each major scene, provide:
1. Unique ID (major_scene_01, major_scene_02, etc.)
2. Start and end time
3. Description of the location/setting
4. Dominant lighting style
5. **ENVIRONMENT-ONLY REFERENCE DESCRIPTION** (CRITICAL - NEW)
For EACH major scene, create a detailed description of the ENVIRONMENT ONLY (NO people, NO characters).
This description will be used to generate a clean background reference image.
**CRITICAL: ENVIRONMENT ONLY - NO CHARACTERS**
- Describe the EMPTY ROOM/SPACE
- Do NOT include any people, characters, or figures
- Remove all references to characters
- Focus ONLY on: room, furniture, lighting, decor, atmosphere
**INCLUDE IN ENVIRONMENT DESCRIPTION**:
- ✅ Room layout and architecture (size, shape, ceiling height)
- ✅ Walls (color, material, texture)
- ✅ Flooring (type, color, pattern, grain)
- ✅ Windows (size, placement, style, view through them)
- ✅ Doors (type, position, handle style)
- ✅ Furniture (type, placement, colors, materials, shapes)
- ✅ Lighting fixtures (type, position, color temperature, intensity)
- ✅ Decorative elements (artwork, plants, rugs, curtains, objects)
- ✅ Atmosphere (lighting quality, mood, time of day)
**EXCLUDE FROM ENVIRONMENT DESCRIPTION**:
- ❌ All people, characters, figures
- ❌ Character actions or movements
- ❌ Character clothing or faces
- ❌ Dialogue or speech
--- OUTPUT FORMAT ---
Return a JSON object:
{{
"major_scenes": [
{{
"scene_id": "major_scene_01",
"start_time": 0.0,
"end_time": 65.5,
"duration": 65.5,
"location_type": "Luxury penthouse living room",
"setting_description": "Modern minimalist room with floor-to-ceiling windows, circular sofa",
"lighting_style": "Natural daylight from windows, cool ambient fill",
"color_palette": "Cool greys, whites, blues",
"environment_description": "A modern minimalist living room with floor-to-ceiling windows on the back wall offering a city view. The room features hardwood oak flooring with warm tone and horizontal grain patterns. A curved grey sectional sofa with textured fabric is positioned in the center facing the windows. The walls are painted in cool light grey with smooth matte finish. The ceiling height is approximately 10 feet with modern recessed lighting fixtures providing soft ambient illumination. A distinctive circular floor lamp with brass stand stands in the left foreground. White sheer curtains frame the windows. The overall atmosphere is bright and airy with soft natural daylight streaming through, creating gentle diffused lighting throughout the space."
}},
{{
"scene_id": "major_scene_02",
"start_time": 65.5,
"end_time": 156.8,
"duration": 91.3,
"location_type": "Office meeting room",
"setting_description": "Corporate boardroom with large table, window background",
"lighting_style": "Artificial indoor lighting, warm ceiling lights",
"color_palette": "Warm browns, golds, cream",
"environment_description": "A corporate boardroom with a large polished wooden conference table in the center. The room features floor-to-ceiling glass windows on one wall showing an office view. The walls are painted in warm cream with wood paneling accents. The flooring is dark hardwood with reflective finish. Three modern pendant lights with warm white LEDs hang above the table. Executive leather chairs surround the table. The overall atmosphere is professional and well-lit with warm artificial lighting from the ceiling fixtures."
}}
]
}}
**CRITICAL OUTPUT RULES (MUST FOLLOW)**:
1. Output ONLY a valid JSON object - nothing else
2. DO NOT include any explanations or text outside the JSON
3. Your response must start with '{{' (single brace) and end with '}}' (single brace)
4. NO markdown code blocks (no ```json ... ```)
5. Use standard JSON format (no trailing commas, all strings quoted)
6. DO NOT use double braces - use single braces only
7. **TIME FORMAT**: start_time, end_time, and duration MUST be numbers (like 0.0, 65.5), NOT strings (NOT "00:00", NOT "01:08")
**IMPORTANT**: The "environment_description" field MUST describe ONLY the empty room/space with NO characters.
"""
print(" Sending major scene detection request to Gemini...")
try:
response = client.models.generate_content(
model="gemini-3-pro-preview",
contents=[myfile, prompt]
)
if not response.text:
print(" ⚠️ No response for major scene detection")
return {}
# Debug: Save raw response
print(f"\n 📋 Raw Gemini Response (first 500 chars):")
print(f" {response.text[:500]}")
cleaned_json = extract_json_from_response(response.text)
# Debug: Display cleaned JSON
print(f"\n 📋 Cleaned JSON (first 500 chars):")
print(f" {cleaned_json[:500]}")
try:
scene_data = json.loads(cleaned_json)
print(f" ✅ Detected {len(scene_data.get('major_scenes', []))} major scenes")
for scene in scene_data.get('major_scenes', []):
start = parse_time_to_seconds(scene['start_time'])
end = parse_time_to_seconds(scene['end_time'])
print(f" - {scene['scene_id']}: {scene.get('location_type', 'N/A')} ({start}s - {end}s)")
# Check if environment_description exists
if scene.get('environment_description'):
print(f" ✓ Environment description generated ({len(scene['environment_description'])} chars)")
else:
print(f" ⚠️ WARNING: No environment_description found!")
return scene_data
except json.JSONDecodeError as e:
print(f" ⚠️ Failed to parse major scene JSON: {e}")
# Save complete response to file for debugging
debug_file = "major_scene_debug_response.txt"
try:
with open(debug_file, 'w', encoding='utf-8') as f:
f.write(f"=== RAW GEMINI RESPONSE ===\n")
f.write(response.text)
f.write(f"\n\n=== CLEANED JSON ===\n")
f.write(cleaned_json)
f.write(f"\n\n=== ERROR ===\n")
f.write(str(e))
print(f" 💾 Saved debug response to: {debug_file}")
except:
pass
return {}
except Exception as e:
print(f" ❌ Error detecting major scenes: {e}")
return {}
def classify_character_roles(client, myfile, character_roster, video_duration):
"""
Classify main and supporting characters based on screen time and importance
Args:
client: Gemini client
myfile: Video file
character_roster: Character roster
video_duration: Total video duration
Returns:
Updated character_roster with role_classification field added to each character
"""
print(f"\n{'='*60}")
print(f"--> Classifying Character Roles (Main/Supporting)")
print(f"{'='*60}")
prompt = f"""
You are analyzing a video to classify characters into MAIN CHARACTERS and SUPPORTING CHARACTERS.
--- CHARACTER ROSTER ---
{json.dumps(character_roster, indent=2, ensure_ascii=False)}
--- YOUR TASK ---
Watch the entire video and classify each character based on:
1. **Screen Time**:
- Main characters: Appear in MULTIPLE scenes, LONG total duration (>20% of video)
- Supporting characters: Appear in FEW scenes, SHORT total duration (<20% of video)
2. **Story Importance**:
- Main characters: Central to the plot, drive the story forward
- Supporting characters: Auxiliary roles, help or hinder main characters
3. **Dialogue/Interaction**: