@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -667,7 +667,7 @@ export function OmniCreatePage({
|
||||
model,
|
||||
ratio,
|
||||
...(outputMode === "video"
|
||||
? { resolution, duration }
|
||||
? { resolution, duration: selectedCase?.name === "剧情反转带货" ? "60 秒" : duration }
|
||||
: { count: duration }),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user