Spaces:
Running
Running
| 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) |