656 lines
23 KiB
Python
656 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
||
|
||
"""
|
||
剪映脚本生成自动化
|
||
"""
|
||
|
||
import asyncio
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import edge_tts
|
||
import pycapcut as capcut
|
||
from mutagen.mp3 import MP3
|
||
from pycapcut import trange
|
||
from pycapcut.keyframe import KeyframeProperty
|
||
from pycapcut.segment import ClipSettings
|
||
|
||
|
||
class EdgeTTS:
|
||
"""
|
||
edge在线语音合成
|
||
"""
|
||
|
||
# 中文音色
|
||
TIMBRES = {
|
||
"女声-晓晓": "zh-CN-XiaoxiaoNeural",
|
||
"女声-晓辰": "zh-CN-XiaochenNeural",
|
||
"女声-晓倩": "zh-CN-XiaoqianNeural",
|
||
}
|
||
|
||
def __init__(
|
||
self,
|
||
materials_path: Path,
|
||
):
|
||
"""
|
||
初始化语音合成器
|
||
:param materials_path: 素材文件夹路径
|
||
"""
|
||
# 素材文件夹路径
|
||
self.materials_path = materials_path
|
||
|
||
def generate_audio(
|
||
self,
|
||
content: str,
|
||
timbre: Optional[str] = "女声-晓晓",
|
||
rate: str = "+0%",
|
||
volume: str = "+0%",
|
||
) -> Tuple[str, int]:
|
||
"""
|
||
根据文本内容合成语音并返回音频素材名称
|
||
:param content: 文本内容
|
||
:param timbre: 音色名称,例如女声-晓晓
|
||
:param rate: 语速
|
||
:param volume: 音量
|
||
:return 音频素材名称和持续时长
|
||
"""
|
||
# noinspection PyBroadException
|
||
try:
|
||
# 异步处理:根据文本内容合成语音并保存为音频素材
|
||
async def _async_generate_audio():
|
||
# 实例化Communicate
|
||
communicator = edge_tts.Communicate(
|
||
text=content,
|
||
voice=self.TIMBRES[timbre],
|
||
rate=rate,
|
||
volume=volume,
|
||
)
|
||
# 音频素材名称
|
||
name = f"{uuid.uuid4().hex[-16:].upper()}.mp3"
|
||
# 音频素材路径
|
||
audio_path = self.materials_path / name
|
||
await communicator.save(audio_path := audio_path.as_posix())
|
||
# 音频持续时长(单位为微妙)
|
||
duration = int(round(MP3(audio_path).info.length * 1000000))
|
||
return name, duration
|
||
|
||
# 同步调用异步逻辑,对外暴露纯同步接口
|
||
return asyncio.run(_async_generate_audio())
|
||
except Exception as exception:
|
||
raise RuntimeError(
|
||
f"根据文本内容合成语音并保存为音频素材发声异常:{str(exception)}"
|
||
)
|
||
|
||
|
||
class GenerateDraft:
|
||
"""剪映脚本生成器"""
|
||
|
||
# noinspection PyShadowingNames
|
||
def __init__(
|
||
self,
|
||
name: str,
|
||
video_width: int = 1080,
|
||
video_height: int = 1920,
|
||
video_fps: int = 30,
|
||
drafts_path: str = r"C:\Users\admin\AppData\Local\JianyingPro\User Data\Projects\com.lveditor.draft",
|
||
materials_path: str = r"C:\Users\admin\PycharmProjects\Python\剪映脚本生成自动化\materials",
|
||
):
|
||
"""
|
||
初始化生成剪映脚本
|
||
:param name: 草稿名称
|
||
:param video_width: 视频宽度
|
||
:param video_height: 视频高度
|
||
:param video_fps: 视频帧率
|
||
:param drafts_path: 剪映草稿文件夹路径
|
||
:param materials_path: 素材文件夹路径
|
||
"""
|
||
print("正在初始化剪映脚本生成器...", end="")
|
||
# noinspection PyBroadException
|
||
try:
|
||
# 草稿名称
|
||
self.name = name
|
||
# 视频宽度和高度
|
||
self.video_width, self.video_height = video_width, video_height
|
||
# 视频帧率
|
||
self.video_fps = video_fps
|
||
# 剪映草稿文件夹路径
|
||
self.drafts_path = drafts_path
|
||
# 素材文件夹路径
|
||
self.materials_path = Path(materials_path) # 转为path对象
|
||
# 检查素材文件夹是否存在,若不存在则抛出异常
|
||
if not self.materials_path.exists():
|
||
raise FileNotFoundError(f"素材文件夹不存在")
|
||
|
||
# 初始化草稿文件夹管理器
|
||
self.draft_folder = capcut.DraftFolder(self.drafts_path)
|
||
|
||
# 新建草稿
|
||
self.draft = self.draft_folder.create_draft(
|
||
draft_name=self.name,
|
||
width=self.video_width,
|
||
height=self.video_height,
|
||
fps=self.video_fps,
|
||
allow_replace=True, # 允许覆盖
|
||
)
|
||
|
||
# 脚本持续时长
|
||
self.duration = 0
|
||
# 实例化EdgeTTS
|
||
self.synthesizer = EdgeTTS(self.materials_path)
|
||
print("已完成")
|
||
except Exception as exception:
|
||
print(f"发生异常:{str(exception)}")
|
||
raise
|
||
|
||
def _get_material(self, name: str) -> str:
|
||
"""
|
||
获取素材
|
||
:param name: 素材名称
|
||
:return 素材路径
|
||
"""
|
||
# 素材路径
|
||
material_path = self.materials_path / name
|
||
if not material_path.exists():
|
||
raise FileNotFoundError(f"素材文件不存在")
|
||
return material_path.as_posix()
|
||
|
||
def _add_audio(
|
||
self,
|
||
track_name: str,
|
||
name: str,
|
||
target_timerange: Tuple[Optional[int, str], Optional[int, str]],
|
||
source_timerange: Optional[Tuple[str, str]] = None,
|
||
speed: Optional[float] = None,
|
||
volume: float = 1.0,
|
||
fade: Optional[Tuple[str, str]] = None,
|
||
) -> None:
|
||
"""
|
||
添加音频片段
|
||
:param track_name: 轨道名称
|
||
:param name: 音频素材名称
|
||
:param target_timerange: 音频素材在轨道上的范围,包括开始时间和持续时长
|
||
:param source_timerange: 截取音频素材范围,包括开始时间和持续时长,默认根据音频素材开始时间根据播放速度截取与音频素材持续时长等长的部分
|
||
:param speed: 播放速度
|
||
:param volume: 播放音量
|
||
:param fade: 淡入淡出设置
|
||
:return: 无
|
||
"""
|
||
try:
|
||
# 构建音频片段
|
||
audio_segment = capcut.AudioSegment(
|
||
material=self._get_material(name),
|
||
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)
|
||
return
|
||
except Exception:
|
||
raise
|
||
|
||
def _add_video(
|
||
self,
|
||
track_name: str,
|
||
name: str,
|
||
target_timerange: Tuple[Optional[int, str], Optional[int, str]],
|
||
source_timerange: Optional[
|
||
Tuple[Optional[int, str], Optional[int, str]],
|
||
] = None,
|
||
speed: Optional[float] = None,
|
||
volume: float = 1.0,
|
||
clip_settings: Optional[Dict[str, Any]] = None,
|
||
keyframes: Optional[List[Tuple[KeyframeProperty, str, float]]] = None,
|
||
animation: Optional[Dict[str, Any]] = None,
|
||
transition: Optional[Dict[str, Any]] = None,
|
||
background_filling: Optional[Tuple[str, Any]] = None,
|
||
) -> None:
|
||
"""
|
||
添加视频/图片片段
|
||
:param track_name: 轨道名称
|
||
:param name: 视频/图片素材名称
|
||
:param target_timerange: 视频素材在轨道上的范围,包括开始时间和持续时长
|
||
:param source_timerange: 截取视频素材范围,包括开始时间和持续时长
|
||
:param speed: 播放速度
|
||
:param volume: 播放音量
|
||
:param clip_settings: 图像调节设置
|
||
:param keyframes: 关键帧设置
|
||
:param animation: 动画设置
|
||
:param transition: 转场设置
|
||
:param background_filling: 背景填充设置
|
||
:param track_name: 轨道名称
|
||
:return: 无
|
||
"""
|
||
try:
|
||
# 构建视频/图片片段
|
||
video_segment = capcut.VideoSegment(
|
||
material=self._get_material(name),
|
||
target_timerange=trange(*target_timerange),
|
||
source_timerange=(
|
||
trange(*source_timerange) if source_timerange else None
|
||
),
|
||
speed=speed,
|
||
volume=volume,
|
||
clip_settings=ClipSettings(**clip_settings) if clip_settings else None,
|
||
)
|
||
# (视频素材)添加关键帧
|
||
if keyframes:
|
||
# noinspection PyShadowingBuiltins
|
||
for property, time, value in keyframes:
|
||
video_segment.add_keyframe(property, time, 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)
|
||
return
|
||
except Exception:
|
||
raise
|
||
|
||
def _add_text(
|
||
self,
|
||
track_name: str,
|
||
content: str,
|
||
timerange: Tuple[Optional[int, str], Optional[int, str]],
|
||
border: Optional[Dict[str, Any]] = None,
|
||
background: Optional[Dict[str, Any]] = None,
|
||
font: Optional[str] = None,
|
||
style: 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 content: 文本内容
|
||
:param timerange: 文本素材在轨道上的范围,包括开始时间和持续时长
|
||
:param border: 文本描边设置
|
||
:param background: 文本背景设置
|
||
:param font: 字体类型
|
||
:param style: 字体样式
|
||
:param clip_settings: 文本调节设置
|
||
:param bubble: 气泡设置
|
||
:param effect: 花字设置
|
||
:param animation: 动画设置
|
||
:return: 无
|
||
"""
|
||
try:
|
||
# 构建文本片段
|
||
text_segment = capcut.TextSegment(
|
||
text=content,
|
||
timerange=trange(*timerange),
|
||
border=capcut.TextBorder(**border) if border else None,
|
||
background=capcut.TextBackground(**background) if background else None,
|
||
font=capcut.FontType(font) if font else None,
|
||
style=capcut.TextStyle(**style) if style else None,
|
||
clip_settings=(
|
||
capcut.ClipSettings(**clip_settings) if clip_settings else None
|
||
),
|
||
)
|
||
# 添加气泡
|
||
if bubble:
|
||
text_segment.add_bubble(**bubble)
|
||
|
||
# 添加花字
|
||
if effect:
|
||
text_segment.add_effect(
|
||
**effect
|
||
) # 可先将花字保存预设,再在C:/Users/admin/AppData/Local/JianyingPro/User Data/Presets/Text_V2/预设文本?.textpreset获取花字resource_id
|
||
|
||
# 添加动画
|
||
if animation:
|
||
text_segment.add_animation(**animation)
|
||
|
||
# 向指定轨道添加文本片段
|
||
self.draft.add_segment(segment=text_segment, track_name=track_name)
|
||
return
|
||
except Exception:
|
||
raise
|
||
|
||
def _add_sticker(
|
||
self,
|
||
track_name: str,
|
||
resource_id: str,
|
||
target_timerange: Tuple[Optional[int, str], Optional[int, str]],
|
||
clip_settings: Optional[Dict[str, Any]] = None,
|
||
) -> None:
|
||
"""
|
||
添加贴纸片段
|
||
:param track_name: 轨道名称
|
||
:param resource_id: 贴纸 resource_id
|
||
:param target_timerange: 贴纸在轨道上的范围,包括开始时间和持续时长
|
||
:param clip_settings: 文本调节设置
|
||
:return: 无
|
||
"""
|
||
try:
|
||
# 构建贴纸
|
||
sticker_segment = capcut.StickerSegment(
|
||
resource_id=resource_id, # 可先将贴纸保存为我的预设,再在C:\Users\admin\AppData\Local\JianyingPro\User Data\Presets\Combination\Presets\我的预设?\preset_draft获取
|
||
target_timerange=trange(*target_timerange),
|
||
clip_settings=(
|
||
capcut.ClipSettings(**clip_settings) if clip_settings else None
|
||
),
|
||
)
|
||
|
||
# 向指定轨道添加贴纸
|
||
self.draft.add_segment(segment=sticker_segment, track_name=track_name)
|
||
except Exception:
|
||
raise
|
||
|
||
def save(self) -> None:
|
||
"""保存草稿"""
|
||
try:
|
||
self.draft.save()
|
||
except Exception:
|
||
raise
|
||
|
||
def add_subtitle(
|
||
self,
|
||
script: str,
|
||
track_name: str = "subtitle",
|
||
timbre: Optional[str] = "女声-晓晓",
|
||
rate: str = "+25%",
|
||
volume: str = "+0%",
|
||
):
|
||
"""
|
||
根据脚本生成文本和音频字幕
|
||
:param track_name: 轨道名称
|
||
:param script: 脚本
|
||
:param timbre: 声音音色
|
||
:param rate: 语速
|
||
:param volume: 音量
|
||
:return: 无
|
||
"""
|
||
print("正在根据脚本生成文本和音频字幕...", end="")
|
||
# 添加文本轨道
|
||
self.draft.add_track(
|
||
track_type=capcut.TrackType.text,
|
||
track_name=(text_track_name := f"{track_name}:text"),
|
||
)
|
||
# 添加音频轨道
|
||
self.draft.add_track(
|
||
track_type=capcut.TrackType.audio,
|
||
track_name=(audio_track_name := f"{track_name}:audio"),
|
||
)
|
||
|
||
start = 0
|
||
for content in script.split(","):
|
||
# 根据文本内容合成语音并返回音频素材名称
|
||
name, duration = self.synthesizer.generate_audio(
|
||
content, timbre, rate, volume
|
||
)
|
||
# 添加文本片段
|
||
self._add_text(
|
||
track_name=text_track_name,
|
||
content=content,
|
||
timerange=(start, duration),
|
||
style={"size": 12.0, "align": 1}, # 字号为12,对齐方式为水平居中
|
||
clip_settings={"transform_y": -0.5}, # 垂直位移
|
||
effect={"effect_id": "6896137858998930701"}, # 第二行第三列花字
|
||
)
|
||
# 添加音频片段
|
||
self._add_audio(
|
||
track_name=audio_track_name,
|
||
name=name,
|
||
target_timerange=(start, duration),
|
||
)
|
||
start += duration
|
||
|
||
# 更新脚本持续时长
|
||
self.duration = start
|
||
print("已完成")
|
||
|
||
def add_video(
|
||
self,
|
||
track_name: str,
|
||
name: str,
|
||
target_timerange: Tuple[Optional[int, str], Optional[int, str]] = None,
|
||
source_timerange: Optional[
|
||
Tuple[Optional[int, str], Optional[int, str]],
|
||
] = None,
|
||
speed: Optional[float] = None,
|
||
volume: float = 1.0,
|
||
clip_settings: Optional[Dict[str, Any]] = None,
|
||
keyframes: Optional[List[Tuple[KeyframeProperty, str, float]]] = None,
|
||
animation: Optional[Dict[str, Any]] = None,
|
||
transition: Optional[Dict[str, Any]] = None,
|
||
background_filling: Optional[Tuple[str, Any]] = None,
|
||
):
|
||
"""
|
||
向指定轨道添加视频/图片片段
|
||
:param track_name: 轨道名称
|
||
:param name: 视频/图片素材名称
|
||
:param target_timerange: 视频素材在轨道上的范围,包括开始时间和持续时长
|
||
:param source_timerange: 截取视频素材范围,包括开始时间和持续时长
|
||
:param speed: 播放速度
|
||
:param volume: 播放音量
|
||
:param clip_settings: 图像调节设置
|
||
:param keyframes: 关键帧设置
|
||
:param animation: 动画设置
|
||
:param transition: 转场设置
|
||
:param background_filling: 背景填充设置
|
||
:return: 无
|
||
"""
|
||
# 预设图像调节设置
|
||
CLIPSETTINGS = {
|
||
"logo": {
|
||
"scale_x": 0.2,
|
||
"scale_y": 0.2,
|
||
"transform_x": -0.68,
|
||
"transform_y": 0.82,
|
||
} # 等比缩放至20%,移动至左上角
|
||
}
|
||
# 根据轨道名称获取预设文本调节设置
|
||
if track_name in CLIPSETTINGS and not clip_settings:
|
||
clip_settings = CLIPSETTINGS.get(track_name)
|
||
|
||
print(f"正在向轨道 {track_name} 添加视频/图片片段...", end="")
|
||
track_name = f"{track_name}:video"
|
||
|
||
# 添加视频轨道
|
||
self.draft.add_track(track_type=capcut.TrackType.video, track_name=track_name)
|
||
|
||
# 添加视频片段
|
||
self._add_video(
|
||
track_name=track_name,
|
||
name=name,
|
||
target_timerange=(
|
||
target_timerange if target_timerange else (0, self.duration)
|
||
),
|
||
source_timerange=source_timerange,
|
||
speed=speed,
|
||
volume=volume,
|
||
clip_settings=clip_settings,
|
||
keyframes=keyframes,
|
||
animation=animation,
|
||
transition=transition,
|
||
background_filling=background_filling,
|
||
)
|
||
print("已完成")
|
||
|
||
def add_text(
|
||
self,
|
||
track_name: str,
|
||
content: str,
|
||
timerange: Tuple[Optional[int, str], Optional[int, str]] = None,
|
||
border: Optional[Dict[str, Any]] = None,
|
||
background: Optional[Dict[str, Any]] = None,
|
||
font: Optional[str] = None,
|
||
style: Optional[Dict[str, Any]] = None,
|
||
clip_settings: Optional[Dict[str, Any]] = None,
|
||
bubble: Optional[Dict[str, Any]] = None,
|
||
effect: Optional[str] = None,
|
||
animation: Optional[Dict[str, Any]] = None,
|
||
):
|
||
"""
|
||
向指定轨道添加文本片段
|
||
:param track_name: 轨道名称
|
||
:param content: 文本内容
|
||
:param timerange: 文本素材在轨道上的范围,包括开始时间和持续时长
|
||
:param border: 文本描边设置
|
||
:param background: 文本背景设置
|
||
:param font: 字体类型
|
||
:param style: 字体样式
|
||
:param clip_settings: 文本调节设置
|
||
:param bubble: 气泡设置
|
||
:param effect: 花字设置
|
||
:param animation: 动画设置
|
||
:return: 无
|
||
"""
|
||
# 预设文本描边设置
|
||
BORDER = {
|
||
"disclaimer": {
|
||
"width": 60.0,
|
||
} # 描边宽度为60
|
||
}
|
||
# 根据轨道名称获取预设文本描边设置
|
||
if track_name in BORDER and not border:
|
||
border = BORDER.get(track_name)
|
||
|
||
# 预设字体样式
|
||
STYLE = {
|
||
"disclaimer": {
|
||
"size": 8.0,
|
||
"align": 1,
|
||
} # 字号为8,对齐方式为水平居中
|
||
}
|
||
# 根据轨道名称获取预设字体样式
|
||
if track_name in STYLE and not style:
|
||
style = STYLE.get(track_name)
|
||
|
||
# 预设文本调节设置
|
||
CLIPSETTINGS = {
|
||
"disclaimer": {
|
||
"transform_y": -0.8,
|
||
} # 垂直位移
|
||
}
|
||
# 根据轨道名称获取预设字体样式
|
||
if track_name in CLIPSETTINGS and not clip_settings:
|
||
clip_settings = CLIPSETTINGS.get(track_name)
|
||
|
||
print(f"正在向轨道 {track_name} 添加文本片段...", end="")
|
||
track_name = f"{track_name}:text"
|
||
|
||
# 添加文本轨道
|
||
self.draft.add_track(track_type=capcut.TrackType.text, track_name=track_name)
|
||
|
||
# 添加文本片段
|
||
self._add_text(
|
||
track_name=track_name,
|
||
content=content,
|
||
timerange=(timerange if timerange else (0, self.duration)),
|
||
border=border,
|
||
background=background,
|
||
font=font,
|
||
style=style,
|
||
clip_settings=clip_settings,
|
||
bubble=bubble,
|
||
effect=effect,
|
||
animation=animation,
|
||
)
|
||
print("已完成")
|
||
|
||
def add_sticker(
|
||
self,
|
||
track_name: str,
|
||
resource_id: str,
|
||
target_timerange: Tuple[Optional[int, str], Optional[int, str]] = None,
|
||
clip_settings: Optional[Dict[str, Any]] = None,
|
||
):
|
||
"""
|
||
向指定轨道添加贴纸
|
||
:param track_name: 轨道名称
|
||
:param resource_id: 贴纸 resource_id,可先将贴纸保存为我的预设
|
||
:param target_timerange: 贴纸在轨道上的范围,包括开始时间和持续时长
|
||
:param clip_settings: 文本调节设置
|
||
:return: 无
|
||
"""
|
||
# 预设文本描边设置
|
||
RESOURCEID = {
|
||
"7026858083393588487": {
|
||
"scale_x": 0.2,
|
||
"scale_y": 0.2,
|
||
"transform_x": -0.75,
|
||
"transform_y": -0.78,
|
||
} # 等比缩放至20%,移动至左上角
|
||
}
|
||
# 根据轨道名称获取预设文本描边设置
|
||
if resource_id in RESOURCEID and not clip_settings:
|
||
clip_settings = RESOURCEID.get(resource_id)
|
||
|
||
print(f"正在向轨道 {track_name} 添加贴纸...", end="")
|
||
track_name = f"{track_name}:video"
|
||
|
||
# 添加贴纸轨道
|
||
self.draft.add_track(track_type=capcut.TrackType.sticker, track_name=track_name)
|
||
|
||
# 添加贴纸
|
||
self._add_sticker(
|
||
track_name=track_name,
|
||
resource_id=resource_id,
|
||
target_timerange=(
|
||
target_timerange if target_timerange else (0, self.duration)
|
||
),
|
||
clip_settings=clip_settings,
|
||
)
|
||
print("已完成")
|
||
|
||
|
||
# ======================== 调用示例(使用抽象后的方法) ========================
|
||
def execute_workflow():
|
||
"""生成剪映草稿"""
|
||
# 实例化
|
||
draft = GenerateDraft(
|
||
name="demo2",
|
||
)
|
||
|
||
# 根据脚本生成文本和音频字幕
|
||
draft.add_subtitle(
|
||
script="所有人今天准备狂点外卖,是真的0.1元起一杯的霸王茶姬,还外卖到家怎么能不来一杯呢,现在淘宝闪购给大家发福利,最高22元无门槛红包,官方链接就在下方,奶茶脑袋快冲"
|
||
)
|
||
# 为背景视频添加视频轨道并添加视频片段
|
||
draft.add_video(
|
||
track_name="background",
|
||
name="background.mp4",
|
||
clip_settings={"scale_x": 2.5, "scale_y": 2.5},
|
||
)
|
||
# 为logo添加视频轨道并添加图片片段
|
||
draft.add_video(
|
||
track_name="logo",
|
||
name="logo.png",
|
||
)
|
||
# 为免责声明添加视频轨道并添加图片片段
|
||
draft.add_text(
|
||
track_name="disclaimer",
|
||
content="支付需谨慎谨防诈骗\n仅限支付宝用户,详情以活动为准",
|
||
)
|
||
|
||
draft.add_sticker(track_name="sticker", resource_id="7026858083393588487")
|
||
|
||
# 保存草稿
|
||
draft.save()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
execute_workflow()
|