diff --git a/core/backend/apps/projects/services/export.py b/core/backend/apps/projects/services/export.py index 22565bc..5606c07 100644 --- a/core/backend/apps/projects/services/export.py +++ b/core/backend/apps/projects/services/export.py @@ -268,7 +268,9 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"] if audio_label: cmd += ["-map", f"[{audio_label}]"] - cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", fps_expr, "-preset", "veryfast"] + # -threads 2:限制 x264 线程数,降低小容器(K8s 内存 limit 小)里的内存峰值,缓解 OOM 被杀; + # 同时避免在受限 CPU 上 fork 过多线程争抢。veryfast 保持编码速度。 + cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", fps_expr, "-preset", "veryfast", "-threads", "2"] if audio_label: cmd += ["-c:a", "aac", "-b:a", "192k"] cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"] @@ -479,7 +481,12 @@ def run_export_job(export_job_id: str) -> ExportJob: sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0, has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays, ) - proc = subprocess.run(command, cwd=str(tmp), capture_output=True) + # 加超时:ffmpeg 卡死(资源不足/被 OOM 杀)时不能让任务永久停在 RUNNING、前端无限转圈; + # 超时即按失败收尾,前端能看到「导出失败」而非一直卡。15 分钟足够正常 60s 成片。 + try: + proc = subprocess.run(command, cwd=str(tmp), capture_output=True, timeout=900) + except subprocess.TimeoutExpired: + raise RuntimeError("ffmpeg 导出超时(15 分钟未完成),通常是服务器资源不足或视频过大,请重试或联系运维扩容") if proc.returncode != 0: raise RuntimeError(f"ffmpeg export failed: {proc.stderr.decode('utf-8', 'ignore')[-1200:]}") output_path = tmp / "output.mp4" diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index edfcbaa..03dce3e 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -371,6 +371,15 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): duration_ms=segment.target_duration_seconds * 1000, ) start_ms += segment.target_duration_seconds * 1000 + # 清理僵尸任务:后台线程跑导出时若 Pod 被重启/OOM 杀掉,旧任务会永远停在 RUNNING、 + # 前端无限轮询。新建前把本时间线超过 5 分钟仍未结束的旧任务标失败,避免界面一直转圈。 + from django.utils import timezone + from datetime import timedelta + ExportJob.objects.filter( + timeline=timeline, + status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING], + updated_at__lt=timezone.now() - timedelta(minutes=5), + ).update(status=ExportJob.Status.FAILED, error_message="任务中断(服务重启或超时),已自动失败,请重新导出") export_job = create_export_job(timeline=timeline, user=request.user) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT) stage.status = ProjectStage.Status.RUNNING diff --git a/k8s/core/api-deployment.yaml b/k8s/core/api-deployment.yaml index 573336f..12286f5 100644 --- a/k8s/core/api-deployment.yaml +++ b/k8s/core/api-deployment.yaml @@ -51,11 +51,13 @@ spec: failureThreshold: 3 resources: requests: - memory: "256Mi" - cpu: "100m" + memory: "512Mi" + cpu: "250m" limits: - memory: "768Mi" - cpu: "1000m" + # 视频导出在本容器后台线程跑 ffmpeg,编码 1080P 竖屏 + 多路输入 + 字幕/配音混音 + # 内存峰值可达 1.5GB,原 768Mi 必被 OOMKill(导出卡 35%、Pod 重启)。提到 2Gi 给足空间。 + memory: "2Gi" + cpu: "1500m" --- apiVersion: v1 kind: Service