From decac3b11d0db7553be5644dc6933868073b4da1 Mon Sep 17 00:00:00 2001 From: linyq Date: Sun, 29 Sep 2024 18:34:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BC=98=E5=8C=96webui?= =?UTF-8?q?=E4=BD=93=E9=AA=8C-=E5=89=AA=E8=BE=91=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E8=BF=9B=E5=BA=A690%=EF=BC=9B=20=E5=BE=85=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E7=82=B9=EF=BC=9A=201.=20=E4=BC=98=E5=8C=96=E8=84=9A=E6=9C=AC-?= =?UTF-8?q?=E8=A7=A3=E8=AF=B4=E8=B4=A8=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/llm.py | 289 ++++++++++++++++++--------------------- app/services/material.py | 15 +- app/services/subtitle.py | 6 +- app/services/task.py | 5 +- app/utils/utils.py | 60 +++++++- webui.py | 210 ++++++++++++++-------------- webui/i18n/zh.json | 5 +- 7 files changed, 317 insertions(+), 273 deletions(-) diff --git a/app/services/llm.py b/app/services/llm.py index d3742df..adb3f6d 100644 --- a/app/services/llm.py +++ b/app/services/llm.py @@ -31,7 +31,7 @@ Method = """ 文案的前三句,是整部电影的概括总结,2-3句介绍后,开始叙述故事剧情! 推荐新手(新号)做:(盘点型) 盘点全球最恐怖的10部电影 -盘点全球最科幻的10部电影 +盘���全球最科幻的10部电影 盘点全球最悲惨的10部电影 盘全球最值得看的10部灾难电影 盘点全球最值得看的10部励志电影 @@ -43,13 +43,13 @@ Method = """ 4.是什么样的一个人被豆瓣网友称之为史上最牛P的老太太,都70岁了还要去贩毒…… 5.他是M国历史上最NB/惨/猖狂/冤枉……的囚犯/抢劫犯/…… 6.这到底是一部什么样的影片,他一个人就拿了4个顶级奖项,第一季8.7分,第二季直接干到9.5分,11万人给出5星好评,一共也就6集,却斩获26项国际大奖,看过的人都说,他是近年来最好的xxx剧,几乎成为了近年来xxx剧的标杆。故事发生在…… -7.他是国产电影的巅峰佳作,更是许多80-90后的青春启蒙,曾入选《时代》周刊,获得年度佳片第一,可在国内却被尘封多年,至今为止都无法在各大视频网站看到完整资源,他就是《xxxxxx》 +7.他是国产电影的巅峰佳作,更是许多80-90后的青春启蒙,曾入选《��代》周刊,获得年度佳片第一,可在国内却被尘封多年,至今为止都无法在各大视频网站看到完整资源,他就是《xxxxxx》 8.这是一部让所有人看得荷尔蒙飙升的爽片…… 9.他被成为世界上最虐心绝望的电影,至今无人敢看第二遍,很难想象,他是根据真实事件改编而来…… 10.这大概是有史以来最令人不寒而栗的电影,当年一经放映,就点燃了无数人的怒火,不少观众不等影片放完,就愤然离场,它比《xxx》更让人绝望,比比《xxx》更让人xxx,能坚持看完全片的人,更是万中无一,包括我。甚至观影结束后,有无数人抵制投诉这部电影,认为影片的导演玩弄了他们的情感!他是顶级神作《xxxx》…… 11.这是X国有史以来最高赞的一部悬疑电影,然而却因为某些原因,国内90%的人,没能看过这部片子,他就是《xxx》…… 12.有这样一部电影,这辈子,你绝对不想再看第二遍,并不是它剧情烂俗,而是它的结局你根本承受不起/想象不到……甚至有80%的观众在观影途中情绪崩溃中途离场,更让许多同行都不想解说这部电影,他就是大名鼎鼎的暗黑神作《xxx》… -13.它被誉为史上最牛悬疑片无数人在看完它时候,一个月不敢照镜子,这样一部仅适合部分年龄段观看的影片,究竟有什么样的魅力,竟然获得某瓣8.2的高分,很多人说这部电影到处都是看点,他就是《xxx》…. +13.它被誉为史上最牛悬疑片无数人在看完它时候,一个月不敢照镜��,这样一部仅适合部分年龄段观看的影片,究竟有什么样的魅力,竟然获得某瓣8.2的高分,很多人说这部电影到处都是看点,他就是《xxx》…. 14.这是一部在某瓣上被70万人打出9.3分的高分的电影……到底是一部什么样的电影,能够在某瓣上被70万人打出9.3分的高分…… 15.这是一部细思极恐的科幻大片,整部电影颠覆你的三观,它的名字叫…… 16.史上最震撼的灾难片,每一点都不舍得快进的电影,他叫…… @@ -66,7 +66,7 @@ Method = """ 2.这是一部印度高分悬疑片, 3.这部电影原在日本因为……而被下架, 4.这是韩国最恐怖的犯罪片, -5.这是最近国产片评分最高的悬疑片 +5.这是最近国产片评分最高的悬疑�� 以上均按照影片国家来区分,然后简单介绍下主题。就可以开始直接叙述作品。也是一个很不错的方法! ### 方式四:如何自由发挥 @@ -97,7 +97,7 @@ Method = """ 后面水平越来越高的时候,可以进行人生道理的讲评。 比如:这部电影告诉我们…… -类似于哲理性质的,作为一个总结! +类似于哲理性质��作为一个总结! 也可以把最后的影视反转,原生放出来,留下悬念。 比如:也可以总结下这部短片如何的好,推荐/值得大家去观看之类的话语。 @@ -426,7 +426,7 @@ def compress_video(input_path: str, output_path: str): def generate_script( - video_path: str, video_plot: str, video_name: str, language: str = "zh-CN", progress_text: st.empty = st.empty() + video_path: str, video_plot: str, video_name: str, language: str = "zh-CN", progress_callback=None ) -> str: """ 生成视频剪辑脚本 @@ -435,73 +435,102 @@ def generate_script( video_plot: 视频剧情内容 video_name: 视频名称 language: 语言 + progress_callback: 进度回调函数 Returns: str: 生成的脚本 """ # 1. 压缩视频 - progress_text.text("压缩视频中...") compressed_video_path = f"{os.path.splitext(video_path)[0]}_compressed.mp4" compress_video(video_path, compressed_video_path) - # # 2. 转录视频 - # transcription = gemini_video_transcription( - # video_name=video_name, - # video_path=compressed_video_path, - # language=language, - # progress_text=progress_text, - # llm_provider_video="gemini" - # ) - transcription = """ -[{"timestamp": "00:00-00:06", "picture": "一个穿着蓝色囚服,戴着手铐的人在房间里走路。", "speech": ""}, -{"timestamp": "00:06-00:09", "picture": "一个穿着蓝色囚服,戴着手铐的人,画面上方显示“李自忠 银行抢劫犯”。", "speech": "李自忠 银行抢劫一案 现在宣判"}, -{"timestamp": "00:09-00:12", "picture": "一个穿着黑色西装,打着红色领带的女人,坐在一个牌子上,牌子上写着“书记员”,身后墙上挂着“国徽”。", "speech": "全体起立"}, -{"timestamp": "00:12-00:15", "picture": "一个穿着黑色法官服的男人坐在一个牌子后面,牌子上写着“审判长”,身后墙上挂着“国徽”。法庭上,很多人站着。", "speech": ""}, -{"timestamp": "00:15-00:19", "picture": "一个穿着黑色西装,打着红色领带的女人,坐在一个牌子上,牌子上写着“书记员”,身后墙上挂着“国徽”。法庭上,很多人站着。", "speech": "本庭二审判决如下 被告李自忠 犯抢劫银行罪"}, -{"timestamp": "00:19-00:24", "picture": "一个穿着蓝色囚服,戴着手铐的人,画面上方显示“李自忠 银行抢劫犯”。", "speech": "维持一审判决 判处有期徒刑 二十年"}, -{"timestamp": "00:24-00:27", "picture": "一个穿着黑色法官服的男人坐在一个牌子后面,牌子上写着“审判长”,他敲了一下法槌。", "speech": ""}, -{"timestamp": "00:27-00:32", "picture": "一个穿着蓝色囚服,戴着手铐的人,画面上方显示“李自忠 银行抢劫犯”。", "speech": "我们要让她们牢底坐穿 越父啊越父 你一个平头老百姓 也敢跟外资银行做对 真是不知天高地厚"}, -{"timestamp": "00:32-00:41", "picture": "一个穿着蓝色囚服,戴着手铐的人跪在地上。", "speech": "我要让她们牢底坐穿 越父啊越父 你一个平头老百姓 也敢跟外资银行做对 真是不知天高地厚"}, -{"timestamp": "00:41-00:47", "picture": "两个警察押解着一个穿着蓝色囚服,戴着手铐的人走在路上,一个女记者在路边报道新闻。", "speech": "李先生 这里是孔雀卫视 这里是黄金眼819新闻直播间 这里是浙江卫视新闻直播间 近日李自忠案引发社会热议"}, -{"timestamp": "00:47-01:03", "picture": "一个穿着灰色外套的男人坐在银行柜台前,和银行工作人员说话。画面中还穿插着女记者在路边报道新闻的画面。", "speech": "李自忠案引发社会热议 李自忠在去银行取钱的时候 由于他拿的是儿子的存折 所以银行要求李自忠证明他的儿子就是他的儿子 我说取不了就是取不了啊 这是你儿子的存折啊 你要证明你儿子是你儿子啊"}, -{"timestamp": "01:03-01:10", "picture": "一个穿着灰色外套的男人坐在银行柜台前,和银行工作人员说话。画面中还穿插着女记者在路边报道新闻的画面。", "speech": "李自忠提供了身份证账户户口本后 银行都不认可他的儿子是他的儿子 就在这个时候 银行发生一起抢劫案"}, -{"timestamp": "01:10-01:17", "picture": "三个戴着帽子和口罩的劫匪持枪闯入银行,银行里的人都很害怕,纷纷蹲下躲避。", "speech": "都给我蹲下 老实点 把钱给我交出来"}, -{"timestamp": "01:17-01:28", "picture": "女记者在路边报道新闻,画面中穿插着银行抢劫案的画面。", "speech": "劫匪看到一旁大哭的李自忠 得知他是因为儿子需要治病才取钱的时候 给了他一打钱 怎么 你儿子在医院等着钱救命啊 银行不给取啊"}, -{"timestamp": "01:28-01:36", "picture": "一个戴着黑色帽子和口罩的劫匪,拿着枪,给一个穿着灰色外套的男人一叠钱。", "speech": "银行不给取啊 好了 给儿子看病去 李自忠在把钱给儿子交完药费后被捕"}, -{"timestamp": "01:36-01:58", "picture": "两个警察押解着一个穿着蓝色囚服,戴着手铐的男人走在路上,一个女记者在路边报道新闻。", "speech": "目前一审二审都维持原判 判处有期徒刑二十年 对此你有什么想说的吗 他怎么证明他儿子是他儿子 要是银行早点把钱给我 我也不会遇到劫匪 我儿子还得救命 不是的 儿子 儿子 儿子"}, -{"timestamp": "01:58-02:03", "picture": "两个警察押解着一个穿着蓝色囚服,戴着手铐的男人走在路上,一个女记者在路边报道新闻。男人情绪激动,大声喊叫。", "speech": "儿子 儿子 儿子"}, -{"timestamp": "02:03-02:12", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边。画面中穿插着新闻报道的画面。", "speech": "近日李自忠案引发社会热议 李自忠在去银行取钱的时候 银行要求李自忠证明他的儿子就是他的儿子"}, -{"timestamp": "02:12-02:25", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生站在门口。", "speech": "爸 这家人也真够可怜的 当爹的坐牢 这儿子 恐怕要成植物人了"}, -{"timestamp": "02:25-02:31", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生站在门口。", "speech": "医生啊 我弟弟的情况怎么样 我先看看"}, -{"timestamp": "02:31-02:40", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生正在给男人做检查。", "speech": ""}, -{"timestamp": "02:40-02:46", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生正在给男人做检查。", "speech": "不太理想啊 你弟弟想要醒过来 希望渺茫"}, -{"timestamp": "02:46-02:57", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生正在给男人做检查。", "speech": "这 麟木 麟木你别吓姐啊麟木 麟木"}, -{"timestamp": "02:57-03:02", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生正在给男人做检查。画面中穿插着新闻报道的画面。", "speech": "麟木 儿子 麟木你别吓姐啊麟木"}, -{"timestamp": "03:02-03:08", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生正在给男人做检查。画面中穿插着新闻报道的画面。女人情绪激动,大声哭泣。", "speech": "儿子 麟木你别吓姐啊麟木 儿子"}, -{"timestamp": "03:08-03:14", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,一个穿着粉色上衣的女人站在病床边,一个白头发的医生正在给男人做检查。画面中穿插着新闻报道的画面。女人情绪激动,大声哭泣。", "speech": "儿子"}, -{"timestamp": "03:14-03:18", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,画面变成紫色光效。", "speech": ""}, -{"timestamp": "03:18-03:20", "picture": "一个病房里,一个年轻男人躺在病床上,戴着呼吸机,他突然睁开了眼睛。", "speech": ""}] - """ - # # 清理压缩后的视频文件 - # try: - # os.remove(compressed_video_path) - # except OSError as e: - # logger.warning(f"删除压缩视频文件失败: {e}") + # 在关键步骤更新进度 + if progress_callback: + progress_callback(15, "压缩完成") # 例如,在压缩视频后 + + # 2. 转录视频 + transcription = gemini_video_transcription( + video_name=video_name, + video_path=compressed_video_path, + language=language, + llm_provider_video="gemini", + progress_callback=progress_callback + ) + if progress_callback: + progress_callback(60, "生成解说文案...") # 例如,在转录视频后 # 3. 编写解说文案 - progress_text.text("解说文案中...") script = writing_short_play(video_plot, video_name, "openai", count=300) + # 在关键步骤更新进度 + if progress_callback: + progress_callback(70, "匹配画面...") # 例如,在生成脚本后 + # 4. 文案匹配画面 if transcription != "": - progress_text.text("画面匹配中...") matched_script = screen_matching(huamian=transcription, wenan=script, llm_provider="openai") - + # 在关键步骤更新进度 + if progress_callback: + progress_callback(80, "匹配成功") return matched_script else: return "" +def gemini_video_transcription(video_name: str, video_path: str, language: str, llm_provider_video: str, progress_callback=None): + ''' + 使用 gemini-1.5-xxx 进行视频画面转录 + ''' + api_key = config.app.get("gemini_api_key") + gemini.configure(api_key=api_key) + + prompt = """ + 请转录音频,包括时间戳,并提供视觉描述,然后以 JSON 格式输出,当前视频中使用的语言为 %s。 + + 在转录视频时,请通过确保以下条件来完成转录: + 1. 画面描述使用语言: %s 进行输出。 + 2. 同一个画面合并为一个转录记录。 + 3. 使用以下 JSON schema: + Graphics = {"timestamp": "MM:SS-MM:SS"(时间戳格式), "picture": "str"(画面描述), "speech": "str"(台词,如果没有人说话,则使用空字符串。)} + Return: list[Graphics] + 4. 请以严格的 JSON 格式返回数据,不要包含任何注释、标记或其他字符。数据应符合 JSON 语法,可以被 json.loads() 函数直接解析, 不要添加 ```json 或其他标记。 + """ % (language, language) + + logger.debug(f"视频名称: {video_name}") + try: + if progress_callback: + progress_callback(20, "上传视频至 Google cloud") + gemini_video_file = gemini.upload_file(video_path) + logger.debug(f"视频 {gemini_video_file.name} 上传至 Google cloud 成功, 开始解析...") + while gemini_video_file.state.name == "PROCESSING": + gemini_video_file = gemini.get_file(gemini_video_file.name) + if progress_callback: + progress_callback(30, "上传成功, 开始解析") # 更新进度为20% + if gemini_video_file.state.name == "FAILED": + raise ValueError(gemini_video_file.state.name) + elif gemini_video_file.state.name == "ACTIVE": + if progress_callback: + progress_callback(40, "解析完成, 开始转录...") # 更新进度为30% + logger.debug("解析完成, 开始转录...") + except ResumableUploadError as err: + logger.error(f"上传视频至 Google cloud 失败, 用户的位置信息不支持用于该API; \n{traceback.format_exc()}") + return False + except FailedPrecondition as err: + logger.error(f"400 用户位置不支持 Google API 使用。\n{traceback.format_exc()}") + return False + + if progress_callback: + progress_callback(50, "开始转录") + try: + response = _generate_response_video(prompt=prompt, llm_provider_video=llm_provider_video, video_file=gemini_video_file) + logger.success("视频转录成功") + logger.debug(response) + print(type(response)) + return response + except Exception as err: + return handle_exception(err) + + def generate_terms(video_subject: str, video_script: str, amount: int = 5) -> List[str]: prompt = f""" # Role: Video Search Terms Generator @@ -652,56 +681,6 @@ def gemini_video2json(video_origin_name: str, video_origin_path: str, video_plot return response -def gemini_video_transcription(video_name: str, video_path: str, language: str, llm_provider_video: str, progress_text: st.empty = ""): - ''' - 使用 gemini-1.5-xxx 进行视频画面转录 - ''' - api_key = config.app.get("gemini_api_key") - gemini.configure(api_key=api_key) - - prompt = """ - 请转录音频,包括时间戳,并提供视觉描述,然后以 JSON 格式输出,当前视频中使用的语言为 %s。 - - 在转录视频时,请通过确保以下条件来完成转录: - 1. 画面描述使用语言: %s 进行输出。 - 2. 同一个画面合并为一个转录记录。 - 3. 使用以下 JSON schema: - Graphics = {"timestamp": "MM:SS-MM:SS"(时间戳格式), "picture": "str"(画面描述), "speech": "str"(台词,如果没有人说话,则使用空字符串。)} - Return: list[Graphics] - 4. 请以严格的 JSON 格式返回数据,不要包含任何注释、标记或其他字符。数据应符合 JSON 语法,可以被 json.loads() 函数直接解析, 不要添加 ```json 或其他标记。 - """ % (language, language) - - logger.debug(f"视频名称: {video_name}") - try: - progress_text.text("上传视频中...") - gemini_video_file = gemini.upload_file(video_path) - logger.debug(f"视频 {gemini_video_file.name} 上传至 Google cloud 成功, 开始解析...") - while gemini_video_file.state.name == "PROCESSING": - gemini_video_file = gemini.get_file(gemini_video_file.name) - progress_text.text(f"解析视频中, 当前状态: {gemini_video_file.state.name}") - if gemini_video_file.state.name == "FAILED": - raise ValueError(gemini_video_file.state.name) - elif gemini_video_file.state.name == "ACTIVE": - progress_text.text("解析完成") - logger.debug("解析完成, 开始转录...") - except ResumableUploadError as err: - logger.error(f"上传视频至 Google cloud 失败, 用户的位置信息不支持用于该API; \n{traceback.format_exc()}") - return False - except FailedPrecondition as err: - logger.error(f"400 用户位置不支持 Google API 使用。\n{traceback.format_exc()}") - return False - - progress_text.text("视频转录中...") - try: - response = _generate_response_video(prompt=prompt, llm_provider_video=llm_provider_video, video_file=gemini_video_file) - logger.success("视频转录成功") - logger.debug(response) - print(type(response)) - return response - except Exception as err: - return handle_exception(err) - - def writing_movie(video_plot, video_name, llm_provider): """ 影视解说(电影解说) @@ -801,58 +780,58 @@ def screen_matching(huamian: str, wenan: str, llm_provider: str): - 请以严格的 JSON 格式返回数据,不要包含任何注释、标记或其他字符。数据应符合 JSON 语法,可以被 json.loads() 函数直接解析, 不要添加 ```json 或其他标记。 """ % (huamian, wenan) - prompt = """ - 你是一位拥有10年丰富经验的影视解说创作专家。你的任务是根据提供的视频转录脚本和解说文案,创作一个引人入胜的解说脚本。请按照以下要求完成任务: - -1. 输入数据: - - 视频转录脚本:包含时间戳、画面描述和人物台词 - - 解说文案:需要你进行匹配和编排的内容 - - 视频转录脚本和文案(由 XML 标记分隔)如下所示: - 视频转录脚本 - - %s - - 文案: - - %s - - -2. 输出要求: - - 格式:严格的JSON格式,可直接被json.loads()解析 - - 结构:list[script],其中script为字典类型 - - script字段: - { - "picture": "画面描述", - "timestamp": "时间戳", - "narration": "解说文案", - "OST": true/false - } - -3. 匹配规则: - a) 时间戳匹配: - - 根据文案内容选择最合适的画面时间段 - - 避免时间重叠,确保画面不重复出现 - - 适当合并或删减片段,不要完全照搬转录脚本 - b) 画面描述:与转录脚本保持一致 - c) 解说文案: - - 当OST为true时,narration为空字符串 - - 当OST为false时,narration为解说文案,但是要确保文案字数不要超过 30字,若文案较长,则添加到下一个片段 - d) OST(原声): - - 按1:1比例穿插原声和解说片段 - - 第一个片段必须是原声,时长不少于20秒 - - 选择整个视频中最精彩的片段作为开场 - -4. 创作重点: - - 确保解说与画面高度匹配 - - 巧妙安排原声和解说的交替,提升观众体验 - - 创造一个引人入胜、节奏紧凑的解说脚本 - -5. 注意事项: - - 严格遵守JSON格式,不包含任何注释或额外标记 - - 充分利用你的专业经验,创作出高质量、吸引人的解说内容 - -请基于以上要求,将提供的视频转录脚本和解说文案整合成一个专业、吸引人的解说脚本。你的创作将直接影响观众的观看体验,请发挥你的专业素养,创作出最佳效果。 - """ % (huamian, wenan) +# prompt = """ +# 你是一位拥有10年丰富经验的影视解说创作专家。你的任务是根据提供的视频转录脚本和解说文案,创作一个引人入胜的解说脚本。请按照以下要求完成任务: +# +# 1. 输入数据: +# - 视频转录脚本:包含时间戳、画面描述和人物台词 +# - 解说文案:需要你进行匹配和编排的内容 +# - 视频转录脚本和文案(由 XML 标记分隔)如下所示: +# 视频转录脚本 +# +# %s +# +# 文案: +# +# %s +# +# +# 2. 输出要求: +# - 格式:严格的JSON格式,可直接被json.loads()解析 +# - 结构:list[script],其中script为字典类型 +# - script字段: +# { +# "picture": "画面描述", +# "timestamp": "时间戳", +# "narration": "解说文案", +# "OST": true/false +# } +# +# 3. 匹配规则: +# a) 时间戳匹配: +# - 根据文案内容选择最合适的画面时间段 +# - 避免时间重叠,确保画面不重复出现 +# - 适当合并或删减片段,不要完全照搬转录脚本 +# b) 画面描述:与转录脚本保持一致 +# c) 解说文案: +# - 当OST为true时,narration为空字符串 +# - 当OST为false时,narration为解说文案,但是要确保文案字数不要超过 30字,若文案较长,则添加到下一个片段 +# d) OST(原声): +# - 按1:1比例穿插原声和解说片段 +# - 第一个片段必须是原声,时长不少于20秒 +# - 选择整个视频中最精彩的片段作为开场 +# +# 4. 创作重点: +# - 确保解说与画面高度匹配 +# - 巧妙安排原声和解说的交替,提升观众体验 +# - 创造一个引人入胜、节奏紧凑的解说脚本 +# +# 5. 注意事项: +# - 严格遵守JSON格式,不包含任何注释或额外标记 +# - 充分利用你的专业经验,创作出高质量、吸引人的解说内容 +# +# 请基于以上要求,将提供的视频转录脚本和解说文案整合成一个专业、吸引人的解说脚本。你的创作将直接影响观众的观看体验,请发挥你的专业素养,创作出最佳效果。 +# """ % (huamian, wenan) try: response = _generate_response(prompt, llm_provider) logger.success("匹配成功") diff --git a/app/services/material.py b/app/services/material.py index d63e6fc..bc4d118 100644 --- a/app/services/material.py +++ b/app/services/material.py @@ -267,7 +267,6 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di if not os.path.exists(save_dir): os.makedirs(save_dir) - # url_hash = utils.md5(str(uuid.uuid4())) video_id = f"vid-{timestamp.replace(':', '_')}" video_path = f"{save_dir}/{video_id}.mp4" @@ -278,7 +277,7 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di # 剪辑视频 start, end = utils.split_timestamp(timestamp) video = VideoFileClip(origin_video).subclip(start, end) - video.write_videofile(video_path) + video.write_videofile(video_path, logger=None) # 禁用 MoviePy 的内置日志 if os.path.getsize(video_path) > 0 and os.path.exists(video_path): try: @@ -297,20 +296,21 @@ def save_clip_video(timestamp: str, origin_video: str, save_dir: str = "") -> di return {} -def clip_videos(task_id: str, timestamp_terms: List[str], origin_video: str, ) -> dict: +def clip_videos(task_id: str, timestamp_terms: List[str], origin_video: str, progress_callback=None): """ 剪辑视频 Args: task_id: 任务id timestamp_terms: 需要剪辑的时间戳列表,如:['00:00-00:20', '00:36-00:40', '07:07-07:22'] origin_video: 原视频路径 + progress_callback: 进度回调函数 Returns: 剪辑后的视频路径 """ video_paths = {} - for item in timestamp_terms: - logger.info(f"需要裁剪 '{origin_video}' 为 {len(timestamp_terms)} 个视频") + total_items = len(timestamp_terms) + for index, item in enumerate(timestamp_terms): material_directory = config.app.get("material_directory", "").strip() if material_directory == "task": material_directory = utils.task_dir(task_id) @@ -318,11 +318,14 @@ def clip_videos(task_id: str, timestamp_terms: List[str], origin_video: str, ) - material_directory = "" try: - logger.info(f"clip video: {item}") saved_video_path = save_clip_video(timestamp=item, origin_video=origin_video, save_dir=material_directory) if saved_video_path: logger.info(f"video saved: {saved_video_path}") video_paths.update(saved_video_path) + + # 更新进度 + if progress_callback: + progress_callback(index + 1, total_items) except Exception as e: logger.error(f"视频裁剪失败: {utils.to_json(item)} => {str(e)}") return {} diff --git a/app/services/subtitle.py b/app/services/subtitle.py index b9894b0..c792667 100644 --- a/app/services/subtitle.py +++ b/app/services/subtitle.py @@ -48,7 +48,10 @@ def create(audio_file, subtitle_file: str = ""): ) try: model = WhisperModel( - model_size_or_path=model_path, device=device, compute_type=compute_type, local_files_only=True + model_size_or_path=model_path, + device=device, + compute_type=compute_type, + local_files_only=True ) except Exception as e: logger.error( @@ -72,6 +75,7 @@ def create(audio_file, subtitle_file: str = ""): word_timestamps=True, vad_filter=True, vad_parameters=dict(min_silence_duration_ms=500), + initial_prompt="以下是普通话的句子" ) logger.info( diff --git a/app/services/task.py b/app/services/task.py index 946b4cd..78941f8 100644 --- a/app/services/task.py +++ b/app/services/task.py @@ -2,6 +2,7 @@ import math import json import os.path import re +import traceback from os import path from loguru import logger @@ -323,7 +324,7 @@ def start(task_id, params: VideoParams, stop_at: str = "video"): return kwargs -def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos): +def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos: list): """ 后台任务(自动剪辑视频进行剪辑) @@ -340,6 +341,7 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos): logger.info("\n\n## 1. 加载视频脚本") video_script_path = path.join(params.video_clip_json_path) + # video_script_path = video_clip_json_path # 判断json文件是否存在 if path.exists(video_script_path): try: @@ -361,6 +363,7 @@ def start_subclip(task_id: str, params: VideoClipParams, subclip_path_videos): logger.error(f"无法读取视频json脚本,请检查配置是否正确。{e}") raise ValueError("无法读取视频json脚本,请检查配置是否正确") else: + logger.error(f"video_script_path: {video_script_path} \n\n", traceback.format_exc()) raise ValueError("解说脚本不存在!请检查配置是否正确。") logger.info("\n\n## 2. 生成音频列表") diff --git a/app/utils/utils.py b/app/utils/utils.py index e4ba419..3a0600f 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -1,9 +1,12 @@ import locale import os +import traceback + import requests import threading from typing import Any from loguru import logger +import streamlit as st import json from uuid import uuid4 import urllib3 @@ -11,6 +14,7 @@ from datetime import datetime, timedelta from app.models import const from app.utils import check_script +from app.services import material urllib3.disable_warnings() @@ -372,10 +376,52 @@ def add_new_timestamps(scenes): def clean_model_output(output): - """ - 模型输出包含 ```json 标记时的处理 - """ - if "```json" in output: - print("##########") - output = output.replace("```json", "").replace("```", "") - return output.strip() + # 移除可能的代码块标记 + output = output.strip('```json').strip('```') + # 移除开头和结尾的空白字符 + output = output.strip() + return output + + +def cut_video(params, progress_callback=None): + try: + task_id = str(uuid4()) + st.session_state['task_id'] = task_id + + if not st.session_state.get('video_clip_json'): + raise ValueError("视频脚本不能为空") + + video_script_list = st.session_state['video_clip_json'] + time_list = [i['timestamp'] for i in video_script_list] + + total_clips = len(time_list) + + def clip_progress(current, total): + progress = int((current / total) * 100) + if progress_callback: + progress_callback(progress) + + subclip_videos = material.clip_videos( + task_id=task_id, + timestamp_terms=time_list, + origin_video=params.video_origin_path, + progress_callback=clip_progress + ) + + if subclip_videos is None: + raise ValueError("裁剪视频失败") + + st.session_state['subclip_videos'] = subclip_videos + + for i, video_script in enumerate(video_script_list): + try: + video_script['path'] = subclip_videos[video_script['timestamp']] + except KeyError as err: + logger.error(f"裁剪视频失败: {err}") + raise ValueError(f"裁剪视频失败: {err}") + + return task_id, subclip_videos + + except Exception as e: + logger.error(f"视频裁剪过程中发生错误: {traceback.format_exc()}") + raise diff --git a/webui.py b/webui.py index c4853d8..4410c2d 100644 --- a/webui.py +++ b/webui.py @@ -61,13 +61,11 @@ config_file = os.path.join(root_dir, "webui", ".streamlit", "webui.toml") system_locale = utils.get_system_locale() if 'video_clip_json' not in st.session_state: - st.session_state['video_clip_json'] = '' + st.session_state['video_clip_json'] = [] if 'video_plot' not in st.session_state: st.session_state['video_plot'] = '' if 'ui_language' not in st.session_state: st.session_state['ui_language'] = config.ui.get("language", system_locale) -if 'script_generation_status' not in st.session_state: - st.session_state['script_generation_status'] = "" def get_all_fonts(): @@ -124,7 +122,7 @@ def init_log(): _lvl = "DEBUG" def format_record(record): - # 获取日志记录中的文件全路径 + # 获取日志记录中的文件全���径 file_path = record["file"].path # 将绝对路径转换为相对于项目根目录的路径 relative_path = os.path.relpath(file_path, root_dir) @@ -272,7 +270,7 @@ with left_panel: # 按创建时间降序排序 script_list.sort(key=lambda x: x["ctime"], reverse=True) - # 脚本文件 下拉框 + # ��本文件 下拉框 script_path = [(tr("Auto Generate"), ""), ] for file in script_list: display_name = file['file'].replace(root_dir, "") @@ -282,8 +280,9 @@ with left_panel: options=range(len(script_path)), # 使用索引作为内部选项值 format_func=lambda x: script_path[x][0] # 显示给用户的是标签 ) - params.video_clip_json = script_path[selected_script_index][1] - video_json_file = params.video_clip_json + params.video_clip_json_path = script_path[selected_script_index][1] + config.app["video_clip_json_path"] = params.video_clip_json_path + st.session_state['video_clip_json_path'] = params.video_clip_json_path # 视频文件处理 video_files = [] @@ -301,18 +300,20 @@ with left_panel: }) # 按创建时间降序排序 video_list.sort(key=lambda x: x["ctime"], reverse=True) - video_path = [("None", ""), (tr("Upload Local Files"), "local")] - for code in [file['file'] for file in video_list]: - video_path.append((code, code)) + video_path = [(tr("None"), ""), (tr("Upload Local Files"), "local")] + for file in video_list: + display_name = file['file'].replace(root_dir, "") + video_path.append((display_name, file['file'])) # 视频文件 selected_video_index = st.selectbox(tr("Video File"), index=0, options=range(len(video_path)), # 使用索引作为内部选项值 - format_func=lambda x: video_path[x][0] # 显示给用户的是标 + format_func=lambda x: video_path[x][0] # 显示给用户的是标签 ) params.video_origin_path = video_path[selected_video_index][1] config.app["video_origin_path"] = params.video_origin_path + st.session_state['video_origin_path'] = params.video_origin_path # 从本地上传 mp4 文件 if params.video_origin_path == "local": @@ -347,40 +348,73 @@ with left_panel: ) # 生成视频脚本 - st.session_state['script_generation_status'] = "开始生成视频脚本" - if st.button(tr("Video Script Generate"), key="auto_generate_script"): - with st.spinner("正在生成脚本..."): - # 这里可以用 st.empty() 来动态更新文本 - progress_text = st.empty() - progress_text.text("正在处理...") + if st.session_state['video_clip_json_path']: + generate_button_name = tr("Video Script Load") + else: + generate_button_name = tr("Video Script Generate") + if st.button(generate_button_name, key="auto_generate_script"): + progress_bar = st.progress(0) + status_text = st.empty() - if video_json_file == "" and params.video_origin_path != "": - progress_text.text("开始压缩...") - # 使用大模型生成视频脚本 - script = llm.generate_script( - video_path=params.video_origin_path, - video_plot=video_plot, - video_name=video_name, - language=params.video_language, - progress_text=progress_text - ) - if script is None: - st.error("生成脚本失败,请检查日志") - st.stop() - st.session_state['video_clip_json'] = script - cleaned_string = script.strip("```json").strip("```") - st.session_state['video_script_list'] = json.loads(cleaned_string) + def update_progress(progress: float, message: str = ""): + progress_bar.progress(progress) + if message: + status_text.text(f"{progress}% - {message}") else: - with open(video_json_file, 'r', encoding='utf-8') as f: - script = f.read() - st.session_state['video_clip_json'] = script - cleaned_string = script.strip("```json").strip("```") - st.session_state['video_script_list'] = json.loads(cleaned_string) + status_text.text(f"进度: {progress}%") + + try: + with st.spinner("正在生成脚本..."): + if not video_name: + st.warning("视频名称不能为空") + st.stop() + if not video_plot: + st.warning("视频剧情不能为空") + st.stop() + if params.video_clip_json_path == "" and params.video_origin_path != "": + update_progress(10, "压缩视频中...") + # 使用大模型生成视频脚本 + script = llm.generate_script( + video_path=params.video_origin_path, + video_plot=video_plot, + video_name=video_name, + language=params.video_language, + progress_callback=update_progress + ) + if script is None: + st.error("生成脚本失败,请检查日志") + st.stop() + else: + update_progress(90) + + script = utils.clean_model_output(script) + st.session_state['video_clip_json'] = json.loads(script) + else: + # 从本地加载 + with open(params.video_clip_json_path, 'r', encoding='utf-8') as f: + update_progress(50) + status_text.text("从本地加载中...") + script = f.read() + script = utils.clean_model_output(script) + st.session_state['video_clip_json'] = json.loads(script) + update_progress(100) + status_text.text("从本地加载成功") + + time.sleep(0.5) # 给进度条一点时间到达100% + progress_bar.progress(100) + status_text.text("脚本生成完成!") + st.success("视频脚本生成成功!") + except Exception as e: + st.error(f"生成过程中发生错误: {traceback.format_exc()}") + finally: + time.sleep(2) # 给用户一些时间查看最终状态 + progress_bar.empty() + status_text.empty() # 视频脚本 video_clip_json_details = st.text_area( tr("Video Script"), - value=st.session_state['video_clip_json'], + value=json.dumps(st.session_state.video_clip_json, indent=2, ensure_ascii=False), height=180 ) @@ -398,73 +432,43 @@ with left_panel: timestamp = datetime.datetime.now().strftime("%Y-%m%d-%H%M%S") save_path = os.path.join(script_dir, f"{timestamp}.json") - # 尝试解析输入的 JSON 数据 - input_json = str(video_clip_json_details) - # 去掉json的头尾标识 - input_json = input_json.strip('```json').strip('```') try: - data = utils.add_new_timestamps(json.loads(input_json)) + data = utils.add_new_timestamps(json.loads(video_clip_json_details)) except Exception as err: st.error(f"视频脚本格式错误,请检查脚本是否符合 JSON 格式;{err} \n\n{traceback.format_exc()}") st.stop() - # 检查是否是一个列表 - if not isinstance(data, list): - st.error("JSON is not a list") - st.stop() - - # 检查列表中的每个元素是否包含所需的键 - required_keys = {"picture", "timestamp", "narration"} - for item in data: - if not isinstance(item, dict): - st.error("List 元素不是字典") - st.stop() - if not required_keys.issubset(item.keys()): - st.error("Dict 元素不包含必需的键") - st.stop() - # 存储为新的 JSON 文件 with open(save_path, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) # 将data的值存储到 session_state 中,类似缓存 - st.session_state['video_script_list'] = data + st.session_state['video_clip_json'] = data st.session_state['video_clip_json_path'] = save_path # 刷新页面 - st.rerun() - - - def caijian(): - with st.spinner(tr("裁剪视频中...")): - st.session_state['task_id'] = str(uuid4()) - - if st.session_state.get('video_script_list', None) is not None: - video_script_list = st.session_state.video_script_list - print(video_script_list) - print(type(video_script_list)) - time_list = [i['timestamp'] for i in video_script_list] - subclip_videos = material.clip_videos( - task_id=st.session_state['task_id'], - timestamp_terms=time_list, - origin_video=params.video_origin_path - ) - if subclip_videos is None: - st.error(tr("裁剪视频失败")) - st.stop() - st.session_state['subclip_videos'] = subclip_videos - for video_script in video_script_list: - try: - video_script['path'] = subclip_videos[video_script['timestamp']] - except KeyError as err: - st.error(f"裁剪视频失败 {err}") - logger.debug(f"当前的脚本为:{st.session_state.subclip_videos}") - else: - st.error(tr("请先生成视频脚本")) - + # st.rerun() # 裁剪视频 with button_columns[1]: if st.button(tr("Crop Video"), key="auto_crop_video", use_container_width=True): - caijian() + progress_bar = st.progress(0) + status_text = st.empty() + + def update_progress(progress): + progress_bar.progress(progress) + status_text.text(f"剪辑进度: {progress}%") + + try: + utils.cut_video(params, update_progress) + time.sleep(0.5) # 给进度条一点时间到达100% + progress_bar.progress(100) + status_text.text("剪辑完成!") + st.success("视频剪辑成功完成!") + except Exception as e: + st.error(f"剪辑过程中发生错误: {str(e)}") + finally: + time.sleep(2) # 给用户一些时间查看最终状态 + progress_bar.empty() + status_text.empty() # 新中间面板 with middle_panel: @@ -703,14 +707,16 @@ with st.expander(tr("Video Check"), expanded=False): # 可编辑的输入框 text_panels = st.columns(2) with text_panels[0]: - text1 = st.text_area(tr("timestamp"), value=initial_timestamp, height=20) + text1 = st.text_area(tr("timestamp"), value=initial_timestamp, height=20, + key=f"timestamp_{index}") with text_panels[1]: - text2 = st.text_area(tr("Picture description"), value=initial_picture, height=20) - logger.debug(initial_narration) - text3 = st.text_area(tr("Narration"), value=initial_narration, height=100) + text2 = st.text_area(tr("Picture description"), value=initial_picture, height=20, + key=f"picture_{index}") + text3 = st.text_area(tr("Narration"), value=initial_narration, height=100, + key=f"narration_{index}") # 重新生成按钮 - if st.button(tr("Rebuild"), key=f"button_{index}"): + if st.button(tr("Rebuild"), key=f"rebuild_{index}"): # 更新video_list中的对应项 video_list[index]['timestamp'] = text1 video_list[index]['picture'] = text2 @@ -719,12 +725,12 @@ with st.expander(tr("Video Check"), expanded=False): for video in video_list: if 'path' in video: del video['path'] - # 更新session_state以确保更改被保存 + # 更新session_state以确保更改被保存 st.session_state['video_clip_json'] = utils.to_json(video_list) # 替换原JSON 文件 - with open(video_json_file, 'w', encoding='utf-8') as file: + with open(params.video_clip_json_path, 'w', encoding='utf-8') as file: json.dump(video_list, file, ensure_ascii=False, indent=4) - caijian() + utils.cut_video(params, progress_callback=None) st.rerun() # 开始按钮 @@ -735,13 +741,15 @@ if start_button: if st.session_state.get('video_script_json_path') is not None: params.video_clip_json = st.session_state.get('video_clip_json') - logger.debug(f"当前的脚本为:{params.video_clip_json}") + logger.debug(f"当前的脚本文件为:{st.session_state.video_clip_json_path}") + logger.debug(f"当前的视频文件为:{st.session_state.video_origin_path}") + logger.debug(f"裁剪后是视频列表:{st.session_state.subclip_videos}") if not task_id: st.error(tr("请先裁剪视频")) scroll_to_bottom() st.stop() - if not params.video_clip_json: + if not params.video_clip_json_path: st.error(tr("脚本文件不能为空")) scroll_to_bottom() st.stop() diff --git a/webui/i18n/zh.json b/webui/i18n/zh.json index dc1da54..aa588fd 100644 --- a/webui/i18n/zh.json +++ b/webui/i18n/zh.json @@ -10,7 +10,7 @@ "Auto Detect": "自动检测", "Auto Generate": "自动生成", "Video Name": "视频名称", - "Video Script": "视频脚本(:blue[①可不填,使用AI生成 ②合理使用标点断句,有助于生成字幕])", + "Video Script": "视频脚本(:blue[①使用AI生成 ②从本机加载])", "Save Script": "保存脚本", "Crop Video": "裁剪视频", "Video File": "视频文件(:blue[1️⃣支持上传视频文件(限制2G) 2️⃣大文件建议直接导入 ./resource/videos 目录])", @@ -91,6 +91,7 @@ "timestamp": "时间戳", "Picture description": "图片描述", "Narration": "视频文案", - "Rebuild": "重新生成" + "Rebuild": "重新生成", + "Video Script Load": "加载视频脚本" } } \ No newline at end of file