@@ -89,7 +89,7 @@ function canCreateSegmentedVideo(config: ModelConfig | undefined, model: string)
|
||||
// 31–60 秒总时长会拆成 ≤30 秒片段(长视频 >60 秒入口已临时关闭)。
|
||||
// 只有 Seedance 2.5(或目录里声明了 30 秒能力的模型)可选这些总时长。
|
||||
return modelDurations(config).some((seconds) => seconds >= 30)
|
||||
|| /seedance\s*2\.5/i.test(model || "");
|
||||
|| normalizeModelKey(model).includes("seedance25");
|
||||
}
|
||||
|
||||
function durationLabelsForModel(config: ModelConfig | undefined, model: string, isVideo: boolean): string[] {
|
||||
@@ -111,6 +111,7 @@ export function OmniParamBar({
|
||||
resolution,
|
||||
ratio,
|
||||
duration,
|
||||
fixedDuration = false,
|
||||
catalogModels,
|
||||
onModel,
|
||||
onResolution,
|
||||
@@ -123,6 +124,7 @@ export function OmniParamBar({
|
||||
resolution: string;
|
||||
ratio: string;
|
||||
duration: string;
|
||||
fixedDuration?: boolean;
|
||||
catalogModels?: ModelConfig[];
|
||||
onModel: (value: string) => void;
|
||||
onResolution: (value: string) => void;
|
||||
@@ -141,10 +143,11 @@ export function OmniParamBar({
|
||||
[catalogModels, model, capability],
|
||||
);
|
||||
|
||||
const models = withCurrent(
|
||||
catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS),
|
||||
model,
|
||||
);
|
||||
const availableModels = catalogModelLabels(catalogModels, capability, isVideo ? OMNI_VIDEO_MODELS : OMNI_IMAGE_MODELS);
|
||||
const compatibleModels = fixedDuration && isVideo
|
||||
? availableModels.filter((label) => canCreateSegmentedVideo(findCatalogModel(catalogModels, label, "video"), label))
|
||||
: [];
|
||||
const models = withCurrent(fixedDuration && isVideo ? compatibleModels : availableModels, model);
|
||||
const allowedRes = isVideo ? modelResolutions(selected) : [];
|
||||
const resolutions = withCurrent(allowedRes.length ? allowedRes : OMNI_RESOLUTIONS, resolution);
|
||||
const durations = withCurrent(durationLabelsForModel(selected, model, isVideo), duration);
|
||||
@@ -200,6 +203,7 @@ export function OmniParamBar({
|
||||
if (nextRes.length && resolution && !nextRes.includes(resolution)) {
|
||||
onResolution(nextRes.includes("720p") ? "720p" : nextRes[0]);
|
||||
}
|
||||
if (fixedDuration) return;
|
||||
const nextDur = durationLabelsForModel(selected, model, true);
|
||||
const seconds = Number(String(duration || "").replace(/\D/g, ""));
|
||||
// 已选 >60 秒的旧会话:收回长视频入口后压回 60 秒。
|
||||
@@ -215,6 +219,12 @@ export function OmniParamBar({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected?.id, isVideo, model]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fixedDuration || !isVideo || !compatibleModels.length) return;
|
||||
if (!canCreateSegmentedVideo(selected, model)) onModel(compatibleModels[0]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fixedDuration, isVideo, model, selected?.id, compatibleModels.join("|")]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="omni-parameter omni-parameter-model">
|
||||
@@ -250,7 +260,7 @@ export function OmniParamBar({
|
||||
options={toOptions(withCurrent(OMNI_RATIOS, ratio))}
|
||||
/>
|
||||
</label>
|
||||
<div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}>
|
||||
{fixedDuration ? null : <div className={`omni-duration-control${isVideo ? "" : " is-image-count"}`} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-duration-trigger"
|
||||
@@ -321,7 +331,7 @@ export function OmniParamBar({
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1249,6 +1249,11 @@ body.sidebar-collapsed .user::after { display: none; }
|
||||
overflow: hidden;
|
||||
animation: none;
|
||||
}
|
||||
.content:has(.omni-session-page) {
|
||||
/* 对话页内部已按顶栏以下的视口高度排版;通用内容 padding 会让自动滚底遮住首条消息。 */
|
||||
padding: 0;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes yz-page-enter {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/** 已保存的旧短剧会话保留原始预设值,界面统一显示新名称。 */
|
||||
export function displayOmniPresetLabel(preset?: string | null): string {
|
||||
if (preset === "剧情反转带货") return "短剧带货";
|
||||
return preset || "自由创作";
|
||||
}
|
||||
@@ -431,11 +431,40 @@
|
||||
}
|
||||
|
||||
/* 横向角色设定图需要留出三视图和两处脸部细节,预览时不得裁掉边缘。 */
|
||||
.omni-person-reference-flow > .omni-result-card.is-person-reference,
|
||||
.omni-person-reference-flow > .omni-result-card.is-person-reference {
|
||||
flex: 0 0 min(372px, 100%);
|
||||
width: min(372px, 100%);
|
||||
max-width: 372px;
|
||||
}
|
||||
|
||||
/* 角色定妆图生成时占满整条流程区域;完成后恢复为可并排的大图卡。 */
|
||||
.omni-result-card.omni-process-card.is-person-reference {
|
||||
width: min(760px, calc(100% - 44px));
|
||||
max-width: 760px;
|
||||
height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.omni-person-reference-flow > .omni-process-card.is-person-reference {
|
||||
flex-basis: 360px;
|
||||
width: min(360px, 100%);
|
||||
max-width: 360px;
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.omni-process-card.is-person-reference .omni-result-media {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.omni-process-card.is-person-reference .omni-result-tile {
|
||||
height: 100%;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.omni-process-card.is-person-reference .omni-process-frame {
|
||||
min-height: 0;
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
|
||||
.omni-result-card.is-person-reference .omni-result-tile img {
|
||||
@@ -1535,6 +1564,11 @@
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.omni-result-card.omni-process-card.is-person-reference {
|
||||
width: 100%;
|
||||
height: clamp(280px, 58vw, 400px);
|
||||
}
|
||||
|
||||
.omni-strategy-grid,
|
||||
.omni-plan-points,
|
||||
.omni-plan-timeline,
|
||||
@@ -2572,27 +2606,47 @@
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* 发送中:不要文案;左侧柔和呼吸高亮,气泡本身不变形 */
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #5b6572;
|
||||
animation: omniSendPulse 1.35s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 发送状态只留在原消息气泡内,不额外占一行。 */
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble {
|
||||
background: color-mix(in srgb, var(--surface) 84%, transparent);
|
||||
border-color: var(--black-alpha-16);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background: var(--black-alpha-4);
|
||||
border-color: var(--black-alpha-12);
|
||||
animation: omniPendingBubble 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-send-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.omni-send-dots i {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: .35;
|
||||
animation: omniSendDotPulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-send-dots i:nth-child(2) { animation-delay: .18s; }
|
||||
.omni-send-dots i:nth-child(3) { animation-delay: .36s; }
|
||||
|
||||
@keyframes omniSendDotPulse {
|
||||
0%, 70%, 100% { opacity: .35; transform: translateY(0); }
|
||||
35% { opacity: 1; transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
@keyframes omniPendingBubble {
|
||||
0%, 100% { border-color: var(--black-alpha-12); }
|
||||
50% { border-color: var(--black-alpha-24); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble,
|
||||
.omni-send-dots i { animation: none; }
|
||||
}
|
||||
|
||||
.omni-send-spinner {
|
||||
@@ -2601,45 +2655,75 @@
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.omni-chat-bubble.is-thinking {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-chat-bubble.is-reasoning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
/* 规划过程不是一行 loading:给过程本身足够横向空间,历史在内部滚动。 */
|
||||
.omni-chat-row.agent.is-live .omni-chat-bubble.is-reasoning {
|
||||
.omni-chat-row.agent.is-live .omni-chat-bubble.omni-wait-card {
|
||||
position: relative;
|
||||
width: min(620px, calc(100% - 44px));
|
||||
max-width: min(620px, calc(100% - 44px));
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding: 18px 20px 16px;
|
||||
border-radius: var(--r-md);
|
||||
color: var(--black-alpha-64);
|
||||
}
|
||||
|
||||
.omni-wait-card::before,
|
||||
.omni-wait-card::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.omni-wait-card::before {
|
||||
width: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--border-muted) 0%,
|
||||
var(--border-muted) 35%,
|
||||
var(--heat-40) 50%,
|
||||
var(--border-muted) 65%,
|
||||
var(--border-muted) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: omniWaitPending 2.6s linear infinite;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .omni-wait-card::before {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--background-base) 0%,
|
||||
var(--background-base) 35%,
|
||||
var(--heat-40) 50%,
|
||||
var(--background-base) 65%,
|
||||
var(--background-base) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.omni-wait-card::after {
|
||||
width: 100%;
|
||||
background: var(--klein);
|
||||
transform: scaleX(.06);
|
||||
transform-origin: left center;
|
||||
animation: omniWaitProgress 36s linear forwards;
|
||||
}
|
||||
|
||||
.omni-think-head {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
color: var(--accent-black);
|
||||
font-size: 13px;
|
||||
color: var(--black-alpha-64);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.omni-think-log {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 248px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 8px 2px 0;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-faint);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.omni-think-log-row {
|
||||
@@ -2673,27 +2757,17 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.omni-typing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@keyframes omniWaitProgress {
|
||||
0% { transform: scaleX(.06); }
|
||||
10% { transform: scaleX(.38); }
|
||||
45% { transform: scaleX(.66); }
|
||||
75% { transform: scaleX(.8); }
|
||||
100% { transform: scaleX(.94); }
|
||||
}
|
||||
|
||||
.omni-typing i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
opacity: .35;
|
||||
animation: omniTyping 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-typing i:nth-child(2) { animation-delay: .15s; }
|
||||
.omni-typing i:nth-child(3) { animation-delay: .3s; }
|
||||
|
||||
@keyframes omniTyping {
|
||||
0%, 80%, 100% { opacity: .28; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
@keyframes omniWaitPending {
|
||||
from { background-position: 100% 0; }
|
||||
to { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
@keyframes omniCurrentStepPulse {
|
||||
@@ -2701,15 +2775,13 @@
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes omniSendPulse {
|
||||
0%, 100% {
|
||||
background: #5b6572;
|
||||
opacity: .45;
|
||||
}
|
||||
50% {
|
||||
background: #8b95a3;
|
||||
opacity: 1;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.omni-wait-card::before,
|
||||
.omni-wait-card::after,
|
||||
.omni-think-log-row.is-current::before { animation: none; }
|
||||
.omni-wait-card::before { background: var(--border-muted); }
|
||||
html[data-theme="dark"] .omni-wait-card::before { background: var(--background-base); }
|
||||
.omni-wait-card::after { transform: scaleX(.6); }
|
||||
}
|
||||
|
||||
.omni-process-skeleton {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import { displayOmniPresetLabel } from "../omni-preset-label";
|
||||
import { findCatalogModel } from "../components/omni-param-bar";
|
||||
import { modelResolutions } from "../components/free-create/constants";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
@@ -46,7 +47,7 @@ type PresetItem = {
|
||||
|
||||
const VIDEO_PRESETS: PresetItem[] = [
|
||||
{ name: "痛点解决演示", category: "commerce", mode: "video", title: "痛点解决演示", desc: "从真实痛点到正常使用,再用看得见的过程证明商品价值。", starter: "基于商品的真实用途创作一条痛点解决演示:先让人看见困扰,再拍清使用步骤和真实结果,不夸张、不出现不合理的测试。", cover: "/assets/video-presets/pain-point-demo.jpg", previewVideo: "/assets/video-presets/pain-point-demo.mp4" },
|
||||
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" },
|
||||
{ name: "短剧带货", category: "story", mode: "video", title: "短剧带货", desc: "用完整故事线和人物冲突建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条从起因到结局完整收束的短剧带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" },
|
||||
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" },
|
||||
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" },
|
||||
{ name: "点击换款", category: "visual", mode: "video", title: "点击换款", desc: "固定商品与机位,手指每次点击都在原位切换下一款。", starter: "创作一条点击换款视频:使用单一固定机位和同一背景,商品始终保持同一位置和比例;每次手指点击商品时,立即在原位切换到下一个颜色或款式,按确认顺序逐款展示,最后以全款式总览收束。不做口播、剧情、换场景或普通商品使用演示。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" },
|
||||
@@ -667,7 +668,7 @@ export function OmniCreatePage({
|
||||
model,
|
||||
ratio,
|
||||
...(outputMode === "video"
|
||||
? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration }
|
||||
? { resolution, duration: selectedCase?.name === "短剧带货" ? "60 秒" : duration }
|
||||
: { count: duration }),
|
||||
},
|
||||
})
|
||||
@@ -931,7 +932,7 @@ export function OmniHistoryPage({
|
||||
<h2>{item.title}</h2>
|
||||
<p>
|
||||
{[
|
||||
item.preset || "自由创作",
|
||||
displayOmniPresetLabel(item.preset),
|
||||
item.mode === "video" ? "视频" : "图片",
|
||||
item.params?.model,
|
||||
item.params?.ratio,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { displayOmniPresetLabel } from "../omni-preset-label";
|
||||
import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar";
|
||||
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
@@ -1620,7 +1621,7 @@ function ElicitCard({
|
||||
{selected ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
className="btn btn-ghost"
|
||||
disabled={disabled}
|
||||
onClick={() => setAnswers((prev) => ({ ...prev, story_direction: "" }))}
|
||||
>
|
||||
@@ -1629,7 +1630,7 @@ function ElicitCard({
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
className="btn btn-primary"
|
||||
disabled={disabled || !selected}
|
||||
onClick={() => onSubmit({ story_direction: selected }, [])}
|
||||
>
|
||||
@@ -2048,6 +2049,7 @@ function ConfirmCard({
|
||||
message,
|
||||
sessionParams,
|
||||
isVideo,
|
||||
isShortDrama,
|
||||
catalogModels,
|
||||
disabled,
|
||||
onConfirm,
|
||||
@@ -2056,6 +2058,7 @@ function ConfirmCard({
|
||||
message: CreationMessage;
|
||||
sessionParams: Record<string, string>;
|
||||
isVideo: boolean;
|
||||
isShortDrama: boolean;
|
||||
catalogModels?: ModelConfig[];
|
||||
disabled: boolean;
|
||||
onConfirm: (params: Record<string, string>) => void;
|
||||
@@ -2066,7 +2069,7 @@ function ConfirmCard({
|
||||
const payloadCredits = Number(message.payload.estimated_credits || 0);
|
||||
const payloadParams = asStringMap(message.payload.params);
|
||||
const snapshot = { ...sessionParams, ...payloadParams };
|
||||
const [draft, setDraft] = useState(snapshot);
|
||||
const [draft, setDraft] = useState(() => isShortDrama && isVideo && !submitted ? { ...snapshot, duration: "60 秒" } : snapshot);
|
||||
const [initialDuration] = useState(snapshot.duration || "");
|
||||
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
||||
const durationChanged =
|
||||
@@ -2112,6 +2115,7 @@ function ConfirmCard({
|
||||
: generationPhase === "failed" ? "可重新确认方案后再生成"
|
||||
: generationPhase === "running" ? "正在出片,请稍候"
|
||||
: generationPhase === "submitted" ? "正在提交生成"
|
||||
: isShortDrama ? "短剧带货固定 60 秒;确认前可以调整模型、分辨率和画面比例。"
|
||||
: "确认前可以改参数。只有改时长时会同步更新视频架构与 Prompt。";
|
||||
const foot =
|
||||
generationPhase === "done" ? "已完成出片"
|
||||
@@ -2137,6 +2141,7 @@ function ConfirmCard({
|
||||
<div className="omni-confirm-params">
|
||||
<OmniParamBar
|
||||
isVideo={cardIsVideo}
|
||||
fixedDuration={cardIsVideo && isShortDrama}
|
||||
disabled={disabled}
|
||||
catalogModels={catalogModels}
|
||||
model={draft.model || ""}
|
||||
@@ -2173,8 +2178,10 @@ function ConfirmCard({
|
||||
|
||||
function ResultCard({
|
||||
payload,
|
||||
fallbackRatio,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
fallbackRatio?: string;
|
||||
}) {
|
||||
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
|
||||
const first = assets[0] || {};
|
||||
@@ -2187,7 +2194,7 @@ function ResultCard({
|
||||
const showSegmentGrid = partialFailure && assets.length > 0;
|
||||
const showSingleMedia = !showSegmentGrid && assets.length > 0;
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const ratio = String(payload.ratio || "").trim();
|
||||
const ratio = String(payload.ratio || fallbackRatio || "").trim();
|
||||
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
|
||||
const personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
|
||||
const tileCount = showSegmentGrid ? assets.length : showSingleMedia ? 1 : 0;
|
||||
@@ -2622,15 +2629,18 @@ function groupVisibleCreationMessages(list: CreationMessage[]): CreationMessage[
|
||||
|
||||
function ProcessCard({
|
||||
payload,
|
||||
fallbackRatio,
|
||||
}: {
|
||||
payload: Record<string, unknown>;
|
||||
fallbackRatio?: string;
|
||||
}) {
|
||||
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 completedSegments = Number(payload.completed_segment_count || 0);
|
||||
const ratio = String(payload.ratio || "").trim();
|
||||
const generationSpec = payload.generation_spec as Record<string, unknown> | undefined;
|
||||
const ratio = String(payload.ratio || generationSpec?.aspect_ratio || fallbackRatio || "").trim();
|
||||
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
|
||||
const personReferenceClass = payload.kind === "person_reference" ? " is-person-reference" : "";
|
||||
|
||||
@@ -2900,14 +2910,23 @@ export function OmniSessionPage({
|
||||
const scrollFrameRef = useRef(0);
|
||||
const initialScrolledForRef = useRef<string | null>(null);
|
||||
|
||||
const bottomScrollTarget = useCallback(() => {
|
||||
const maxScroll = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
|
||||
const latest = feedRef.current?.lastElementChild as HTMLElement | null;
|
||||
if (!latest || latest.getBoundingClientRect().height > window.innerHeight * 0.6) return maxScroll;
|
||||
const topbarHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--topbar-height")) || 0;
|
||||
const latestTop = latest.getBoundingClientRect().top + window.scrollY;
|
||||
// 短对话或低视口时,滚到底部不能把最后一条消息推进 sticky 顶栏背后。
|
||||
return Math.min(maxScroll, Math.max(0, latestTop - topbarHeight - 16));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const gap = document.documentElement.scrollHeight - window.scrollY - window.innerHeight;
|
||||
stickToBottomRef.current = gap < 120;
|
||||
stickToBottomRef.current = bottomScrollTarget() - window.scrollY < 120;
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
}, [bottomScrollTarget]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth: boolean) => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
@@ -2915,11 +2934,11 @@ export function OmniSessionPage({
|
||||
scrollFrameRef.current = requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = 0;
|
||||
window.scrollTo({
|
||||
top: document.documentElement.scrollHeight,
|
||||
top: bottomScrollTarget(),
|
||||
behavior: smooth ? "smooth" : "instant",
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
}, [bottomScrollTarget]);
|
||||
|
||||
// 重进已有会话时,等详情与消息一起落到 DOM 后直接显示最新内容。
|
||||
// 首次定位不能用 smooth:长历史的滚动动画会在中途触发 onScroll,误判成用户上滑。
|
||||
@@ -2928,12 +2947,12 @@ export function OmniSessionPage({
|
||||
initialScrolledForRef.current = conversationId;
|
||||
stickToBottomRef.current = true;
|
||||
if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current);
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
|
||||
window.scrollTo({ top: bottomScrollTarget(), behavior: "instant" });
|
||||
const frame = requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
|
||||
window.scrollTo({ top: bottomScrollTarget(), behavior: "instant" });
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [conversation, conversationId]);
|
||||
}, [bottomScrollTarget, conversation, conversationId]);
|
||||
|
||||
// 新消息落地:平滑滚一次
|
||||
useEffect(() => {
|
||||
@@ -3729,6 +3748,7 @@ export function OmniSessionPage({
|
||||
message={message}
|
||||
sessionParams={params}
|
||||
isVideo={isVideo}
|
||||
isShortDrama={Boolean(conversation?.preset && ["短剧带货", "剧情反转带货"].includes(conversation.preset))}
|
||||
catalogModels={modelConfigs}
|
||||
generationPhase={generationPhase}
|
||||
disabled={
|
||||
@@ -3745,6 +3765,7 @@ export function OmniSessionPage({
|
||||
<ProcessCard
|
||||
key={message.clientKey || message.id}
|
||||
payload={message.payload}
|
||||
fallbackRatio={params.ratio}
|
||||
/>
|
||||
);
|
||||
case "result":
|
||||
@@ -3752,6 +3773,7 @@ export function OmniSessionPage({
|
||||
<ResultCard
|
||||
key={message.clientKey || message.id}
|
||||
payload={message.payload}
|
||||
fallbackRatio={params.ratio}
|
||||
/>
|
||||
);
|
||||
default: {
|
||||
@@ -3786,6 +3808,11 @@ export function OmniSessionPage({
|
||||
{(body || message.role !== "user") ? (
|
||||
<div className="omni-chat-bubble" aria-busy={pending || undefined}>
|
||||
{body ? (message.role === "assistant" ? <ChatMarkdown text={body} /> : <p className="omni-chat-text">{body}</p>) : null}
|
||||
{pending ? (
|
||||
<span className="omni-send-dots" role="status" aria-label="发送中">
|
||||
<i aria-hidden="true" /><i aria-hidden="true" /><i aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{showReplyGuide ? (
|
||||
@@ -3848,7 +3875,7 @@ export function OmniSessionPage({
|
||||
<h1>{conversation?.title || "未命名创作"}</h1>
|
||||
<div className="omni-session-meta">
|
||||
{[
|
||||
conversation?.preset || "自由创作",
|
||||
displayOmniPresetLabel(conversation?.preset),
|
||||
params.model,
|
||||
params.resolution && params.ratio
|
||||
? `${params.resolution} · ${params.ratio}`
|
||||
@@ -3903,7 +3930,7 @@ export function OmniSessionPage({
|
||||
&& Boolean(conversation?.agent_progress)
|
||||
&& !currentPlanningHasError ? (
|
||||
/生成图片|生成视频/.test(activeTool) && !liveText ? (
|
||||
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} />
|
||||
<ProcessCard payload={{ kind: /生成视频/.test(activeTool) ? "video" : "image" }} fallbackRatio={params.ratio} />
|
||||
) : liveText ? (
|
||||
<div className="omni-chat-row agent is-live">
|
||||
<span className="omni-chat-avatar">
|
||||
@@ -3914,22 +3941,20 @@ export function OmniSessionPage({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="omni-chat-row agent is-live" aria-label="正在回复">
|
||||
<div className="omni-chat-row agent is-live" aria-label="正在生成创作内容">
|
||||
<span className="omni-chat-avatar">
|
||||
<Sparkles />
|
||||
</span>
|
||||
<div className={`omni-chat-bubble${liveReasoning ? " is-reasoning" : " is-thinking"}`}>
|
||||
<div className="omni-chat-bubble omni-wait-card" role="status" aria-live="polite">
|
||||
<div className="omni-think-head">
|
||||
<span className="omni-typing" aria-hidden="true"><i /><i /><i /></span>
|
||||
<span>{activeTool || (liveReasoning ? "正在思考" : isVideo ? "正在整理方案" : "正在整理画面")}</span>
|
||||
</div>
|
||||
{liveReasoning ? (
|
||||
<div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程">
|
||||
{(visibleProgressHistory.length
|
||||
? visibleProgressHistory
|
||||
<div className="omni-think-log" ref={thinkingLogRef} aria-label="创作过程">
|
||||
{(visibleProgressHistory.length
|
||||
? visibleProgressHistory.slice(-2)
|
||||
: [{
|
||||
label: activeTool || "正在思考",
|
||||
detail: liveReasoning,
|
||||
detail: liveReasoning || "正在准备创作内容,请稍候…",
|
||||
}]
|
||||
).map((progress, index, history) => (
|
||||
<div
|
||||
@@ -3939,8 +3964,7 @@ export function OmniSessionPage({
|
||||
<p>{progress.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user