e6e4090f3c
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 8s
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 8s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m17s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m41s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m13s
CI/CD Pipeline / Integration Tests (push) Successful in 2m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 42s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 38s
CI/CD Pipeline / Validate - Style (push) Failing after 3m52s
AI Code Review / AI Code Review (pull_request) Failing after 6m30s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 27s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m13s
CI/CD Pipeline / Validate - Security (push) Failing after 11m41s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 10m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m22s
CI/CD Pipeline / Unit Tests (push) Successful in 13m10s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m43s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m46s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 16s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 16s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m40s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m47s
CI/CD Pipeline / Validate - Style (pull_request) Failing after 3m13s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m11s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m8s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
770 lines
32 KiB
Python
Executable File
770 lines
32 KiB
Python
Executable File
import json
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from celery.utils.log import get_task_logger
|
||
from video_processing.oss_helpers import download_asset
|
||
from worker_app.celery_app import celery_app
|
||
from worker_app.core.asset_types import infer_mime_type_from_storage_key
|
||
from worker_app.db import SessionLocal
|
||
|
||
from packages.adapters.sqlalchemy_impl import (
|
||
SQLAlchemyAssetRepository,
|
||
SQLAlchemyIngestJobRepository,
|
||
)
|
||
from packages.domain import Asset, AssetStatus, IngestJobStatus
|
||
from packages.domain.media_validation import is_valid_media as _is_valid_media
|
||
from packages.domain.media_validation import safe_parse_fps as _safe_parse_fps
|
||
|
||
logger = get_task_logger(__name__)
|
||
|
||
|
||
def extract_media_metadata(file_url: str, media_type: str) -> tuple[dict, bool]:
|
||
"""
|
||
提取媒体文件的元数据。
|
||
|
||
Args:
|
||
file_url: 媒体文件 URL 或本地路径
|
||
media_type: 媒体类型 (video, audio, image)
|
||
|
||
Returns:
|
||
(metadata_dict, success)
|
||
- metadata_dict: 提取的元数据字典,失败时返回空字典
|
||
- success: 是否成功提取到有效元数据
|
||
"""
|
||
metadata = {}
|
||
success = False
|
||
|
||
try:
|
||
if media_type == "video":
|
||
# 使用 ffprobe 提取视频元数据
|
||
from video_processing.ffmpeg_utils import run_ffprobe
|
||
|
||
cmd = [
|
||
"ffprobe",
|
||
"-v",
|
||
"quiet",
|
||
"-print_format",
|
||
"json",
|
||
"-show_format",
|
||
"-show_streams",
|
||
file_url,
|
||
]
|
||
try:
|
||
stdout, _ = run_ffprobe(cmd, timeout=30)
|
||
import json as json_lib
|
||
|
||
probe_data = json_lib.loads(stdout)
|
||
|
||
# 提取视频流信息
|
||
for stream in probe_data.get("streams", []):
|
||
if stream.get("codec_type") == "video":
|
||
metadata["width"] = int(stream.get("width", 0))
|
||
metadata["height"] = int(stream.get("height", 0))
|
||
metadata["codec"] = stream.get("codec_name", "")
|
||
metadata["fps"] = (
|
||
_safe_parse_fps(stream.get("r_frame_rate", "0/1")) if stream.get("r_frame_rate") else 0 # type: ignore[assignment]
|
||
)
|
||
break
|
||
|
||
# 提取格式信息
|
||
format_info = probe_data.get("format", {})
|
||
metadata["duration"] = float(format_info.get("duration", 0)) # type: ignore[assignment]
|
||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||
metadata["bitrate"] = int(format_info.get("bit_rate", 0))
|
||
|
||
# 有效性判断:有视频流 + duration > 0 + size > 0
|
||
has_video_stream = any(s.get("codec_type") == "video" for s in probe_data.get("streams", []))
|
||
has_audio_stream = any(s.get("codec_type") == "audio" for s in probe_data.get("streams", []))
|
||
if (has_video_stream or has_audio_stream) and metadata.get("duration", 0) > 0:
|
||
success = True
|
||
|
||
except Exception as e:
|
||
logger.warning("视频元数据提取失败: %s", e)
|
||
|
||
elif media_type == "audio":
|
||
from video_processing.ffmpeg_utils import run_ffprobe
|
||
|
||
cmd = [
|
||
"ffprobe",
|
||
"-v",
|
||
"quiet",
|
||
"-print_format",
|
||
"json",
|
||
"-show_format",
|
||
"-show_streams",
|
||
file_url,
|
||
]
|
||
try:
|
||
stdout, _ = run_ffprobe(cmd, timeout=30)
|
||
import json as json_lib
|
||
|
||
probe_data = json_lib.loads(stdout)
|
||
|
||
has_audio_stream = any(s.get("codec_type") == "audio" for s in probe_data.get("streams", []))
|
||
format_info = probe_data.get("format", {})
|
||
metadata["duration"] = float(format_info.get("duration", 0)) # type: ignore[assignment]
|
||
metadata["size_bytes"] = int(format_info.get("size", 0))
|
||
metadata["bitrate"] = int(format_info.get("bit_rate", 0))
|
||
metadata["codec"] = next(
|
||
(s.get("codec_name", "") for s in probe_data.get("streams", []) if s.get("codec_type") == "audio"),
|
||
"",
|
||
)
|
||
|
||
if has_audio_stream and metadata.get("duration", 0) > 0:
|
||
success = True
|
||
|
||
except Exception as e:
|
||
logger.warning("音频元数据提取失败: %s", e)
|
||
|
||
elif media_type == "image":
|
||
# 使用 Pillow 提取图片元数据
|
||
try:
|
||
from PIL import Image
|
||
|
||
with Image.open(file_url) as img:
|
||
img.verify() # 验证文件完整性
|
||
# verify后需要重新打开才能读尺寸
|
||
with Image.open(file_url) as img2:
|
||
metadata["width"] = img2.width
|
||
metadata["height"] = img2.height
|
||
metadata["format"] = img2.format
|
||
metadata["mode"] = img2.mode
|
||
if img2.width > 0 and img2.height > 0:
|
||
success = True
|
||
|
||
except ImportError:
|
||
logger.warning("Pillow not available for image metadata extraction")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to extract image metadata: {e}")
|
||
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning(f"Timeout extracting metadata from {file_url}")
|
||
except FileNotFoundError:
|
||
logger.warning("ffprobe not found, cannot extract video metadata")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to extract metadata: {e}")
|
||
|
||
return metadata, success
|
||
|
||
|
||
# ── HEVC 自动转码辅助函数(模块级,便于单元测试)─────────────────────────
|
||
HEVC_CODECS = ("hevc", "h265", "hvh1")
|
||
# 转码目标:长边封顶 1920(只缩不放,与 validate 的 max_long_edge 一致),
|
||
# 竖屏/横屏/超宽屏统一按长边等比缩放,短边自动按比例(-2 保证偶数)。
|
||
TRANSCODE_MAX_LONG_EDGE = 1920
|
||
TRANSCODE_TIMEOUT_SECONDS = 900
|
||
# ffmpeg scale 滤镜中 if(...) 表达式内的逗号必须用 \, 转义,
|
||
# 否则逗号被当作 filter 分隔符解析,报 "No such filter" / Invalid size。
|
||
# subprocess list 传参不经 shell,\ 在 Python 字符串里直接写一个字面反斜杠即可。
|
||
# 横屏(iw>=ih)限宽 min(1920,iw)、高 -2 自适应;竖屏(ih>iw)限高、宽自适应;
|
||
# min() 保证小视频不放大。与 validate_transcode_output 的"长边 <= 1920"规则对齐,
|
||
# 超宽屏(如 4000x1000)短边不触发旧的短边缩放、长边超限被误降级的问题由此消除。
|
||
_TRANSCODE_VF = (
|
||
rf"scale=w=if(gte(iw\,ih)\,min({TRANSCODE_MAX_LONG_EDGE}\,iw)\,-2):"
|
||
rf"h=if(gt(ih\,iw)\,min({TRANSCODE_MAX_LONG_EDGE}\,ih)\,-2),format=yuv420p"
|
||
)
|
||
|
||
|
||
def is_hevc_codec(codec: str | None) -> bool:
|
||
"""判断编码是否为 HEVC(不区分大小写)。"""
|
||
return (codec or "").lower() in HEVC_CODECS
|
||
|
||
|
||
def probe_rotation(path: str) -> int | None:
|
||
"""ffprobe 读取视频旋转角度(display matrix side data)。
|
||
|
||
返回 0/90/-90/180 等整数;无 side data 或探测失败返回 None。
|
||
|
||
注意:旧实现同时请求 side_data 和 stream_tags 且取输出第一行,
|
||
iOS 文件会输出两行(如 "270\\n90")导致取到错误值,现仅读 side_data。
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
[
|
||
"ffprobe",
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_entries",
|
||
"side_data=rotation",
|
||
"-of",
|
||
"default=noprint_wrappers=1:nokey=1",
|
||
str(path),
|
||
],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.DEVNULL,
|
||
text=True,
|
||
timeout=60,
|
||
)
|
||
first_line = (result.stdout or "").strip().split("\n")[0].strip()
|
||
if not first_line:
|
||
return None
|
||
return int(float(first_line))
|
||
except (subprocess.TimeoutExpired, ValueError, OSError):
|
||
return None
|
||
|
||
|
||
def is_portrait_rotation(rotation: int | None) -> bool:
|
||
"""rotation side data 为 ±90/270 时表示竖屏拍摄。
|
||
|
||
注意:这只覆盖"存储横屏 + display matrix 旋转"的 iOS 风格视频;
|
||
物理竖屏视频(Android 常见,存储即 h>w、rotation=None/0)不会命中,
|
||
方向判定请用 is_portrait_video()。
|
||
"""
|
||
return rotation in (90, 270, -90)
|
||
|
||
|
||
def is_portrait_video(
|
||
stored_width: int | None,
|
||
stored_height: int | None,
|
||
rotation: int | None,
|
||
) -> bool:
|
||
"""按显示方向判断是否竖屏(显示高度 > 显示宽度)。
|
||
|
||
- rotation 为 90/270/-90 时,显示方向的宽高相对存储维度互换;
|
||
- rotation 为 0/180/None 时,显示方向即存储维度。
|
||
|
||
这样两类竖屏都能正确识别:
|
||
- iOS:存储 1920x1080 + rotation=90 → 显示 1080x1920 竖屏
|
||
- Android/物理竖屏:存储 1080x1920、无 rotation → 显示 1080x1920 竖屏
|
||
探测失败(维度为 None)时退回仅看 rotation,保证调用链不中断。
|
||
"""
|
||
if not stored_width or not stored_height:
|
||
return is_portrait_rotation(rotation)
|
||
if is_portrait_rotation(rotation):
|
||
return stored_width > stored_height
|
||
return stored_height > stored_width
|
||
|
||
|
||
def build_transcode_vf() -> str:
|
||
"""构建转码视频滤镜(竖屏/横屏统一,按显示长边封顶 1920、只缩不放)。
|
||
|
||
依赖 ffmpeg 内置 autorotate(默认开启)按 display matrix 物理旋转画面,
|
||
输出自动剥离 rotation side data;滤镜只做等比缩放,方向无关:
|
||
横屏限宽、竖屏限高,短边 -2 自适应偶数,min() 保证小视频不放大。
|
||
|
||
旧实现的问题:
|
||
- 显式 transpose=1 与 autorotate 叠加,竖屏被二次旋转成横屏;
|
||
- 竖屏沿用按高缩放表达式,1080x1920 被错误缩成 608x1080;
|
||
- 仅按短边 1080 触发缩放,超宽屏(如 4000x1000)长边超 1920 会被
|
||
validate 拦截误降级,用户拿到浏览器无法播放的 HEVC 原文件。
|
||
"""
|
||
return _TRANSCODE_VF
|
||
|
||
|
||
def probe_dimensions(path: str) -> tuple[int | None, int | None]:
|
||
"""ffprobe 读取视频宽高(像素维度)。"""
|
||
try:
|
||
result = subprocess.run(
|
||
[
|
||
"ffprobe",
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_entries",
|
||
"stream=width,height",
|
||
"-of",
|
||
"csv=p=0:s=x",
|
||
str(path),
|
||
],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.DEVNULL,
|
||
text=True,
|
||
timeout=60,
|
||
)
|
||
text = (result.stdout or "").strip().split("\n")[0].strip()
|
||
width_str, height_str = text.split("x")
|
||
return int(width_str), int(height_str)
|
||
except (subprocess.TimeoutExpired, ValueError, OSError):
|
||
return None, None
|
||
|
||
|
||
def probe_video_info(path: str) -> tuple[int | None, int | None, int | None]:
|
||
"""一次 ffprobe 同时读取视频宽高与旋转角度(display matrix side data)。
|
||
|
||
返回 (width, height, rotation);探测失败对应位置为 None。
|
||
合并维度/角度两次探测,减少大文件、高并发下的 ffprobe 进程开销。
|
||
rotation 仅取 stream side_data_list 的 Display Matrix(不读 tags.rotate,
|
||
避免 iOS 文件 tag 值与 side data 双来源取错)。用 -show_streams 全量 JSON
|
||
输出解析,兼容 ffmpeg 4.x/7.x(show_entries 嵌套 section 名跨版本不一致)。
|
||
"""
|
||
try:
|
||
result = subprocess.run(
|
||
[
|
||
"ffprobe",
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_streams",
|
||
"-of",
|
||
"json",
|
||
str(path),
|
||
],
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.DEVNULL,
|
||
text=True,
|
||
timeout=60,
|
||
)
|
||
data = json.loads(result.stdout or "{}")
|
||
streams = data.get("streams") or []
|
||
if not streams:
|
||
return None, None, None
|
||
stream = streams[0]
|
||
width = int(stream["width"]) if stream.get("width") else None
|
||
height = int(stream["height"]) if stream.get("height") else None
|
||
rotation = None
|
||
for side in stream.get("side_data_list") or []:
|
||
if side.get("side_data_type") == "Display Matrix" and side.get("rotation") is not None:
|
||
deg = int(round(float(side["rotation"]))) % 360
|
||
# ffprobe:顺时针 90 拍摄输出 90,逆时针 90 输出 -90(归一为 270)
|
||
rotation = {0: 0, 90: 90, 180: 180, 270: -90}.get(deg, deg if deg in (90, 180) else None)
|
||
break
|
||
return width, height, rotation
|
||
except (subprocess.TimeoutExpired, ValueError, OSError, json.JSONDecodeError, KeyError, TypeError):
|
||
return None, None, None
|
||
|
||
|
||
def validate_transcode_output(
|
||
output_path: str,
|
||
expected_portrait: bool,
|
||
max_long_edge: int = TRANSCODE_MAX_LONG_EDGE,
|
||
) -> bool:
|
||
"""校验转码产物方向与维度。
|
||
|
||
- 竖屏源:产物必须 height > width,且仍有 rotation side data 视为失败
|
||
(播放器会二次旋转成横屏)
|
||
- 横屏源:产物必须 width >= height
|
||
- 长边不得超过 max_long_edge(只缩不放)
|
||
校验失败时调用方应降级使用原始文件,不允许产出方向错误的文件覆盖。
|
||
"""
|
||
width, height = probe_dimensions(output_path)
|
||
if not width or not height:
|
||
return False
|
||
if expected_portrait and height <= width:
|
||
return False
|
||
if not expected_portrait and width < height:
|
||
return False
|
||
if max(width, height) > max_long_edge:
|
||
return False
|
||
# 产物仍带 rotation side data 说明方向没有物理固化,播放器会再次旋转
|
||
if probe_rotation(output_path) is not None:
|
||
return False
|
||
return True
|
||
|
||
|
||
@celery_app.task(name="worker.ingest_asset")
|
||
def ingest_asset(job_id: str) -> dict:
|
||
"""
|
||
Ingest asset task.
|
||
|
||
Steps:
|
||
1. Fetch IngestJob from repository
|
||
2. Extract metadata from storage_key
|
||
3. Create Asset entity
|
||
4. Update IngestJob status to COMPLETED
|
||
5. Return result
|
||
"""
|
||
db = SessionLocal()
|
||
try:
|
||
job_repo = SQLAlchemyIngestJobRepository(db)
|
||
asset_repo = SQLAlchemyAssetRepository(db)
|
||
|
||
job = job_repo.get(job_id)
|
||
if job is None:
|
||
return {"status": "failed", "error": "job not found"}
|
||
|
||
# Update job status to PROCESSING
|
||
job.status = IngestJobStatus.PROCESSING
|
||
job.updated_at = datetime.now(timezone.utc)
|
||
job_repo.update(job)
|
||
db.commit()
|
||
|
||
# Extract real metadata from media file
|
||
filename = job.storage_key.split("/")[-1]
|
||
mime_type = infer_mime_type_from_storage_key(job.storage_key)
|
||
|
||
# Determine media type from mime_type
|
||
media_type = "video"
|
||
if mime_type.startswith("image/"):
|
||
media_type = "image"
|
||
elif mime_type.startswith("audio/"):
|
||
media_type = "audio"
|
||
|
||
# 先从 OSS 下载文件到本地临时目录,再提取元数据
|
||
# (storage_key 是 OSS 内部路径,不能直接传给 ffprobe/Pillow)
|
||
local_file = None
|
||
thumbnail_url = None
|
||
try:
|
||
suffix = Path(job.storage_key).suffix or ".bin"
|
||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||
local_file = Path(tmp.name)
|
||
|
||
download_ok = download_asset(job.storage_key, local_file)
|
||
if not download_ok:
|
||
logger.warning("素材下载失败,无法提取元数据: job_id=%s storage_key=%s", job_id, job.storage_key)
|
||
metadata, extract_success = {}, False
|
||
else:
|
||
metadata, extract_success = extract_media_metadata(str(local_file), media_type)
|
||
|
||
# 视频类型:生成缩略图(文件还在的时候生成)
|
||
thumbnail_url = None
|
||
if media_type == "video" and extract_success:
|
||
frame_path = None
|
||
try:
|
||
from video_processing.oss_helpers import upload_to_oss
|
||
from video_processing.thumbnail_generator import extract_first_frame
|
||
|
||
frame_path = extract_first_frame(str(local_file), width=640)
|
||
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
|
||
upload_ok = upload_to_oss(frame_path, thumb_storage_key)
|
||
if upload_ok:
|
||
# 存储 storage_key(非公开 URL),API 层通过 get_download_url 生成签名 URL
|
||
thumbnail_url = thumb_storage_key
|
||
logger.info(
|
||
"素材缩略图生成成功: job_id=%s key=%s",
|
||
job_id,
|
||
thumb_storage_key[:80],
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"素材缩略图上传 OSS 失败: job_id=%s",
|
||
job_id,
|
||
)
|
||
# frame_path 是临时文件,及时清理
|
||
if frame_path:
|
||
try:
|
||
Path(frame_path).unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
except Exception as thumb_err:
|
||
logger.warning(
|
||
"素材缩略图生成失败(不影响主流程): job_id=%s error=%s",
|
||
job_id,
|
||
thumb_err,
|
||
)
|
||
|
||
# ── HEVC 自动转码为 1080p H.264 ──────────────────────────────
|
||
# 浏览器 WebCodecs 硬件解码 HEVC 输出黑帧,上传时自动转码
|
||
# 失败时降级使用原始文件,不阻塞上传流程
|
||
if media_type == "video" and local_file and local_file.exists():
|
||
if is_hevc_codec(metadata.get("codec")):
|
||
logger.info(
|
||
"检测到 HEVC 编码 (codec=%s),启动转码: job_id=%s",
|
||
metadata.get("codec"),
|
||
job_id,
|
||
)
|
||
_tc_tmp = None
|
||
|
||
# ── Step 1: 磁盘空间检查(独立 try/except,失败仍尝试转码)──
|
||
try:
|
||
_disk_usage = shutil.disk_usage("/tmp")
|
||
_free_gb = _disk_usage.free / (1024**3)
|
||
if _free_gb < 2:
|
||
raise RuntimeError(f"磁盘空间不足 ({_free_gb:.1f}GB < 2GB)")
|
||
except Exception as _disk_err:
|
||
logger.warning("磁盘检查失败,仍尝试转码: job_id=%s err=%s", job_id, _disk_err)
|
||
|
||
# ── Step 2: 方向检测(按显示方向判定竖/横屏)──────────────
|
||
# 不能只看 rotation side data:Android 等设备的物理竖屏视频
|
||
# 存储维度已是 h>w 且 rotation=0/None,只看 rotation 会误判横屏、
|
||
# 套用横屏滤镜把 1080x1920 压成 608x1080,转码产物校验失败降级,
|
||
# 用户拿到 HEVC 原文件浏览器仍黑帧。
|
||
_src_w, _src_h, _rotation = probe_video_info(str(local_file))
|
||
_is_portrait = is_portrait_video(_src_w, _src_h, _rotation)
|
||
logger.info(
|
||
"视频方向检测: stored=%sx%s rotation=%s portrait=%s: job_id=%s",
|
||
_src_w,
|
||
_src_h,
|
||
_rotation,
|
||
_is_portrait,
|
||
job_id,
|
||
)
|
||
|
||
# ── Step 3: ffmpeg 转码(独立 try/except)──
|
||
try:
|
||
_tc_tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix="_h264.mp4")
|
||
_tc_tmp = Path(_tc_tmp_file.name)
|
||
_tc_tmp_file.close() # 关闭文件描述符,ffmpeg 会自己打开
|
||
|
||
# 旋转交给 ffmpeg 内置 autorotate(按 display matrix 物理旋转,
|
||
# 输出自动剥离 side data);滤镜只做 1080p 等比"只缩不放"。
|
||
# 注意不能再加 transpose:旧逻辑 autorotate + transpose 双重旋转,
|
||
# 竖屏被转成横屏;scale 表达式内逗号必须 \, 转义(见 build_transcode_vf)。
|
||
_vf = build_transcode_vf()
|
||
|
||
_cmd = [
|
||
"ffmpeg",
|
||
"-y",
|
||
"-i",
|
||
str(local_file),
|
||
"-c:v",
|
||
"libx264",
|
||
"-preset",
|
||
"fast",
|
||
"-crf",
|
||
"18",
|
||
"-vf",
|
||
_vf,
|
||
"-colorspace",
|
||
"bt709",
|
||
"-color_primaries",
|
||
"bt709",
|
||
"-color_trc",
|
||
"bt709",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-level",
|
||
"4.2",
|
||
"-c:a",
|
||
"aac",
|
||
"-b:a",
|
||
"128k",
|
||
"-movflags",
|
||
"+faststart",
|
||
str(_tc_tmp),
|
||
]
|
||
_proc = subprocess.run(
|
||
_cmd,
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
timeout=TRANSCODE_TIMEOUT_SECONDS,
|
||
)
|
||
if _proc.returncode == 0 and _tc_tmp.exists() and _tc_tmp.stat().st_size > 0:
|
||
# ── Step 4: 方向/维度校验,不符则降级,杜绝横屏文件覆盖 ──
|
||
if not validate_transcode_output(str(_tc_tmp), _is_portrait):
|
||
_w, _h = probe_dimensions(str(_tc_tmp))
|
||
_rot = probe_rotation(str(_tc_tmp))
|
||
logger.error(
|
||
"转码产物方向/维度校验失败,降级使用原始文件: "
|
||
"job_id=%s source_rotation=%s portrait=%s out=%sx%s out_rotation=%s",
|
||
job_id,
|
||
_rotation,
|
||
_is_portrait,
|
||
_w,
|
||
_h,
|
||
_rot,
|
||
)
|
||
else:
|
||
from video_processing.oss_helpers import upload_to_oss
|
||
|
||
_p = Path(job.storage_key)
|
||
_new_key = str(_p.parent / (_p.stem + "_h264" + _p.suffix))
|
||
_url = upload_to_oss(_tc_tmp, _new_key)
|
||
if _url:
|
||
# 先提取元数据,确认成功后再更新 storage_key(避免脏数据)
|
||
_new_metadata, _new_extract_success = extract_media_metadata(
|
||
str(_tc_tmp),
|
||
media_type,
|
||
)
|
||
if _new_extract_success:
|
||
job.storage_key = _new_key
|
||
metadata = _new_metadata
|
||
extract_success = _new_extract_success
|
||
logger.info(
|
||
"HEVC→H.264 转码完成: job_id=%s key=%s",
|
||
job_id,
|
||
_new_key[:80],
|
||
)
|
||
else:
|
||
logger.warning(
|
||
"转码文件上传 OSS 失败,使用原始文件: job_id=%s",
|
||
job_id,
|
||
)
|
||
else:
|
||
_tail = _proc.stderr[-300:] if _proc.stderr else ""
|
||
logger.warning(
|
||
"FFmpeg 转码失败 rc=%s stderr=%s: job_id=%s",
|
||
_proc.returncode,
|
||
_tail,
|
||
job_id,
|
||
)
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning(
|
||
"FFmpeg 转码超时(900s),降级原始文件: job_id=%s",
|
||
job_id,
|
||
)
|
||
except Exception as _e:
|
||
logger.warning(
|
||
"HEVC 转码异常(降级原始文件): job_id=%s err=%s",
|
||
job_id,
|
||
_e,
|
||
)
|
||
finally:
|
||
if _tc_tmp and _tc_tmp.exists():
|
||
try:
|
||
_tc_tmp.unlink()
|
||
except OSError:
|
||
pass
|
||
finally:
|
||
if local_file and local_file.exists():
|
||
try:
|
||
local_file.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
# 有效性校验:ffprobe/Pillow 必须成功,且文件大小/时长/尺寸满足最小要求
|
||
is_valid = extract_success and _is_valid_media(metadata, media_type)
|
||
|
||
if not is_valid:
|
||
# 文件无效,创建 ERROR 状态的 asset 并标记 job 失败
|
||
error_reason = "metadata extraction failed" if not extract_success else "media validation failed"
|
||
logger.warning(
|
||
"素材有效性校验失败,标记为ERROR: job_id=%s storage_key=%s media_type=%s reason=%s",
|
||
job_id,
|
||
job.storage_key,
|
||
media_type,
|
||
error_reason,
|
||
)
|
||
|
||
asset = Asset.create(
|
||
project_id=job.project_id,
|
||
library_id=job.library_id,
|
||
name=filename,
|
||
storage_key=job.storage_key,
|
||
mime_type=mime_type,
|
||
metadata={"source": "upload", "ingest_error": error_reason},
|
||
file_size=int(metadata.get("size_bytes", 0)),
|
||
duration=float(metadata.get("duration", 0)),
|
||
width=int(metadata.get("width", 0)),
|
||
height=int(metadata.get("height", 0)),
|
||
codec=metadata.get("codec") or None,
|
||
status=AssetStatus.ERROR,
|
||
file_hash=job.file_hash,
|
||
)
|
||
asset_repo.create(asset)
|
||
|
||
# Update job status to FAILED
|
||
job.status = IngestJobStatus.FAILED
|
||
job.error_message = f"Invalid media file: {error_reason}"
|
||
job.result_asset_id = asset.id
|
||
job.updated_at = datetime.now(timezone.utc)
|
||
job_repo.update(job)
|
||
db.commit()
|
||
|
||
return {
|
||
"status": "failed",
|
||
"job_id": job.id,
|
||
"asset_id": asset.id,
|
||
"error": error_reason,
|
||
}
|
||
|
||
# 查找已存在的 Asset 记录(由 API 端在上传完成时立即创建为 PROCESSING 状态)
|
||
existing_asset = None
|
||
try:
|
||
existing_asset = asset_repo.find_by_storage_key(job.storage_key)
|
||
except Exception:
|
||
logger.warning("find_by_storage_key not available, trying fallback lookup")
|
||
|
||
if existing_asset is None:
|
||
# 兜底:如果 API 端没有预先创建 Asset(旧版本兼容),则创建新记录
|
||
logger.info("No pre-created asset found for storage_key=%s, creating new", job.storage_key)
|
||
metadata["source"] = "upload"
|
||
asset = Asset.create(
|
||
project_id=job.project_id,
|
||
library_id=job.library_id,
|
||
name=filename,
|
||
storage_key=job.storage_key,
|
||
mime_type=mime_type,
|
||
metadata=metadata,
|
||
file_size=int(metadata.get("size_bytes", 0)),
|
||
duration=float(metadata.get("duration", 0)),
|
||
width=int(metadata.get("width", 0)),
|
||
height=int(metadata.get("height", 0)),
|
||
codec=metadata.get("codec") or None,
|
||
status=AssetStatus.READY,
|
||
file_hash=job.file_hash,
|
||
thumbnail_url=thumbnail_url,
|
||
)
|
||
asset_repo.create(asset)
|
||
else:
|
||
# 更新已有的 Asset 记录,补充元数据并将状态改为 READY
|
||
asset = existing_asset
|
||
asset.mime_type = mime_type
|
||
metadata["source"] = "upload"
|
||
asset.metadata = metadata
|
||
asset.file_size = int(metadata.get("size_bytes", 0))
|
||
asset.duration = float(metadata.get("duration", 0))
|
||
asset.width = int(metadata.get("width", 0))
|
||
asset.height = int(metadata.get("height", 0))
|
||
codec_val = metadata.get("codec")
|
||
if codec_val:
|
||
asset.codec = str(codec_val)
|
||
fps_val = metadata.get("fps")
|
||
if fps_val:
|
||
try:
|
||
asset.fps = float(fps_val)
|
||
except (ValueError, TypeError):
|
||
pass
|
||
asset.status = AssetStatus.READY
|
||
asset.thumbnail_url = thumbnail_url
|
||
asset.updated_at = datetime.now(timezone.utc)
|
||
asset_repo.update(asset)
|
||
|
||
# Update job status to COMPLETED
|
||
job.status = IngestJobStatus.COMPLETED
|
||
job.result_asset_id = asset.id
|
||
job.updated_at = datetime.now(timezone.utc)
|
||
job_repo.update(job)
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"status": "completed",
|
||
"job_id": job.id,
|
||
"asset_id": asset.id,
|
||
}
|
||
except Exception as e:
|
||
db.rollback()
|
||
logger.error(f"Failed to ingest asset {job_id}: {e}")
|
||
|
||
# Update job status to FAILED and mark pre-created Asset as ERROR
|
||
try:
|
||
job_repo = SQLAlchemyIngestJobRepository(db)
|
||
asset_repo = SQLAlchemyAssetRepository(db)
|
||
job = job_repo.get(job_id)
|
||
if job:
|
||
job.status = IngestJobStatus.FAILED
|
||
job.error_message = str(e)
|
||
job.updated_at = datetime.now(timezone.utc)
|
||
job_repo.update(job)
|
||
|
||
# 将上传时创建的占位 Asset(PROCESSING/UPLOADING)标记为 ERROR,
|
||
# 避免素材永远卡在中间状态
|
||
try:
|
||
existing = asset_repo.find_by_storage_key(job.storage_key)
|
||
if existing and existing.status in (
|
||
AssetStatus.PROCESSING,
|
||
AssetStatus.UPLOADING,
|
||
):
|
||
existing.status = AssetStatus.ERROR
|
||
existing.metadata = {**(existing.metadata or {}), "ingest_error": str(e)}
|
||
existing.updated_at = datetime.now(timezone.utc)
|
||
asset_repo.update(existing)
|
||
logger.info(
|
||
"Marked asset as ERROR due to ingest failure: asset_id=%s job_id=%s",
|
||
existing.id,
|
||
job_id,
|
||
)
|
||
except Exception as asset_err:
|
||
logger.warning("Failed to mark asset as ERROR: %s", asset_err)
|
||
|
||
db.commit()
|
||
except Exception:
|
||
db.rollback()
|
||
|
||
return {
|
||
"status": "failed",
|
||
"job_id": job_id,
|
||
"error": str(e),
|
||
}
|
||
finally:
|
||
db.close()
|