266 lines
17 KiB
Python
266 lines
17 KiB
Python
import json
|
||
import os
|
||
import unittest
|
||
from typing import List, Optional
|
||
import httpx
|
||
from loguru import logger
|
||
from tqdm import tqdm
|
||
from BowongModalFunctions.models.ffmpeg_tasks.models import FFMpegSliceSegment, FFMPEGSliceOptions
|
||
from BowongModalFunctions.utils.VideoUtils import VideoUtils
|
||
|
||
|
||
class FFMPEGTestCase(unittest.IsolatedAsyncioTestCase):
|
||
temp_dir = f"./videos"
|
||
douyin_cookies = "ttwid=1%7CB1qls3GdnZhUov9o2NxOMxxYS2ff6OSvEWbv0ytbES4%7C1680522049%7C280d802d6d478e3e78d0c807f7c487e7ffec0ae4e5fdd6a0fe74c3c6af149511; my_rd=1; passport_csrf_token=3ab34460fa656183fccfb904b16ff742; passport_csrf_token_default=3ab34460fa656183fccfb904b16ff742; d_ticket=9f562383ac0547d0b561904513229d76c9c21; n_mh=hvnJEQ4Q5eiH74-84kTFUyv4VK8xtSrpRZG1AhCeFNI; store-region=cn-fj; store-region-src=uid; LOGIN_STATUS=1; __security_server_data_status=1; FORCE_LOGIN=%7B%22videoConsumedRemainSeconds%22%3A180%7D; pwa2=%223%7C0%7C3%7C0%22; download_guide=%223%2F20230729%2F0%22; volume_info=%7B%22isUserMute%22%3Afalse%2C%22isMute%22%3Afalse%2C%22volume%22%3A0.6%7D; strategyABtestKey=%221690824679.923%22; stream_recommend_feed_params=%22%7B%5C%22cookie_enabled%5C%22%3Atrue%2C%5C%22screen_width%5C%22%3A1536%2C%5C%22screen_height%5C%22%3A864%2C%5C%22browser_online%5C%22%3Atrue%2C%5C%22cpu_core_num%5C%22%3A8%2C%5C%22device_memory%5C%22%3A8%2C%5C%22downlink%5C%22%3A10%2C%5C%22effective_type%5C%22%3A%5C%224g%5C%22%2C%5C%22round_trip_time%5C%22%3A150%7D%22; VIDEO_FILTER_MEMO_SELECT=%7B%22expireTime%22%3A1691443863751%2C%22type%22%3Anull%7D; home_can_add_dy_2_desktop=%221%22; __live_version__=%221.1.1.2169%22; device_web_cpu_core=8; device_web_memory_size=8; xgplayer_user_id=346045893336; csrf_session_id=2e00356b5cd8544d17a0e66484946f28; odin_tt=724eb4dd23bc6ffaed9a1571ac4c757ef597768a70c75fef695b95845b7ffcd8b1524278c2ac31c2587996d058e03414595f0a4e856c53bd0d5e5f56dc6d82e24004dc77773e6b83ced6f80f1bb70627; __ac_nonce=064caded4009deafd8b89; __ac_signature=_02B4Z6wo00f01HLUuwwAAIDBh6tRkVLvBQBy9L-AAHiHf7; ttcid=2e9619ebbb8449eaa3d5a42d8ce88ec835; webcast_leading_last_show_time=1691016922379; webcast_leading_total_show_times=1; webcast_local_quality=sd; live_can_add_dy_2_desktop=%221%22; msToken=1JDHnVPw_9yTvzIrwb7cQj8dCMNOoesXbA_IooV8cezcOdpe4pzusZE7NB7tZn9TBXPr0ylxmv-KMs5rqbNUBHP4P7VBFUu0ZAht_BEylqrLpzgt3y5ne_38hXDOX8o=; msToken=jV_yeN1IQKUd9PlNtpL7k5vthGKcHo0dEh_QPUQhr8G3cuYv-Jbb4NnIxGDmhVOkZOCSihNpA2kvYtHiTW25XNNX_yrsv5FN8O6zm3qmCIXcEe0LywLn7oBO2gITEeg=; tt_scid=mYfqpfbDjqXrIGJuQ7q-DlQJfUSG51qG.KUdzztuGP83OjuVLXnQHjsz-BRHRJu4e986"
|
||
|
||
def setUp(self) -> None:
|
||
os.makedirs(self.temp_dir, exist_ok=True)
|
||
|
||
async def test_ffmpeg_concat(self):
|
||
videos = {
|
||
"urls": [
|
||
"https://d2nj71io21vkj2.cloudfront.net/vod/ap-seoul/1500034234/1397757909715120723.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/vod/ap-seoul/1500034234/1397757909695745707.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP2H5B547XSNM2VC9ZGF4/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP2H5B547XSNM2VC9ZGF4/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP2H5B547XSNM2VC9ZGF4/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP0KJPKHBSEFFPT9KY4DY/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP1XYBQ1A34XJM566WPZ8/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP1C13J3CDQESJF99QT5K/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP1PXBQ0MQGM9YBNPZQ1E/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP4A7J7RPRFHA1KGWW3S6/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP2RD3MTRJ5D54KX6T9D3/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP3Y6JZMFK2WDP2A4KF3N/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP3PFTM1A2Y7PFQ96HQJJ/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP4JQVHFWBBN0JEJ5HBHX/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP54TK35VP8E659AP2201/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP1PXBQ0MQGM9YBNPZQ1E/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP6GM06MZEPB5Y12CA2ZX/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP3A7MSSSAKF4EJAG8H70/output_0.mp4",
|
||
"https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JWDYP4TPNGHW9W12JCTTA80V/output_0.mp4"
|
||
]
|
||
}
|
||
outputs: List[str] = []
|
||
with httpx.Client() as client:
|
||
for i, video in enumerate(videos.get("urls")):
|
||
output_path = f"{self.temp_dir}/input_{i}.mp4"
|
||
with client.stream("GET", video) as response:
|
||
response.raise_for_status()
|
||
file_size = int(response.headers.get("content-length", 0))
|
||
progress_bar = tqdm(
|
||
total=file_size,
|
||
unit='iB',
|
||
unit_scale=True,
|
||
desc='Downloading'
|
||
)
|
||
# 以二进制写模式打开文件
|
||
with open(output_path, 'wb') as file:
|
||
# 分块下载,每次读取1MB
|
||
chunk_size = 1024 * 1024 # 1MB
|
||
downloaded_size = 0
|
||
for chunk in response.iter_bytes(chunk_size=chunk_size):
|
||
if chunk:
|
||
file.write(chunk)
|
||
downloaded_size += len(chunk)
|
||
|
||
progress_bar.update(len(chunk))
|
||
# 每下载100MB记录一次日志
|
||
if downloaded_size % (100 * 1024 * 1024) == 0:
|
||
logger.info(
|
||
f"Downloaded: {downloaded_size / (1024 * 1024 * 1024):.2f} GB")
|
||
progress_bar.close()
|
||
self.assertTrue(os.path.exists(output_path))
|
||
outputs.append(output_path)
|
||
final_output_path = f"{self.temp_dir}/output.mp4"
|
||
await VideoUtils.ffmpeg_concat_medias(media_paths=outputs,
|
||
output_path=final_output_path)
|
||
self.assertTrue(os.path.exists(final_output_path))
|
||
|
||
async def test_ffmpeg_extract_frame(self):
|
||
video_url = "https://d2nj71io21vkj2.cloudfront.net/test/slice/outputs/fc-01JW84BFYAH0F9BS9T9DHDRKKH/output_0.mp4"
|
||
with httpx.Client() as client:
|
||
output_path = f"{self.temp_dir}/input.mp4"
|
||
with client.stream("GET", video_url) as response:
|
||
response.raise_for_status()
|
||
file_size = int(response.headers.get("content-length", 0))
|
||
progress_bar = tqdm(
|
||
total=file_size,
|
||
unit='iB',
|
||
unit_scale=True,
|
||
desc='Downloading'
|
||
)
|
||
# 以二进制写模式打开文件
|
||
with open(output_path, 'wb') as file:
|
||
# 分块下载,每次读取1MB
|
||
chunk_size = 1024 * 1024 # 1MB
|
||
downloaded_size = 0
|
||
for chunk in response.iter_bytes(chunk_size=chunk_size):
|
||
if chunk:
|
||
file.write(chunk)
|
||
downloaded_size += len(chunk)
|
||
|
||
progress_bar.update(len(chunk))
|
||
# 每下载100MB记录一次日志
|
||
if downloaded_size % (100 * 1024 * 1024) == 0:
|
||
logger.info(
|
||
f"Downloaded: {downloaded_size / (1024 * 1024 * 1024):.2f} GB")
|
||
progress_bar.close()
|
||
self.assertTrue(os.path.exists(output_path))
|
||
image_path, metadata = await VideoUtils.ffmpeg_extract_frame_image(video_path=output_path, frame_index=1)
|
||
logger.info(f"Extracted {image_path} and metadata: {metadata}")
|
||
|
||
async def test_ffmpeg_slice_stream(self):
|
||
from pydantic import TypeAdapter
|
||
# #EXT-X-PROGRAM-DATE-TIME:2025-06-23T06:20:30.720+0000
|
||
# #EXT-X-PROGRAM-DATE-TIME:2025-06-23T10:09:38.476+0000
|
||
|
||
# stream_url = "https://cdn.roasmax.cn/test/records/hls/fc-01JXY1AS1HDGS300EQ54ATAKHF/playlist.m3u8"
|
||
stream_url = "https://cdn.roasmax.cn/test/records/hls/fc-01JYDQ3RVMPAEKMYHA0HQ14WAJ/playlist.m3u8"
|
||
adapter = TypeAdapter(List[FFMpegSliceSegment])
|
||
segments = adapter.validate_json("""[
|
||
{
|
||
"start": 12600,
|
||
"end": 13500
|
||
}
|
||
]""")
|
||
for segment in segments:
|
||
logger.info(f"{segment.start.toFormatStr()} --> {segment.end.toFormatStr()}")
|
||
options = FFMPEGSliceOptions()
|
||
outputs = await VideoUtils.ffmpeg_slice_media(media_path=stream_url,
|
||
media_markers=segments,
|
||
options=options,
|
||
is_streams=True,
|
||
output_path=f"{self.temp_dir}/output_stream.mp4")
|
||
for (output, metadata) in outputs:
|
||
self.assertTrue(os.path.exists(output))
|
||
|
||
async def test_ffmpeg_streamlink(self):
|
||
webcast_id = "333555252930"
|
||
segment_duration = 5
|
||
title = None
|
||
live_url: Optional[str] = None
|
||
quality: Optional[str] = None
|
||
|
||
with httpx.Client() as client:
|
||
cookie_getter_response = client.get(url='https://live.douyin.com/')
|
||
cookie_getter_response.raise_for_status()
|
||
stream_url_response = client.get(url='https://live.douyin.com/webcast/room/web/enter/',
|
||
params={
|
||
'aid': 6383,
|
||
'device_platform': 'web',
|
||
'browser_language': 'zh-CN',
|
||
'browser_platform': 'Win32',
|
||
'browser_name': 'Chrome',
|
||
'browser_version': '100.0.0.0',
|
||
'web_rid': webcast_id
|
||
})
|
||
stream_url_response.raise_for_status()
|
||
stream_info_json = stream_url_response.json()
|
||
if data := stream_info_json['data']['data']:
|
||
data = data[0]
|
||
if data['status'] == 2:
|
||
title = data['title']
|
||
live_url = ''
|
||
stream_data = json.loads(data['stream_url']['live_core_sdk_data']['pull_data']['stream_data'])
|
||
for quality_code in ('origin', 'uhd', 'hd', 'sd', 'md', 'ld'):
|
||
if quality_data := stream_data['data'].get(quality_code):
|
||
logger.info(f"质量={quality_code}")
|
||
live_url = quality_data['main']['flv']
|
||
quality = quality_code
|
||
break
|
||
else:
|
||
raise ValueError("直播间没有开播")
|
||
logger.info(f"{title} url = {live_url}")
|
||
if live_url:
|
||
output_dir = f"{self.temp_dir}/{title}"
|
||
os.makedirs(output_dir, exist_ok=True)
|
||
# output_file_pattern = "%10d.ts"
|
||
# output_pattern = f"{output_dir}/{output_file_pattern}"
|
||
logger.info(f"开始录制 [{title}|{quality}] {live_url} 到 {output_dir}")
|
||
# await VideoUtils.ffmpeg_stream_record_as_hls(stream_url=live_url,
|
||
# segment_duration=segment_duration,
|
||
# stream_content_timeout=stream_content_timeout,
|
||
# segments_output_dir=output_dir,
|
||
# playlist_output_dir=manifest_output_dir,
|
||
# playlist_output_method='PUT',
|
||
# playlist_output_headers={
|
||
# "Content-Type": "application/vnd.apple.mpegurl",
|
||
# "Authorization": f"Bearer bowong7777"
|
||
# },
|
||
# manifest_segment_prefix=manifest_segment_prefix)
|
||
logger.info(f'停止录制:{output_dir}')
|
||
|
||
async def test_ffprobe_read_video(self):
|
||
# video = "./videos/input_0.mp4"
|
||
video = "./videos/fc-01JXKK5VWK6B924TDB9WZETACW/playlist.m3u8"
|
||
video = "https://cdn.roasmax.cn/prod/records/hls/fc-01JXKK5VWK6B924TDB9WZETACW/playlist.m3u8"
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(video)
|
||
logger.info(video_metadata)
|
||
self.assertIsNotNone(video_metadata)
|
||
|
||
async def test_ffprobe_read_audio(self):
|
||
audio = "./videos/output.wav"
|
||
audio_metadata = VideoUtils.ffprobe_media_metadata(audio)
|
||
self.assertIsNotNone(audio_metadata)
|
||
|
||
async def test_ffprobe_read_hls(self):
|
||
# hls = "./fc-01JWX1CD01JTV0DECWVWX1X9FM/playlist.m3u8"
|
||
hls = "./fc-01JWZS8954RZP4B13H3TA6TKMM/playlist.m3u8"
|
||
hls_metadata = VideoUtils.ffprobe_media_metadata(hls)
|
||
self.assertIsNotNone(hls_metadata)
|
||
|
||
async def test_ffprobe_read_image(self):
|
||
image = "./1928302183527354368.png"
|
||
image_metadata = VideoUtils.ffprobe_media_metadata(image)
|
||
self.assertIsNotNone(image_metadata)
|
||
|
||
async def test_ffprobe_read_gif(self):
|
||
gif = "./flow_frame.gif"
|
||
gif_metadata = VideoUtils.ffprobe_media_metadata(gif)
|
||
self.assertIsNotNone(gif_metadata)
|
||
|
||
async def test_ffmpeg_convert_stream(self):
|
||
stream_url = "https://cdn.roasmax.cn/prod/records/hls/fc-01JXKJ2JCTDKWJ6J97FN6ABQPM/playlist.m3u8"
|
||
output_path = f"{self.temp_dir}/fc-01JXKJ2JCTDKWJ6J97FN6ABQPM/output.mp4"
|
||
options = FFMPEGSliceOptions()
|
||
local_output, metadata = await VideoUtils.ffmpeg_convert_stream_media(media_stream_url=stream_url,
|
||
options=options,
|
||
output_path=output_path)
|
||
self.assertIsNotNone(local_output)
|
||
|
||
async def test_ffmpeg_best_convert(self):
|
||
media_stream_url = "https://cdn.roasmax.cn/test/records/hls/fc-01JY0ZRKCG6K7WSD9H4Q0X922K/playlist.m3u8"
|
||
local_m3u8_path, temp_dir, diff = await VideoUtils.convert_m3u8_to_local_source(
|
||
media_stream_url=media_stream_url, temp_dir=f"./videos/large")
|
||
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(local_m3u8_path,
|
||
protocol_whitelist="file,http,https,tcp,tls")
|
||
h264_options = {
|
||
"reset_timestamps": "1",
|
||
"vcodec": "libx264",
|
||
"movflags": "+faststart", # 优化MP4文件结构
|
||
"pix_fmt": "yuv420p",
|
||
"acodec": "aac",
|
||
"aac_coder": "fast",
|
||
"ar": 44100,
|
||
"ac": 2,
|
||
"ab": "192k",
|
||
"crf": 18,
|
||
"r": 30
|
||
}
|
||
ffmpeg_cmd.output("./videos/output_large_h264.mp4", options=h264_options)
|
||
await ffmpeg_cmd.execute()
|
||
|
||
async def test_ffmpeg_subtitle_apply(self):
|
||
video = "./videos/subtitle_test/A美洋MEIYANG 方圆牛仔马甲 率性哲学!八支精棉牛仔无袖上衣-周二.mp4"
|
||
subtitle = "./videos/subtitle_test/A美洋MEIYANG 方圆牛仔马甲 率性哲学!八支精棉牛仔无袖上衣-周二.vtt"
|
||
output = "./videos/subtitle_test/output.mp4"
|
||
output_video, metadata = await VideoUtils.ffmpeg_subtitle_apply(media_path=video, subtitle_path=None,
|
||
embed_subtitle_path=subtitle, font_dir=None,
|
||
output_path=output)
|
||
self.assertEqual(len(metadata.streams), 3)
|
||
|
||
if __name__ == '__main__':
|
||
unittest.main()
|