补
This commit is contained in:
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user