XD-MU commited on
Commit
d00e784
·
1 Parent(s): 0cd739e

Upload 5 files

Browse files
Files changed (3) hide show
  1. .gitattributes +3 -0
  2. app.py +703 -0
  3. requirement.txt +21 -0
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ demo1.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ demo2.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ demo3.mp4 filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,703 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import torch
3
+ from transformers import AutoModelForCausalLM, AutoTokenizer
4
+ from transformers.models.qwen2_5_omni import Qwen2_5OmniForConditionalGeneration, Qwen2_5OmniProcessor
5
+ import warnings
6
+ import os
7
+ import time
8
+ import re
9
+ import base64
10
+ import datetime
11
+ import uuid
12
+ import logging
13
+ from typing import List, Dict, Tuple, Optional
14
+ from PIL import Image
15
+ from huggingface_hub import snapshot_download
16
+ from swift.llm import PtEngine, RequestConfig, InferRequest
17
+
18
+ # --- 依赖库检查 ---
19
+ try:
20
+ import cv2
21
+ from moviepy.editor import VideoFileClip, concatenate_videoclips
22
+ from openai import OpenAI
23
+ from google import genai
24
+ from google.genai import types
25
+ except ImportError as e:
26
+ print(f"❌ 缺少必要库: {e}")
27
+ print("请运行: pip install opencv-python moviepy openai google-genai")
28
+ cv2 = None
29
+ VideoFileClip = None
30
+ OpenAI = None
31
+ genai = None
32
+
33
+ # --- 环境设置 ---
34
+ warnings.filterwarnings("ignore")
35
+ os.environ['PYTHONWARNINGS'] = 'ignore'
36
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
37
+ LOGGER = logging.getLogger(__name__)
38
+
39
+ # ==========================================
40
+ # PART 0: 配置常量 & Demo 数据
41
+ # ==========================================
42
+
43
+ # 1. 模型参数配置
44
+ MODEL_CONFIGS = {
45
+ "sora-2": {
46
+ "sizes": ["1792x1024", "1024x1792", "1280x720", "720x1280"],
47
+ "seconds_range": {"minimum": 4, "maximum": 12, "step": 4, "value": 4},
48
+ "seconds_label": "单镜时长 (Sora: 4/8/12秒)"
49
+ },
50
+ "sora-2-pro": {
51
+ "sizes": ["1792x1024", "1024x1792", "1280x720", "720x1280"],
52
+ "seconds_range": {"minimum": 4, "maximum": 12, "step": 4, "value": 4},
53
+ "seconds_label": "单镜时长 (Sora Pro: 4/8/12秒)"
54
+ },
55
+ "veo-3.1": {
56
+ "sizes": ["1080p", "720p"],
57
+ "seconds_range": {"minimum": 4, "maximum": 8, "step": 2, "value": 4},
58
+ "seconds_label": "单镜时长 (Veo: 4/6/8秒)"
59
+ }
60
+ }
61
+
62
+ # 2. 提示词与风格
63
+ CONTINUITY_PROMPT = (
64
+ "保持统一的视觉风格与世界观,场景与光影保持稳定,角色服装、发型、体型与表情连贯,仅根据剧情调整动作;"
65
+ "如果有参考图片,请严格保持人物形象与参考图一致,人物站位不得变化,镜头衔接需流畅自然。"
66
+ "旁白不需要朗读或配音,仅作为剧情提示使用。要求视频生成的最后一帧要展示所有人物的正面形象和此时的站位。"
67
+ )
68
+
69
+ STYLE_PROMPTS = {
70
+ "Anime (二次元)": "整体画面要求:高质量二次元动漫渲染风格,角色为手绘动漫人物,肤色与材质为动画质感,背景为虚构的动画场景;禁止出现写实/真人或真实摄影元素。",
71
+ "Realistic (写实)": "整体画面要求:高写实摄影风格,人物与环境光影细节丰富,材质与质感贴近真实世界,禁止出现卡通或夸张笔触,确保色彩与光线符合真实物理规律。",
72
+ "Animated (动画/3D)": "整体画面要求:动画/卡通风格,支持二维或三维渲染,人物线条与轮廓清晰,色彩饱和且富有层次,可适当夸张动作与表情。",
73
+ "Painterly (艺术/绘画)": "整体画面要求:艺术绘画风格,可呈现厚重笔触或水彩晕染质感,允许保留艺术性的纹理与笔法痕迹,整体色彩与构图需统一。",
74
+ "Abstract (抽象/实验)": "整体画面要求:抽象/实验风格,鼓励运用超现实、故障艺术或非传统构图手法,可打破写实规律,突出视觉冲击力与创意表现。"
75
+ }
76
+ STYLE_KEYS = list(STYLE_PROMPTS.keys())
77
+
78
+ # 3. Demo 案例数据(保持不变)
79
+ DEMO_DATA = [
80
+ {
81
+ "file": "demo1.mp4",
82
+ "title": "案例 1",
83
+ "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.苏洛侧指远方。"
84
+ },
85
+ {
86
+ "file": "demo2.mp4",
87
+ "title": "案例 2",
88
+ "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"
89
+ },
90
+ {
91
+ "file": "demo3.mp4",
92
+ "title": "案例 3",
93
+ "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:主角转身,准备离开,背向机器。"
94
+ },
95
+ {
96
+ "file": "demo4.mp4",
97
+ "title": "案例 4",
98
+ "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. 白衫侧身指向西边,主角随其指向望向同一方向。"
99
+ }
100
+ ]
101
+
102
+ # ==========================================
103
+ # PART 1: 剧本生成模型 (ScriptAgent)
104
+ # ==========================================
105
+ from swift.llm import get_model_tokenizer, get_template, inference
106
+ import torch
107
+
108
+ # 全局变量
109
+ MODEL_NAME = "XD-MU/ScriptAgent"
110
+ LOCAL_MODEL_PATH = "./downloaded_models/ScriptAgent"
111
+ OFFLOAD_FOLDER = "./offload"
112
+ model = None # 模型对象
113
+ tokenizer = None # 分词器对象
114
+ template = None # 模板对象
115
+
116
+ # 确保目录存在
117
+ os.makedirs(LOCAL_MODEL_PATH, exist_ok=True)
118
+ os.makedirs(OFFLOAD_FOLDER, exist_ok=True)
119
+
120
+ def load_llm_model():
121
+ """使用 SWIFT 加载 ScriptAgent 模型 - CPU优化版本"""
122
+ global model, tokenizer, template
123
+ if model is not None:
124
+ return
125
+
126
+ try:
127
+ # 1. 检查本地是否已下载模型
128
+ if not os.path.exists(LOCAL_MODEL_PATH):
129
+ print(f"正在从 HuggingFace 下载模型到 {LOCAL_MODEL_PATH}...")
130
+ snapshot_download(
131
+ repo_id=MODEL_NAME,
132
+ local_dir=LOCAL_MODEL_PATH,
133
+ local_dir_use_symlinks=False,
134
+ resume_download=True
135
+ )
136
+ print(f"✅ 模型已下载到: {LOCAL_MODEL_PATH}")
137
+ else:
138
+ print(f"✅ 模型已存在: {LOCAL_MODEL_PATH}")
139
+
140
+ # 2. 使用 SWIFT 正确加载模型
141
+ print("正在使用 SWIFT 加载模型(CPU + 半精度优化)...")
142
+
143
+ # 🔥 关键修改:使用 get_model_tokenizer
144
+ model, tokenizer = get_model_tokenizer(
145
+ model_id_or_path=LOCAL_MODEL_PATH,
146
+ torch_dtype=torch.float16, # 半精度
147
+ model_kwargs={
148
+ 'device_map': 'cpu', # CPU设备
149
+ 'low_cpu_mem_usage': True, # 低内存模式
150
+ 'offload_folder': OFFLOAD_FOLDER, # 内存溢出卸载到磁盘
151
+ },
152
+ max_model_len=4096, # 限制上下文长度
153
+ )
154
+
155
+ # 设置为评估模式
156
+ model.eval()
157
+
158
+ # 获取模板
159
+ template = get_template(tokenizer=tokenizer, model=model)
160
+
161
+ print("✅ SWIFT 模型加载完成(已启用内存优化)")
162
+
163
+ except Exception as e:
164
+ print(f"❌ 模型加载失败: {e}")
165
+ import traceback
166
+ traceback.print_exc()
167
+
168
+ def chat_with_scriptagent(user_input: str):
169
+ """使用 SWIFT 与 ScriptAgent 对话生成剧本"""
170
+ global model, tokenizer, template
171
+
172
+ if model is None:
173
+ load_llm_model()
174
+ if model is None:
175
+ return "❌ 模型加载失败,请检查后台日志。"
176
+
177
+ user_input = user_input.strip()
178
+ if not user_input:
179
+ return "请输入内容"
180
+
181
+ try:
182
+ print("🤖 正在使用 SWIFT 推理剧本...")
183
+
184
+ # 🔥 使用 SWIFT 的 inference 函数
185
+ response, _ = inference(
186
+ model=model,
187
+ tokenizer=tokenizer,
188
+ template=template,
189
+ query=user_input,
190
+ max_new_tokens=4096, # 从8192降低到4096
191
+ temperature=0.7,
192
+ top_p=0.9,
193
+ repetition_penalty=1.1,
194
+ do_sample=True,
195
+ num_beams=1, # 贪婪解码
196
+ )
197
+
198
+ print(f"✅ 生成结果长度: {len(response)} 字符")
199
+ return response if response else "⚠️ 生成为空,请重试"
200
+
201
+ except Exception as e:
202
+ print(f"❌ 生成出错: {e}")
203
+ import traceback
204
+ traceback.print_exc()
205
+ return f"生成失败: {str(e)}"
206
+ # ==========================================
207
+ # PART 2: 视频生成 API 封装
208
+ # ==========================================
209
+
210
+ class OpenAISoraAPI:
211
+ """OpenAI Sora API 封装"""
212
+
213
+ def __init__(self, api_key: str):
214
+ if OpenAI is None:
215
+ raise RuntimeError("未安装 openai 库,请运行: pip install openai")
216
+ self.client = OpenAI(api_key=api_key)
217
+
218
+ def generate_video(
219
+ self,
220
+ prompt: str,
221
+ output_path: str,
222
+ model: str,
223
+ size: str,
224
+ seconds: int,
225
+ ref_img_path: str = None
226
+ ) -> Optional[str]:
227
+ """
228
+ 生成视频
229
+ 返回: None (成功) 或 错误信息字符串
230
+ """
231
+ try:
232
+ LOGGER.info(f"🎬 Sora API 调用: {model} | {size} | {seconds}秒")
233
+
234
+ # 构建请求参数
235
+ kwargs = {
236
+ "model": model,
237
+ "prompt": prompt,
238
+ "size": size,
239
+ "seconds": str(seconds),
240
+ }
241
+
242
+ # 添加参考图片(如果有)
243
+ if ref_img_path and os.path.exists(ref_img_path):
244
+ with open(ref_img_path, 'rb') as f:
245
+ kwargs["input_reference"] = f
246
+
247
+ # 创建视频任务
248
+ video_job = self.client.videos.create(**kwargs)
249
+
250
+ # 轮询任务状态
251
+ while video_job.status in ["queued", "processing"]:
252
+ LOGGER.info(f"⏳ 视频生成中... 进度: {video_job.progress}%")
253
+ time.sleep(10)
254
+ video_job = self.client.videos.retrieve(video_job.id)
255
+
256
+ # 检查任务状态
257
+ if video_job.status == "completed":
258
+ # 下载视频
259
+ video_url = video_job.url
260
+ import requests
261
+ video_data = requests.get(video_url).content
262
+ with open(output_path, 'wb') as f:
263
+ f.write(video_data)
264
+ LOGGER.info(f"✅ 视频已保存: {output_path}")
265
+ return None
266
+ else:
267
+ error_msg = f"视频生成失败,状态: {video_job.status}"
268
+ LOGGER.error(error_msg)
269
+ return error_msg
270
+
271
+ except Exception as e:
272
+ error_msg = f"Sora API 错误: {str(e)}"
273
+ LOGGER.error(error_msg)
274
+ import traceback
275
+ traceback.print_exc()
276
+ return error_msg
277
+
278
+
279
+ class GoogleVeoAPI:
280
+ """Google Veo 3.1 API 封装"""
281
+
282
+ def __init__(self, api_key: str):
283
+ if genai is None:
284
+ raise RuntimeError("未安装 google-genai 库,请运行: pip install google-genai")
285
+ self.client = genai.Client(api_key=api_key)
286
+
287
+ def generate_video(
288
+ self,
289
+ prompt: str,
290
+ output_path: str,
291
+ size: str,
292
+ seconds: int,
293
+ ref_img_path: str = None
294
+ ) -> Optional[str]:
295
+ """
296
+ 生成视频
297
+ 返回: None (成功) 或 错误信息字符串
298
+ """
299
+ try:
300
+ LOGGER.info(f"🎬 Veo API 调用: {size} | {seconds}秒")
301
+
302
+ # 构建配置
303
+ config_kwargs = {}
304
+
305
+ # 添加参考图片(如果有)
306
+ if ref_img_path and os.path.exists(ref_img_path):
307
+ ref_image = Image.open(ref_img_path)
308
+ reference = types.VideoGenerationReferenceImage(
309
+ image=ref_image,
310
+ reference_type="asset"
311
+ )
312
+ config_kwargs["reference_images"] = [reference]
313
+
314
+ # 映射分辨率
315
+ resolution_map = {"1080p": "1080p", "720p": "720p"}
316
+ resolution = resolution_map.get(size, "720p")
317
+
318
+ # 创建视频生成任务
319
+ operation = self.client.models.generate_videos(
320
+ model="veo-3.1-generate-preview",
321
+ prompt=prompt,
322
+ config=types.GenerateVideosConfig(
323
+ duration_seconds=seconds,
324
+ resolution=resolution,
325
+ aspect_ratio="16:9",
326
+ **config_kwargs
327
+ ),
328
+ )
329
+
330
+ # 轮询任务状态
331
+ while not operation.done:
332
+ LOGGER.info("⏳ 视频生成中...")
333
+ time.sleep(10)
334
+ operation = self.client.operations.get(operation)
335
+
336
+ # 下载视频
337
+ video = operation.response.generated_videos[0]
338
+ self.client.files.download(file=video.video, output_path=output_path)
339
+
340
+ LOGGER.info(f"✅ 视频已保存: {output_path}")
341
+ return None
342
+
343
+ except Exception as e:
344
+ error_msg = f"Veo API 错误: {str(e)}"
345
+ LOGGER.error(error_msg)
346
+ import traceback
347
+ traceback.print_exc()
348
+ return error_msg
349
+
350
+
351
+ # ==========================================
352
+ # PART 3: 视频处理工具函数
353
+ # ==========================================
354
+
355
+ def parse_script_nodes(script_text: str) -> List[str]:
356
+ """解析剧本为分镜列表"""
357
+ cleaned = script_text.replace("\r\n", "\n").strip()
358
+ pattern = re.compile(r"\s*(\d+)\.\s*")
359
+ matches = list(pattern.finditer(cleaned))
360
+ if not matches:
361
+ return [line.strip() for line in cleaned.split('\n') if line.strip()]
362
+ nodes = []
363
+ for index, match in enumerate(matches):
364
+ start = match.end()
365
+ end = matches[index + 1].start() if index + 1 < len(matches) else len(cleaned)
366
+ content = cleaned[start:end].strip()
367
+ if content:
368
+ nodes.append(content)
369
+ return nodes
370
+
371
+
372
+ def extract_last_frame(video_path: str, output_path: str) -> Optional[str]:
373
+ """提取视频最后一帧作为参考图"""
374
+ if cv2 is None:
375
+ return None
376
+
377
+ cap = cv2.VideoCapture(video_path)
378
+ if not cap.isOpened():
379
+ return None
380
+
381
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
382
+ cap.set(cv2.CAP_PROP_POS_FRAMES, max(total_frames - 1, 0))
383
+ ret, frame = cap.read()
384
+ cap.release()
385
+
386
+ if ret:
387
+ cv2.imwrite(output_path, frame)
388
+ return output_path
389
+ return None
390
+
391
+
392
+ def stitch_videos(video_paths: List[str], output_path: str):
393
+ """拼接多个视频为最终成片"""
394
+ if not video_paths:
395
+ raise ValueError("未提供可拼接的视频文件。")
396
+
397
+ if VideoFileClip is None or concatenate_videoclips is None:
398
+ raise RuntimeError("未找到 moviepy,请安装依赖。")
399
+
400
+ clips = []
401
+ try:
402
+ for path in video_paths:
403
+ if not os.path.exists(path):
404
+ continue
405
+ clips.append(VideoFileClip(path))
406
+
407
+ if not clips:
408
+ raise ValueError("没有有效的视频片段")
409
+
410
+ final_clip = concatenate_videoclips(clips, method="compose")
411
+ final_clip.write_videofile(
412
+ output_path,
413
+ codec="libx264",
414
+ audio_codec="aac",
415
+ verbose=False,
416
+ logger=None,
417
+ remove_temp=True
418
+ )
419
+ finally:
420
+ for clip in clips:
421
+ clip.close()
422
+
423
+
424
+ # ==========================================
425
+ # PART 4: 视频生成流水线
426
+ # ==========================================
427
+
428
+ def run_video_generation_pipeline(
429
+ script_text: str,
430
+ api_key: str,
431
+ model_name: str,
432
+ style_choice: str,
433
+ size: str,
434
+ seconds: int
435
+ ):
436
+ """
437
+ 视频生成流水线
438
+
439
+ Yields: (分镜列表, 最终视频路径, 日志信息)
440
+ """
441
+ # 验证输入
442
+ if not script_text:
443
+ yield [], None, "❌ 请输入剧本!"
444
+ return
445
+
446
+ if not api_key or api_key == "Your API Key":
447
+ yield [], None, "❌ 请输入有效的 API Key!"
448
+ return
449
+
450
+ # 解析剧本
451
+ nodes = parse_script_nodes(script_text)
452
+ run_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
453
+ output_dir = os.path.join("output_videos", run_id)
454
+ os.makedirs(output_dir, exist_ok=True)
455
+
456
+ # 初始化 API 客户端
457
+ try:
458
+ if model_name.startswith("sora"):
459
+ api_client = OpenAISoraAPI(api_key)
460
+ elif model_name.startswith("veo"):
461
+ api_client = GoogleVeoAPI(api_key)
462
+ else:
463
+ yield [], None, f"❌ 不支持的模型: {model_name}"
464
+ return
465
+ except Exception as e:
466
+ yield [], None, f"❌ API 初始化失败: {str(e)}"
467
+ return
468
+
469
+ generated_videos = []
470
+ last_frame_path = None
471
+ style_prompt = STYLE_PROMPTS.get(style_choice, "")
472
+
473
+ yield [], None, f"🚀 开始任务,共 {len(nodes)} 个分镜。模型: {model_name}"
474
+
475
+ # 逐个生成分镜
476
+ for i, node_text in enumerate(nodes):
477
+ idx = i + 1
478
+ video_filename = os.path.join(output_dir, f"segment_{idx:02d}.mp4")
479
+ full_prompt = f"{CONTINUITY_PROMPT}\n{style_prompt}\n镜头编号:{idx}/{len(nodes)}。\n镜头脚本:{node_text}"
480
+
481
+ yield generated_videos, None, f"🎥 生成中: 分镜 {idx}/{len(nodes)}..."
482
+
483
+ # 调用 API 生成视频
484
+ if model_name.startswith("sora"):
485
+ err = api_client.generate_video(
486
+ prompt=full_prompt,
487
+ output_path=video_filename,
488
+ model=model_name,
489
+ size=size,
490
+ seconds=seconds,
491
+ ref_img_path=last_frame_path
492
+ )
493
+ else: # veo
494
+ err = api_client.generate_video(
495
+ prompt=full_prompt,
496
+ output_path=video_filename,
497
+ size=size,
498
+ seconds=seconds,
499
+ ref_img_path=last_frame_path
500
+ )
501
+
502
+ if err:
503
+ yield generated_videos, None, f"❌ 分镜 {idx} 失败: {err}"
504
+ return
505
+
506
+ generated_videos.append(video_filename)
507
+
508
+ # 提取最后一帧作为下一个分镜的参考
509
+ if i < len(nodes) - 1:
510
+ frame_path = os.path.join(output_dir, f"ref_{idx:02d}.png")
511
+ last_frame_path = extract_last_frame(video_filename, frame_path)
512
+
513
+ yield generated_videos, None, f"✅ 分镜 {idx} 完成"
514
+
515
+ # 拼接视频
516
+ yield generated_videos, None, "🎬 正在拼接..."
517
+ final_video_path = os.path.join(output_dir, "final_movie.mp4")
518
+
519
+ try:
520
+ stitch_videos(generated_videos, final_video_path)
521
+ yield generated_videos, final_video_path, "🎉 任务完成!"
522
+ except Exception as e:
523
+ yield generated_videos, None, f"❌ 拼接失败: {str(e)}"
524
+
525
+
526
+ # ==========================================
527
+ # PART 5: Gradio 界面
528
+ # ==========================================
529
+
530
+ def update_model_params(model_name):
531
+ """根据模型更新界面参数"""
532
+ config = MODEL_CONFIGS.get(model_name, MODEL_CONFIGS["sora-2"])
533
+ return (
534
+ gr.Dropdown(
535
+ choices=config["sizes"],
536
+ value=config["sizes"][0],
537
+ label=f"分辨率 ({model_name})"
538
+ ),
539
+ gr.Slider(
540
+ minimum=config["seconds_range"]["minimum"],
541
+ maximum=config["seconds_range"]["maximum"],
542
+ step=config["seconds_range"]["step"],
543
+ value=config["seconds_range"]["value"],
544
+ label=config["seconds_label"]
545
+ )
546
+ )
547
+
548
+
549
+ def get_demo_path(filename):
550
+ """获取 Demo 文件路径"""
551
+ return filename if os.path.exists(filename) else None
552
+
553
+
554
+ # 构建 Gradio 界面
555
+ with gr.Blocks(title="AI 剧本视频工厂") as demo:
556
+ gr.Markdown("# 🎬 ScriptAgent & Sora/Veo 视频生成工坊")
557
+
558
+ with gr.Tabs():
559
+ # --- TAB 1: 剧本创作 ---
560
+ with gr.Tab("📝 第一步:剧本创作"):
561
+ with gr.Row():
562
+ with gr.Column():
563
+ llm_input = gr.Textbox(
564
+ label="剧情输入",
565
+ placeholder="主角:你在做什么?...",
566
+ lines=6
567
+ )
568
+ llm_btn = gr.Button("生成/续写剧本", variant="primary")
569
+
570
+ with gr.Column():
571
+ llm_output = gr.Textbox(
572
+ label="生成的剧本",
573
+ lines=10,
574
+ interactive=True
575
+ )
576
+ to_video_btn = gr.Button("⬇️ 发送到视频生成", variant="secondary")
577
+
578
+ gr.Examples(
579
+ [[
580
+ "主角:你在做什么?指引图是不能随便篡改的。\n"
581
+ "蒋前:篡改指引图?不不不,你误会了。\n"
582
+ "蒋前:我才加入风物家没多久,哪有这个本事能篡改它..."
583
+ ]],
584
+ inputs=llm_input
585
+ )
586
+
587
+ # --- TAB 2: 视频生成 ---
588
+ with gr.Tab("🎥 第二步:视频生成"):
589
+ with gr.Row():
590
+ # 左侧配置区
591
+ with gr.Column(scale=1):
592
+ with gr.Accordion("⚙️ API 设置", open=True):
593
+ api_key_input = gr.Textbox(
594
+ label="API Key",
595
+ type="password",
596
+ value="Your API Key",
597
+ info="根据选择的模型输入 OpenAI 或 Google API Key"
598
+ )
599
+
600
+ gr.Markdown("### 🎨 风格与模型配置")
601
+ style_radio = gr.Radio(
602
+ choices=STYLE_KEYS,
603
+ value=STYLE_KEYS[0],
604
+ label="画风"
605
+ )
606
+ model_sel = gr.Dropdown(
607
+ choices=["sora-2", "sora-2-pro", "veo-3.1"],
608
+ value="sora-2",
609
+ label="选择模型",
610
+ info="Sora 使用 OpenAI Key,Veo 使用 Google Key"
611
+ )
612
+
613
+ with gr.Row():
614
+ size_sel = gr.Dropdown(
615
+ choices=MODEL_CONFIGS["sora-2"]["sizes"],
616
+ value=MODEL_CONFIGS["sora-2"]["sizes"][0],
617
+ label="分辨率"
618
+ )
619
+ sec_slider = gr.Slider(
620
+ minimum=4,
621
+ maximum=12,
622
+ step=4,
623
+ value=4,
624
+ label="单镜时长"
625
+ )
626
+
627
+ video_script_input = gr.TextArea(
628
+ label="分镜脚本",
629
+ lines=8,
630
+ placeholder="1. [0-8秒] ..."
631
+ )
632
+ gen_btn = gr.Button("🚀 开始生成", variant="primary")
633
+ status_log = gr.Textbox(label="日志", interactive=False)
634
+
635
+ # 右侧展示区
636
+ with gr.Column(scale=2):
637
+ gr.Markdown("### 🎞️ 分镜预览")
638
+ gallery = gr.Gallery(
639
+ label="分镜序列",
640
+ columns=3,
641
+ height="auto"
642
+ )
643
+
644
+ gr.Markdown("### 🎬 最终成片")
645
+ final_video = gr.Video(label="成片输出")
646
+
647
+ # Demo 展示区(保持不变)
648
+ gr.Markdown("---")
649
+ gr.Markdown("### 🌟 精选成片案例 (Demo Showcase)")
650
+
651
+ # 使用 2x2 布局
652
+ for i in range(0, 4, 2):
653
+ with gr.Row():
654
+ for j in range(2):
655
+ idx = i + j
656
+ if idx < len(DEMO_DATA):
657
+ item = DEMO_DATA[idx]
658
+ with gr.Column():
659
+ # 使用 Group 制造卡片效果
660
+ with gr.Group():
661
+ gr.Video(value=get_demo_path(item["file"]), label=item["title"], interactive=False)
662
+ # 使用 Accordion 折叠剧本
663
+ with gr.Accordion(f"📄 查看剧本: {item['title']}", open=False):
664
+ gr.Textbox(
665
+ value=item["script"],
666
+ show_label=False,
667
+ lines=6,
668
+ max_lines=6,
669
+ interactive=False
670
+
671
+ )
672
+
673
+ # --- 逻辑绑定 ---
674
+ llm_btn.click(chat_with_scriptagent, llm_input, llm_output)
675
+
676
+ to_video_btn.click(
677
+ lambda x: (x, gr.Tabs(selected="🎥 第二步:视频生成")),
678
+ inputs=llm_output,
679
+ outputs=[video_script_input, demo]
680
+ )
681
+
682
+ model_sel.change(
683
+ fn=update_model_params,
684
+ inputs=model_sel,
685
+ outputs=[size_sel, sec_slider]
686
+ )
687
+
688
+ gen_btn.click(
689
+ fn=run_video_generation_pipeline,
690
+ inputs=[
691
+ video_script_input,
692
+ api_key_input,
693
+ model_sel,
694
+ style_radio,
695
+ size_sel,
696
+ sec_slider
697
+ ],
698
+ outputs=[gallery, final_video, status_log]
699
+ )
700
+
701
+ if __name__ == "__main__":
702
+ demo.queue()
703
+ demo.launch(server_name="0.0.0.0", server_port=7860)
requirement.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio
2
+ torch
3
+ transformers==4.52.3
4
+ accelerate
5
+ bitsandbytes
6
+ sentencepiece
7
+ protobuf
8
+ scipy
9
+ opencv-python
10
+ moviepy==1.0.3
11
+ numpy
12
+ imageio
13
+ imageio-ffmpeg
14
+ requests
15
+ torchvision
16
+ google
17
+ openai
18
+ google-genai
19
+ ms-swift
20
+ qwen-omni-utils
21
+ soundfile