更新2个ffmpeg worker方法和web调用接口

This commit is contained in:
shuohigh@gmail.com
2025-05-07 19:00:32 +08:00
parent a05e153717
commit d1557920f5
2 changed files with 235 additions and 143 deletions

View File

@@ -12,10 +12,10 @@ ffmpeg_worker_image = (
app = modal.App(image=ffmpeg_worker_image, ) app = modal.App(image=ffmpeg_worker_image, )
with ffmpeg_worker_image.imports(): with ffmpeg_worker_image.imports():
from src.cluster.config import config
from typing import List, Optional, Tuple, Dict, Any from typing import List, Optional, Tuple, Dict, Any
from loguru import logger from loguru import logger
from ffmpeg.asyncio import FFmpeg from ffmpeg.asyncio import FFmpeg
from src.cluster.config import config
from src.cluster.ffmpeg_worker.Utils.PathUtils import FileUtils from src.cluster.ffmpeg_worker.Utils.PathUtils import FileUtils
from src.cluster.video_downloader.model import (MediaSources, from src.cluster.video_downloader.model import (MediaSources,
MediaSource, MediaSource,
@@ -25,8 +25,6 @@ with ffmpeg_worker_image.imports():
from src.cluster.web.model import SentryTransactionInfo from src.cluster.web.model import SentryTransactionInfo
from src.cluster.ffmpeg_worker.model import FFMpegSliceSegment from src.cluster.ffmpeg_worker.model import FFMpegSliceSegment
import shutil, psutil, os, backoff, json, sentry_sdk import shutil, psutil, os, backoff, json, sentry_sdk
from psutil import svmem
from psutil._common import scpufreq
from modal import current_function_call_id from modal import current_function_call_id
sentry_sdk.init( sentry_sdk.init(
@@ -44,6 +42,7 @@ with ffmpeg_worker_image.imports():
s3_mount = "/mntS3" s3_mount = "/mntS3"
output_path_prefix = "/mnt/outputs" output_path_prefix = "/mnt/outputs"
@sentry_sdk.trace @sentry_sdk.trace
def get_cache_filepath(media: MediaSource) -> MediaCache: def get_cache_filepath(media: MediaSource) -> MediaCache:
""" """
@@ -52,7 +51,7 @@ with ffmpeg_worker_image.imports():
:return: :return:
""" """
# 本地挂载缓存 # 本地挂载缓存
if media.protocol == MediaProtocol.s3 and media.endpoint == config.s3_region and media.bucket == config.bucket: if media.protocol == MediaProtocol.s3 and media.endpoint == config.s3_region and media.bucket == config.s3_bucket_name:
cache_data = MediaCache(status=MediaCacheStatus.ready, cache_filepath=f"{s3_mount}/{media.get_cdn_url()}") cache_data = MediaCache(status=MediaCacheStatus.ready, cache_filepath=f"{s3_mount}/{media.get_cdn_url()}")
else: else:
cache_data_json = modal_kv.get(media.cache_key) cache_data_json = modal_kv.get(media.cache_key)
@@ -63,7 +62,7 @@ with ffmpeg_worker_image.imports():
@sentry_sdk.trace @sentry_sdk.trace
def capture_hardware_info() -> Tuple[scpufreq, int, svmem]: def capture_hardware_info() -> Tuple[Any, int, Any]:
cpu_freq = psutil.cpu_freq() cpu_freq = psutil.cpu_freq()
cpu_count = psutil.cpu_count() cpu_count = psutil.cpu_count()
mem = psutil.virtual_memory() mem = psutil.virtual_memory()
@@ -71,73 +70,6 @@ with ffmpeg_worker_image.imports():
return (cpu_freq, cpu_count, mem) return (cpu_freq, cpu_count, mem)
@sentry_sdk.trace
async def ffmpeg_slice_process(media_source: MediaSource, media_markers: List[FFMpegSliceSegment], fn_id: str) -> \
List[str]:
cache = get_cache_filepath(media_source)
logger.info(cache.cache_filepath)
outputs: List[str] = []
ffmpeg_cmd = FFmpeg().option('y').option('hide_banner').input(cache.cache_filepath)
filter_complex: List[str] = []
for index, marker in enumerate(media_markers):
segment_out_index = f"cut{index}"
filter_complex.append(
f"[0]trim=start={marker.start.total_seconds()}:end={marker.end.total_seconds()},setpts=PTS-STARTPTS[{segment_out_index}]")
ffmpeg_cmd.option('filter_complex', ';'.join(filter_complex))
for i, marker in enumerate(media_markers):
filename = FileUtils.file_path_extend(os.path.basename(cache.cache_filepath), str(i))
output_filepath = f"{output_path_prefix}/slice/outputs/{fn_id}/{filename}"
output_filepath = output_filepath.replace('//', '/')
workdir = os.path.dirname(output_filepath)
os.makedirs(workdir, exist_ok=True)
outputs.append(output_filepath)
ffmpeg_cmd.output(output_filepath,
map=f"[cut{i}]",
reset_timestamps="1",
sc_threshold="0",
g="1",
force_key_frames="expr:gte(t,n_forced*1)",
vcodec="libx264",
crf=16,
r=30, )
@ffmpeg_cmd.on("start")
def on_start(arguments: list[str]):
try:
filter_index = arguments.index("-filter_complex")
filter_content = arguments[filter_index + 1]
arguments[filter_index + 1] = f'"{filter_content}"'
args = " ".join(arguments)
logger.info(f"FFmpeg command:{args}")
arguments[filter_index + 1] = filter_content
except ValueError:
args = " ".join(arguments)
logger.info(f"FFmpeg command:{args}")
@ffmpeg_cmd.on("progress")
def on_progress(progress):
logger.info(f"处理进度: {progress}")
@ffmpeg_cmd.on("completed")
def on_completed():
logger.info(f"FFMpeg task completed.")
@ffmpeg_cmd.on("stderr")
def on_stderr(line: str):
if line.startswith('Error'):
logger.error(line)
raise RuntimeError(line)
else:
logger.warning(line)
await ffmpeg_cmd.execute()
s3_outputs = local_copy_to_s3(outputs)
return s3_outputs
@backoff.on_exception(backoff.constant, exception=Exception, max_tries=5, max_time=60, raise_on_giveup=True) @backoff.on_exception(backoff.constant, exception=Exception, max_tries=5, max_time=60, raise_on_giveup=True)
def local_copy_to_s3(local_outputs: List[str]) -> List[str]: def local_copy_to_s3(local_outputs: List[str]) -> List[str]:
s3_outputs = [] s3_outputs = []
@@ -151,9 +83,10 @@ with ffmpeg_worker_image.imports():
out_s3.replace(s3_mount + '/', f"s3://{config.s3_region}/{config.s3_bucket_name}/")) out_s3.replace(s3_mount + '/', f"s3://{config.s3_region}/{config.s3_bucket_name}/"))
return s3_outputs return s3_outputs
@app.function( @app.function(
name="concat_medias", cpu=12, timeout=1800,
cpu=12, timeout=1800, memory=(2048, 6144), memory=6144,
cloud="aws", cloud="aws",
# region='ap-northeast', # region='ap-northeast',
max_containers=config.ffmpeg_worker_concurrency, max_containers=config.ffmpeg_worker_concurrency,
@@ -198,47 +131,53 @@ with ffmpeg_worker_image.imports():
logger.info(cache.cache_filepath) logger.info(cache.cache_filepath)
ffmpeg_cmd.input(cache.cache_filepath) ffmpeg_cmd.input(cache.cache_filepath)
input_videos.append(cache.cache_filepath) input_videos.append(cache.cache_filepath)
target_width = 1080
target_height = 1920
# video_info = await print_media_streams_info(media_paths=input_videos)
video_info = print_media_streams_info(media_paths=input_videos)
filter_complex = [] filter_complex = []
total_videos = len(input_videos)
for i in range(len(input_videos)): # 2. 统一所有视频的格式、分辨率和帧率
info = video_info[i] for i in range(total_videos):
streams = info.get("streams", [])
video_stream = streams[0]
# audio_stream = streams[1]
video_duration = video_stream.get("duration")
# audio_duration = audio_stream.get("duration")
'''
filter_complex.extend( filter_complex.extend(
[ [
# 先缩放到统一分辨率,然后设置帧率和格式 # 先缩放到统一分辨率,然后设置帧率和格式
f"[{i}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease," f"[{i}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2," f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2,"
f"setsar=1:1," # 新增强制设置SAR
f"fps=30,format=yuv420p[v{i}]", f"fps=30,format=yuv420p[v{i}]",
f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]", f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]",
] ]
) )
'''
filter_complex.extend(
[
f"[{i}:v]trim=0:{video_duration},setpts=PTS-STARTPTS[v{i}]",
f"[{i}:a]atrim=0:{video_duration},asetpts=PTS-STARTPTS[a{i}]",
]
)
# 3. 准备处理后的视频流和音频流的连接字符串 # 3. 准备处理后的视频流和音频流的连接字符串
video_streams = "".join(f"[v{i}]" for i in range(len(input_videos))) video_streams = "".join(f"[v{i}]" for i in range(total_videos))
audio_streams = "".join(f"[a{i}]" for i in range(len(input_videos))) audio_streams = "".join(f"[a{i}]" for i in range(total_videos))
# 4. 使用concat过滤器合并视频和音频 # 4. 使用concat过滤器合并视频和音频
filter_complex.extend( filter_complex.extend(
[ [
f"{video_streams}concat=n={len(input_videos)}:v=1:a=0[vconcated]", f"{video_streams}concat=n={total_videos}:v=1:a=0[vconcated]",
f"{audio_streams}concat=n={len(input_videos)}:v=0:a=1[aconcated]", f"{audio_streams}concat=n={total_videos}:v=0:a=1[aconcated]",
] ]
) )
ffmpeg_cmd.output(
output_filepath,
{
"filter_complex": ";".join(filter_complex),
"map": ["[vconcated]", "[aconcated]"],
"vcodec": "libx264",
"crf": 16,
"r": 30,
"acodec": "aac",
"ar": 44100,
"ac": 2,
"ab": "192k",
},
)
@ffmpeg_cmd.on("start") @ffmpeg_cmd.on("start")
def on_start(arguments: list[str]): def on_start(arguments: list[str]):
try: try:
@@ -268,21 +207,6 @@ with ffmpeg_worker_image.imports():
else: else:
logger.warning(line) logger.warning(line)
ffmpeg_cmd.output(
output_filepath,
{
"filter_complex": ";".join(filter_complex),
"map": ["[vconcated]", "[aconcated]"],
"vcodec": "libx264",
"crf": 16,
"r": 30,
"acodec": "aac",
"ar": 44100,
"ac": 2,
"ab": "192k",
},
)
await ffmpeg_cmd.execute() await ffmpeg_cmd.execute()
fn_id = current_function_call_id() fn_id = current_function_call_id()
@@ -304,11 +228,13 @@ with ffmpeg_worker_image.imports():
with transaction: with transaction:
with transaction.start_child(op='ffmpeg.concat', name='视频合并任务') as span: with transaction.start_child(op='ffmpeg.concat', name='视频合并任务') as span:
sentry_sdk.set_tag('fn.id', fn_id)
span.set_data('fn.id', fn_id) span.set_data('fn.id', fn_id)
span.set_data('cpu.count', cpu_count) span.set_data('cpu.count', cpu_count)
span.set_data('cpu.frequency', f"{cpu_freq.current / 1000:.2f} GHz") span.set_data('cpu.frequency', f"{cpu_freq.current / 1000:.2f} GHz")
span.set_data('memory.available', f"{total_mb} Mb") span.set_data('memory.available', f"{total_mb} Mb")
output_path = f"{output_path_prefix}/concat/outputs/{fn_id}/output.mp4" output_path = f"{output_path_prefix}/concat/outputs/{fn_id}/output.mp4"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
await ffmpeg_process(medias, output_filepath=output_path) await ffmpeg_process(medias, output_filepath=output_path)
s3_outputs = local_copy_to_s3([output_path]) s3_outputs = local_copy_to_s3([output_path])
@@ -316,8 +242,8 @@ with ffmpeg_worker_image.imports():
@app.function( @app.function(
name="slice_media", cpu=12, timeout=900,
cpu=12, timeout=900, memory=(2048, 4096), memory=6144,
cloud="aws", cloud="aws",
max_containers=config.ffmpeg_worker_concurrency, max_containers=config.ffmpeg_worker_concurrency,
volumes={ volumes={
@@ -334,6 +260,73 @@ with ffmpeg_worker_image.imports():
sentry_trace: Optional[SentryTransactionInfo] = None) -> Tuple[ sentry_trace: Optional[SentryTransactionInfo] = None) -> Tuple[
List[str], Optional[SentryTransactionInfo]]: List[str], Optional[SentryTransactionInfo]]:
@sentry_sdk.trace
async def ffmpeg_slice_process(media_source: MediaSource, media_markers: List[FFMpegSliceSegment],
fn_id: str) -> List[str]:
cache = get_cache_filepath(media_source)
logger.info(cache.cache_filepath)
outputs: List[str] = []
ffmpeg_cmd = FFmpeg().option('y').option('hide_banner').input(cache.cache_filepath)
filter_complex: List[str] = []
for index, marker in enumerate(media_markers):
segment_out_index = f"cut{index}"
filter_complex.append(
f"[0]trim=start={marker.start.total_seconds()}:end={marker.end.total_seconds()},setpts=PTS-STARTPTS[{segment_out_index}]")
ffmpeg_cmd.option('filter_complex', ';'.join(filter_complex))
for i, marker in enumerate(media_markers):
filename = FileUtils.file_path_extend(os.path.basename(cache.cache_filepath), str(i))
output_filepath = f"{output_path_prefix}/slice/outputs/{fn_id}/{filename}"
output_filepath = output_filepath.replace('//', '/')
workdir = os.path.dirname(output_filepath)
os.makedirs(workdir, exist_ok=True)
outputs.append(output_filepath)
ffmpeg_cmd.output(output_filepath,
map=f"[cut{i}]",
reset_timestamps="1",
sc_threshold="0",
g="1",
force_key_frames="expr:gte(t,n_forced*1)",
vcodec="libx264",
acodec="aac",
crf=16,
r=30, )
@ffmpeg_cmd.on("start")
def on_start(arguments: list[str]):
try:
filter_index = arguments.index("-filter_complex")
filter_content = arguments[filter_index + 1]
arguments[filter_index + 1] = f'"{filter_content}"'
args = " ".join(arguments)
logger.info(f"FFmpeg command:{args}")
arguments[filter_index + 1] = filter_content
except ValueError:
args = " ".join(arguments)
logger.info(f"FFmpeg command:{args}")
@ffmpeg_cmd.on("progress")
def on_progress(progress):
logger.info(f"处理进度: {progress}")
@ffmpeg_cmd.on("completed")
def on_completed():
logger.info(f"FFMpeg task completed.")
@ffmpeg_cmd.on("stderr")
def on_stderr(line: str):
if line.startswith('Error'):
logger.error(line)
raise RuntimeError(line)
else:
logger.warning(line)
await ffmpeg_cmd.execute()
s3_outputs = local_copy_to_s3(outputs)
return s3_outputs
fn_id = current_function_call_id() fn_id = current_function_call_id()
cpu_freq, cpu_count, mem = capture_hardware_info() cpu_freq, cpu_count, mem = capture_hardware_info()
total_mb = mem.total >> 20 total_mb = mem.total >> 20
@@ -350,6 +343,7 @@ with ffmpeg_worker_image.imports():
x_baggage=sentry_sdk.get_baggage()) x_baggage=sentry_sdk.get_baggage())
with transaction: with transaction:
with transaction.start_child(op='ffmpeg.slice', name='视频切割任务') as span: with transaction.start_child(op='ffmpeg.slice', name='视频切割任务') as span:
sentry_sdk.set_tag('fn.id', fn_id)
span.set_data('fn.id', fn_id) span.set_data('fn.id', fn_id)
span.set_data('cpu.count', cpu_count) span.set_data('cpu.count', cpu_count)
span.set_data('cpu.frequency', f"{cpu_freq.current / 1000:.2f} GHz") span.set_data('cpu.frequency', f"{cpu_freq.current / 1000:.2f} GHz")
@@ -357,3 +351,105 @@ with ffmpeg_worker_image.imports():
outputs = await ffmpeg_slice_process(media_source=media, media_markers=markers, fn_id=fn_id) outputs = await ffmpeg_slice_process(media_source=media, media_markers=markers, fn_id=fn_id)
return outputs, sentry_trace return outputs, sentry_trace
@app.local_entrypoint()
async def test():
from ffmpeg import FFmpeg
async def ffmpeg_process(media_sources: List[str], output_filepath: str) -> None:
ffmpeg_cmd = FFmpeg().option('y').option('hide_banner')
input_videos = []
for media_source in media_sources:
# cache = get_cache_filepath(media_source)
ffmpeg_cmd.input(media_source)
input_videos.append(media_source)
target_width = 1080
target_height = 1920
# video_info = await print_media_streams_info(media_paths=input_videos)
filter_complex = []
total_videos = len(input_videos)
# 2. 统一所有视频的格式、分辨率和帧率
for i in range(total_videos):
filter_complex.extend(
[
# 先缩放到统一分辨率,然后设置帧率和格式
f"[{i}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2,"
f"setsar=1:1," # 新增强制设置SAR
f"fps=30,format=yuv420p[v{i}]",
f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]",
]
)
# 3. 准备处理后的视频流和音频流的连接字符串
video_streams = "".join(f"[v{i}]" for i in range(total_videos))
audio_streams = "".join(f"[a{i}]" for i in range(total_videos))
# 4. 使用concat过滤器合并视频和音频
filter_complex.extend(
[
f"{video_streams}concat=n={total_videos}:v=1:a=0[vconcated]",
f"{audio_streams}concat=n={total_videos}:v=0:a=1[aconcated]",
]
)
ffmpeg_cmd.output(
output_filepath,
{
"filter_complex": ";".join(filter_complex),
"map": ["[vconcated]", "[aconcated]"],
"vcodec": "libx264",
"crf": 16,
"r": 30,
"acodec": "aac",
"ar": 44100,
"ac": 2,
"ab": "192k",
},
)
@ffmpeg_cmd.on("start")
def on_start(arguments: list[str]):
try:
filter_index = arguments.index("-filter_complex")
filter_content = arguments[filter_index + 1]
arguments[filter_index + 1] = f'"{filter_content}"'
args = " ".join(arguments)
logger.info(f"FFmpeg command:{args}")
arguments[filter_index + 1] = filter_content
except ValueError:
args = " ".join(arguments)
logger.info(f"FFmpeg command:{args}")
@ffmpeg_cmd.on("progress")
def on_progress(progress):
logger.info(f"处理进度: {progress}")
@ffmpeg_cmd.on("completed")
def on_completed():
logger.info(f"FFMpeg task completed.")
@ffmpeg_cmd.on("stderr")
def on_stderr(line: str):
if line.startswith('Error'):
logger.error(line)
raise RuntimeError(line)
else:
logger.warning(line)
ffmpeg_cmd.execute()
medias = [
"./videos/1397757910405340824_0.mp4",
"./videos/1397757910405340824_1.mp4",
"./videos/1397757910405340824_2.mp4",
"./videos/1397757910405340824_3.mp4",
"./videos/1397757910405340824_4.mp4"
]
await ffmpeg_process(medias, "./videos/output.mp4")

View File

@@ -35,6 +35,7 @@ with fastapi_image.imports():
fastapi_app = modal.App(image=fastapi_image) fastapi_app = modal.App(image=fastapi_image)
@fastapi_app.function(scaledown_window=60, @fastapi_app.function(scaledown_window=60,
secrets=[ secrets=[
modal.Secret.from_name("cf-kv-secret", environment_name=config.environment), modal.Secret.from_name("cf-kv-secret", environment_name=config.environment),
@@ -153,8 +154,6 @@ with fastapi_image.imports():
:param task_id: :param task_id:
:return: (TaskStaus, errorReason, results, sentryTransactionInfo) :return: (TaskStaus, errorReason, results, sentryTransactionInfo)
""" """
try:
fn_task = modal.FunctionCall.from_id(task_id) fn_task = modal.FunctionCall.from_id(task_id)
call_graph = fn_task.get_call_graph() call_graph = fn_task.get_call_graph()
task = call_graph[0] task = call_graph[0]
@@ -170,8 +169,8 @@ with fastapi_image.imports():
except TimeoutError: except TimeoutError:
return TaskStatus.running, None, None, None return TaskStatus.running, None, None, None
except Exception as e: except Exception as e:
logger.error(e) logger.exception(e)
return TaskStatus.failed, None, None, None raise e
case _: case _:
error = 'FAILURE' error = 'FAILURE'
match task.status: match task.status:
@@ -182,9 +181,6 @@ with fastapi_image.imports():
case InputStatus.TIMEOUT: case InputStatus.TIMEOUT:
error = 'TIMEOUT' error = 'TIMEOUT'
return TaskStatus.failed, error, None, None return TaskStatus.failed, error, None, None
except Exception as e:
logger.exception(e)
raise RuntimeError("Modal task status check failed")
@web_app.get("/scalar", include_in_schema=False) @web_app.get("/scalar", include_in_schema=False)
async def scalar(): async def scalar():
@@ -360,7 +356,7 @@ with fastapi_image.imports():
if transaction: if transaction:
response.headers["x-trace-id"] = transaction.x_trace_id response.headers["x-trace-id"] = transaction.x_trace_id
response.headers["x-baggage"] = transaction.x_baggage response.headers["x-baggage"] = transaction.x_baggage
return FFMPEGSliceTaskStatusResponse(taskId=task_id, status=TaskStatus.failed, error=reason) return FFMPEGSliceTaskStatusResponse(taskId=task_id, status=task_status, error=reason, result=results)
@web_app.post("/ffmpeg/concat", @web_app.post("/ffmpeg/concat",
tags=["发起任务"], tags=["发起任务"],