|
|
@ -0,0 +1,375 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
创建剪映草稿
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from pathlib import Path
|
||||||
|
from random import choice
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
from typing import Tuple, Union
|
||||||
|
|
||||||
|
import edge_tts
|
||||||
|
from pyJianYingDraft import (
|
||||||
|
AudioMaterial,
|
||||||
|
AudioSegment,
|
||||||
|
ClipSettings,
|
||||||
|
DraftFolder,
|
||||||
|
FontType,
|
||||||
|
KeyframeProperty,
|
||||||
|
TextBackground,
|
||||||
|
TextBorder,
|
||||||
|
TextSegment,
|
||||||
|
TextStyle,
|
||||||
|
TrackType,
|
||||||
|
VideoMaterial,
|
||||||
|
VideoSegment,
|
||||||
|
trange,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JianYingDraftGenerator:
|
||||||
|
"""
|
||||||
|
剪映草稿生成器
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
drafts_folder_path: str = r"E:\JianYingPro Drafts",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
初始化
|
||||||
|
:param drafts_folder_path: 剪映草稿文件夹路径
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 初始化草稿文件夹管理器
|
||||||
|
self.drafts_manager = DraftFolder(
|
||||||
|
folder_path=Path(drafts_folder_path).as_posix()
|
||||||
|
)
|
||||||
|
# 初始化素材文件夹路径
|
||||||
|
self.materials_folder_path = Path(__file__).parent
|
||||||
|
except Exception as exception:
|
||||||
|
raise RuntimeError(f"发生异常:{str(exception)}") from exception
|
||||||
|
|
||||||
|
def text_to_speech(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
text: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
合成音频并本地保存音频文件
|
||||||
|
:param audio_path: 音频文件路径
|
||||||
|
:param text: 待合成音频的文本
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 异步调用 EdgeTTS 合成音频
|
||||||
|
async def _async_synthetize():
|
||||||
|
communicator = edge_tts.Communicate(
|
||||||
|
text=text.strip(),
|
||||||
|
voice="zh-CN-XiaoxiaoNeural", # 语音角色
|
||||||
|
rate="+20%", # 语速
|
||||||
|
volume="+10%", # 音量
|
||||||
|
pitch="+15Hz", # 音调
|
||||||
|
)
|
||||||
|
await communicator.save(audio_path.as_posix())
|
||||||
|
return
|
||||||
|
|
||||||
|
return asyncio.run(_async_synthetize())
|
||||||
|
except Exception as exception:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"合成音频并本地保存音频文件发生异常:{str(exception)}"
|
||||||
|
) from exception
|
||||||
|
|
||||||
|
def add_text_segment(
|
||||||
|
self,
|
||||||
|
track_name: str,
|
||||||
|
text: str,
|
||||||
|
timerange: Tuple[Union[int, str], Union[int, str]],
|
||||||
|
font: Optional[str] = None,
|
||||||
|
style: Optional[Dict[str, Any]] = None,
|
||||||
|
border: Optional[Dict[str, Any]] = None,
|
||||||
|
background: Optional[Dict[str, Any]] = None,
|
||||||
|
clip_settings: Optional[Dict[str, Any]] = None,
|
||||||
|
bubble: Optional[Dict[str, Any]] = None,
|
||||||
|
effect: Optional[Dict[str, Any]] = None,
|
||||||
|
animation: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
向指定文本轨道添加文本片段
|
||||||
|
:param track_name: 轨道名称
|
||||||
|
:param add_track: 添加文本轨道,默认为是
|
||||||
|
:param text: 文本
|
||||||
|
:param timerange: 文本素材在轨道上的范围,包括开始时间和持续时长,默认为草稿持续时长
|
||||||
|
:param font: 字体,默认为系统
|
||||||
|
:param style: 字体样式,默认为字号 15,对齐方式 左对齐
|
||||||
|
:param border: 文本描边设置,默认为无
|
||||||
|
:param background: 文本背景设置,默认为无
|
||||||
|
:param clip_settings: 图像调节设置,默认为移动至 (0, 0)
|
||||||
|
:param bubble: 气泡设置,默认为无
|
||||||
|
:param effect: 花字设置,默认为无
|
||||||
|
:param animation: 动画设置,默认为无
|
||||||
|
:return: 无
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 构建文本片段
|
||||||
|
text_segment = TextSegment(
|
||||||
|
text=text.replace("\\n", "\n"),
|
||||||
|
timerange=trange(*timerange),
|
||||||
|
font=FontType(font) if font else None,
|
||||||
|
style=TextStyle(**style) if style else None,
|
||||||
|
border=TextBorder(**border) if border else None,
|
||||||
|
background=(TextBackground(**background) if background else None),
|
||||||
|
clip_settings=(
|
||||||
|
ClipSettings(**clip_settings) if clip_settings else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 添加气泡
|
||||||
|
if bubble:
|
||||||
|
text_segment.add_bubble(**bubble)
|
||||||
|
|
||||||
|
# 添加花字
|
||||||
|
# 将花字保存预设后在C:/Users/admin/AppData/Local/JianyingPro/User Data/Presets/Text_V2/预设文本?.textpreset获取花字resource_id
|
||||||
|
if effect:
|
||||||
|
text_segment.add_effect(**effect)
|
||||||
|
|
||||||
|
# 添加动画
|
||||||
|
if animation:
|
||||||
|
text_segment.add_animation(**animation)
|
||||||
|
|
||||||
|
# 向指定文本轨道添加文本片段
|
||||||
|
self.draft.add_segment(segment=text_segment, track_name=track_name)
|
||||||
|
|
||||||
|
except Exception as exception:
|
||||||
|
raise RuntimeError(str(exception)) from exception
|
||||||
|
|
||||||
|
def add_audio_segment(
|
||||||
|
self,
|
||||||
|
track_name: str,
|
||||||
|
material: str,
|
||||||
|
target_timerange: Tuple[Union[int, str], Union[int, str]],
|
||||||
|
source_timerange: Optional[Tuple[str, str]] = None,
|
||||||
|
speed: float = 1.0,
|
||||||
|
volume: float = 1.0,
|
||||||
|
fade: Optional[Tuple[str, str]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
向指定音频轨道添加音频片段
|
||||||
|
:param track_name: 轨道名称
|
||||||
|
:param add_track: 添加音频轨道,默认为是
|
||||||
|
:param material: 音频素材
|
||||||
|
:param target_timerange: 音频素材在轨道上的范围,包括开始时间和持续时长
|
||||||
|
:param source_timerange: 截取音频素材范围,包括开始时间和持续时长,默认根据音频素材开始时间根据播放速度截取与音频素材持续时长等长的部分
|
||||||
|
:param speed: 播放速度,默认为 1.0
|
||||||
|
:param volume: 播放音量,默认为 1.0
|
||||||
|
:param fade: 淡入淡出设置,默认为无
|
||||||
|
:return: 无
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 构建音频片段
|
||||||
|
audio_segment = AudioSegment(
|
||||||
|
material=material,
|
||||||
|
target_timerange=trange(*target_timerange),
|
||||||
|
source_timerange=(
|
||||||
|
trange(*source_timerange) if source_timerange else None
|
||||||
|
),
|
||||||
|
speed=speed,
|
||||||
|
volume=volume,
|
||||||
|
)
|
||||||
|
# 添加淡入淡出
|
||||||
|
if fade:
|
||||||
|
audio_segment.add_fade(*fade)
|
||||||
|
|
||||||
|
# 向指定音频轨道添加音频片段
|
||||||
|
self.draft.add_segment(segment=audio_segment, track_name=track_name)
|
||||||
|
|
||||||
|
except Exception as exception:
|
||||||
|
raise RuntimeError(str(exception)) from exception
|
||||||
|
|
||||||
|
def add_video_segment(
|
||||||
|
self,
|
||||||
|
track_name: str,
|
||||||
|
material: str,
|
||||||
|
target_timerange: Tuple[Union[int, str], Union[int, str]],
|
||||||
|
speed: float = 1.0,
|
||||||
|
volume: float = 1.0,
|
||||||
|
clip_settings: Optional[Dict[str, Any]] = None,
|
||||||
|
keyframes: Optional[
|
||||||
|
List[Tuple[KeyframeProperty, Union[str, int], float]]
|
||||||
|
] = None,
|
||||||
|
animation: Optional[Dict[str, Any]] = None,
|
||||||
|
transition: Optional[Dict[str, Any]] = None,
|
||||||
|
background_filling: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
向指定视频轨道添加视频或图片片段
|
||||||
|
:param track_name: 轨道名称
|
||||||
|
:param materia: 视频或图片素材
|
||||||
|
:param target_timerange: 视频或图片素材在轨道上的范围,包括开始时间和持续时长,默认为草稿持续时长
|
||||||
|
:param source_timerange: 截取视频或图片素材范围,包括开始时间和持续时长,默认为空
|
||||||
|
:param speed: 播放速度,默认为 1.0
|
||||||
|
:param volume: 播放音量,默认为 1.0
|
||||||
|
:param clip_settings: 图像调节设置,默认为无
|
||||||
|
:param keyframes: 关键帧设置,默认为无
|
||||||
|
:param animation: 动画设置,默认为无
|
||||||
|
:param transition: 转场设置,默认为无
|
||||||
|
:param background_filling: 背景填充设置,默认为无
|
||||||
|
:return: 无
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 构建视频或图片片段
|
||||||
|
video_segment = VideoSegment(
|
||||||
|
material=material,
|
||||||
|
target_timerange=trange(*target_timerange),
|
||||||
|
speed=speed,
|
||||||
|
volume=volume,
|
||||||
|
clip_settings=(
|
||||||
|
ClipSettings(**clip_settings) if clip_settings else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# 添加关键帧
|
||||||
|
if keyframes:
|
||||||
|
for _property, offset, value in keyframes:
|
||||||
|
video_segment.add_keyframe(_property, offset, value)
|
||||||
|
|
||||||
|
# 添加动画
|
||||||
|
if animation:
|
||||||
|
video_segment.add_animation(**animation)
|
||||||
|
|
||||||
|
# 添加转场
|
||||||
|
if transition:
|
||||||
|
video_segment.add_transition(**transition)
|
||||||
|
|
||||||
|
# 添加背景填充
|
||||||
|
if background_filling:
|
||||||
|
video_segment.add_background_filling(**background_filling)
|
||||||
|
|
||||||
|
# 向指定视频轨道添加视频或图片片段
|
||||||
|
self.draft.add_segment(segment=video_segment, track_name=track_name)
|
||||||
|
|
||||||
|
except Exception as exception:
|
||||||
|
raise RuntimeError(str(exception)) from exception
|
||||||
|
|
||||||
|
def create_draft(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
contents: List[str],
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建草稿
|
||||||
|
:param task_id: 任务标识
|
||||||
|
:param contents: 分镜内容列表
|
||||||
|
:return: 无
|
||||||
|
"""
|
||||||
|
# 新建草稿
|
||||||
|
self.draft = self.drafts_manager.create_draft(
|
||||||
|
draft_name=task_id,
|
||||||
|
allow_replace=True, # 允许覆盖
|
||||||
|
width=1080,
|
||||||
|
height=1920,
|
||||||
|
fps=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加视频轨道
|
||||||
|
self.draft.add_track(
|
||||||
|
track_type=TrackType.video,
|
||||||
|
track_name=(video_track_name := "video"),
|
||||||
|
)
|
||||||
|
# 添加文本轨道
|
||||||
|
self.draft.add_track(
|
||||||
|
track_type=TrackType.text,
|
||||||
|
track_name=(text_track_name := "text"),
|
||||||
|
)
|
||||||
|
# 添加音频轨道(字幕音频)
|
||||||
|
self.draft.add_track(
|
||||||
|
track_type=TrackType.audio,
|
||||||
|
track_name=(subtitle_audio_track_name := "subtitle_audio"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加音频轨道(背景音频)
|
||||||
|
self.draft.add_track(
|
||||||
|
track_type=TrackType.audio,
|
||||||
|
track_name=(background_audio_track_name := "background_audio"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构造字幕音频文件夹路径
|
||||||
|
subtitle_audios_folder_path = self.materials_folder_path / "subtitle_audios"
|
||||||
|
subtitle_audios_folder_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
duration = 0 # 视频持续时长
|
||||||
|
for i, content in enumerate(contents, start=1):
|
||||||
|
content_duration = 0 # 素材持续时长
|
||||||
|
for j, text in enumerate(
|
||||||
|
[text for text in content.split(",") if text.strip()], start=1
|
||||||
|
):
|
||||||
|
# 构建片段字幕音频文件路径
|
||||||
|
audio_path = (
|
||||||
|
subtitle_audios_folder_path / f"{task_id}_{i:02d}_{j:02d}.mp3"
|
||||||
|
)
|
||||||
|
if not audio_path.exists():
|
||||||
|
# 合成片段字幕音频并本地保存片段字幕音频文件
|
||||||
|
self.text_to_speech(
|
||||||
|
audio_path=audio_path,
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
# 片段字幕音频素材化
|
||||||
|
audio = AudioMaterial(path=audio_path.as_posix())
|
||||||
|
# 向文本轨道添加文本片段(片段字幕文本)
|
||||||
|
self.add_text_segment(
|
||||||
|
track_name=text_track_name,
|
||||||
|
text=text,
|
||||||
|
timerange=(duration, audio.duration),
|
||||||
|
style={
|
||||||
|
"size": 12.0,
|
||||||
|
"align": 1, # 水平居中(0为左对齐、1为水平居中、2为右对齐)
|
||||||
|
"auto_wrapping": True, # 自动换行
|
||||||
|
}, # 字体样式,字号为12,水平居中
|
||||||
|
clip_settings={
|
||||||
|
"transform_y": -0.85,
|
||||||
|
}, # 图像设置垂直下移60%
|
||||||
|
effect={
|
||||||
|
"effect_id": "6896137924853763336", # 使用花字
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 向字幕音频的音频轨道添加音频片段(片段字幕音频)
|
||||||
|
self.add_audio_segment(
|
||||||
|
track_name=subtitle_audio_track_name,
|
||||||
|
material=audio.path,
|
||||||
|
target_timerange=(duration, audio.duration),
|
||||||
|
volume=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
duration += audio.duration
|
||||||
|
content_duration += audio.duration
|
||||||
|
|
||||||
|
# 构建分镜视频路径
|
||||||
|
video_path = Path(
|
||||||
|
self.materials_folder_path / "videos" / f"{task_id}_{i:02d}.mp4"
|
||||||
|
)
|
||||||
|
# 分镜视频素材化
|
||||||
|
video = VideoMaterial(path=video_path.as_posix())
|
||||||
|
# 向视频轨道添加视频片段(分镜视频)
|
||||||
|
self.add_video_segment(
|
||||||
|
track_name=video_track_name,
|
||||||
|
material=video.path,
|
||||||
|
target_timerange=(duration - content_duration, content_duration),
|
||||||
|
volume=0.0,
|
||||||
|
speed=round(video.duration / content_duration, 2) - 0.01, # 冗余安全量
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建背景音频文件路径
|
||||||
|
background_audio_path = choice(
|
||||||
|
list((self.materials_folder_path / "background_audios").glob("*.mp3"))
|
||||||
|
)
|
||||||
|
# 背景音频素材化
|
||||||
|
background_audio = AudioMaterial(path=background_audio_path.as_posix())
|
||||||
|
# 向背景音频的音频轨道添加音频片段(背景音频)
|
||||||
|
self.add_audio_segment(
|
||||||
|
track_name=background_audio_track_name,
|
||||||
|
material=background_audio.path,
|
||||||
|
target_timerange=(0, duration),
|
||||||
|
volume=0.4,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 将草稿保存至剪映草稿文件夹内
|
||||||
|
self.draft.save()
|
||||||
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 605 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 548 KiB |
|
After Width: | Height: | Size: 645 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 834 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 722 KiB |
|
After Width: | Height: | Size: 905 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 608 KiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 197 KiB |
282
短视频AI生成/main.py
|
|
@ -1,6 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
主模块
|
主程序
|
||||||
"""
|
"""
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
|
@ -8,8 +8,11 @@ warnings.filterwarnings(
|
||||||
action="ignore", category=UserWarning, module="volcenginesdkarkruntime.*"
|
action="ignore", category=UserWarning, module="volcenginesdkarkruntime.*"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from base64 import b64decode, b64encode
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from time import sleep
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
@ -19,6 +22,11 @@ from volcenginesdkarkruntime.types.responses import (
|
||||||
ResponseOutputText,
|
ResponseOutputText,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sys.path.append(Path(__file__).parent.parent.as_posix())
|
||||||
|
|
||||||
|
from utils.request import Request
|
||||||
|
from create_draft import JianYingDraftGenerator
|
||||||
|
|
||||||
|
|
||||||
# 初始化火山引擎 AsyncArk 客户端
|
# 初始化火山引擎 AsyncArk 客户端
|
||||||
ark_client = Ark(
|
ark_client = Ark(
|
||||||
|
|
@ -26,13 +34,8 @@ ark_client = Ark(
|
||||||
api_key="2c28ab07-888c-45be-84a2-fc4b2cb5f3f2",
|
api_key="2c28ab07-888c-45be-84a2-fc4b2cb5f3f2",
|
||||||
) # 本人火山引擎账密
|
) # 本人火山引擎账密
|
||||||
|
|
||||||
|
# 初始化请求客户端
|
||||||
def generate_task_id() -> str:
|
request_client = Request()
|
||||||
"""
|
|
||||||
生成任务标识
|
|
||||||
:return: 任务ID
|
|
||||||
"""
|
|
||||||
return uuid4().hex.upper().replace("-", "")
|
|
||||||
|
|
||||||
|
|
||||||
def get_brand_words() -> List[str]:
|
def get_brand_words() -> List[str]:
|
||||||
|
|
@ -53,24 +56,12 @@ def get_brand_words() -> List[str]:
|
||||||
raise exception
|
raise exception
|
||||||
|
|
||||||
|
|
||||||
def refactor_storyboard_prompt(brand_word: str) -> str:
|
def generate_task_id() -> str:
|
||||||
"""
|
"""
|
||||||
重构分镜脚本的提示词
|
生成任务标识
|
||||||
:param brand_word: 品牌词
|
:return: 任务ID
|
||||||
:return: 重构后分镜脚本的提示词
|
|
||||||
"""
|
"""
|
||||||
try:
|
return uuid4().hex.upper().replace("-", "")
|
||||||
with open(
|
|
||||||
file=Path(__file__).parent / "storyboard_prompt_template.txt",
|
|
||||||
mode="r",
|
|
||||||
encoding="utf-8",
|
|
||||||
) as file:
|
|
||||||
storyboard_prompt = file.read()
|
|
||||||
return storyboard_prompt.replace("{{品牌词}}", brand_word)
|
|
||||||
except FileNotFoundError:
|
|
||||||
raise FileNotFoundError("未找到分镜脚本的提示词模板文件")
|
|
||||||
except Exception as exception:
|
|
||||||
raise exception
|
|
||||||
|
|
||||||
|
|
||||||
def get_storyboard(brand_word: str) -> Dict[str, Any]:
|
def get_storyboard(brand_word: str) -> Dict[str, Any]:
|
||||||
|
|
@ -79,13 +70,24 @@ def get_storyboard(brand_word: str) -> Dict[str, Any]:
|
||||||
:param brand_word: 品牌词
|
:param brand_word: 品牌词
|
||||||
:return: 分镜脚本
|
:return: 分镜脚本
|
||||||
"""
|
"""
|
||||||
# 重构分镜脚本的提示词
|
try:
|
||||||
storyboard_prompt = refactor_storyboard_prompt(brand_word)
|
with open(
|
||||||
|
file=Path(__file__).parent / "storyboard_prompt_template.txt",
|
||||||
|
mode="r",
|
||||||
|
encoding="utf-8",
|
||||||
|
) as file:
|
||||||
|
storyboard_prompt = file.read()
|
||||||
|
# 重构分镜脚本的提示词
|
||||||
|
storyboard_prompt = storyboard_prompt.replace("{{品牌词}}", brand_word)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise FileNotFoundError("未找到分镜脚本的提示词模板文件")
|
||||||
|
except Exception as exception:
|
||||||
|
raise exception
|
||||||
|
|
||||||
retries = 0 # 重试次数
|
retries = 0 # 重试次数
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# 调用豆包Seed语言大模型,基于分镜脚本的提示词生成分镜脚本
|
# 调用 doubao-seed 语言大模型,基于分镜脚本的提示词生成分镜脚本
|
||||||
response = ark_client.responses.create(
|
response = ark_client.responses.create(
|
||||||
model="doubao-seed-2-0-pro-260215",
|
model="doubao-seed-2-0-pro-260215",
|
||||||
input=[
|
input=[
|
||||||
|
|
@ -107,58 +109,186 @@ def get_storyboard(brand_word: str) -> Dict[str, Any]:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
storyboard = {
|
def generate_frame(frame_prompt: str, frame_name: str) -> None:
|
||||||
"核心营销要点": "淘宝闪购每日上新全品类正品好物,限时超低价开抢,购物省心又划算",
|
|
||||||
"分镜脚本": [
|
|
||||||
{
|
|
||||||
"阶段": "前段",
|
|
||||||
"口播": "买好物想省钱看这里!",
|
|
||||||
"TTS情绪": "energetic",
|
|
||||||
"首帧提示词": "橙色淘宝闪购logo 画面中央偏上 半透明小尺寸、特效未展开 浅橙色渐变背景 亮橙+米白 柔和光影 9:16竖屏 1080P 高清干净 无水印",
|
|
||||||
"尾帧提示词": "橙色立体淘宝闪购logo 画面正中央 清晰显示、周围带细碎金光闪效 浅橙色渐变背景 亮橙+暖白 明亮光影 9:16竖屏 1080P 高清干净 无水印",
|
|
||||||
"运镜提示词": "slight zoom in",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"阶段": "中段",
|
|
||||||
"口播": "淘宝闪购全是官方正品,服饰美妆数码家居全品类,每天限时开抢价格超划算",
|
|
||||||
"TTS情绪": "happy",
|
|
||||||
"首帧提示词": "服饰、美妆、数码、家居四类商品缩略图围绕淘宝闪购logo 画面中央 半透明小尺寸、特效未展开 暖白色背景 多彩明快 柔和光影 9:16竖屏 1080P 高清干净 无水印",
|
|
||||||
"尾帧提示词": "服饰、美妆、数码 、家居爆款商品清晰展示,旁附「直降50%」「限时抢」标签,淘宝闪购logo居上方中央 高亮饱满、爆闪优惠光效完全展开 暖白色背景 多彩明亮 充足光影 9:16竖屏 1080P 高清干净 无水印",
|
|
||||||
"运镜提示词": "slow push in",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"阶段": "后段",
|
|
||||||
"口播": "快点击下方链接抢!",
|
|
||||||
"TTS情绪": "cheer",
|
|
||||||
"首帧提示词": "黄色「点击下方链接」按钮+淘宝闪购logo 画面中央 半透明小尺寸、特效未展开 亮橙色背景 亮黄+橙红 柔和光影 9:16竖屏 1080P 高清干净 无水印",
|
|
||||||
"尾帧提示词": "高亮立体「点击下方链接」按钮+闪烁箭头指向屏幕下方,淘宝闪购logo居按钮上方 完全展示、高亮饱满、脉冲光效完整 亮橙色背景 亮黄+橙红 明亮耀眼光影 9:16竖屏 1080P 高清干净 无水印",
|
|
||||||
"运镜提示词": "slight zoom in",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def generate_frame(frame_prompt: str) -> str:
|
|
||||||
"""
|
"""
|
||||||
生成视频帧
|
生成视频帧
|
||||||
:param frame_prompt: 视频帧提示词
|
:param frame_prompt: 视频帧提示词
|
||||||
:return: 视频帧
|
:param frame_name: 视频帧名称
|
||||||
|
:return: None
|
||||||
"""
|
"""
|
||||||
# 调用豆包Seed图像大模型,基于视频帧提示词生成视频帧
|
# 构建视频帧路径
|
||||||
response = ark_client.responses.create(
|
frame_path = Path(__file__).parent / "frames" / frame_name
|
||||||
model="doubao-seed-2-0-pro-260215",
|
# 若视频帧已存在则直接返回
|
||||||
input=[
|
if frame_path.exists():
|
||||||
{
|
return
|
||||||
"role": "user",
|
|
||||||
"content": [{"type": "input_text", "text": frame_prompt}],
|
retries = 0 # 重试次数
|
||||||
}
|
while True:
|
||||||
],
|
try:
|
||||||
)
|
# 调用 doubao-seedream 图像大模型,基于视频帧提示词生成视频帧
|
||||||
# 解析响应并反序列化,以此作为视频帧
|
#
|
||||||
frame = json.loads(
|
response = ark_client.images.generate(
|
||||||
[item for item in [item for item in response.output if isinstance(item, ResponseOutputMessage)][0].content if isinstance(item, ResponseOutputText)][0].text # type: ignore
|
model="doubao-seedream-4-5-251128",
|
||||||
)
|
prompt=frame_prompt,
|
||||||
return frame
|
sequential_image_generation="disabled", # 关闭组图输出
|
||||||
|
size="1600x2848",
|
||||||
|
watermark=False, # 关闭水印
|
||||||
|
response_format="b64_json", # 图像数据为base64编码的JSON字符串
|
||||||
|
)
|
||||||
|
# 解析响应
|
||||||
|
frame_base64 = b64decode(response.data[0].b64_json)
|
||||||
|
# 本地保存视频帧
|
||||||
|
with open(file=frame_path, mode="wb") as file:
|
||||||
|
file.write(frame_base64)
|
||||||
|
return
|
||||||
|
except Exception as exception:
|
||||||
|
retries += 1
|
||||||
|
if retries > 2:
|
||||||
|
raise Exception(f"生成视频帧发生异常,{str(exception)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
print(storyboard)
|
def generate_video(
|
||||||
|
video_name: str,
|
||||||
|
first_frame_name: str,
|
||||||
|
last_frame_name: str,
|
||||||
|
shot_prompt: str,
|
||||||
|
video_duration: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
生成视频
|
||||||
|
:param video_name: 视频名称
|
||||||
|
:param first_frame_name: 首帧名称
|
||||||
|
:param last_frame_name: 尾帧名称
|
||||||
|
:param shot_prompt: 运镜提示词
|
||||||
|
:param video_duration: 视频时长
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# 若视频已存在则直接返回
|
||||||
|
if (Path(__file__).parent / "videos" / video_name).exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# 构建首帧路径
|
||||||
|
first_frame_path = Path(__file__).parent / "frames" / first_frame_name
|
||||||
|
if not first_frame_path.exists():
|
||||||
|
raise RuntimeError(f"首帧 {first_frame_name} 不存在")
|
||||||
|
with open(file=first_frame_path, mode="rb") as file:
|
||||||
|
first_frame_base64 = b64encode(file.read()).decode("utf-8")
|
||||||
|
|
||||||
|
# 构建尾帧路径
|
||||||
|
last_frame_path = Path(__file__).parent / "frames" / last_frame_name
|
||||||
|
if not last_frame_path.exists():
|
||||||
|
raise RuntimeError(f"尾帧 {last_frame_name} 不存在")
|
||||||
|
with open(file=last_frame_path, mode="rb") as file:
|
||||||
|
last_frame_base64 = b64encode(file.read()).decode("utf-8")
|
||||||
|
|
||||||
|
retries = 0 # 重试次数
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# 调用 doubao-seedrance 视频大模型,基于首尾帧和运镜提示词生成视频
|
||||||
|
# 创建视频生成任务
|
||||||
|
create_response = ark_client.content_generation.tasks.create(
|
||||||
|
model="doubao-seedance-1-5-pro-251215",
|
||||||
|
content=[
|
||||||
|
{"type": "text", "text": shot_prompt},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/jpeg;base64,{first_frame_base64}"
|
||||||
|
},
|
||||||
|
"role": "first_frame",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/jpeg;base64,{last_frame_base64}"
|
||||||
|
},
|
||||||
|
"role": "last_frame",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ratio="9:16", # 视频的宽高比例
|
||||||
|
duration=video_duration, # 视频时长(doubao-seedance-1-5-pro-251215 有效范围为[4, 12])
|
||||||
|
watermark=False, # 关闭水印
|
||||||
|
)
|
||||||
|
|
||||||
|
# 轮询查询视频生成任务
|
||||||
|
while True:
|
||||||
|
# 查询视频生成任务
|
||||||
|
query_response = ark_client.content_generation.tasks.get(
|
||||||
|
task_id=create_response.id,
|
||||||
|
)
|
||||||
|
# 根据视频生成任务的状态匹配处理方法
|
||||||
|
match query_response.status:
|
||||||
|
case "succeeded":
|
||||||
|
video_url = query_response.content.video_url
|
||||||
|
# 下载视频
|
||||||
|
chunk_generator = request_client.download(
|
||||||
|
url=video_url,
|
||||||
|
stream_enabled=True, # 开启流式传输
|
||||||
|
)
|
||||||
|
# 本地保存视频
|
||||||
|
with open(
|
||||||
|
file=Path(__file__).parent / "videos" / video_name,
|
||||||
|
mode="wb",
|
||||||
|
) as file:
|
||||||
|
for chunk in chunk_generator:
|
||||||
|
file.write(chunk)
|
||||||
|
return
|
||||||
|
case "failed":
|
||||||
|
raise Exception(f"{query_response.error}")
|
||||||
|
case _:
|
||||||
|
sleep(5) # 避免频繁请求查询视频生成任务故显性等待
|
||||||
|
except Exception as exception:
|
||||||
|
retries += 1
|
||||||
|
if retries > 2:
|
||||||
|
raise Exception(f"生成视频发生异常,{str(exception)}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 分镜视频时长列表
|
||||||
|
video_durations = [15, 4, 8, 8, 4]
|
||||||
|
|
||||||
|
# 遍历品牌词
|
||||||
|
for brand_word in get_brand_words():
|
||||||
|
# 生成任务标识
|
||||||
|
task_id = generate_task_id()
|
||||||
|
|
||||||
|
# 获取分镜脚本
|
||||||
|
storyboard = get_storyboard(
|
||||||
|
brand_word=brand_word,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 遍历分镜
|
||||||
|
for i, shot in enumerate(storyboard["分镜脚本"], start=1):
|
||||||
|
# 构建分镜首帧名称
|
||||||
|
first_frame_name = f"{task_id}_{i:02d}_first.jpeg"
|
||||||
|
# 生成分镜首帧
|
||||||
|
generate_frame(
|
||||||
|
frame_prompt=shot["首帧提示词"], # 分镜首帧提示词
|
||||||
|
frame_name=first_frame_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建分镜尾帧名称
|
||||||
|
last_frame_name = f"{task_id}_{i:02d}_last.jpeg"
|
||||||
|
# 生成分镜尾帧
|
||||||
|
generate_frame(
|
||||||
|
frame_prompt=shot["尾帧提示词"], # 分镜尾帧提示词
|
||||||
|
frame_name=last_frame_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成视频
|
||||||
|
generate_video(
|
||||||
|
video_name=f"{task_id}_{i:02d}.mp4", # 分镜视频名称
|
||||||
|
video_duration=video_durations[i], # 分镜视频时长
|
||||||
|
first_frame_name=first_frame_name,
|
||||||
|
last_frame_name=last_frame_name,
|
||||||
|
shot_prompt=shot["运镜提示词"], # 分镜运镜提示词
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成剪映草稿
|
||||||
|
draft_generator = JianYingDraftGenerator()
|
||||||
|
draft_generator.create_draft(
|
||||||
|
task_id=task_id,
|
||||||
|
contents=[shot["口播"] for shot in storyboard["分镜脚本"]],
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,35 @@
|
||||||
你作为一名专业、优秀的广告编导,请为品牌“{{品牌词}}”生成一条15秒营销广告分镜脚本。该脚本用于AI生成短视频,投放于快手、今日头条、视频号等短视频平台。
|
你作为一名专业、优秀的广告编导,请为品牌“{{品牌词}}”生成一条24秒营销广告分镜脚本。该脚本用于AI生成短视频,投放于快手、今日头条、视频号等短视频平台。
|
||||||
|
|
||||||
必须严格遵守以下所有规则,不可遗漏任何字段:
|
必须严格遵守以下所有规则,不可遗漏任何字段:
|
||||||
|
|
||||||
1. 内容要求
|
1. 内容要求
|
||||||
- 符合品牌真实经营范围与营销风格
|
- 符合品牌真实经营范围与营销风格
|
||||||
- 符合短视频平台用户偏好
|
|
||||||
- 不得虚构、不得违规
|
- 不得虚构、不得违规
|
||||||
|
|
||||||
2. 时长结构(总15秒)
|
2. 时长结构
|
||||||
- 前段:3秒,吸引注意力
|
- 第一段:4秒,就该品牌所解决的核心痛点构思场景,吸引用户注意
|
||||||
- 中段:10秒,传递核心卖点
|
- 第二段:8秒,体现该品牌并构思该品牌2~3个价值点
|
||||||
- 后段:2秒,引导点击下方链接
|
- 第三段:8秒,围绕价值点构思实际场景
|
||||||
|
- 第四段:4秒,引导用户点击视频下方链接
|
||||||
|
|
||||||
3. 口播要求(用于 EdgeTTS 语音合成)
|
3. 口播要求(用于 EdgeTTS )
|
||||||
- 前段口播:8~12个汉字
|
- 第一段口播:一句话,5~15个汉字
|
||||||
- 中段口播:25~35个汉字
|
- 第二段口播:15~30个汉字
|
||||||
- 后段口播:5~10个汉字
|
- 第三段口播:15~30个汉字
|
||||||
|
- 第四段口播:一句话,5~15个汉字
|
||||||
- 口语化、情绪饱满、适合短视频传播
|
- 口语化、情绪饱满、适合短视频传播
|
||||||
|
|
||||||
4. TTS 情绪要求(必须返回英文单词)
|
4. 首帧 / 尾帧提示词(用于 Doubao-Seedream-5.0 文生图)
|
||||||
- 从以下列表中选择:cheer, happy, energetic, warm, friendly, confident
|
- 用简洁连贯的自然语言完整且详细描述:主体+位置+状态+环境+色彩+光影
|
||||||
- 字段名固定为:TTS情绪
|
- 9:16 竖屏,1080P 高清,干净 无水印
|
||||||
|
|
||||||
5. 首帧 / 尾帧提示词(用于 Seedream 4.5 文生图)
|
|
||||||
- 格式:主体+位置+状态+环境+色彩+光影
|
|
||||||
- 9:16 竖屏,1080P,高清干净,无水印
|
|
||||||
- 首帧:元素刚出现,半透明,小尺寸,特效未展开
|
- 首帧:元素刚出现,半透明,小尺寸,特效未展开
|
||||||
- 尾帧:元素完全展示,高亮饱满,特效完整
|
- 尾帧:元素完全展示,高亮饱满,特效完整
|
||||||
- 画面自然、有质感、适合电商营销
|
- 画面和本段口播匹配,自然、有质感,适合广告营销,
|
||||||
|
|
||||||
6. 运镜提示词(用于 Seedance 1.5 图生视频,必须英文)
|
5. 运镜提示词(用于 Doubao-Seedance-2.0 首尾帧生视频,必须英文)
|
||||||
- 运镜平稳、无抖动
|
- 运镜平稳、无抖动
|
||||||
- 主体居中不变形
|
|
||||||
- 可选:slow push in, slow pan, slight zoom in, slow circle
|
|
||||||
- 与本段时长匹配
|
|
||||||
|
|
||||||
7. 输出强制要求
|
6. 输出强制要求
|
||||||
- 只输出标准 JSON,无任何多余文字
|
- 只输出标准 JSON,无任何多余文字
|
||||||
- 不要解释、不要前言、不要后缀
|
- 不要解释、不要前言、不要后缀
|
||||||
- 不要 ```json 标记
|
- 不要 ```json 标记
|
||||||
|
|
@ -45,25 +39,29 @@
|
||||||
"核心营销要点": "一句话总结品牌核心价值",
|
"核心营销要点": "一句话总结品牌核心价值",
|
||||||
"分镜脚本": [
|
"分镜脚本": [
|
||||||
{
|
{
|
||||||
"阶段": "前段",
|
"阶段": "第一段",
|
||||||
"口播": "...",
|
"口播": "一句话口播",
|
||||||
"TTS情绪": "...",
|
|
||||||
"首帧提示词": "...",
|
"首帧提示词": "...",
|
||||||
"尾帧提示词": "...",
|
"尾帧提示词": "...",
|
||||||
"运镜提示词": "..."
|
"运镜提示词": "..."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"阶段": "中段",
|
"阶段": "第二段",
|
||||||
"口播": "...",
|
"口播": "...",
|
||||||
"TTS情绪": "...",
|
|
||||||
"首帧提示词": "...",
|
"首帧提示词": "...",
|
||||||
"尾帧提示词": "...",
|
"尾帧提示词": "...",
|
||||||
"运镜提示词": "..."
|
"运镜提示词": "..."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"阶段": "后段",
|
"阶段": "第三段",
|
||||||
|
"口播": "...",
|
||||||
|
"首帧提示词": "...",
|
||||||
|
"尾帧提示词": "...",
|
||||||
|
"运镜提示词": "..."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"阶段": "第四段",
|
||||||
"口播": "...",
|
"口播": "...",
|
||||||
"TTS情绪": "...",
|
|
||||||
"首帧提示词": "...",
|
"首帧提示词": "...",
|
||||||
"尾帧提示词": "...",
|
"尾帧提示词": "...",
|
||||||
"运镜提示词": "..."
|
"运镜提示词": "..."
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class EdgeTTS:
|
||||||
)
|
)
|
||||||
await communicator.save(file_path.as_posix())
|
await communicator.save(file_path.as_posix())
|
||||||
# 持续时长(单位为微秒)
|
# 持续时长(单位为微秒)
|
||||||
duration = int(round(MP3(file_path.as_posix()).info.length * 1000000))
|
duration = int(round(MP3(file_path.as_posix()).info.length * 1_000_000))
|
||||||
return file_path, duration
|
return file_path, duration
|
||||||
|
|
||||||
return asyncio.run(_async_synthetize())
|
return asyncio.run(_async_synthetize())
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ from pathlib import WindowsPath
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
|
||||||