File size: 52,728 Bytes
d00e784
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import gradio as gr
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.models.qwen2_5_omni import Qwen2_5OmniForConditionalGeneration, Qwen2_5OmniProcessor
import warnings
import os
import time
import re
import base64
import datetime
import uuid
import logging
from typing import List, Dict, Tuple, Optional
from PIL import Image
from huggingface_hub import snapshot_download
from swift.llm import PtEngine, RequestConfig, InferRequest

# --- 依赖库检查 ---
try:
    import cv2
    from moviepy.editor import VideoFileClip, concatenate_videoclips
    from openai import OpenAI
    from google import genai
    from google.genai import types
except ImportError as e:
    print(f"❌ 缺少必要库: {e}")
    print("请运行: pip install opencv-python moviepy openai google-genai")
    cv2 = None
    VideoFileClip = None
    OpenAI = None
    genai = None

# --- 环境设置 ---
warnings.filterwarnings("ignore")
os.environ['PYTHONWARNINGS'] = 'ignore'
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
LOGGER = logging.getLogger(__name__)

# ==========================================
# PART 0: 配置常量 & Demo 数据
# ==========================================

# 1. 模型参数配置
MODEL_CONFIGS = {
    "sora-2": {
        "sizes": ["1792x1024", "1024x1792", "1280x720", "720x1280"],
        "seconds_range": {"minimum": 4, "maximum": 12, "step": 4, "value": 4},
        "seconds_label": "单镜时长 (Sora: 4/8/12秒)"
    },
    "sora-2-pro": {
        "sizes": ["1792x1024", "1024x1792", "1280x720", "720x1280"],
        "seconds_range": {"minimum": 4, "maximum": 12, "step": 4, "value": 4},
        "seconds_label": "单镜时长 (Sora Pro: 4/8/12秒)"
    },
    "veo-3.1": {
        "sizes": ["1080p", "720p"],
        "seconds_range": {"minimum": 4, "maximum": 8, "step": 2, "value": 4},
        "seconds_label": "单镜时长 (Veo: 4/6/8秒)"
    }
}

# 2. 提示词与风格
CONTINUITY_PROMPT = (
    "保持统一的视觉风格与世界观,场景与光影保持稳定,角色服装、发型、体型与表情连贯,仅根据剧情调整动作;"
    "如果有参考图片,请严格保持人物形象与参考图一致,人物站位不得变化,镜头衔接需流畅自然。"
    "旁白不需要朗读或配音,仅作为剧情提示使用。要求视频生成的最后一帧要展示所有人物的正面形象和此时的站位。"
)

STYLE_PROMPTS = {
    "Anime (二次元)": "整体画面要求:高质量二次元动漫渲染风格,角色为手绘动漫人物,肤色与材质为动画质感,背景为虚构的动画场景;禁止出现写实/真人或真实摄影元素。",
    "Realistic (写实)": "整体画面要求:高写实摄影风格,人物与环境光影细节丰富,材质与质感贴近真实世界,禁止出现卡通或夸张笔触,确保色彩与光线符合真实物理规律。",
    "Animated (动画/3D)": "整体画面要求:动画/卡通风格,支持二维或三维渲染,人物线条与轮廓清晰,色彩饱和且富有层次,可适当夸张动作与表情。",
    "Painterly (艺术/绘画)": "整体画面要求:艺术绘画风格,可呈现厚重笔触或水彩晕染质感,允许保留艺术性的纹理与笔法痕迹,整体色彩与构图需统一。",
    "Abstract (抽象/实验)": "整体画面要求:抽象/实验风格,鼓励运用超现实、故障艺术或非传统构图手法,可打破写实规律,突出视觉冲击力与创意表现。"
}
STYLE_KEYS = list(STYLE_PROMPTS.keys())

# 3. Demo 案例数据(保持不变)
DEMO_DATA = [
    {
        "file": "demo1.mp4",
        "title": "案例 1",
        "script": "【对话】:1. [0秒-9秒] (运镜:Handheld+远景-中近景-远景) 林间空地,苏洛焦躁踱步,主角静立;手持推进中近景,苏洛抓发踢石:“真不该答应秦飞,这么短哪找齐三个时光盒!”跺脚,切回远景。2. [9-18秒] (浅景深+远景-中景-远景) 主角走近,过肩中景虚焦问:“时光盒是什么?”苏洛回头,专业比划:“云丝控制组倒计时机关盒。”远景二人对站。3. [18-26秒] (Tilt+远景-近景-远景) 远景续讲,切近景苏洛学者状:“指定时间才能开,所以叫时光盒。”镜头下扫手势再回脸,远景收。4. [26-36秒] (Arc+远景-中近景-远景) 远景苏洛夸张,环绕中近景:“秦飞埋三盒说找不到,我当场拍胸口:风物家苏洛一小时全搞定!”下巴扬拍胸,远景仍骄傲。5. [36-44秒] (Shaky+远景-中景-远景) 主角中景揶揄:“看你这么急,没完成吧?”背景苏洛瞬间泄气肩耷,微晃暗示心虚,远景低头。6. [44-53秒] (Pan+远景-近景-远景) 远景垂头,近景苏洛搓衣角小声:“谁料他耍诈盒子远……帮我吗?”抬头平移恳求对视,远景视线交汇。7. [53-62秒] (Crash Zoom+远景-中景-远景) 远景主角点头微笑,急推中景特写苏洛惊喜:“太好了!那两个方向拜托你,余下我搞定!”捶胸指远,远景振作出发。【人设】苏洛:中等轻盈,大眼亚麻棕乱马尾,短夹克工具腰带上阵,情绪秒切藏不住。主角:中高挺拔,柔和深短发深衣简约,内敛冷静微点头。【场景】午后斑驳林间空地,碎石落叶静树,焦躁转轻松合作。【站位】1.苏洛空地中心来回,主角边缘远距。2.主角走近两三步面对面。3-5.保持近距离,苏洛对主角/侧身垂头。6.再对视。7.苏洛侧指远方。"
    },
    {
        "file": "demo2.mp4",
        "title": "案例 2",
        "script": "【对话】:1. [0-8秒](运镜类型:Handheld Camera Effect+全景镜头、中景镜头)\n故事情节:远景镜头,幽暗的遗迹室内,蒋风正俯身在一块散发着微光的古代石碑(指引图)前。主角从阴影中走出,站定在他身后几步远。镜头切换为手持拍摄的中景镜头,跟随主角的视线,画面有轻微晃动,聚焦在主角锐利的眼神上。主角双臂环抱,带着审视的口吻质问:“你在做什么?指引图是不能随便篡改的。”声音打破了室内的寂静。结尾回到远景,主角保持质问的姿态,蒋风的背影僵住。\n\n2. [8-16秒](运镜类型:Arc Shot+全景镜头、中景镜头)\n故事情节:远景镜头,蒋风缓缓转过身。镜头以一个平滑的弧度围绕蒋风移动,切换为中景镜头。他看到主角时明显一愣,双手下意识地抬起,掌心向前,做出一个无辜且防御的姿态,眼神慌乱地解释:“篡改指引图?不不不,你误会了。”他的表情诚恳又急切。结尾远景,两人对峙,气氛紧张。\n\n3. [16-24秒](运'运镜类型:Shallow Depth of Field+全景镜头、中近景镜头)\n故事情节:远景镜头,蒋风放下了手,姿态变得谦卑。镜头切换为中近景,焦点落在蒋风身上,他略带窘迫地笑了笑,背景中的主角身影变得模糊。他一边说一边用手比划着自己:“我才加入风物家没多久,哪有这个本事能篡改它。”结尾回到远景,蒋风仍在解释,主角静静地听着,没有打断。\n\n4. [24-33秒](运镜类型:Tilt Shot+全景镜头、特写镜头)\n故事情节:远景镜头,蒋风再次转向指引图。镜头给到蒋风的中近景,他伸出手指,小心翼翼地指向石碑上的一个发光符文,但并未触碰:“我只是想查看指引图上的身份印鉴。”镜头向下倾斜,给到他手指所指之处的符文一个特写,符文复杂而古老。主角的声音从画外传来,带着一丝疑惑:“身份印鉴?”结尾回到远景,主角微微探身,视线也落在了那个符文上。\n\n5. [33-42秒](运镜类型:Panning Shot+全景镜头、近景镜头)\n故事情节:远景镜头,两人都注视着指引图。镜头切换为近景,从蒋风的侧脸开始,他温和地解释着:“嗯,就是一种类似签名的东西。”镜头缓缓横移,扫过石碑上更多类似签名的印鉴,光芒流转。他的声音变得低沉而充满怀念:“在考古界,早期开荒的人员有权在指引图上留下自己的名字,我们称之为身份印鉴。”镜头移回,定格在他充满希冀的眼神上:“我想看看这些指引图上有没有我父亲的名字。”结尾回到远景,整个房间的氛围因这番话而悄然改变。\n\n6. [42-51秒](运镜类型:Lens Flare+全景镜头、中景镜头)\n故事情节:远景镜头,蒋风垂下目光。镜头切换为中景,他背对着石碑,仿佛陷入了久远的回忆,一道柔和的镜头光晕扫过画面,他眼神飘向远方,带着一丝不易察觉的落寞:“我父亲是主攻考古的风物家,但他常年在外勘察……我已经很久很久没见到他了。”结尾回到远景,主角的注意力已经完全从石碑转移到了蒋风身上。\n\n7. [51-59秒](运镜类型:Deep Depth of Field+全景镜头、中近景镜头)\n故事情节:远景镜头,蒋风转过头,重新看向主角。镜头切为中近景,景深拉远,我们能清晰看到前景中蒋风努力挤出一个微笑,眼神却流露着不确定,以及背景里主角严肃倾听的轮廓。蒋风说:“母亲说稷下不少的开荒考古是他完成的,我想看看是不是他真的来过。”结尾回到远景,蒋风的微笑显得有些无力。\n\n8. [59-67秒](运镜类型:Shallow Depth of Field+全景镜头、特写镜头)\n故事情节:远景镜头,室内一片沉寂。镜头推进到蒋风脸部的特写,极浅的景深模糊了周围的一切,只剩下他复杂的表情。他的笑容消失了,嘴唇微微颤抖:“虽然我相信母亲不会骗我,但……”他停顿了一下,低下头,用几不可闻的声音说出心底的委屈,“哪有人经常在外不回家的。”结尾远景,蒋风低着头,肩膀微微垮下。\n\n9. [67-74秒](运镜类型:Shaky Cam+全景镜头、中近景镜头)\n故事情节:远景镜头,主角打破了沉默。镜头切换为中近景,聚焦在主角身上,轻微的镜头晃动暗示着他内心的触动。他原本锐利的眼神已经完全柔和下来,取而代之的是理解与同情。他轻声问道:“那你找到答案了吗?”结尾远景,听到问话,蒋风缓缓抬起头。\n\n10. [74-82秒](运镜类型:Arc Shot+全景镜头、中景镜头)\n故事情节:远景镜头,两人视线交汇。镜头给到蒋风的中景,他轻轻摇头,眼中闪过一丝失望:“目前还没有,我想多找几个地方再下定论。”随即,他深吸一口气,鼓起勇气向前迈了一小步,镜头以一个微小的弧度跟随着他,增加了请求的郑重感。他恳切地问:“那个……可以拜托你帮我深入遗迹内部看看吗?”结尾远景,两人间的距离缩短了。\n\n11. [82-90秒](运镜类型:Shallow Depth of Field+全景镜头、近景镜头)\n故事情节:远景镜头,主角静待下文。镜头切到蒋风的近景,他有些难为情地低下头,看了看自己无力的双手,再抬头望向主角时,眼神充满了坦诚的无助:“以我的实力,里面的机关人我实在无法应付……更别说接近指引图了。”背景中的主角被虚化,突出了蒋风此刻的窘迫与孤立。结尾回到远景,蒋风的姿态显得格外渺小。\n\n12. [90-98秒](运镜类型:Handheld Camera Effect+全景镜头、中近景镜头)\n故事情节:远景镜头,蒋风等待着判决。镜头切为中近景,手持拍摄的画面极度稳定,仿佛连摄影师都屏住了呼吸。蒋风微微躬身,这是一个郑重的请求:“我想最后再确认一下……”他抬起眼,目光灼灼地直视主角,声音里带着颤音,“能请你帮忙完成我这个心愿吗?”结尾回到远景,空气仿佛凝固了,主角一动不动。\n\n13. [98-102秒](运镜类型:Deep Depth of Field+全景镜头、特写镜头)\n故事情节:远景镜头,主角终于有了动作。镜头给到主角面部特写,他沉默地审视着蒋风的眼睛,几秒钟的权衡之后,嘴角无奈地向上一撇,随即发出一声轻不可闻的叹息。景深拉开,我们能看到他身后不远处,蒋风紧张等待的模糊身影。主角终于开口,语气平淡却掷地有声:“行吧。”结尾回到远景,听到回答的蒋风如释重负地松了口气,紧绷的身体瞬间放松下来。\n【人物形象】:主角:身形挺拔,体态匀称有力,面部轮廓分明,眼神锐利如鹰。留着一头便于打理的深色短发,发丝间或夹杂风霜痕迹。身着深色调、材质耐磨的探险服,肩部和肘部有皮革补丁,腰间挂着若干实用工具包。气质沉稳老练,初期动作多为双臂环抱的审视姿态,后期眼神转为柔和,流露同情与无奈,是一位经验丰富、外冷内热的行动派。\n蒋风:身高略低于主角,体态偏瘦,书生气较重,面部线条柔和,眼神清澈但时常流露慌乱与不确定。发型是略显蓬乱的黑色中短发,似乎无暇打理。穿着一身崭新的“风物家”制服,款式简洁但略显宽大,与身形不甚贴合。气质真诚而笨拙,常有抬手、低头、窘迫微笑等下意识动作,在提及父亲时,会从紧张转为充满希冀与感伤的脆弱,是一位涉世未深的年轻后辈。\n【场景描述】:幽暗的古代遗迹室内,唯一的稳定光源来自一块散发着微光的石碑指引图,石壁上刻有古老符文。场景氛围从初始的紧张对峙,随着角色对话的深入,逐渐转变为充满感伤与理解的静谧与私密。\n【站位】:1:主角站在蒋风身后几步远处,蒋风俯身于石碑前。\n2:蒋风完全转过身,与主角正面相对,形成对峙。\n3:两人保持面对面的站位,距离不变。\n4:蒋风转身面向石碑,主角在其侧后方,视线投向石碑。\n5:两人大致并排,共同注视着石碑。\n6:蒋风背对石碑,面向空旷处;主角从侧面注视着蒋风。\n7:蒋风转身,再次与主角面对面站立。\n8:两人位置不变,蒋风低头,避开主角视线。\n9:蒋风抬头,与主角视线交汇,维持原有距离。\n10:蒋风向前迈出一小步,缩短了与主角的距离。\n11:两人位置不变,蒋风抬头直视主角。\n12:蒋风微微躬身,更显谦卑地仰视着主角。\n13:主角与蒋风保持着略近的面对面距离,主角站姿笔直,蒋风躬身等待。\n"
    },
    {
        "file": "demo3.mp4",
        "title": "案例 3",
        "script": "【对话】:1. [0-8]秒(Handheld Camera Effect+中近景镜头)\n故事情节:远景中,主角小心翼翼地靠近一具巨大的、半埋在瓦砾中的废弃机器,机器周身缠绕着微光的丝线。镜头推近至主角的中近景,他面带忧虑,试探性地向前伸了伸手,又缩了回来,最终开口问道:“你还好吗?”说完,他沉默地观察着,等待回应。镜头拉回远景,一人一机在废墟中对峙。\n\n2. [8-18]秒(Shallow Depth of Field+特写镜头)\n故事情节:远景中,废弃机器毫无动静。镜头切入机器光学感应器的特写,红光微弱地闪烁了几下,发出断续的电流声。感应器缓缓亮起,聚焦在主角身上,传来一阵沙哑的合成音:“谢……谢,我好多了,”镜头的焦点在它破损的金属外壳和裸露的线缆上游走,“这个躯壳似乎越来越难以承受我的意识了……”镜头回到远景,机器的灯光在昏暗中显得格外醒目。\n\n3. [18-26]秒(Panning Shot+中景镜头)\n故事情节:远景中,机器的姿态未变。镜头缓缓平移,以中景构图框住机器的上半部分,它发出几声清嗓子般的杂音,似乎在整理思绪:“我说到哪了?算了,”它的光学感应器转向主角,“不如你向我提问吧。来,你想了解什么?”镜头回到远景,主角依然保持着倾听的姿态。\n\n4. [26-36]秒(Deep Depth of Field+中远景镜头)\n故事情节:远景中,主角与机器的位置关系不变。镜头切为中远景,将一人一机都清晰地纳入画面,背景是广阔而荒凉的废墟。机器主动开始了话题,语气变得像一位学者:“我的研究对象是云丝,你知道吧?一开始我研究的只是用云丝牵拉傀儡……傀儡的躯干是云根所制,本身就是固化的云丝。”镜头回到远景。\n\n5. [36-44]秒(Arc Shot+近景镜头)\n故事情节:远景中,机器继续讲述。镜头开始围绕机器进行缓慢的弧形运动,切入它的近景,光学感应器闪烁着思索的光芒。“我意识到,自己在做的不过是用云丝拉扯另一条云丝罢了,到此还好……”它的声音里透出一丝自嘲。镜头回到远景,弧光灯在机器表面划过。\n\n6. [44-54]秒(Crash Zoom+特写镜头)\n故事情节:远景中,气氛开始变化。镜头突然快速推向机器的光学感应器,形成一个特写,红光变得明亮而急促。“但我又多想了一点……是什么牵拉着你我这副肉躯呢?肌肉?骨骼?”声音变得尖锐而充满蛊惑,“难道不是另一种云丝和云根吗?”镜头回到远景,主角下意识地后退了半步。\n\n7. [54-63]秒(Shaky Cam+中景镜头)\n故事情节:远景中,主角显得有些不安。镜头切换为手持摇晃效果,对准机器的中景,仿佛在感受它激动的情绪。“既然如此,我们的世界又和云丝编制的一场幻觉有何分别呢?”它的一根机械臂因过载而轻微抽搐了一下。镜头回到远景,那根抽搐的机械臂在静止的废墟中格外显眼。\n\n8. [63-71]秒(Tilt Shot+中近景镜头)\n故事情节:远景中,机器的宣讲仍在继续。镜头从中近景开始,从主角紧锁的眉头的脸上向下倾斜,扫过他紧握的拳头,最后停在地面上。“我们不过是提线木偶……举手投足都是决定好的剧本,”机器的声音回荡着,主角的视线也随之落在自己手上,仿佛在寻找看不见的丝线,“于是我假设了自己的设定。”镜头回到远景。\n\n9. [71-79]秒(Shallow Depth of Field+极端特写镜头)\n故事情节:远景中,机器的音调突然沉重下来。镜头切至极端特写,对准它外壳上一道深刻的裂痕,仿佛一道伤疤,背景完全虚化。“后来改变一切的……”它的声音里充满了悔恨与自负交织的复杂情绪。镜头回到远景,机器陷入短暂的沉默。\n\n10. [79-89]秒(Action Shot+近景镜头)\n故事情节:远景中,机器再次激动起来。镜头切换为模拟剧烈晃动的动作镜头,对准机器的近景,周身的云丝因能量波动而发光、绷紧。“是一次愚蠢的实验,我太急于论证这个世界是一场云丝编制的虚幻了!既然是一场虚幻,就能根据自己的意志改写!”伴随话语,几点电火花从它关节处迸射出来。镜头回到远景。\n\n11. [89-98]秒(Whip Pan+中景镜头)\n故事情节:远景中,机器的能量波动更强了。镜头从机器转向周围缠绕的云丝,再猛地甩回机器身上,形成一次快速摇摄。“一切都是经线和纬线,可以被牵拉、被抽取、被剥离、被绕制……”它用一种近乎癫狂的语气描述着。镜头回到远景,云丝的光芒渐渐暗淡。\n\n12. [98-107]秒(Tilt Shot+中远景镜头)\n故事情节:远景中,机器似乎耗尽了能量。镜头从中远景开始,自上而下缓缓扫过它庞大而残破的躯体,最终停在它被地面卡住的基座上。“用语言解释原理太难了……总之,”它的声音疲惫不堪,“我成功地把自己‘改写’成了这副模样。至于为什么卡在这动弹不得,又是另一个故事了。”镜头回到远景。\n\n13. [107-115]秒(Shallow Depth of Field+中景镜头)\n故事情节:远景中,主角静静地消化着信息。镜头切到机器的中景,它光学感应器里的光芒稳定下来,直视着主角,语气平静但充满挑战:“怎样,现在你愿意相信这个世界是虚假的吗?我说的还是很浅的一部分,这个世界比你想象的更深……”主角的表情在虚化的背景中模糊不清。镜头回到远景。\n\n14. [115-123]秒(Handheld Camera Effect+近景镜头)\n故事情节:远景中,主角没有回应。镜头以手持效果靠近主角的近景,捕捉他脸上怀疑与动摇交织的神情。机器的声音再次响起,带着一丝不耐烦:“还是不信?看来我需要列举更多的证据……”主角的视线从机器移开,望向远方,似乎在思考。镜头回到远景。\n\n15. [123-132]秒(Crane Shot+全景镜头)\n故事情节:远景中,机器的气场陡然增强。镜头如吊臂般向上拉升,俯瞰着以机器为中心、云丝如蛛网般铺开的废墟全景。“通过云丝,如今我可以像一只活在宇宙中心的蜘蛛一样探访世界的所有角落……”声音充满了全知全能的优越感。镜头回到最初的远景视角。\n\n16. [132-142]秒(Panning Shot+特写镜头)\n故事情节:远景中,机器开始列举证据。镜头平移,给到主角面部的特写,他的眼睛因惊讶而微微睁大。机器的声音仿佛从四面八方传来:“我知道在地下世界生活着会说话的小木篱,我听过它们梦中的呓语……我知道善良而勤劳的咕呱,也会在背地里筹划各种坏事……”镜头回到远景。\n\n17. [142-152]秒(Deep Depth of Field+中远景镜头)\n故事情节:远景中,主角的视线被远处的瀑布吸引。镜头切为深景深的中远景,机器在前景,远处的瀑布清晰可见。机器的声音指向那个方向:“我知道在我所处的这个瀑布后面,藏有一份卷轴……上面标识出了一份宝藏……”主角的目光锁定在瀑布上。镜头回到远景。\n\n18. [152-162]秒(Arc Shot+中近景镜头)\n故事情节:远景中,机器继续揭示秘密。镜头围绕主角进行弧形运动,主角的神情从惊讶变为专注。机器的声音继续引导:“而开启它的钥匙,就藏在东南山崖上,某个孤立石刻的底部……那里如今已经被地狼占据了……”主角的视线转向东南方向。镜头回到远景。\n\n19. [162-170]秒(Crash Zoom+近景镜头)\n故事情节:远景中,机器的讲述达到高潮。镜头猛地推向主角的近景,他脸上写满了震惊和决断。机器的声音变得急切而充满煽动性:“你还愣着干什么,快把它们找回来,好印证我的发现啊!”主角深吸一口气,眼神变得坚定。镜头回到远景,主角转身,似乎准备出发。\n【人物形象】:主角:身高约175cm,体态精悍矫健/面容年轻但显风霜,眼神深邃,表情丰富,黑色短发略显凌乱/身着便于行动的暗色系旅行者装束,材质耐磨,配有皮质护具和多功能腰包/气质沉稳内敛,初期动作谨慎试探,善于倾听与观察,后期情绪从忧虑、怀疑转为震惊与决断/他习惯在思考时将视线投向远方,紧握的拳头透露出内心的挣扎与力量。\n废弃机器:体型巨大,如一座小山般半埋于瓦砾中,结构复杂,非人形态/核心是单颗巨大的光学感应器,通过红光的明暗与闪烁频率表达情绪,无传统面部/金属外壳严重锈蚀破损,遍布裂痕与裸露的线缆,周身缠绕着蛛网般发光的“云丝”/声音是沙哑的合成音,语气从虚弱转为学者般的平静,再到癫狂、蛊惑与急切/作为被困的意识体,它的“动作”仅限于感应器灯光变化、电流声、机械臂的轻微抽搐和周身云丝的能量波动。\n【场景描述】:在一片广阔荒凉的废墟之中,光线昏暗,气氛神秘而压抑。视觉中心是一具半埋于地的巨大废弃机器,其周身缠绕的微光丝线是环境中唯一的动态光源。\n【站位】:1:主角在机器前方数米处站定,面朝机器。\n2:两者相对位置不变,机器光学感应器聚焦于主角。\n3:位置不变,主角保持倾听姿态。\n4:位置不变,一人一机远距离对峙。\n5:相对位置不变。\n6:主角向后退半步,与机器距离稍稍拉远。\n7:相对位置不变。\n8:主角低头看向自己的手,视线暂时离开机器。\n9:相对位置不变,主角抬头重新望向机器。\n10:相对位置不变。\n11:相对位置不变。\n12:相对位置不变。\n13:相对位置不变,机器感应器直视主角。\n14:主角身体朝向不变,但视线移向远方。\n15:相对位置不变,俯瞰视角下主角位于机器前方。\n16:主角视线回到前方。\n17:主角身体未动,头部与视线转向远方瀑布。\n18:主角身体与视线转向东南方向。\n19:主角转身,准备离开,背向机器。"
    },
    {
        "file": "demo4.mp4",
        "title": "案例 4",
        "script": "【对话】:1. [0-8秒] (Shallow Depth of Field+全景镜头、中近景镜头) 故事情节:远景展示公园小径上,白衫和主角并肩站立,一只胖乎乎的宠物“呱呱”趴在他们脚边的草地上气喘吁吁。镜头随即以浅景深推向白衫的中近景,他低头看着呱呱,脸上交织着无奈与宠溺,接着他抬眼望向主角,问道:“怎么样?呱呱有好好锻炼吗?” 结尾回到远景,主角正准备回答。\n2. [8-17秒] (Handheld Camera Effect+全景镜头、中景镜头) 故事情节:远景中,三人位置不变。镜头切换为手持效果下的中景,画面随着白衫的动作有轻微晃动。他无奈地摊开手,叹了口气:“没办法,之前太惯着它了。” 他的视线落在懒洋洋翻了个身的呱呱身上,语气里满是无可奈何:“现在没有吃的半步也不挪。” 结尾远景,白衫轻轻摇头,主角在一旁安静地听着。\n3. [17-26秒] (Arc Shot+全景镜头、中景镜头) 故事情节:远景展现整个场景。镜头开始围绕白衫进行弧线运动,他蹲下身,温柔地抚摸着呱呱的后背,语气放缓:“不过它今天起码完成了一点运动,晚上允许它多吃一点。” 镜头继续沿弧线转向一旁的主角,他看着这温情的一幕,微笑着点头附和:“今天运动的还不错。” 结尾远景,白衫蹲着,主角站着,形成一高一低的构图。\n4. [26-34秒] (Shallow Depth of Field+全景镜头、近景镜头) 故事情节:远景中,白衫依旧蹲在呱呱身边,主角静立一旁。镜头切入白衫的近景,背景完全虚化。他抚摸呱呱的动作没停,但眼神中浮现出真切的忧虑,声音也低沉下来,充满了担忧:“我知道运动很辛苦,但呱呱真的太胖了,我很怕它胖得生病。” 结尾远景,能看到主角脸上的笑容也收敛了,神情变得严肃。\n5. [34-42秒] (Tilt Shot+全景镜头、中景镜头) 故事情节:远景确认场景站位。镜头从中景开始,从地上心满意足地摇着尾巴的呱呱缓缓向上抬升,最终定格在刚站起身的白衫脸上。他脸上的忧虑一扫而空,转为一种故作爽朗的兴奋,对着呱呱大声宣布:“表现真不错。今晚它可以多吃一点!” 结尾远景,白衫高兴地拍了拍手。\n6. [42-51秒] (Shaky Cam+全景镜头、中近景镜头) 故事情节:远景中,主角看着兴奋的白衫。镜头切换为对准主角的中近景,轻微的摇晃反映出他内心的无语。他看着白衫,嘴角微微抽动,眼神里是哭笑不得的怀疑,几乎是对自己低语:“这么吃还能瘦么……” 随即他像是突然想起了正事,表情一正,视线重新聚焦在白衫身上,问道:“对了,你知道刘叶的情况怎么样了吗?” 结尾远景,主角向前迈了半步,成功转移了话题。\n7. [51-58秒] (Deep Depth of Field+全景镜头、中景镜头) 故事情节:远景中,两人相对而立。镜头切到白衫的中景,听到“刘叶”的名字,他先是愣了一下,随即眼神投向远方,景深变大,背景中的公园路径和行人都变得清晰。他皱眉思索片刻,然后略带歉意地摇了摇头,收回目光:“刘叶?看着是没长高,具体我就不知道了。” 结尾远景,白衫看着主角,摊了摊手表示不知情。\n8. [58-67秒] (Panning Shot+全景镜头、中远景镜头) 故事情节:远景中,三人保持着最后的站位。镜头给到白衫的中远景,他侧过身,抬手指向西边的方向,语气变得热心:“同学要是想知道,不如去找他问问吧。” 镜头随着他的手臂平滑地向西边摇摄,画面中出现一条通往远处开阔广场的小径。“你往西走,他就在那边的广场。” 结尾远景,镜头停下,主角顺着白衫所指的方向望去,若有所思。\n【人物形象】:白衫:身高约180cm,体态匀称修长,略带少年感。面部线条柔和,眉眼清秀,笑起来时眼角有细微纹路。发型是自然的黑色短发,刘海稍长,显得随性。身穿一件干净的白色棉麻衬衫和浅色休闲裤,脚踩白色运动鞋。气质温和亲切,与人交谈时真诚,对待宠物时眼神宠溺,会用摊手、挠头等小动作表达无奈,是个内心细腻的暖男。\n主角:身高与白衫相仿,身形挺拔,站姿稳重。面部轮廓分明,眼神锐利但内敛,表情变化细微,善于观察。发型为深色利落短发,显得干练。穿着深色系的休闲夹克,内搭纯色T恤,下身是工装裤,整体风格偏向实用和低调。气质沉稳,话不多,习惯通过嘴角抽动、眼神聚焦等微表情传递内心活动,行动果断且有目的性。\n呱呱:一只体型极度肥胖的宠物,身躯圆滚滚,四肢短小,趴在地上像个肉球。拥有一双憨态可掬的大眼睛,表情总是懒洋洋的。毛发短而顺滑,脖子上戴着一个简单的项圈。动作迟缓,极度懒散,没有食物的诱惑便不愿动弹,对主人的抚摸会表现出心满意足的样子,是一只被宠坏了的“吃货”。\n【场景描述】:午后阳光明媚的公园草坪,氛围从轻松宠溺的日常,转为对宠物健康的真切担忧,最终变为热心指路的平实交流。主要视觉元素是茵茵绿草、蜿蜒的小径,以及趴在地上一动不动的胖宠物。\n【站位】:1. 白衫与主角并肩站立,呱呱在他们脚边的草地上。\n2. 三人位置不变,白衫面向主角和呱呱。\n3. 白衫蹲在呱呱旁边,主角站在他身侧,形成高低位。\n4. 白衫维持蹲姿,主角站在一旁注视。\n5. 白衫从呱呱身边站起,转身面对呱呱。\n6. 主角面向白衫,两人相对而立,主角向前半步拉近距离。\n7. 两人保持相对站立,白衫短暂望向远方后,目光回到主角身上。\n8. 白衫侧身指向西边,主角随其指向望向同一方向。"
    }
]

# ==========================================
# PART 1: 剧本生成模型 (ScriptAgent)
# ==========================================
from swift.llm import get_model_tokenizer, get_template, inference
import torch

# 全局变量
MODEL_NAME = "XD-MU/ScriptAgent"
LOCAL_MODEL_PATH = "./downloaded_models/ScriptAgent"
OFFLOAD_FOLDER = "./offload"
model = None  # 模型对象
tokenizer = None  # 分词器对象
template = None  # 模板对象

# 确保目录存在
os.makedirs(LOCAL_MODEL_PATH, exist_ok=True)
os.makedirs(OFFLOAD_FOLDER, exist_ok=True)

def load_llm_model():
    """使用 SWIFT 加载 ScriptAgent 模型 - CPU优化版本"""
    global model, tokenizer, template
    if model is not None:
        return
    
    try:
        # 1. 检查本地是否已下载模型
        if not os.path.exists(LOCAL_MODEL_PATH):
            print(f"正在从 HuggingFace 下载模型到 {LOCAL_MODEL_PATH}...")
            snapshot_download(
                repo_id=MODEL_NAME,
                local_dir=LOCAL_MODEL_PATH,
                local_dir_use_symlinks=False,
                resume_download=True
            )
            print(f"✅ 模型已下载到: {LOCAL_MODEL_PATH}")
        else:
            print(f"✅ 模型已存在: {LOCAL_MODEL_PATH}")
        
        # 2. 使用 SWIFT 正确加载模型
        print("正在使用 SWIFT 加载模型(CPU + 半精度优化)...")
        
        # 🔥 关键修改:使用 get_model_tokenizer
        model, tokenizer = get_model_tokenizer(
            model_id_or_path=LOCAL_MODEL_PATH,
            torch_dtype=torch.float16,           # 半精度
            model_kwargs={
                'device_map': 'cpu',              # CPU设备
                'low_cpu_mem_usage': True,        # 低内存模式
                'offload_folder': OFFLOAD_FOLDER, # 内存溢出卸载到磁盘
            },
            max_model_len=4096,                   # 限制上下文长度
        )
        
        # 设置为评估模式
        model.eval()
        
        # 获取模板
        template = get_template(tokenizer=tokenizer, model=model)
        
        print("✅ SWIFT 模型加载完成(已启用内存优化)")
        
    except Exception as e:
        print(f"❌ 模型加载失败: {e}")
        import traceback
        traceback.print_exc()

def chat_with_scriptagent(user_input: str):
    """使用 SWIFT 与 ScriptAgent 对话生成剧本"""
    global model, tokenizer, template
    
    if model is None:
        load_llm_model()
        if model is None:
            return "❌ 模型加载失败,请检查后台日志。"
    
    user_input = user_input.strip()
    if not user_input:
        return "请输入内容"

    try:
        print("🤖 正在使用 SWIFT 推理剧本...")
        
        # 🔥 使用 SWIFT 的 inference 函数
        response, _ = inference(
            model=model,
            tokenizer=tokenizer,
            template=template,
            query=user_input,
            max_new_tokens=4096,        # 从8192降低到4096
            temperature=0.7,
            top_p=0.9,
            repetition_penalty=1.1,
            do_sample=True,
            num_beams=1,                # 贪婪解码
        )
        
        print(f"✅ 生成结果长度: {len(response)} 字符")
        return response if response else "⚠️ 生成为空,请重试"
    
    except Exception as e:
        print(f"❌ 生成出错: {e}")
        import traceback
        traceback.print_exc()
        return f"生成失败: {str(e)}"
# ==========================================
# PART 2: 视频生成 API 封装
# ==========================================

class OpenAISoraAPI:
    """OpenAI Sora API 封装"""
    
    def __init__(self, api_key: str):
        if OpenAI is None:
            raise RuntimeError("未安装 openai 库,请运行: pip install openai")
        self.client = OpenAI(api_key=api_key)
    
    def generate_video(
        self, 
        prompt: str, 
        output_path: str, 
        model: str, 
        size: str, 
        seconds: int, 
        ref_img_path: str = None
    ) -> Optional[str]:
        """
        生成视频
        返回: None (成功) 或 错误信息字符串
        """
        try:
            LOGGER.info(f"🎬 Sora API 调用: {model} | {size} | {seconds}秒")
            
            # 构建请求参数
            kwargs = {
                "model": model,
                "prompt": prompt,
                "size": size,
                "seconds": str(seconds),
            }
            
            # 添加参考图片(如果有)
            if ref_img_path and os.path.exists(ref_img_path):
                with open(ref_img_path, 'rb') as f:
                    kwargs["input_reference"] = f
            
            # 创建视频任务
            video_job = self.client.videos.create(**kwargs)
            
            # 轮询任务状态
            while video_job.status in ["queued", "processing"]:
                LOGGER.info(f"⏳ 视频生成中... 进度: {video_job.progress}%")
                time.sleep(10)
                video_job = self.client.videos.retrieve(video_job.id)
            
            # 检查任务状态
            if video_job.status == "completed":
                # 下载视频
                video_url = video_job.url
                import requests
                video_data = requests.get(video_url).content
                with open(output_path, 'wb') as f:
                    f.write(video_data)
                LOGGER.info(f"✅ 视频已保存: {output_path}")
                return None
            else:
                error_msg = f"视频生成失败,状态: {video_job.status}"
                LOGGER.error(error_msg)
                return error_msg
                
        except Exception as e:
            error_msg = f"Sora API 错误: {str(e)}"
            LOGGER.error(error_msg)
            import traceback
            traceback.print_exc()
            return error_msg


class GoogleVeoAPI:
    """Google Veo 3.1 API 封装"""
    
    def __init__(self, api_key: str):
        if genai is None:
            raise RuntimeError("未安装 google-genai 库,请运行: pip install google-genai")
        self.client = genai.Client(api_key=api_key)
    
    def generate_video(
        self, 
        prompt: str, 
        output_path: str, 
        size: str, 
        seconds: int, 
        ref_img_path: str = None
    ) -> Optional[str]:
        """
        生成视频
        返回: None (成功) 或 错误信息字符串
        """
        try:
            LOGGER.info(f"🎬 Veo API 调用: {size} | {seconds}秒")
            
            # 构建配置
            config_kwargs = {}
            
            # 添加参考图片(如果有)
            if ref_img_path and os.path.exists(ref_img_path):
                ref_image = Image.open(ref_img_path)
                reference = types.VideoGenerationReferenceImage(
                    image=ref_image,
                    reference_type="asset"
                )
                config_kwargs["reference_images"] = [reference]
            
            # 映射分辨率
            resolution_map = {"1080p": "1080p", "720p": "720p"}
            resolution = resolution_map.get(size, "720p")
            
            # 创建视频生成任务
            operation = self.client.models.generate_videos(
                model="veo-3.1-generate-preview",
                prompt=prompt,
                config=types.GenerateVideosConfig(
                    duration_seconds=seconds,
                    resolution=resolution,
                    aspect_ratio="16:9",
                    **config_kwargs
                ),
            )
            
            # 轮询任务状态
            while not operation.done:
                LOGGER.info("⏳ 视频生成中...")
                time.sleep(10)
                operation = self.client.operations.get(operation)
            
            # 下载视频
            video = operation.response.generated_videos[0]
            self.client.files.download(file=video.video, output_path=output_path)
            
            LOGGER.info(f"✅ 视频已保存: {output_path}")
            return None
                
        except Exception as e:
            error_msg = f"Veo API 错误: {str(e)}"
            LOGGER.error(error_msg)
            import traceback
            traceback.print_exc()
            return error_msg


# ==========================================
# PART 3: 视频处理工具函数
# ==========================================

def parse_script_nodes(script_text: str) -> List[str]:
    """解析剧本为分镜列表"""
    cleaned = script_text.replace("\r\n", "\n").strip()
    pattern = re.compile(r"\s*(\d+)\.\s*")
    matches = list(pattern.finditer(cleaned))
    if not matches:
        return [line.strip() for line in cleaned.split('\n') if line.strip()]
    nodes = []
    for index, match in enumerate(matches):
        start = match.end()
        end = matches[index + 1].start() if index + 1 < len(matches) else len(cleaned)
        content = cleaned[start:end].strip()
        if content:
            nodes.append(content)
    return nodes


def extract_last_frame(video_path: str, output_path: str) -> Optional[str]:
    """提取视频最后一帧作为参考图"""
    if cv2 is None:
        return None
    
    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        return None
    
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    cap.set(cv2.CAP_PROP_POS_FRAMES, max(total_frames - 1, 0))
    ret, frame = cap.read()
    cap.release()
    
    if ret:
        cv2.imwrite(output_path, frame)
        return output_path
    return None


def stitch_videos(video_paths: List[str], output_path: str):
    """拼接多个视频为最终成片"""
    if not video_paths:
        raise ValueError("未提供可拼接的视频文件。")
    
    if VideoFileClip is None or concatenate_videoclips is None:
        raise RuntimeError("未找到 moviepy,请安装依赖。")
    
    clips = []
    try:
        for path in video_paths:
            if not os.path.exists(path):
                continue
            clips.append(VideoFileClip(path))
        
        if not clips:
            raise ValueError("没有有效的视频片段")
        
        final_clip = concatenate_videoclips(clips, method="compose")
        final_clip.write_videofile(
            output_path, 
            codec="libx264", 
            audio_codec="aac", 
            verbose=False, 
            logger=None, 
            remove_temp=True
        )
    finally:
        for clip in clips:
            clip.close()


# ==========================================
# PART 4: 视频生成流水线
# ==========================================

def run_video_generation_pipeline(
    script_text: str,
    api_key: str,
    model_name: str,
    style_choice: str,
    size: str,
    seconds: int
):
    """
    视频生成流水线
    
    Yields: (分镜列表, 最终视频路径, 日志信息)
    """
    # 验证输入
    if not script_text:
        yield [], None, "❌ 请输入剧本!"
        return
    
    if not api_key or api_key == "Your API Key":
        yield [], None, "❌ 请输入有效的 API Key!"
        return
    
    # 解析剧本
    nodes = parse_script_nodes(script_text)
    run_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    output_dir = os.path.join("output_videos", run_id)
    os.makedirs(output_dir, exist_ok=True)
    
    # 初始化 API 客户端
    try:
        if model_name.startswith("sora"):
            api_client = OpenAISoraAPI(api_key)
        elif model_name.startswith("veo"):
            api_client = GoogleVeoAPI(api_key)
        else:
            yield [], None, f"❌ 不支持的模型: {model_name}"
            return
    except Exception as e:
        yield [], None, f"❌ API 初始化失败: {str(e)}"
        return
    
    generated_videos = []
    last_frame_path = None
    style_prompt = STYLE_PROMPTS.get(style_choice, "")
    
    yield [], None, f"🚀 开始任务,共 {len(nodes)} 个分镜。模型: {model_name}"

    # 逐个生成分镜
    for i, node_text in enumerate(nodes):
        idx = i + 1
        video_filename = os.path.join(output_dir, f"segment_{idx:02d}.mp4")
        full_prompt = f"{CONTINUITY_PROMPT}\n{style_prompt}\n镜头编号:{idx}/{len(nodes)}。\n镜头脚本:{node_text}"
        
        yield generated_videos, None, f"🎥 生成中: 分镜 {idx}/{len(nodes)}..."
        
        # 调用 API 生成视频
        if model_name.startswith("sora"):
            err = api_client.generate_video(
                prompt=full_prompt,
                output_path=video_filename,
                model=model_name,
                size=size,
                seconds=seconds,
                ref_img_path=last_frame_path
            )
        else:  # veo
            err = api_client.generate_video(
                prompt=full_prompt,
                output_path=video_filename,
                size=size,
                seconds=seconds,
                ref_img_path=last_frame_path
            )
        
        if err:
            yield generated_videos, None, f"❌ 分镜 {idx} 失败: {err}"
            return
        
        generated_videos.append(video_filename)
        
        # 提取最后一帧作为下一个分镜的参考
        if i < len(nodes) - 1:
            frame_path = os.path.join(output_dir, f"ref_{idx:02d}.png")
            last_frame_path = extract_last_frame(video_filename, frame_path)
        
        yield generated_videos, None, f"✅ 分镜 {idx} 完成"

    # 拼接视频
    yield generated_videos, None, "🎬 正在拼接..."
    final_video_path = os.path.join(output_dir, "final_movie.mp4")
    
    try:
        stitch_videos(generated_videos, final_video_path)
        yield generated_videos, final_video_path, "🎉 任务完成!"
    except Exception as e:
        yield generated_videos, None, f"❌ 拼接失败: {str(e)}"


# ==========================================
# PART 5: Gradio 界面
# ==========================================

def update_model_params(model_name):
    """根据模型更新界面参数"""
    config = MODEL_CONFIGS.get(model_name, MODEL_CONFIGS["sora-2"])
    return (
        gr.Dropdown(
            choices=config["sizes"], 
            value=config["sizes"][0], 
            label=f"分辨率 ({model_name})"
        ),
        gr.Slider(
            minimum=config["seconds_range"]["minimum"], 
            maximum=config["seconds_range"]["maximum"], 
            step=config["seconds_range"]["step"], 
            value=config["seconds_range"]["value"], 
            label=config["seconds_label"]
        )
    )


def get_demo_path(filename):
    """获取 Demo 文件路径"""
    return filename if os.path.exists(filename) else None


# 构建 Gradio 界面
with gr.Blocks(title="AI 剧本视频工厂") as demo:
    gr.Markdown("# 🎬 ScriptAgent & Sora/Veo 视频生成工坊")
    
    with gr.Tabs():
        # --- TAB 1: 剧本创作 ---
        with gr.Tab("📝 第一步:剧本创作"):
            with gr.Row():
                with gr.Column():
                    llm_input = gr.Textbox(
                        label="剧情输入", 
                        placeholder="主角:你在做什么?...", 
                        lines=6
                    )
                    llm_btn = gr.Button("生成/续写剧本", variant="primary")
                
                with gr.Column():
                    llm_output = gr.Textbox(
                        label="生成的剧本", 
                        lines=10, 
                        interactive=True
                    )
                    to_video_btn = gr.Button("⬇️ 发送到视频生成", variant="secondary")
            
            gr.Examples(
                [[
                    "主角:你在做什么?指引图是不能随便篡改的。\n"
                    "蒋前:篡改指引图?不不不,你误会了。\n"
                    "蒋前:我才加入风物家没多久,哪有这个本事能篡改它..."
                ]], 
                inputs=llm_input
            )

        # --- TAB 2: 视频生成 ---
        with gr.Tab("🎥 第二步:视频生成"):
            with gr.Row():
                # 左侧配置区
                with gr.Column(scale=1):
                    with gr.Accordion("⚙️ API 设置", open=True):
                        api_key_input = gr.Textbox(
                            label="API Key", 
                            type="password", 
                            value="Your API Key",
                            info="根据选择的模型输入 OpenAI 或 Google API Key"
                        )
                    
                    gr.Markdown("### 🎨 风格与模型配置")
                    style_radio = gr.Radio(
                        choices=STYLE_KEYS, 
                        value=STYLE_KEYS[0], 
                        label="画风"
                    )
                    model_sel = gr.Dropdown(
                        choices=["sora-2", "sora-2-pro", "veo-3.1"], 
                        value="sora-2", 
                        label="选择模型",
                        info="Sora 使用 OpenAI Key,Veo 使用 Google Key"
                    )
                    
                    with gr.Row():
                        size_sel = gr.Dropdown(
                            choices=MODEL_CONFIGS["sora-2"]["sizes"], 
                            value=MODEL_CONFIGS["sora-2"]["sizes"][0], 
                            label="分辨率"
                        )
                        sec_slider = gr.Slider(
                            minimum=4, 
                            maximum=12, 
                            step=4, 
                            value=4, 
                            label="单镜时长"
                        )

                    video_script_input = gr.TextArea(
                        label="分镜脚本", 
                        lines=8, 
                        placeholder="1. [0-8秒] ..."
                    )
                    gen_btn = gr.Button("🚀 开始生成", variant="primary")
                    status_log = gr.Textbox(label="日志", interactive=False)

                # 右侧展示区
                with gr.Column(scale=2):
                    gr.Markdown("### 🎞️ 分镜预览")
                    gallery = gr.Gallery(
                        label="分镜序列", 
                        columns=3, 
                        height="auto"
                    )
                    
                    gr.Markdown("### 🎬 最终成片")
                    final_video = gr.Video(label="成片输出")

                    # Demo 展示区(保持不变)
                    gr.Markdown("---")
                    gr.Markdown("### 🌟 精选成片案例 (Demo Showcase)")
                    
                    # 使用 2x2 布局
                    for i in range(0, 4, 2): 
                        with gr.Row():
                            for j in range(2): 
                                idx = i + j
                                if idx < len(DEMO_DATA):
                                    item = DEMO_DATA[idx]
                                    with gr.Column():
                                        # 使用 Group 制造卡片效果
                                        with gr.Group():
                                            gr.Video(value=get_demo_path(item["file"]), label=item["title"], interactive=False)
                                            # 使用 Accordion 折叠剧本
                                            with gr.Accordion(f"📄 查看剧本: {item['title']}", open=False):
                                                gr.Textbox(
                                                    value=item["script"], 
                                                    show_label=False, 
                                                    lines=6,       
                                                    max_lines=6,   
                                                    interactive=False

                                                )

    # --- 逻辑绑定 ---
    llm_btn.click(chat_with_scriptagent, llm_input, llm_output)
    
    to_video_btn.click(
        lambda x: (x, gr.Tabs(selected="🎥 第二步:视频生成")), 
        inputs=llm_output, 
        outputs=[video_script_input, demo]
    )
    
    model_sel.change(
        fn=update_model_params, 
        inputs=model_sel, 
        outputs=[size_sel, sec_slider]
    )
    
    gen_btn.click(
        fn=run_video_generation_pipeline,
        inputs=[
            video_script_input, 
            api_key_input, 
            model_sel, 
            style_radio, 
            size_sel, 
            sec_slider
        ],
        outputs=[gallery, final_video, status_log]
    )

if __name__ == "__main__":
    demo.queue()
    demo.launch(server_name="0.0.0.0", server_port=7860)