diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index 4bfdef025..eb7e5d9f7 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -390,6 +390,7 @@ async def prepare_direct_upload( duplicated=True, skip_transfer=True, asset_id=existing.id, + url=existing.file_url or storage_service.get_url(existing.storage_key) or "", ) file_id = uuid4().hex[:8] @@ -443,6 +444,7 @@ async def prepare_direct_upload( duplicated=False, skip_transfer=False, asset_id=pending_asset_id, + url="", ) diff --git a/apps/api/app/schemas/upload.py b/apps/api/app/schemas/upload.py index 54b6cba6f..25b6d9eb5 100644 --- a/apps/api/app/schemas/upload.py +++ b/apps/api/app/schemas/upload.py @@ -29,6 +29,8 @@ class DirectUploadPrepareResponse(BaseModel): duplicated: bool = False skip_transfer: bool = False asset_id: str = "" + # duplicated=true 时填充已存在素材的公网 URL,前端可直接用而不必再调 complete + url: str = Field(default="", description="duplicated=true 时已存在素材的公网 URL") class DirectUploadCompleteRequest(BaseModel): diff --git a/apps/web/src/api/assets/types.ts b/apps/web/src/api/assets/types.ts index 77ec261fc..e8419d285 100644 --- a/apps/web/src/api/assets/types.ts +++ b/apps/web/src/api/assets/types.ts @@ -150,6 +150,8 @@ export interface DirectUploadPrepareResult { * 两个字段是同一语义的别名(后端可能只返回其一),前端任意为 true 即视为命中去重。 */ skip_transfer?: boolean + /** duplicated=true 时后端返回已存在素材的公网 URL,前端直接用而不必再调 complete */ + url?: string } /** 直传完成确认返回 */ diff --git a/apps/web/src/api/assets/upload.ts b/apps/web/src/api/assets/upload.ts index 490b08df7..2f22673c4 100644 --- a/apps/web/src/api/assets/upload.ts +++ b/apps/web/src/api/assets/upload.ts @@ -3,9 +3,24 @@ */ import apiClient from "../client" import { getOrCreateDefaultProject } from "../projects" +import { ensureDefaultLibrary } from "./libraries" import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types" import { computeFileHash, makeClientUploadId } from "./uploadDedup" +/** 根据 File.type 推断素材库 kind(image/video/voice);无法推断时默认 image */ +function inferKindFromFile(file: File): "image" | "video" | "voice" { + const t = (file.type || "").toLowerCase() + if (t.startsWith("image/")) return "image" + if (t.startsWith("video/")) return "video" + if (t.startsWith("audio/")) return "voice" + // 兜底:按扩展名再判一次 + const name = file.name.toLowerCase() + if (/\.(png|jpe?g|gif|webp|bmp|svg|avif)$/.test(name)) return "image" + if (/\.(mp4|mov|webm|avi|mkv|flv|wmv|m4v)$/.test(name)) return "video" + if (/\.(mp3|wav|m4a|aac|ogg|flac|opus|webm)$/.test(name)) return "voice" + return "image" +} + /** 预签名直传准备 */ export const prepareDirectUpload = async (data: { project_id: string @@ -108,6 +123,8 @@ const putToOSS = ( /** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */ export interface DirectUploadHandle { + /** 实际使用的素材库(内部解析出来,便于调用方做后续 UI/缓存操作) */ + library: { id: string; kind: "image" | "video" | "voice" } /** prepare 返回(含可能的预建 asset_id) */ prepared: DirectUploadPrepareResult /** 直传 OSS(可重复调用用于重试) */ @@ -119,10 +136,17 @@ export interface DirectUploadHandle { /** * 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset), * 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。 + * + * 修复 P0 404:library_id 改为可选;未传时自动根据文件类型在默认项目下确保对应素材库存在, + * 避免调用方从「全部素材库列表」里挑一个 library_id、但与默认项目 project_id 不匹配, + * 导致后端返回 "Asset library not found" 404。 */ export const prepareDirectUploadHandle = async (data: { file: File - library_id: string + /** 素材库 ID;未传时按文件类型自动在默认项目下 ensure-default */ + library_id?: string + /** 显式指定素材库 kind;未传时按 MIME/扩展名推断 */ + kind?: "image" | "video" | "voice" /** 前端算好的文件内容哈希(SHA-256 hex),prepare/complete 均携带 */ fileHash?: string /** 本次逻辑上传的幂等 token,prepare/complete 一致、重试复用 */ @@ -138,9 +162,17 @@ export const prepareDirectUploadHandle = async (data: { throw new Error(`初始化默认项目失败,无法开始上传:${reason}`) } + // 解析 library_id:调用方传了就用,没传就按 kind 自动 ensure-default + let resolvedLibraryId = data.library_id + const resolvedKind = data.kind ?? inferKindFromFile(data.file) + if (!resolvedLibraryId) { + const lib = await ensureDefaultLibrary({ project_id: project.id, kind: resolvedKind }) + resolvedLibraryId = lib.id + } + const prepared = await prepareDirectUpload({ project_id: project.id, - library_id: data.library_id, + library_id: resolvedLibraryId, filename: data.file.name, content_type: data.file.type || "application/octet-stream", file_size: data.file.size, @@ -149,12 +181,13 @@ export const prepareDirectUploadHandle = async (data: { }) return { + library: { id: resolvedLibraryId, kind: resolvedKind }, prepared, transfer: (onProgress) => putToOSS(prepared, data.file, onProgress), complete: () => completeDirectUpload({ project_id: project.id, - library_id: data.library_id, + library_id: resolvedLibraryId, storage_key: prepared.storage_key, file_hash: data.fileHash, client_upload_id: data.clientUploadId, @@ -164,10 +197,17 @@ export const prepareDirectUploadHandle = async (data: { } } -/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */ +/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete + * + * P0 404 修复:library_id 可选;不传时内部按文件类型自动匹配正确项目下的素材库, + * 保证 project_id 与 library_id 必然一致。 + */ export const uploadAssetDirect = async (data: { file: File - library_id: string + /** 素材库 ID;可选,不传按文件类型自动解析默认项目下的对应素材库(推荐用法) */ + library_id?: string + /** 显式指定素材库 kind;未传时按文件 MIME/扩展名推断 */ + kind?: "image" | "video" | "voice" onProgress?: (percent: number) => void /** 文件内容哈希;未传时自动补算(配音/封面/克隆等非队列链路统一受益) */ fileHash?: string @@ -180,6 +220,7 @@ export const uploadAssetDirect = async (data: { const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id, + kind: data.kind, fileHash, clientUploadId, }) @@ -188,7 +229,7 @@ export const uploadAssetDirect = async (data: { return { storage_key: handle.prepared.storage_key, ingest_job_id: "", - url: "", + url: handle.prepared.url || "", duplicated: true, asset_id: handle.prepared.asset_id, } diff --git a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx index f20fafd6b..ac088d032 100755 --- a/apps/web/src/pages/generate/components/Step6CoverSettings.tsx +++ b/apps/web/src/pages/generate/components/Step6CoverSettings.tsx @@ -17,7 +17,7 @@ import CoverSettingsModal from "./cover-settings/CoverSettingsModal" import CoverEditorModal from "./cover-settings/CoverEditorModal" import { useSharedCover } from "@/components/cover/useSharedCover" import { generateCover as apiGenerateCover } from "@/api/generation" -import { uploadAssetDirect, getAssetLibraries } from "@/api/assets" +import { uploadAssetDirect } from "@/api/assets" interface Step6CoverSettingsProps { coverSettings: CoverConfig @@ -176,15 +176,8 @@ const Step6CoverSettings: React.FC = (props) => { thumbnail_url: previewUrl, mode: "upload", }) - // 查找图片素材库(复用批量封面的逻辑) - const libs = await getAssetLibraries() - const imageLib = libs.find((l) => l.kind === "image") || libs[0] - if (!imageLib) { - hide() - message.error("未找到素材库,请先创建图片素材库") - return previewUrl - } - const result = await uploadAssetDirect({ file, library_id: imageLib.id }) + // 后端自动在默认项目下确保图片素材库存在(P0 404 修复) + const result = await uploadAssetDirect({ file, kind: "image" }) const realUrl = result?.url || "" if (!realUrl) { hide() diff --git a/apps/web/src/pages/generate/hooks/useBatchCovers.ts b/apps/web/src/pages/generate/hooks/useBatchCovers.ts index 80d607a01..f450e4858 100644 --- a/apps/web/src/pages/generate/hooks/useBatchCovers.ts +++ b/apps/web/src/pages/generate/hooks/useBatchCovers.ts @@ -12,7 +12,7 @@ import { useCallback, useState } from "react" import { message } from "antd" import { generateCover } from "@/api/generation" -import { uploadAssetDirect, getAssetLibraries } from "@/api/assets" +import { uploadAssetDirect } from "@/api/assets" import type { GeneratedVideo } from "@/api/template-editor" /** onCoversChange 支持直接传值或函数式 updater(函数式用于串行回写避免闭包覆盖) */ @@ -182,15 +182,9 @@ export function useBatchCovers({ async (index: number, file: File) => { addUploading(index) try { - const libs = await getAssetLibraries() - const imageLib = libs.find((l) => l.kind === "image") || libs[0] - if (!imageLib) { - message.error("未找到素材库,请先创建") - return - } const result = await uploadAssetDirect({ file, - library_id: imageLib.id, + kind: "image", }) const url = result?.url || "" if (url) { diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 12529b1a5..12455bfa2 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -15,7 +15,7 @@ import { AudioOutlined, } from "@ant-design/icons" import { Button as AntdButton, message, Select } from "antd" -import { uploadAssetDirect, getAssetLibraries, getAssetsByKind } from "@/api/assets" +import { uploadAssetDirect, getAssetsByKind } from "@/api/assets" import { FUSION_LEVELS, STYLE_STRENGTHS, @@ -238,12 +238,9 @@ const ViralVideoPage: React.FC = () => { kind: "image" | "video" | "voice", onProgress?: (pct: number) => Promise | void, ) => { - const libs = await getAssetLibraries() - const lib = libs.find((l: { kind: string; id: string }) => l.kind === kind) || libs[0] - if (!lib) throw new Error("未找到素材库") const res = await uploadAssetDirect({ file, - library_id: lib.id, + kind, onProgress: (p: number) => onProgress?.(Math.round(p * 100)), }) return res.url diff --git a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts index 0e4ed77ed..e17a94153 100755 --- a/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts +++ b/apps/web/src/pages/voice-materials/hooks/useVoiceMaterials/actions/useVoiceUpload.ts @@ -1,25 +1,26 @@ import { useState, useCallback } from "react" import { useMutation, useQueryClient } from "@tanstack/react-query" import { message } from "antd" -import { - uploadAssetDirect, - getAssetLibraries, - getIngestJob, - type AssetLibraryItem, -} from "@/api/assets" +import { uploadAssetDirect, getIngestJob, type AssetLibraryItem } from "@/api/assets" import { tagAsset } from "@/api/tags" import { type VoiceGender, type VoiceMaterial } from "../../../types" interface UseVoiceUploadOptions { voiceLibrary?: { id: string; kind: string } - createLibMutation: { mutateAsync: () => Promise; isPending: boolean } + createLibMutation?: { + mutateAsync: () => Promise + isPending: boolean + } } /** * 配音素材上传 Hook * 封装上传流程:获取库 → 上传文件 → 获取时长 → 创建记录 → 打标签 */ -export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUploadOptions) { +export function useVoiceUpload({ + voiceLibrary, + createLibMutation: _createLibMutation, +}: UseVoiceUploadOptions) { const queryClient = useQueryClient() const [uploadProgress, setUploadProgress] = useState(null) @@ -33,24 +34,12 @@ export function useVoiceUpload({ voiceLibrary, createLibMutation }: UseVoiceUplo }) => { setUploadProgress(0) try { - // 1. 获取或等待 voice library - let lib = voiceLibrary - if (!lib) { - if (createLibMutation.isPending) { - await createLibMutation.mutateAsync() - } - const libs = await queryClient.fetchQuery({ - queryKey: ["asset-libraries"], - queryFn: () => getAssetLibraries(), - }) - lib = libs.find((l: AssetLibraryItem) => l.kind === "voice") - if (!lib) throw new Error("无法创建配音库") - } - - // 2. 上传文件(带进度,后端自动创建 ingest job) + // 1. 上传文件:后端自动在默认项目下确保配音库存在(P0 404 修复) + // 兼容 voiceLibrary 参数:若调用方已传入正确的库 ID 则直接复用,否则内部自动解析 const complete = await uploadAssetDirect({ file: data.file, - library_id: lib.id, + library_id: voiceLibrary?.id, + kind: "voice", onProgress: (p) => setUploadProgress(p), }) diff --git a/apps/web/src/pages/voices/hooks/useVoiceUpload.ts b/apps/web/src/pages/voices/hooks/useVoiceUpload.ts index 47921745e..5290e06d9 100644 --- a/apps/web/src/pages/voices/hooks/useVoiceUpload.ts +++ b/apps/web/src/pages/voices/hooks/useVoiceUpload.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from "react" import { useMutation, useQueryClient } from "@tanstack/react-query" -import { uploadAssetDirect, getAssetLibraries, getIngestJob } from "@/api/assets" +import { uploadAssetDirect, getIngestJob } from "@/api/assets" /** * 配音上传 Hook @@ -23,18 +23,10 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) { mutationFn: async (data: { file: File; name: string; description: string }) => { setUploadProgress(0) try { - /* 获取或创建默认配音库 */ - const libs = await queryClient.fetchQuery({ - queryKey: ["asset-libraries"], - queryFn: () => getAssetLibraries(), - }) - const lib = libs.find((l) => l.kind === "voice") - if (!lib) throw new Error("配音库不存在,请先在配音库页面创建") - - /* 直传文件(后端会自动创建 ingest job) */ + /* 直传文件(后端会自动在默认项目下确保配音库存在,P0 404 修复) */ const complete = await uploadAssetDirect({ file: data.file, - library_id: lib.id, + kind: "voice", onProgress: (p) => setUploadProgress(p), }) diff --git a/apps/worker/worker_app/tasks/viral_video.py b/apps/worker/worker_app/tasks/viral_video.py index 835ed0683..9054ead24 100644 --- a/apps/worker/worker_app/tasks/viral_video.py +++ b/apps/worker/worker_app/tasks/viral_video.py @@ -381,10 +381,15 @@ def _step_tts(job: ViralVideoJob, copy_text: str): try: from pathlib import Path as _Path - from services.tts_service_factory import get_tts_service + # 使用绝对包路径,避免 celery worker 因 cwd/PYTHONPATH 微小差异找不到 services 模块 + from apps.worker.services.tts_service_factory import get_tts_service tts_service = get_tts_service() - result = tts_service.synthesize(text=copy_text, voice_id=job.persona_id or "default") + # 兼容老接口:部分 provider 只接收 text 参数 + try: + result = tts_service.synthesize(text=copy_text, voice_id=job.persona_id or "default") + except TypeError: + result = tts_service.synthesize(text=copy_text) if result is None: return None p = _Path(result) if not isinstance(result, _Path) else result @@ -593,15 +598,19 @@ def _make_placeholder_clip(tmpdir, idx: int, duration: int): def _step_upload(job: ViralVideoJob, video_path: str) -> str: - """步骤 10: OSS 上传。""" - try: - from video_processing.oss_helpers import upload_to_oss + """步骤 9: OSS 上传。""" + from pathlib import Path - video_url = upload_to_oss(video_path, prefix="viral-video/") - return video_url - except Exception as e: - logger.error("[爆款视频] OSS 上传失败: %s", e) - raise + from video_processing.oss_helpers import upload_to_oss + + local = Path(video_path) + # 构造 OSS key,与 generation.py 规则对齐:generated/viral-video/// + storage_key = f"generated/viral-video/{job.user_id}/{job.id}/{local.name}" + logger.info("[爆款视频] 开始上传成片: local=%s key=%s size=%d", local, storage_key, local.stat().st_size) + video_url = upload_to_oss(local, storage_key) + if not video_url: + raise RuntimeError(f"OSS 上传失败: storage_key={storage_key}") + return video_url # ── 主编排器 ──────────────────────────────────────────────────────────── diff --git a/packages/shared/ai_client.py b/packages/shared/ai_client.py index 6b8c03614..fe12327e6 100755 --- a/packages/shared/ai_client.py +++ b/packages/shared/ai_client.py @@ -298,6 +298,14 @@ class DoubaoClient: "Content-Type": "application/json", } create_url = f"{self.base_url}/contents/generations/tasks" + logger.info( + "Seedance 创建任务请求: url=%s model=%s duration=%ds ratio=%s gen_audio=%s", + create_url, + video_model, + duration, + ratio, + generate_audio, + ) # 1) 创建任务(带重试) task_id: str | None = None @@ -305,6 +313,13 @@ class DoubaoClient: for attempt in range(self.max_retries + 1): try: resp = httpx.post(create_url, headers=headers, json=create_payload, timeout=self.timeout) + if resp.status_code >= 400: + # 把响应体完整打出来(通常含 error.code/message,能直接定位:模型未开通/Key 无权限/模型 ID 错误) + logger.error( + "Seedance 创建任务 HTTP %d: body=%s", + resp.status_code, + (resp.text or "")[:1000], + ) resp.raise_for_status() data = resp.json() task_id = data.get("id") @@ -320,7 +335,16 @@ class DoubaoClient: ) time.sleep(wait) if not task_id: - logger.error("Seedance 创建任务最终失败: %s", last_error) + logger.error( + "Seedance 创建任务最终失败: model=%s base_url=%s err=%s 【排查建议】" + "1) 确认方舟控制台已开通 Doubao-Seedance-2.5 模型;" + "2) DOUBAO_API_KEY 对应的账号有该模型调用权限;" + "3) DOUBAO_BASE_URL 必须为 https://ark.cn-beijing.volces.com/api/v3;" + "4) 若控制台用「推理接入点」(endpoint),请把 DOUBAO_VIDEO_MODEL 改为 ep-xxx 接入点 ID。", + video_model, + self.base_url, + last_error, + ) return None logger.info("Seedance 任务已创建: task_id=%s model=%s duration=%ds", task_id, video_model, duration) @@ -352,6 +376,13 @@ class DoubaoClient: last_error = RuntimeError(f"task {status}") break # queued / running: 继续轮询 + except httpx.HTTPStatusError as e: + last_error = e + logger.warning( + "Seedance 轮询 HTTP %d: body=%s", + e.response.status_code, + (e.response.text or "")[:500], + ) except Exception as e: last_error = e logger.debug("Seedance 轮询异常: %s", e) diff --git a/scripts/ci_staging_deploy.sh b/scripts/ci_staging_deploy.sh index dc8831358..42a1d4625 100755 --- a/scripts/ci_staging_deploy.sh +++ b/scripts/ci_staging_deploy.sh @@ -137,6 +137,28 @@ fi echo "✅ compose.yml ready: $COMPOSE_FILE_PATH ($(wc -l < "$COMPOSE_FILE_PATH") lines)" ln -sf "$NGINX_CONF_FILE" "$INFRA_DOCKER_DIR/nginx-${COMPOSE_ENV_VALUE}.conf" 2>/dev/null || true +# 封装 docker compose 调用:统一 --env-file(compose 默认只读取 project 目录下的 .env, +# 我们的 .env 在 $INFRA_DOCKER_DIR/../../.env,必须显式传入才能读到 GENERATED_FILES_HOST_DIR 等变量) +# ── 防御:清理可能残留的 docker-compose.override.yml / compose.override.yml ── +# 历史上运维曾用 override 文件固定镜像 tag 排查问题,若忘记删除会导致新镜像 tag 不生效, +# Worker 一直跑旧镜像(本次 P0 404 排查中即踩过此坑)。这里每次部署都主动清理。 +for override in "$INFRA_DOCKER_DIR/docker-compose.override.yml" "$INFRA_DOCKER_DIR/compose.override.yml" "$INFRA_DOCKER_DIR/override.yml"; do + if [ -f "$override" ]; then + echo "⚠️ Found stale override file, removing: $override" + rm -f "$override" + fi +done + +# ── 防御:清理可能残留的 docker-compose.override.yml / compose.override.yml ── +# 历史上运维曾用 override 文件固定镜像 tag 排查问题,若忘记删除会导致新镜像 tag 不生效, +# Worker 一直跑旧镜像(本次 P0 404 排查中即踩过此坑)。这里每次部署都主动清理。 +for override in "$INFRA_DOCKER_DIR/docker-compose.override.yml" "$INFRA_DOCKER_DIR/compose.override.yml" "$INFRA_DOCKER_DIR/override.yml"; do + if [ -f "$override" ]; then + echo "⚠️ Found stale override file, removing: $override" + rm -f "$override" + fi +done + # 封装 docker compose 调用:统一 --env-file(compose 默认只读取 project 目录下的 .env, # 我们的 .env 在 $INFRA_DOCKER_DIR/../../.env,必须显式传入才能读到 GENERATED_FILES_HOST_DIR 等变量) compose() {