This commit is contained in:
Azmat@qq.com
2026-09-16 16:52:01 +08:00
parent 7f5307d97c
commit b487b65575
5 changed files with 158 additions and 26 deletions
+38 -7
View File
@@ -150,7 +150,8 @@ def _sync_segmented_video_message(message: CreationMessage) -> bool:
"""同步全能创作的多段出片。 """同步全能创作的多段出片。
每段仍是普通 FREE_VIDEO 任务,故可沿用既有计费、轮询和资产落库;这里仅把它们聚合成 每段仍是普通 FREE_VIDEO 任务,故可沿用既有计费、轮询和资产落库;这里仅把它们聚合成
一张结果卡。所有分段完成后停在结果卡,绝不在此处触发 ffmpeg。 一张结果卡。任一片段先完成就先写回 GENERATING 卡供用户预览;所有分段完成后才转成
可合并的结果卡,绝不在此处触发 ffmpeg。
""" """
from .free_video import finalize_free_video from .free_video import finalize_free_video
from .models import AITask 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) 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 '请重试'}") fail_generating_message(message, f"{number} 段生成失败:{failed.error_message or '请重试'}")
return True 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): 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 return False
assets: list[dict] = [] # 任务都成功但有片段的资产还在落库,继续保持生成中,避免最终结果缺片。
for index, task in enumerate(refreshed, start=1): if completed_segments != len(refreshed):
task_assets = _assets_from_task(task) progress = {
if not task_assets: **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 return False
for asset in task_assets:
assets.append({**asset, "label": f"{index}"})
first = refreshed[0] first = refreshed[0]
finish_generating_message( finish_generating_message(
message, message,
@@ -161,6 +161,33 @@ class GenerationBackfillTests(TestCase):
message.refresh_from_db() message.refresh_from_db()
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING) 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): def test_retrieve_backfills_before_returning_messages(self):
task = self._task(AITask.Status.SUCCEEDED, key="k-api") task = self._task(AITask.Status.SUCCEEDED, key="k-api")
self._asset(task, url="https://cdn.example/api.png") self._asset(task, url="https://cdn.example/api.png")
@@ -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_RATIOS = ["9:16", "16:9", "1:1", "4:3", "3:4"];
export const OMNI_VIDEO_DURATIONS = [ export const OMNI_VIDEO_DURATIONS = [
"智能时长", "4 秒", "5 秒", "6 秒", "7 秒", "8 秒", "9 秒", "10 秒", "智能时长", "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 张"]; export const OMNI_IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"];
@@ -70,11 +70,21 @@ function catalogModelLabels(
return unique.length ? unique : fallback; 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]; if (!isVideo) return [...OMNI_IMAGE_COUNTS];
const seconds = modelDurations(config); const seconds = modelDurations(config);
if (!seconds.length) return [...OMNI_VIDEO_DURATIONS]; 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({ export function OmniParamBar({
@@ -120,7 +130,7 @@ export function OmniParamBar({
); );
const allowedRes = isVideo ? modelResolutions(selected) : []; const allowedRes = isVideo ? modelResolutions(selected) : [];
const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution); 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(() => { useEffect(() => {
setCustomOn(isVideo ? duration !== "智能时长" : true); setCustomOn(isVideo ? duration !== "智能时长" : true);
@@ -173,12 +183,15 @@ export function OmniParamBar({
if (nextRes.length && resolution && !nextRes.includes(resolution)) { if (nextRes.length && resolution && !nextRes.includes(resolution)) {
onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]); onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]);
} }
const nextDur = durationLabelsForModel(selected, true); const nextDur = durationLabelsForModel(selected, model, true);
if (duration && duration !== "智能时长" && !nextDur.includes(duration)) { 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 !== "智能时长") || "智能时长")); onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.id, isVideo]); }, [selected?.id, isVideo, model]);
return ( return (
<> <>
+4
View File
@@ -1237,6 +1237,10 @@
height: 220px; height: 220px;
} }
.omni-result-media.is-grid .omni-process-frame {
height: 220px;
}
.omni-result-play { .omni-result-play {
position: absolute; position: absolute;
inset: 0; inset: 0;
+63 -6
View File
@@ -1488,6 +1488,8 @@ function ConfirmCard({
const cardIsVideo = message.payload.kind !== "image" && isVideo; const cardIsVideo = message.payload.kind !== "image" && isVideo;
const durationChanged = const durationChanged =
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration; 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 setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
const summary = paramLine(draft, cardIsVideo) || "当前参数"; const summary = paramLine(draft, cardIsVideo) || "当前参数";
// 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1) // 确认卡积分:改模型/分辨率/时长/张数时按后台挂牌实时重算(与后端 quote_* 同口径;标准团队系数=1)
@@ -1537,6 +1539,8 @@ function ConfirmCard({
)} )}
{durationChanged ? ( {durationChanged ? (
<p className="omni-confirm-hint"></p> <p className="omni-confirm-hint"></p>
) : willGenerateInSegments ? (
<p className="omni-confirm-hint">{durationSeconds} 2 30 </p>
) : null} ) : null}
<div className="omni-confirm-foot"> <div className="omni-confirm-foot">
<span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span> <span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span>
@@ -1917,25 +1921,72 @@ function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[]
return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list; return hidden.size ? list.filter((message) => !hidden.has(message.id)) : list;
} }
function ProcessCard({ payload }: { payload: Record<string, unknown> }) { function ProcessCard({
payload,
onPreview,
}: {
payload: Record<string, unknown>;
onPreview?: (src: string, kind: "image" | "video", name: string) => void;
}) {
const isSegmentedVideo = payload.kind === "video_segments"; const isSegmentedVideo = payload.kind === "video_segments";
const isMerge = payload.kind === "video_merge"; const isMerge = payload.kind === "video_merge";
const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge; const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge;
const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0; const segmentCount = Array.isArray(payload.segments) ? payload.segments.length : 0;
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const completedSegments = Number(payload.completed_segment_count || 0);
const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1;
return ( return (
<section className="omni-result-card omni-process-card"> <section className="omni-result-card omni-process-card">
<div className="omni-result-media"> <div className={`omni-result-media${isSegmentedVideo ? " is-grid" : ""}`}>
<figure className="omni-result-tile"> {assets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
const video = asset.type === "video";
const label = String(asset.label || `${index + 1}`);
return (
<figure className="omni-result-tile" key={asset.id || `${url}-${index}`}>
<button
type="button"
className="omni-result-preview"
onClick={() => url && onPreview?.(url, video ? "video" : "image", label)}
>
<img src={cover} alt={label} />
{video ? (
<span className="omni-result-play" aria-hidden="true">
<Play />
</span>
) : null}
</button>
</figure>
);
})}
{Array.from({ length: waitingSegments }).map((_, index) => (
<figure className="omni-result-tile" key={`generating-${index}`}>
<div className="omni-process-frame" aria-hidden="true"> <div className="omni-process-frame" aria-hidden="true">
<span className="omni-process-ring" /> <span className="omni-process-ring" />
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong> <strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
</div> </div>
</figure> </figure>
))}
</div> </div>
<div className="omni-result-info"> <div className="omni-result-info">
<div> <div>
<strong>{isMerge ? "正在合并成片" : isSegmentedVideo ? `正在生成 ${segmentCount || 2} 段视频` : isVideo ? "正在生成视频" : "正在出图"}</strong> <strong>{
<small>{isMerge ? "正在拼接已确认的片段" : isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"}</small> isMerge
? "正在合并成片"
: isSegmentedVideo && completedSegments
? `${completedSegments} 段已生成,正在生成第 ${completedSegments + 1}`
: isSegmentedVideo
? `正在生成 ${segmentCount || 2} 段视频`
: isVideo ? "正在生成视频" : "正在出图"
}</strong>
<small>{
isMerge
? "正在拼接已确认的片段"
: isSegmentedVideo && completedSegments
? "已生成的片段可以先点击预览"
: isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
}</small>
</div> </div>
</div> </div>
</section> </section>
@@ -2772,7 +2823,13 @@ export function OmniSessionPage({
); );
} }
case "generating": case "generating":
return <ProcessCard key={message.clientKey || message.id} payload={message.payload} />; return (
<ProcessCard
key={message.clientKey || message.id}
payload={message.payload}
onPreview={(src, kind, name) => setAssetPreview({ src, kind, name })}
/>
);
case "result": case "result":
return ( return (
<ResultCard <ResultCard