import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, Box, ChevronRight, FolderOpen, History, Image as ImageIcon, LoaderCircle, Play, Plus, Sparkles, Trash2, Upload, UserRound, Users, Video, WandSparkles, X, } from "lucide-react"; import { api, ApiError } from "../api"; import { findCatalogModel } from "../components/omni-param-bar"; import { modelResolutions } from "../components/free-create/constants"; import { ConfirmModal, MediaLightbox } from "../components/overlays"; import { AssetSelectModal, type AssetModalType } from "../components/asset-select-modal"; import { getMentionFreeText, RichMentionEditor, type RichMentionEditorHandle, } from "../components/rich-mention-editor"; import type { CreationConversation, CreationRef, ModelConfig } from "../types"; import type { NavigateFn } from "./route-config"; type OutputMode = "video" | "image"; type PresetItem = { name: string; category: string; mode: OutputMode; title: string; desc?: string; starter?: string; cover: string; previewVideo?: string; }; 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: "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" }, { name: "AI 宠物拟人", category: "story", mode: "video", title: "AI 宠物拟人", desc: "让宠物角色参与有趣小剧情,同时保留商品真实结构与用途。", starter: "创作一条 AI 宠物拟人短片:宠物有明确性格和动作,商品以真实结构和正常用法自然参与剧情。", cover: "/assets/video-presets/ai-pet-personification.jpg", previewVideo: "/assets/video-presets/ai-pet-personification.mp4" }, ]; const IMAGE_PRESETS: PresetItem[] = [ { name: "电影感", category: "style", mode: "image", title: "电影感", cover: "/assets/image-presets/cinematic.png" }, { name: "日式清新", category: "style", mode: "image", title: "日式清新", cover: "/assets/image-presets/japanese-fresh.png" }, { name: "复古胶片", category: "style", mode: "image", title: "复古胶片", cover: "/assets/image-presets/film-vintage.png" }, { name: "暖调生活感", category: "style", mode: "image", title: "暖调生活感", cover: "/assets/image-presets/warm-lifestyle.png" }, { name: "高质感杂志", category: "style", mode: "image", title: "高质感杂志", cover: "/assets/image-presets/editorial.png" }, { name: "梦幻柔光", category: "style", mode: "image", title: "梦幻柔光", cover: "/assets/image-presets/dreamy-soft.png" }, { name: "黑金高级", category: "style", mode: "image", title: "黑金高级", cover: "/assets/image-presets/black-gold.png" }, { name: "冷峻科技", category: "style", mode: "image", title: "冷峻科技", cover: "/assets/image-presets/tech-cool.png" }, { name: "赛博朋克", category: "style", mode: "image", title: "赛博朋克", cover: "/assets/image-presets/cyberpunk.png" }, { name: "日系手绘动画", category: "style", mode: "image", title: "日系手绘动画", cover: "/assets/image-presets/japanese-hand-drawn-anime.png" }, { name: "国风水墨", category: "style", mode: "image", title: "国风水墨", cover: "/assets/image-presets/chinese-ink-wash.png" }, { name: "新中式", category: "style", mode: "image", title: "新中式", cover: "/assets/image-presets/neo-chinese.png" }, ]; const MENTION_REF_LIMIT = 20; const ROLE_REF_LIMIT = 3; function playPresetPreview(card: HTMLElement) { const video = card.querySelector("video"); if (!video || video.dataset.broken === "1") return; const reveal = () => { if (video.dataset.broken === "1" || video.videoWidth === 0) return; video.classList.add("is-playing"); }; const fail = () => { video.dataset.broken = "1"; video.classList.remove("is-playing"); video.pause(); }; video.addEventListener("error", fail, { once: true }); video.addEventListener("playing", reveal, { once: true }); void video.play().catch(fail); } function stopPresetPreview(card: HTMLElement) { const video = card.querySelector("video"); if (!video) return; video.pause(); if (video.dataset.broken !== "1" && Number.isFinite(video.duration)) { try { video.currentTime = 0; } catch { /* Safari 在还没解码时不允许回绕 */ } } video.classList.remove("is-playing"); } const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [ { type: "asset", label: "素材", Icon: ImageIcon }, { type: "character", label: "角色", Icon: UserRound }, { type: "model", label: "模特", Icon: Users }, { type: "product", label: "商品", Icon: Box }, { type: "scene", label: "场景", Icon: FolderOpen }, ]; function mergeSessionUploads(results: CreationRef[], uploads: CreationRef[], query: string, type: CreationRef["type"]) { if (type !== "asset") return results; const q = query.trim().toLowerCase(); const extras = uploads.filter((item) => !q || item.name.toLowerCase().includes(q)); const seen = new Set(extras.map((item) => item.id)); return [...extras, ...results.filter((item) => !seen.has(item.id))]; } function formatRelativeTime(iso: string) { const then = new Date(iso).getTime(); if (Number.isNaN(then)) return ""; const minutes = Math.floor((Date.now() - then) / 60000); if (minutes < 1) return "刚刚"; if (minutes < 60) return `${minutes} 分钟前`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} 小时前`; const days = Math.floor(hours / 24); return days < 30 ? `${days} 天前` : new Date(then).toLocaleDateString("zh-CN"); } export function OmniCreatePage({ modelConfigs, navigate, onNotify, }: { modelConfigs?: ModelConfig[]; navigate: NavigateFn; onNotify?: (type: "success" | "error" | "info", text: string) => void; }) { const [outputMode, setOutputMode] = useState("video"); const [selectedCase, setSelectedCase] = useState(null); const [prompt, setPrompt] = useState(""); const [sessionUploads, setSessionUploads] = useState([]); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState<{ current: number; total: number } | null>(null); const [model, setModel] = useState("Seedance 2.5"); const [resolution, setResolution] = useState("1080p"); const [ratio, setRatio] = useState("9:16"); const [duration, setDuration] = useState("智能时长"); const [uploadMenuOpen, setUploadMenuOpen] = useState(false); const [mentionMenuOpen, setMentionMenuOpen] = useState(false); const [mentionTab, setMentionTab] = useState("asset"); const [mentionLoading, setMentionLoading] = useState(false); const [mentionResults, setMentionResults] = useState([]); const [typeLabels, setTypeLabels] = useState>({}); const [pendingRefs, setPendingRefs] = useState([]); const [previewCase, setPreviewCase] = useState(null); const [assetPreview, setAssetPreview] = useState<{ src: string; name: string } | null>(null); const [assetModalOpen, setAssetModalOpen] = useState(false); const [assetModalType, setAssetModalType] = useState("product"); const fileInputRef = useRef(null); const promptRef = useRef(null); const toolsRef = useRef(null); const [starting, setStarting] = useState(false); useEffect(() => { if (outputMode === "image") { const img = findCatalogModel(modelConfigs, "", "image"); setModel(img ? (img.display_name || img.name) : "Seedream5.0"); setResolution("模型默认"); setRatio("1:1"); setDuration("1 张"); } else { const vid = findCatalogModel(modelConfigs, "", "video"); const label = vid ? (vid.display_name || vid.name) : "Seedance 2.5"; const resList = modelResolutions(vid); setModel(label); setResolution(resList.includes("720p") ? "720p" : (resList[0] || "1080p")); setRatio("9:16"); setDuration("智能时长"); } setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev)); }, [outputMode, modelConfigs]); useEffect(() => { const onDown = (event: MouseEvent) => { const target = event.target as Node; if (toolsRef.current?.contains(target)) return; setUploadMenuOpen(false); setMentionMenuOpen(false); }; document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, []); const presets = useMemo(() => { return outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS; }, [outputMode]); const productRefs = useMemo( () => pendingRefs.filter((ref) => ref.type === "product"), [pendingRefs], ); const roleRefs = useMemo( () => pendingRefs.filter((ref) => ref.type === "character" || ref.type === "model"), [pendingRefs], ); const materialRefs = useMemo( () => pendingRefs.filter((ref) => ref.type === "asset" || ref.type === "scene"), [pendingRefs], ); const hasPromptDescription = useMemo( () => Boolean(getMentionFreeText(prompt, pendingRefs)), [pendingRefs, prompt], ); const visibleSlotCount = (productRefs.length || 1) + roleRefs.length + (roleRefs.length < ROLE_REF_LIMIT ? 1 : 0) + materialRefs.length; const slotColumnCount = Math.min(Math.max(visibleSlotCount, 1), 4); const applyPreset = (preset: PresetItem) => { setSelectedCase(preset); // 预设只作为后台的创作方向。无论图片还是视频,都不把内部生成指令塞进用户输入区。 setPreviewCase(null); onNotify?.("info", `已选择预设:${preset.name}`); }; const openMentions = async (query = "", type: CreationRef["type"] = mentionTab) => { setMentionTab(type); setMentionMenuOpen(true); setUploadMenuOpen(false); setMentionLoading(true); setMentionResults([]); try { const res = await api.searchMentions({ q: query, types: [type], limit: 12 }); setMentionResults(mergeSessionUploads(res.results, sessionUploads, query, type)); setTypeLabels(res.type_labels); } catch (error) { onNotify?.("error", (error as Error).message); } finally { setMentionLoading(false); } }; const insertVisibleMention = (ref: CreationRef, replaceTrigger: boolean) => { const name = ref.name.split(" · ")[0].trim(); const tag = `@${name} `; if (promptRef.current) { promptRef.current.insertMention(ref, replaceTrigger); } else { setPrompt((prev) => (prev ? `${prev} ${tag}` : tag)); } }; const insertMention = (ref: CreationRef) => { const exists = pendingRefs.some((item) => item.type === ref.type && item.id === ref.id); if (!exists && pendingRefs.length >= MENTION_REF_LIMIT) { onNotify?.("info", `最多引用 ${MENTION_REF_LIMIT} 个`); setMentionMenuOpen(false); return; } if (!exists) setPendingRefs((prev) => [...prev, ref]); insertVisibleMention(ref, true); setMentionMenuOpen(false); }; const insertCardMention = (ref: CreationRef) => insertVisibleMention(ref, false); const removePendingRef = (ref: CreationRef) => { setPendingRefs((prev) => prev.filter((item) => !(item.type === ref.type && item.id === ref.id))); const name = ref.name.split(" · ")[0].trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); setPrompt((prev) => prev.replace(new RegExp(`(^|\\s)@${name}(?=\\s|$)`, "g"), " ").replace(/\s{2,}/g, " ").trimStart()); }; const addStructuredRef = (ref: CreationRef) => { setPendingRefs((prev) => { if (prev.some((item) => item.type === ref.type && item.id === ref.id)) return prev; if (prev.length >= MENTION_REF_LIMIT) { onNotify?.("info", `最多添加 ${MENTION_REF_LIMIT} 个引用`); return prev; } const isRole = ref.type === "character" || ref.type === "model"; const currentRoleCount = prev.filter((item) => item.type === "character" || item.type === "model").length; if (isRole && currentRoleCount >= ROLE_REF_LIMIT) { onNotify?.("info", `最多添加 ${ROLE_REF_LIMIT} 个角色`); return prev; } return [...prev, ref]; }); }; const handleFileChange = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); event.target.value = ""; setUploadMenuOpen(false); if (!files.length) return; const images = files.filter((file) => file.type.startsWith("image/")); if (images.length !== files.length) { onNotify?.("info", "全能创作仅支持上传图片"); } if (!images.length) return; setUploading(true); setUploadProgress({ current: 1, total: images.length }); try { for (const [index, file] of images.entries()) { setUploadProgress({ current: index + 1, total: images.length }); const form = new FormData(); form.append("file", file); const data = await api.uploadFreeVideoRef(form); const ref: CreationRef = { type: "asset", id: data.asset_id, name: data.name || file.name, cover: data.thumb_url || data.url, }; setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref])); setPendingRefs((prev) => { if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev; return [...prev, ref]; }); } } catch (error) { onNotify?.("error", (error as Error).message); } finally { setUploading(false); setUploadProgress(null); } }; return (
YINGQING CREATIVE STUDIO

和全能创作聊聊你的想法

选择一种创作方法,或直接描述你想生成的画面。

{selectedCase && (
{selectedCase.title}
)}
{/* 1. 商品槽位 */} {productRefs.map((ref) => (
商品 {ref.cover ? ( ) : (
)}
))} {productRefs.length === 0 && ( )} {/* 2. 角色槽位 */} {roleRefs.map((ref, idx) => (
{`角色${idx + 1}`} {ref.cover ? ( ) : (
)}
))} {roleRefs.length < ROLE_REF_LIMIT && ( )} {/* 3. 素材槽位 (直接上传算素材) */} {materialRefs.map((ref) => (
{ref.type === "scene" ? "场景" : "素材"} {ref.cover ? ( ) : (
)}
))}
{pendingRefs.length}/20
void openMentions()} />
{uploading ? ( ) : null}
{outputMode === "video" ? "从一个视频方向开始" : "从一个风格开始"} {outputMode === "video" ? "可选,也可以直接描述你想拍的视频。" : "可选,也可以直接描述你想要的画面。"}
{presets.map((card) => ( ))}
)} setAssetModalOpen(false)} onSelect={addStructuredRef} onNotify={onNotify} /> setAssetPreview(null)} /> ); } export function OmniHistoryPage({ navigate, onNotify, }: { navigate: NavigateFn; onNotify?: (type: "success" | "error" | "info", text: string) => void; }) { const [modeFilter, setModeFilter] = useState<"all" | "video" | "image">("all"); const [items, setItems] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); // 同会话页:onNotify 是内联箭头,进依赖会让列表每次 App 重渲染都重拉一遍 const notifyRef = useRef(onNotify); notifyRef.current = onNotify; useEffect(() => { let cancelled = false; setItems(null); api .listCreations(modeFilter === "all" ? {} : { mode: modeFilter }) .then((page) => { if (!cancelled) setItems(page.results || []); }) .catch((error) => { if (cancelled) return; setItems([]); notifyRef.current?.("error", (error as Error).message); }); return () => { cancelled = true; }; }, [modeFilter]); const remove = async (id: string) => { // 软删:会话没了,已生成的图和视频仍留在资产库里 setItems((prev) => (prev || []).filter((item) => item.id !== id)); try { await api.deleteCreation(id); } catch (error) { notifyRef.current?.("error", (error as Error).message); } }; return (
ALL-IN-ONE CREATION

创作历史

按视频创作或图片创作查看,并继续每一次独立会话。

{([ ["all", "全部"], ["video", "视频创作"], ["image", "图片创作"], ] as const).map(([key, label]) => ( ))}
{items === null ? (

正在加载创作记录……

) : items.length === 0 ? (

{modeFilter === "video" ? "还没有视频创作记录,从全能创作开始。" : modeFilter === "image" ? "还没有图片创作记录,从全能创作开始。" : "还没有创作记录,从全能创作开始。"}

) : ( items.map((item) => (
navigate("omniSession", { conversationId: item.id })} > {item.cover_url ? ( {item.title} ) : ( {item.mode === "video" ? )}
{item.agent_status === "planning" ? "生成中" : item.status === "completed" ? "已完成" : "进行中"}

{item.title}

{[ item.preset || "自由创作", item.mode === "video" ? "视频" : "图片", item.params?.model, item.params?.ratio, ] .filter(Boolean) .join(" · ")}

{formatRelativeTime(item.last_active_at)}
)) )}
} detail={`确定删除「${deleteTarget?.title || "未命名创作"}」?会话会从创作历史中移除,已生成的图和视频仍留在资产库里。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={() => { const id = deleteTarget?.id; setDeleteTarget(null); if (id) void remove(id); }} />
); }