优化视频创作

This commit is contained in:
Azmat@qq.com
2026-09-15 15:17:05 +08:00
parent 99a126d19b
commit 7098245a4a
21 changed files with 262 additions and 220 deletions
+2 -2
View File
@@ -274,14 +274,14 @@ aside.sidebar {
.brand { display: flex; align-items: center; justify-content: center; min-width: 0; color: var(--accent-black); }
.brand-clip {
display: block;
width: 112px;
width: 132px;
height: 90px;
overflow: hidden;
transition: width var(--t-base), height var(--t-base);
}
.brand-logo {
display: block;
width: 112px;
width: 132px;
height: 90px;
max-width: none;
margin: 0;
+12 -44
View File
@@ -790,47 +790,9 @@
transform: translateY(7px);
}
.omni-case-head {
display: flex;
align-items: flex-end;
justify-content: flex-start;
gap: 20px;
margin-bottom: 15px;
}
.omni-case-head-label {
flex: 0 0 auto;
color: #252b35;
font-size: 17px;
font-weight: 750;
line-height: 30px;
}
.omni-case-filters {
display: flex;
align-items: center;
gap: 4px;
}
.omni-case-filters button {
height: 30px;
padding: 0 12px;
border-radius: 8px;
color: #707681;
background: transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.omni-case-filters button.active {
color: #fff;
background: var(--black);
}
.omni-case-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
@@ -840,24 +802,24 @@
gap: 14px;
}
.omni-image-preset-intro {
.omni-preset-intro {
margin: 0 0 14px;
text-align: center;
}
.omni-image-preset-intro strong,
.omni-image-preset-intro span {
.omni-preset-intro strong,
.omni-preset-intro span {
display: block;
}
.omni-image-preset-intro strong {
.omni-preset-intro strong {
color: var(--accent-black);
font-size: 16px;
font-weight: 500;
line-height: 1.4;
}
.omni-image-preset-intro span {
.omni-preset-intro span {
margin-top: 4px;
color: var(--black-alpha-56);
font-size: 13px;
@@ -899,6 +861,12 @@
background: #edf0f4;
}
/* 视频样片本身是竖版,按竖构图展示,避免横向裁切掉人物和商品。 */
.omni-case-card[data-mode="video"] .omni-case-visual {
height: auto;
aspect-ratio: 9 / 15;
}
.omni-case-visual img,
.omni-case-visual video {
width: 100%;
+12 -10
View File
@@ -2250,23 +2250,25 @@
.omni-gate-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: 6px;
}
.omni-gate-actions button {
padding: 8px 14px;
border: 1px solid rgba(34, 42, 54, .12);
border-radius: 999px;
background: #fff;
color: #575d66;
font-size: 13px;
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
background: var(--surface);
color: var(--accent-black);
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.omni-gate-actions button.primary {
border-color: var(--klein);
background: var(--klein);
color: #fff;
border-color: var(--heat-20);
background: var(--heat-12);
color: var(--heat);
}
.omni-gate-actions button:disabled {
+51 -62
View File
@@ -35,18 +35,16 @@ type PresetItem = {
desc?: string;
starter?: string;
cover: string;
previewVideo?: string;
};
const VIDEO_PRESETS: PresetItem[] = [
{ name: "真实使用演示", category: "commerce", mode: "video", title: "真实使用演示", desc: "从商品真实用途出发,拍清使用过程与卖点证据,避免不合理的夸张演示。", starter: "基于商品的真实用途创作一条可信的使用演示:拍清使用步骤、产品状态和核心卖点,不夸张、不出现不合理的使用场景。", cover: "/assets/prototype/video-oneclick-film-v3.png" },
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/prototype/photo-1529139574466-a303027c1d8b.jpg" },
{ name: "商品拟人广告", category: "commerce", mode: "video", title: "商品拟人广告", desc: "让商品成为故事主角,通过个性、动作和情绪完成趣味表达。", starter: "把商品塑造成有性格的拟人角色,创作一条轻快、有趣的短广告。", cover: "/assets/prototype/photo-1608248543803-ba4f8c70ae0b.jpg" },
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/prototype/photo-1494790108377-be9c29b29330.jpg" },
{ name: "商品图一键成片", category: "commerce", mode: "video", title: "商品图一键成片", desc: "从商品参考图建立镜头语言,自动补齐场景、动作与转场。", starter: "根据我上传的商品图片生成一条节奏清晰的电商短视频。", cover: "/assets/prototype/photo-1556228578-8c89e6adf883.jpg" },
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/prototype/photo-1483985988355-763728e1935b.jpg" },
{ name: "点触换款", category: "visual", mode: "video", title: "点触换款", desc: "用统一构图和动作触发商品变化,快速展示多个款式或 SKU。", starter: "创作一条通过点击动作连续切换不同商品款式的短视频。", cover: "/assets/prototype/unbranded-running-shoe-remix.png" },
{ name: "探店漫游", category: "story", mode: "video", title: "探店漫游", desc: "以空间动线串联门店环境、服务细节与主推项目。", starter: "根据门店素材创作一条有路线感和空间氛围的探店漫游视频。", cover: "/assets/prototype/photo-1497366754035-f200968a6e72.jpg" },
{ name: "品牌质感大片", category: "visual", mode: "video", title: "品牌质感大片", desc: "通过统一的光影、材质与镜头节奏建立更强的品牌识别。", starter: "创作一条强调光影、材质和品牌气质的高级感商品短片。", cover: "/assets/prototype/video-free-cinematic.png" },
{ 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[] = [
@@ -64,19 +62,6 @@ const IMAGE_PRESETS: PresetItem[] = [
{ name: "新中式", category: "style", mode: "image", title: "新中式", cover: "/assets/image-presets/neo-chinese.png" },
];
const VIDEO_FILTERS = [
{ key: "all", label: "全部" },
{ key: "story", label: "剧情" },
{ key: "commerce", label: "电商" },
{ key: "speaker", label: "口播" },
{ key: "visual", label: "视觉" },
];
const IMAGE_FILTERS = [
{ key: "all", label: "全部" },
{ key: "style", label: "风格" },
];
const MENTION_REF_LIMIT = 5;
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
@@ -134,7 +119,6 @@ export function OmniCreatePage({
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
const [previewCase, setPreviewCase] = useState<PresetItem | null>(null);
const [activeCategory, setActiveCategory] = useState("all");
const fileInputRef = useRef<HTMLInputElement>(null);
const toolsRef = useRef<HTMLDivElement>(null);
const [starting, setStarting] = useState(false);
@@ -155,7 +139,6 @@ export function OmniCreatePage({
setRatio("9:16");
setDuration("智能时长");
}
setActiveCategory("all");
setSelectedCase((prev) => (prev && prev.mode !== outputMode ? null : prev));
}, [outputMode, modelConfigs]);
@@ -170,17 +153,13 @@ export function OmniCreatePage({
return () => document.removeEventListener("mousedown", onDown);
}, []);
const filters = outputMode === "video" ? VIDEO_FILTERS : IMAGE_FILTERS;
const presets = useMemo(() => {
const list = outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
if (activeCategory === "all") return list;
return list.filter((item) => item.category === activeCategory);
}, [outputMode, activeCategory]);
return outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
}, [outputMode]);
const applyPreset = (preset: PresetItem) => {
setSelectedCase(preset);
// 图片预设只作为隐藏的生成风格,用户输入区保留自己的创作描述,不把内部 Prompt 展示出来
if (preset.mode !== "image" && preset.starter) setPrompt(preset.starter);
// 预设只作为后台的创作方向。无论图片还是视频,都不把内部生成指令塞进用户输入区
setPreviewCase(null);
onNotify?.("info", `已选择预设:${preset.name}`);
};
@@ -460,32 +439,12 @@ export function OmniCreatePage({
<section
className={`omni-case-library${outputMode === "image" ? " is-image-presets" : ""}`}
aria-labelledby={outputMode === "video" ? "omniCaseTitle" : undefined}
aria-label={outputMode === "image" ? "图片风格预设" : undefined}
aria-label={outputMode === "image" ? "图片风格预设" : "视频创作预设"}
>
{outputMode === "video" ? (
<div className="omni-case-head">
<strong className="omni-case-head-label" id="omniCaseTitle"></strong>
<div className="omni-case-filters" role="tablist" aria-label="预设筛选">
{filters.map((filter) => (
<button
type="button"
key={filter.key}
className={activeCategory === filter.key ? "active" : ""}
onClick={() => setActiveCategory(filter.key)}
>
{filter.label}
</button>
))}
</div>
</div>
) : null}
{outputMode === "image" ? (
<div className="omni-image-preset-intro">
<strong></strong>
<span></span>
</div>
) : null}
<div className="omni-preset-intro">
<strong>{outputMode === "video" ? "从一个视频方向开始" : "从一个风格开始"}</strong>
<span>{outputMode === "video" ? "可选,也可以直接描述你想拍的视频。" : "可选,也可以直接描述你想要的画面。"}</span>
</div>
<div className="omni-case-grid">
{presets.map((card) => (
<button
@@ -493,16 +452,39 @@ export function OmniCreatePage({
className={`omni-case-card${selectedCase?.name === card.name ? " active" : ""}`}
data-mode={card.mode}
key={card.name}
onMouseEnter={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
void video?.play().catch(() => undefined);
}}
onMouseLeave={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
if (!video) return;
video.pause();
video.currentTime = 0;
}}
onFocus={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
void video?.play().catch(() => undefined);
}}
onBlur={(event) => {
const video = event.currentTarget.querySelector<HTMLVideoElement>("video");
if (!video) return;
video.pause();
video.currentTime = 0;
}}
onClick={() => {
if (card.mode === "image") applyPreset(card);
else setPreviewCase(card);
}}
>
<span className="omni-case-visual">
<img src={card.cover} alt="" />
<span className="omni-case-play">
{card.mode === "video" ? <Play className="lucide-play" /> : <ImageIcon />}
</span>
{card.previewVideo ? (
<video muted loop playsInline preload="metadata" poster={card.cover} aria-label={`${card.title} 样片预览`}>
<source src={card.previewVideo} type="video/mp4" />
</video>
) : (
<img src={card.cover} alt="" />
)}
</span>
<span className="omni-case-copy">
<strong>{card.title}</strong>
@@ -530,8 +512,15 @@ export function OmniCreatePage({
<X />
</button>
<div className="omni-preset-media-frame">
<img src={previewCase.cover} alt="预设示例" />
{previewCase.mode === "video" && (
{previewCase.previewVideo ? (
<video controls playsInline preload="metadata" poster={previewCase.cover}>
<source src={previewCase.previewVideo} type="video/mp4" />
</video>
) : (
<img src={previewCase.cover} alt="预设示例" />
)}
{previewCase.mode === "video" && !previewCase.previewVideo && (
<span className="omni-preset-video-mark">
<Play />
</span>
+51 -55
View File
@@ -399,17 +399,6 @@ function collectSessionAssets(
});
}
if (message.kind === "prompt_file") {
const title = String(message.payload?.title || "视频生成Prompt.md");
const body = String(message.payload?.body || "");
push({
key: `prompt:${message.id || title}`,
kind: "document",
title,
promptTitle: title,
promptBody: body || "暂无内容",
});
}
}
return out;
@@ -512,7 +501,7 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
<section className="omni-video-plan-card">
<header className="omni-video-plan-head">
<strong></strong>
<span> Prompt</span>
<span></span>
</header>
<section className="omni-plan-section">
<strong></strong>
@@ -555,9 +544,9 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
</span>
) : null}
<span className="omni-plan-final-input">
<b>
Prompt.md + <em className="omni-plan-point-body">{String(payload.ref_count ?? 0)}</em>
<em className="omni-plan-point-body">{String(payload.ref_count ?? 0)}</em>
</b>
</span>
</div>
@@ -565,30 +554,21 @@ function PlanCard({ payload }: { payload: Record<string, unknown> }) {
);
}
function PromptFileCard({
payload,
onView,
}: {
payload: Record<string, unknown>;
onView: () => void;
}) {
function PromptFileCard({ payload }: { payload: Record<string, unknown> }) {
return (
<section className="omni-prompt-file-card">
<span className="omni-prompt-file-icon">
<FileText />
</span>
<div className="omni-prompt-file-copy">
<strong>{String(payload.title || "视频生成Prompt.md")}</strong>
<small> {String(payload.ref_count ?? 0)} </small>
<strong></strong>
<small> {String(payload.ref_count ?? 0)} </small>
</div>
<button type="button" onClick={onView}>
Prompt
</button>
</section>
);
}
/** 视频 5 步闸门:策略/方案/Prompt 下方的「按这个继续 / 我想改」。
/** 视频阶段闸门:策略/方案下方的「按这个继续 / 我想改」。
* 「我想改」在卡片内展开反馈输入,不再依赖底部 composer。 */
function StepConfirmCard({
message,
@@ -606,14 +586,14 @@ function StepConfirmCard({
const answers = (message.payload.answers as Record<string, string> | undefined) || {};
const action = String(answers.step_action || "");
const stepLabel =
step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片 Prompt" : "这一步";
step === "strategy" ? "创作策略" : step === "plan" ? "视频方案" : step === "prompt" ? "出片指令" : "这一步";
const placeholder =
step === "strategy"
? "说说你想怎么改策略…"
: step === "plan"
? "说说你想怎么改方案…"
: step === "prompt"
? "说说你想怎么改 Prompt…"
? "说说你想怎么改出片细节…"
: "说说你想怎么改…";
const [revising, setRevising] = useState(false);
const [feedback, setFeedback] = useState("");
@@ -781,21 +761,6 @@ function ElicitCard({
);
}
if (interaction === "chat") {
return (
<div className="omni-chat-row agent">
<span className="omni-chat-avatar">
<Sparkles />
</span>
<div className="omni-chat-bubble">
<p className="omni-chat-text">
{message.text || fields[0]?.label || "这项你想怎么定?"}
</p>
</div>
</div>
);
}
const toggleMulti = (key: string, value: string) => {
setAnswers((prev) => {
const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : [];
@@ -828,12 +793,12 @@ function ElicitCard({
<div className="omni-chat-bubble omni-gate-bubble">
<p className="omni-chat-text">{message.text || gateLabel}</p>
{!submitted ? (
<div className="omni-gate-actions">
{(gateField?.options || []).map((option) => (
<div className="omni-gate-actions" aria-label="商品选择操作">
{(gateField?.options || []).map((option, index) => (
<button
type="button"
key={option.value}
className={option.value === "send" ? "primary" : ""}
className={index === 0 ? "primary" : ""}
disabled={disabled}
onClick={() => {
if (!gateField?.key) return;
@@ -854,6 +819,32 @@ function ElicitCard({
);
}
if (interaction === "chat") {
const question = message.text || fields[0]?.label || "这项你想怎么定?";
return (
<div className="omni-chat-row agent">
<span className="omni-chat-avatar">
<Sparkles />
</span>
<div className="omni-chat-stack">
<div className="omni-chat-bubble">
<p className="omni-chat-text">{question}</p>
</div>
{!submitted ? (
<div className="omni-reply-guide" aria-label="下一步引导">
<p><span></span></p>
<div className="omni-reply-guide-options">
<button type="button" className="primary" onClick={() => document.getElementById("omniSessionPrompt")?.focus()}>
</button>
</div>
</div>
) : null}
</div>
</div>
);
}
return (
<div className="omni-elicit-slot">
<section className={`omni-elicit-card${submitted ? " is-submitted" : ""}`}>
@@ -1051,11 +1042,15 @@ function ConfirmCard({
);
}
function ResultCard({ payload }: { payload: Record<string, unknown> }) {
function ResultCard({
payload,
}: {
payload: Record<string, unknown>;
}) {
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const first = assets[0] || {};
const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · ");
const isVideo = first.type === "video";
const isGeneratedVideo = first.type === "video";
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
return (
@@ -1100,7 +1095,7 @@ function ResultCard({ payload }: { payload: Record<string, unknown> }) {
</div>
<div className="omni-result-info">
<div>
<strong>{isVideo ? "视频已生成" : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"}</strong>
<strong>{isGeneratedVideo ? "视频已生成" : assets.length > 1 ? `已生成 ${assets.length} 张图` : "图片已生成"}</strong>
<small>{meta}</small>
</div>
</div>
@@ -2178,10 +2173,6 @@ export function OmniSessionPage({
<PromptFileCard
key={message.clientKey || message.id}
payload={message.payload}
onView={() => setPromptView({
title: String(message.payload.title || "视频生成Prompt.md"),
body: String(message.payload.body || "暂无内容"),
})}
/>
);
case "confirm": {
@@ -2213,7 +2204,12 @@ export function OmniSessionPage({
case "generating":
return <ProcessCard key={message.clientKey || message.id} payload={message.payload} />;
case "result":
return <ResultCard key={message.clientKey || message.id} payload={message.payload} />;
return (
<ResultCard
key={message.clientKey || message.id}
payload={message.payload}
/>
);
default: {
const refs = message.refs || [];
const body = stripMentionText(message.text, refs);