补
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -1237,6 +1237,10 @@
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.omni-result-media.is-grid .omni-process-frame {
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
.omni-result-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -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 ? (
|
||||
<p className="omni-confirm-hint">改时长会重新生成脚本。确认后先重写方案,不会直接出片。</p>
|
||||
) : willGenerateInSegments ? (
|
||||
<p className="omni-confirm-hint">{durationSeconds} 秒将拆成 2 段生成,每段最长 30 秒;片段完成后先给你预览,再由你决定是否合并成片。</p>
|
||||
) : null}
|
||||
<div className="omni-confirm-foot">
|
||||
<span>{submitted ? "已确认,正在出片" : "点确认后才会提交生成"}</span>
|
||||
@@ -1917,25 +1921,72 @@ function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[]
|
||||
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 isMerge = payload.kind === "video_merge";
|
||||
const isVideo = payload.kind === "video" || isSegmentedVideo || isMerge;
|
||||
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 (
|
||||
<section className="omni-result-card omni-process-card">
|
||||
<div className="omni-result-media">
|
||||
<figure className="omni-result-tile">
|
||||
<div className="omni-process-frame" aria-hidden="true">
|
||||
<span className="omni-process-ring" />
|
||||
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
|
||||
</div>
|
||||
</figure>
|
||||
<div className={`omni-result-media${isSegmentedVideo ? " is-grid" : ""}`}>
|
||||
{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">
|
||||
<span className="omni-process-ring" />
|
||||
<strong>{isVideo ? "视频生成中" : "图片生成中"}</strong>
|
||||
</div>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
<div className="omni-result-info">
|
||||
<div>
|
||||
<strong>{isMerge ? "正在合并成片" : isSegmentedVideo ? `正在生成 ${segmentCount || 2} 段视频` : isVideo ? "正在生成视频" : "正在出图"}</strong>
|
||||
<small>{isMerge ? "正在拼接已确认的片段" : isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"}</small>
|
||||
<strong>{
|
||||
isMerge
|
||||
? "正在合并成片"
|
||||
: isSegmentedVideo && completedSegments
|
||||
? `第 ${completedSegments} 段已生成,正在生成第 ${completedSegments + 1} 段`
|
||||
: isSegmentedVideo
|
||||
? `正在生成 ${segmentCount || 2} 段视频`
|
||||
: isVideo ? "正在生成视频" : "正在出图"
|
||||
}</strong>
|
||||
<small>{
|
||||
isMerge
|
||||
? "正在拼接已确认的片段"
|
||||
: isSegmentedVideo && completedSegments
|
||||
? "已生成的片段可以先点击预览"
|
||||
: isSegmentedVideo ? "片段完成后会先展示给你确认" : isVideo ? "方案编译中,请稍候" : "画面生成中,请稍候"
|
||||
}</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -2772,7 +2823,13 @@ export function OmniSessionPage({
|
||||
);
|
||||
}
|
||||
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":
|
||||
return (
|
||||
<ResultCard
|
||||
|
||||
Reference in New Issue
Block a user