优化全能创作
Deploy dev / deploy (push) Successful in 39s

This commit is contained in:
Azmat@qq.com
2026-09-29 15:15:54 +08:00
parent 1130ecd4ba
commit c687ec4bbd
16 changed files with 632 additions and 258 deletions
@@ -1,14 +1,16 @@
// 自由创作·底部输入条:参考素材上传区(universal 混排 / keyframe 首尾帧两格)+ @mention 提示词 + 工具栏。
// 拖拽/点击上传;素材选中即传后端(blob 预览 → 服务端 URL 替换),blob 状态禁止提交。
import { useRef, useState, type RefObject } from "react";
import { ImagePlus, Images, UserRoundPlus } from "lucide-react";
import { Film, ImagePlus, Images, Music2 } from "lucide-react";
import type { ModelConfig } from "../../types";
import { MODE_LABELS, type BillingRates, type FreeMode, type LocalRef } from "./constants";
import { PromptInput, type PromptInputHandle } from "./prompt-input";
import { FreeToolbar } from "./toolbar";
const ACCEPT_UNIVERSAL = "image/jpeg,image/png,image/webp,video/mp4,video/quicktime,audio/mpeg,audio/wav";
const ACCEPT_IMAGE = "image/jpeg,image/png,image/webp";
const ACCEPT_VIDEO = "video/mp4,video/quicktime";
const ACCEPT_AUDIO = "audio/mpeg,audio/wav,audio/x-wav,audio/wave";
type UploadKind = "image" | "video" | "audio";
function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void }) {
return (
@@ -59,7 +61,7 @@ function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
);
}
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onOpenPlatformLibrary, onSend }: {
export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, submitting, promptRef, onFiles, onRemoveRef, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onOpenLibrary, onSend }: {
mode: FreeMode;
model: string;
ratio: string;
@@ -81,19 +83,16 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
onOpenPlatformLibrary: (role?: "first_frame" | "last_frame") => void;
onSend: () => void;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const pendingRoleRef = useRef<"first_frame" | "last_frame" | undefined>(undefined);
const [dragOver, setDragOver] = useState(false);
const [hasPrompt, setHasPrompt] = useState(false);
const pickFiles = (role?: "first_frame" | "last_frame") => {
pendingRoleRef.current = role;
const pickFiles = (kind: UploadKind) => {
if (fileRef.current) {
fileRef.current.accept = mode === "keyframe" ? ACCEPT_IMAGE : ACCEPT_UNIVERSAL;
fileRef.current.multiple = mode === "universal";
fileRef.current.accept = kind === "image" ? ACCEPT_IMAGE : kind === "video" ? ACCEPT_VIDEO : ACCEPT_AUDIO;
fileRef.current.multiple = true;
fileRef.current.click();
}
};
@@ -120,21 +119,20 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onChange={(event) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length) onFiles(files, pendingRoleRef.current);
pendingRoleRef.current = undefined;
if (files.length) onFiles(files);
}}
/>
<div className="fc-prompt-main">
{mode === "universal" ? (
<div className="fc-refs">
<button type="button" className="fc-ref-btn" title="上传参考素材(图≤9 / 视频≤3 / 音频≤3)" onClick={() => pickFiles()}>
<ImagePlus />
<button type="button" className="fc-ref-btn fc-ref-upload" title="上传参考图片,最多 9 张" aria-label="上传参考图片" onClick={() => pickFiles("image")}>
<ImagePlus /><span>图片</span>
</button>
<button type="button" className="fc-ref-btn" title="人物素材库" onClick={() => onOpenLibrary()}>
<UserRoundPlus />
<button type="button" className="fc-ref-btn fc-ref-upload" title="上传参考视频,最多 3 条" aria-label="上传参考视频" onClick={() => pickFiles("video")}>
<Film /><span>视频</span>
</button>
<button type="button" className="fc-ref-btn" title="引用平台素材(成品库 / 模特库 / 商品库)" onClick={() => onOpenPlatformLibrary()}>
<Images />
<button type="button" className="fc-ref-btn fc-ref-upload" title="上传参考音频,最多 3 条;需搭配图片或视频" aria-label="上传参考音频" onClick={() => pickFiles("audio")}>
<Music2 /><span>音频</span>
</button>
{refs.map((item) => (
<RefThumb key={item.key} item={item} onRemove={() => onRemoveRef(item.key)} />
@@ -151,7 +149,6 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
ref={promptRef}
refs={refs}
onSubmit={onSend}
onOpenLibrary={mode === "universal" ? () => onOpenLibrary() : undefined}
onTextChange={setHasPrompt}
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材,例如:产品在晨光下缓慢旋转,镜头推近展示包装细节……"}
/>
@@ -25,7 +25,6 @@ type Props = {
placeholder?: string;
disabled?: boolean;
onSubmit: () => void;
onOpenLibrary?: () => void;
onTextChange?: (hasText: boolean) => void;
};
@@ -58,7 +57,7 @@ function serializeNode(node: Node): string {
}
export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptInput(
{ refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onOpenLibrary, onTextChange },
{ refs, placeholder = "描述你想生成的视频,@ 可引用参考素材…", disabled, onSubmit, onTextChange },
handleRef
) {
const editorRef = useRef<HTMLDivElement>(null);
@@ -121,6 +120,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
const closeMenu = () => { setMenuOpen(false); setMenuQuery(""); setMenuIndex(0); };
const openMenuAtCaret = () => {
if (labeledRefs.length === 0) return;
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const rect = sel.getRangeAt(0).getBoundingClientRect();
@@ -240,25 +240,13 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
return () => document.removeEventListener("mousedown", onDown);
}, [menuOpen]);
const menuItems: { key: string; label: string; thumb?: string; kind: "ref" | "library" }[] = [
...candidates.map((r) => ({
key: r.key,
label: r.label || "",
thumb: r.thumb_url || (r.type === "image" ? r.url : ""),
kind: "ref" as const
})),
...(onOpenLibrary ? [{ key: "__library__", label: "从素材库选择…", kind: "library" as const }] : [])
];
const menuItems = candidates.map((r) => ({
key: r.key,
label: r.label || "",
thumb: r.thumb_url || (r.type === "image" ? r.url : "")
}));
const pickMenuItem = (item: (typeof menuItems)[number]) => {
if (item.kind === "library") {
// 先删掉触发串,再开素材库(选中后由页面 insertMention 插 chip)
const trigger = findTrigger();
trigger?.range.deleteContents();
closeMenu();
onOpenLibrary?.();
return;
}
insertChipAtTrigger(item.label, item.thumb);
};
@@ -330,14 +318,8 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
onMouseEnter={() => setMenuIndex(i)}
onMouseDown={(event) => { event.preventDefault(); pickMenuItem(item); }}
>
{item.kind === "ref" ? (
<>
{item.thumb ? <img src={item.thumb} alt="" /> : <span className="fc-mention-ph" />}
<span className="lbl">@{item.label}</span>
</>
) : (
<span className="lbl lib">{item.label}</span>
)}
{item.thumb ? <img src={item.thumb} alt="" /> : <span className="fc-mention-ph" />}
<span className="lbl">@{item.label}</span>
</button>
))}
</div>
-7
View File
@@ -1134,13 +1134,6 @@ html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-ring
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-frame strong {
color: var(--accent-black);
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-process-meta {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .06em;
line-height: 1;
}
html[data-theme="dark"] .omni-session-page .omni-process-card .omni-result-info {
border-top: 1px solid var(--border-faint);
background: var(--surface);
+12
View File
@@ -455,6 +455,18 @@
}
.fc-page .fc-ref-btn:hover { background: rgba(34, 42, 54, 0.07); }
.fc-page .fc-ref-btn svg { width: 18px; height: 18px; }
.fc-page .fc-ref-btn.fc-ref-upload {
width: 64px;
height: 54px;
display: flex;
flex-direction: column;
gap: 3px;
border-radius: var(--r-md);
font: inherit;
font-size: 10px;
font-weight: 500;
line-height: 1;
}
.fc-page .fc-ref {
position: relative;
width: 46px;
+12
View File
@@ -430,6 +430,18 @@
margin: 0;
}
/* 横向角色设定图需要留出三视图和两处脸部细节,预览时不得裁掉边缘。 */
.omni-person-reference-flow > .omni-result-card.is-person-reference,
.omni-person-reference-flow > .omni-process-card.is-person-reference {
flex-basis: 360px;
width: min(360px, 100%);
max-width: 360px;
}
.omni-result-card.is-person-reference .omni-result-tile img {
object-fit: contain;
}
.omni-strategy-card,
.omni-video-plan-card,
.omni-prompt-file-card,
+6 -8
View File
@@ -511,13 +511,12 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
}, [deleteTarget, detailId, stopPolling, notify]);
const openLibrary = useCallback((role?: "first_frame" | "last_frame") => {
if (mode === "keyframe" && !role) {
notify("info", "请点击首帧或尾帧槽位上的人物素材库入口");
return;
}
setLibraryTargetRole(role || null);
const target = mode === "keyframe"
? role || (refs.some((r) => r.role === "first_frame") ? "last_frame" : "first_frame")
: null;
setLibraryTargetRole(target);
setLibraryOpen(true);
}, [mode, notify]);
}, [mode, refs]);
const closeLibrary = useCallback(() => {
setLibraryOpen(false);
@@ -640,7 +639,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
<div className="fc-empty">
<div className="fc-empty-icon"><Clapperboard /></div>
<strong>准备生成新视频</strong>
<p>在下方输入提示词,或从素材库引用参考素材</p>
<p>在下方添加图片、视频或音频参考;也可从右上角引用素材库</p>
</div>
) : feedGroups.length === 0 ? (
<div className={`fc-empty${hasMore ? " is-soft" : ""}`}>
@@ -696,7 +695,6 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
onDurationChange={setDuration}
onSeedChange={setSeed}
onOpenLibrary={openLibrary}
onOpenPlatformLibrary={openPlatformLibrary}
onSend={() => void handleSend()}
/>
</div>
+1 -1
View File
@@ -667,7 +667,7 @@ export function OmniCreatePage({
model,
ratio,
...(outputMode === "video"
? { resolution, duration }
? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration }
: { count: duration }),
},
})
+27 -6
View File
@@ -1351,9 +1351,14 @@ function ElicitCard({
const [customDirection, setCustomDirection] = useState("");
const [chatAnswer, setChatAnswer] = useState("");
const [personPromptOpen, setPersonPromptOpen] = useState(false);
const [personPrompt, setPersonPrompt] = useState("");
const [personPrompt, setPersonPrompt] = useState(() => String(message.payload.default_person_prompt || ""));
const productBriefNoteRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setPersonPrompt(String(message.payload.default_person_prompt || ""));
setPersonPromptOpen(false);
}, [message.id]);
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
// 选素材字段要现拉候选:后端只给了 asset_types,具体有哪些素材是团队数据
@@ -1454,7 +1459,7 @@ function ElicitCard({
placeholder={
isPet
? "例如:呆萌可爱的金毛幼犬,毛发蓬松干净,系着小红领巾,眼神灵动"
: "单人例如:25岁女性,短发通勤风。多人物可写:角色1:年轻空乘短发;角色2:中年旅客长发"
: "可调整当前这位角色的年龄、发型、服装和气质"
}
onChange={(event) => setPersonPrompt(event.target.value)}
/>
@@ -1462,7 +1467,7 @@ function ElicitCard({
<span>
{isPet
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
: "不填写也可以。若脚本是多人物,平台会按人数一次生成多张定妆图,避免长视频前后形象漂移。"}
: "已按视频 Prompt 中当前角色的身份预填。将生成一张横向设定图:左侧全身三视图,右侧正脸和侧脸特写;确认这一位后再准备下一位。"}
{Number(message.payload?.estimated_credits || 0) > 0
? ` 预计 ${Number(message.payload.estimated_credits)} 积分。`
: ""}
@@ -2184,13 +2189,14 @@ function ResultCard({
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const ratio = String(payload.ratio || "").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;
const multiClass = tileCount > 1 ? "has-multiple" : "has-single";
const manyClass = tileCount > 4 ? " has-many" : "";
const displayAssets = showSegmentGrid ? assets : showSingleMedia ? assets.slice(0, 1) : [];
return (
<section className={`omni-result-card ${multiClass}${manyClass} ${ratioClass}`}>
<section className={`omni-result-card ${multiClass}${manyClass} ${ratioClass}${personReferenceClass}`}>
<div className={`omni-result-media${displayAssets.length > 1 ? " is-grid" : ""}`}>
{displayAssets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
@@ -2626,15 +2632,15 @@ function ProcessCard({
const completedSegments = Number(payload.completed_segment_count || 0);
const ratio = String(payload.ratio || "").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" : "";
return (
<section className={`omni-result-card omni-process-card has-single ${ratioClass}`}>
<section className={`omni-result-card omni-process-card has-single ${ratioClass}${personReferenceClass}`}>
<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>
<span className="omni-process-meta">[ RENDERING ]</span>
</div>
</figure>
</div>
@@ -2892,6 +2898,7 @@ export function OmniSessionPage({
// 3. 用 rAF 合并同一帧里的多次调用,不要每个 delta 都真滚一次。
const stickToBottomRef = useRef(true);
const scrollFrameRef = useRef(0);
const initialScrolledForRef = useRef<string | null>(null);
useEffect(() => {
const onScroll = () => {
@@ -2914,6 +2921,20 @@ export function OmniSessionPage({
});
}, []);
// 重进已有会话时,等详情与消息一起落到 DOM 后直接显示最新内容。
// 首次定位不能用 smooth:长历史的滚动动画会在中途触发 onScroll,误判成用户上滑。
useLayoutEffect(() => {
if (!conversation || conversation.id !== conversationId || initialScrolledForRef.current === conversationId) return;
initialScrolledForRef.current = conversationId;
stickToBottomRef.current = true;
if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current);
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
const frame = requestAnimationFrame(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
});
return () => cancelAnimationFrame(frame);
}, [conversation, conversationId]);
// 新消息落地:平滑滚一次
useEffect(() => {
scrollToBottom(true);