diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 1870816..ea21620 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -150,7 +150,8 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool: """同步全能创作的多段出片。 每段仍是普通 FREE_VIDEO 任务,故可沿用既有计费、轮询和资产落库;这里仅把它们聚合成 - 一张结果卡。所有分段完成后停在结果卡,绝不在此处触发 ffmpeg。 + 一张结果卡。任一片段先完成就先写回 GENERATING 卡供用户预览;所有分段完成后才转成 + 可合并的结果卡,绝不在此处触发 ffmpeg。 """ from .free_video import finalize_free_video from .models import AITask @@ -189,16 +190,46 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool: number = next((index + 1 for index, task in enumerate(refreshed) if task.id == failed.id), 1) fail_generating_message(message, f"第 {number} 段生成失败:{failed.error_message or '请重试'}") return True + assets: list[dict] = [] + completed_segments = 0 + for index, task in enumerate(refreshed, start=1): + if task.status != AITask.Status.SUCCEEDED: + continue + task_assets = _assets_from_task(task) + # 上游状态先成功、资产稍后才落库时,保留 GENERATING,下一轮再展示这段。 + if not task_assets: + continue + completed_segments += 1 + for asset in task_assets: + assets.append({**asset, "label": f"第 {index} 段", "segment_index": index}) + + # 先完成的片段必须立刻回到前端,不能等另一段慢任务一起完成才出现。 + # 保持同一条 GENERATING 消息,避免把一个 60 秒视频拆成多条对话消息。 if not all(task.status == AITask.Status.SUCCEEDED for task in refreshed): + progress = { + **payload, + "assets": assets, + "completed_segment_count": completed_segments, + } + if progress != payload: + message.payload = progress + message.save(update_fields=["payload", "updated_at"]) + return True + return False + + # 任务都成功但有片段的资产还在落库,继续保持生成中,避免最终结果缺片。 + if completed_segments != len(refreshed): + progress = { + **payload, + "assets": assets, + "completed_segment_count": completed_segments, + } + if progress != payload: + message.payload = progress + message.save(update_fields=["payload", "updated_at"]) + return True return False - assets: list[dict] = [] - for index, task in enumerate(refreshed, start=1): - task_assets = _assets_from_task(task) - if not task_assets: - return False - for asset in task_assets: - assets.append({**asset, "label": f"第 {index} 段"}) first = refreshed[0] finish_generating_message( message, diff --git a/core/backend/apps/ai/test_creation_conversation.py b/core/backend/apps/ai/test_creation_conversation.py index c6ac1cf..bc754af 100644 --- a/core/backend/apps/ai/test_creation_conversation.py +++ b/core/backend/apps/ai/test_creation_conversation.py @@ -161,6 +161,33 @@ class GenerationBackfillTests(TestCase): message.refresh_from_db() self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) + def test_segmented_video_shows_completed_first_segment_before_second_finishes(self): + first = self._task(AITask.Status.SUCCEEDED, key="k-segment-first") + second = self._task(AITask.Status.POLLING, key="k-segment-second") + self._asset(first, url="https://cdn.example/segment-1.mp4") + message = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.GENERATING, + task=first, + payload={ + "kind": "video_segments", + "task_id": str(first.id), + "task_ids": [str(first.id), str(second.id)], + "segments": [ + {"index": 1, "task_id": str(first.id)}, + {"index": 2, "task_id": str(second.id)}, + ], + }, + ) + + self.assertEqual(sync_generating_messages(self.conversation), 1) + message.refresh_from_db() + self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) + self.assertEqual(message.payload["completed_segment_count"], 1) + self.assertEqual(message.payload["assets"][0]["label"], "第 1 段") + self.assertEqual(message.payload["assets"][0]["url"], "https://cdn.example/segment-1.mp4") + def test_retrieve_backfills_before_returning_messages(self): task = self._task(AITask.Status.SUCCEEDED, key="k-api") self._asset(task, url="https://cdn.example/api.png") diff --git a/core/frontend/src/components/omni-param-bar.tsx b/core/frontend/src/components/omni-param-bar.tsx index c78259b..d33e8c5 100644 --- a/core/frontend/src/components/omni-param-bar.tsx +++ b/core/frontend/src/components/omni-param-bar.tsx @@ -12,7 +12,7 @@ export const OMNI_RESOLUTIONS = ["1080p", "720p", "480p"]; export const OMNI_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"]; export const OMNI_VIDEO_DURATIONS = [ "智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒", - "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒", + "11 秒", "12 秒", "13 秒", "14 秒", "15 秒", "30 秒", "45 秒", "60 秒", ]; export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"]; @@ -70,11 +70,21 @@ function catalogModelLabels( return unique.length ? unique : fallback; } -function durationLabelsForModel(config: ModelConfig | undefined, isVideo: boolean): string[] { +function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string): boolean { + // 31–60 秒不是单次 API 时长,而是平台会自动拆成 <=30 秒的两段。 + // 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这个总时长。 + return modelDurations(config).some((seconds) => seconds >= 30) + || /seedance\s*2\.5/i.test(model || ""); +} + +function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] { if (!isVideo) return [...OMNI_IMAGE_COUNTS]; const seconds = modelDurations(config); if (!seconds.length) return [...OMNI_VIDEO_DURATIONS]; - return ["智能时长", ...seconds.map((n) => `${n} 秒`)]; + const segmented = canCreateSegmentedVideo(config, model) ? [45, 60] : []; + return ["智能时长", ...seconds, ...segmented] + .filter((seconds, index, values) => values.indexOf(seconds) === index) + .map((seconds) => typeof seconds === "number" ? `${seconds} 秒` : seconds); } export function OmniParamBar({ @@ -120,7 +130,7 @@ export function OmniParamBar({ ); const allowedRes = isVideo ? modelResolutions(selected) : []; const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution); - const durations = withCurrent(durationLabelsForModel(selected, isVideo), duration); + const durations = withCurrent(durationLabelsForModel(selected, model, isVideo), duration); useEffect(() => { setCustomOn(isVideo ? duration !== "智能时长" : true); @@ -173,12 +183,15 @@ export function OmniParamBar({ if (nextRes.length && resolution && !nextRes.includes(resolution)) { onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]); } - const nextDur = durationLabelsForModel(selected, true); - if (duration && duration !== "智能时长" && !nextDur.includes(duration)) { + const nextDur = durationLabelsForModel(selected, model, true); + const seconds = Number(String(duration || "").replace(/\D/g, "")); + const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model); + // 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。 + if (duration && duration !== "智能时长" && !nextDur.includes(duration) && !isPlannedSegmentedDuration) { onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长")); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selected?.id, isVideo]); + }, [selected?.id, isVideo, model]); return ( <> diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 2ec9aa2..d1466cd 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -1237,6 +1237,10 @@ height: 220px; } +.omni-result-media.is-grid .omni-process-frame { + height: 220px; +} + .omni-result-play { position: absolute; inset: 0; diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 0e2c335..d5a7a75 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -1488,6 +1488,8 @@ function ConfirmCard({ const cardIsVideo = message.payload.kind !== "image" && isVideo; const durationChanged = cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration; + const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, "")); + const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60; const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value })); const summary = paramLine(draft, cardIsVideo) || "当前参数"; // 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1) @@ -1537,6 +1539,8 @@ function ConfirmCard({ )} {durationChanged ? (
改时长会重新生成脚本。确认后先重写方案,不会直接出片。
+ ) : willGenerateInSegments ? ( +{durationSeconds} 秒将拆成 2 段生成,每段最长 30 秒;片段完成后先给你预览,再由你决定是否合并成片。
) : null}