极速成片页面和提炼提示词
This commit is contained in:
@@ -28,7 +28,7 @@ const CREATE_GROUPS: Array<{
|
||||
label: "从商品开始",
|
||||
hint: "围绕商品卖点生成完整带货内容",
|
||||
cards: [
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "projectWizard", icon: "wand" },
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
|
||||
],
|
||||
},
|
||||
@@ -36,8 +36,8 @@ const CREATE_GROUPS: Array<{
|
||||
label: "从参考视频开始",
|
||||
hint: "复用成熟视频的镜头表达与节奏",
|
||||
cards: [
|
||||
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
|
||||
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
|
||||
{ title: "提炼提示词", desc: "上传参考视频,提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", tone: "subtle", page: "freeCreate", icon: "replace" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -139,7 +139,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
if (!draft) return;
|
||||
sessionStorage.removeItem(REMIX_PROMPT_KEY);
|
||||
window.setTimeout(() => promptRef.current?.setContent(draft, []), 0);
|
||||
notify("info", "已填入复刻提示词,可改完再生成");
|
||||
notify("info", "已填入分镜稿,可改完再生成");
|
||||
}, [notify]);
|
||||
|
||||
// —— 弹窗 ——
|
||||
|
||||
@@ -11,5 +11,6 @@ export { TeamPage } from "./team";
|
||||
export { MessagesPage } from "./messages";
|
||||
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
|
||||
export { FreeCreatePage } from "./free-create";
|
||||
export { QuickCreatePage } from "./quick-create";
|
||||
export { VideoRemixPage } from "./video-remix";
|
||||
export { SettingsPage } from "./settings";
|
||||
|
||||
@@ -1356,7 +1356,7 @@ export function PipelinePage(props: {
|
||||
}, [chatText]);
|
||||
const chatFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返)
|
||||
// 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息
|
||||
// 1.11 上传视频提炼:整段视频交给 Gemini,大约 1–2 分钟,单独一个 busy 位并在聊天区留一条进度消息
|
||||
const videoFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [videoDigestBusy, setVideoDigestBusy] = useState(false);
|
||||
const chatBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -1751,7 +1751,7 @@ export function PipelinePage(props: {
|
||||
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
|
||||
}
|
||||
}
|
||||
// 1.11 上传视频提炼:后端抽帧读帧出分镜稿 → 填进输入框。
|
||||
// 1.11 上传视频提炼:整段视频(含口播)交给 Gemini → 中文分镜稿填进输入框。
|
||||
// 稿子**故意落在可编辑的输入框里而不是直接出脚本**:AI 拆解必然有错,用户先逐镜改完再交给脚本 agent。
|
||||
async function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -1769,7 +1769,7 @@ export function PipelinePage(props: {
|
||||
}
|
||||
setVideoDigestBusy(true);
|
||||
pushMsg("user", `已上传视频《${file.name}》`);
|
||||
pushMsg("ai", "正在抽帧拆解这条视频,大约需要半分钟…");
|
||||
pushMsg("ai", "正在把整段视频交给模型,大约需要一到两分钟…");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
@@ -1779,8 +1779,12 @@ export function PipelinePage(props: {
|
||||
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
|
||||
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
|
||||
setChatMode("video");
|
||||
pushMsg("ai", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,`
|
||||
+ "请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。");
|
||||
pushMsg(
|
||||
"ai",
|
||||
digest.input === "native_video"
|
||||
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
);
|
||||
chatTextareaRef.current?.focus();
|
||||
} catch (error) {
|
||||
pushMsg("ai", "这条视频没能拆开,可以换一条再试。");
|
||||
|
||||
@@ -440,8 +440,8 @@ const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string
|
||||
];
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
|
||||
{ title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
|
||||
{ title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
|
||||
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
|
||||
];
|
||||
|
||||
function projCardSub(project: Project, productTitle: string): string {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Sparkles, Trash2, Upload, WandSparkles } from "lucide-react";
|
||||
import type { Page } from "./route-config";
|
||||
|
||||
export function QuickCreatePage({ onBack }: { onBack: () => void; navigate?: (page: Page) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [preview, setPreview] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!images[0]) {
|
||||
setPreview("");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(images[0]);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [images]);
|
||||
|
||||
function selectImages(files: FileList | null) {
|
||||
setImages(Array.from(files || []).slice(0, 9));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="quick-create-page">
|
||||
<header className="project-builder-header quick-create-header">
|
||||
<div className="project-builder-title">
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label="返回工作台"><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1><p>输入商品名称并上传图片,系统将自动完成从商品理解到视频生成的全部流程</p></div>
|
||||
</div>
|
||||
<div className="project-builder-status"><strong>AI 自动编排</strong><span>· 无需逐步配置</span></div>
|
||||
</header>
|
||||
|
||||
<div className="quick-create-shell" id="quickCreateShell">
|
||||
<section className="quick-create-panel quick-form-panel">
|
||||
<div className="quick-form-copy">
|
||||
<h2>告诉我们这是什么商品</h2>
|
||||
<p>系统会识别商品信息,自动选择带货结构、表现形式、模特和场景,并完成15秒竖屏视频。</p>
|
||||
</div>
|
||||
|
||||
<label className="quick-field">
|
||||
<span className="quick-field-label"><span>商品名称</span><small>必填</small></span>
|
||||
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" />
|
||||
</label>
|
||||
|
||||
<div className="quick-field">
|
||||
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9张</small></span>
|
||||
<label className={`quick-upload${preview ? " has-image" : ""}`}>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => selectImages(event.target.files)} />
|
||||
<span className="quick-upload-copy"><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></span>
|
||||
<span className="quick-upload-preview">
|
||||
<img src={preview} alt="极速成片商品预览" />
|
||||
<span className="quick-image-count">{images.length}张图片</span>
|
||||
<button type="button" className="quick-image-clear" onClick={(event) => { event.preventDefault(); setImages([]); }} aria-label="删除已上传图片"><Trash2 /></button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="quick-auto-note" aria-label="系统自动完成内容"><span>识别商品与卖点</span><span>推荐脚本方向</span><span>匹配模特与场景</span><span>生成故事板与视频</span></div>
|
||||
|
||||
<div className="quick-form-footer">
|
||||
<div className="quick-cost"><span>仅在视频生成成功后扣费</span><strong>预计 240 积分</strong></div>
|
||||
<button type="button" className="quick-generate-button" disabled><WandSparkles /><span>立即生成视频</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="quick-create-panel quick-status-panel" aria-live="polite">
|
||||
<div className="quick-state quick-state-ready">
|
||||
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
|
||||
<h2>系统会替你完成所有选择</h2>
|
||||
<p>上传商品后,AI 将根据品类、图片信息和适用场景自动编排完整视频,不需要理解复杂的制作参数。</p>
|
||||
<div className="quick-ready-tags"><span>自动推荐结构</span><span>自动选择表现形式</span><span>自动匹配资产</span><span>自动质量检查</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type Page =
|
||||
| "messages"
|
||||
| "assetFactory"
|
||||
| "freeCreate"
|
||||
| "quickCreate"
|
||||
| "videoRemix"
|
||||
| "imageOptimize"
|
||||
| "modelPhoto"
|
||||
@@ -102,7 +103,8 @@ export const routeLabels: Record<Page, string> = {
|
||||
messages: "消息",
|
||||
assetFactory: "图片工具",
|
||||
freeCreate: "自由创作",
|
||||
videoRemix: "视频复刻",
|
||||
quickCreate: "极速成片",
|
||||
videoRemix: "提炼提示词",
|
||||
imageOptimize: "图片创作",
|
||||
modelPhoto: "模特上身图",
|
||||
modelPhotoDemoA: "模特图方案 A",
|
||||
@@ -120,7 +122,7 @@ export function isPage(value: string): value is Page {
|
||||
|
||||
export function parentPage(page: Page): Page {
|
||||
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
|
||||
if (["projectWizard", "freeCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
|
||||
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
|
||||
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
|
||||
return "assetFactory";
|
||||
}
|
||||
@@ -163,6 +165,7 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path === "/messages") return { page: "messages", authMode: "login", hash };
|
||||
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
|
||||
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
|
||||
if (path === "/quick-create") return { page: "quickCreate", authMode: "login", hash };
|
||||
if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
|
||||
if (path === "/image-optimize") {
|
||||
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash };
|
||||
@@ -207,6 +210,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
return "/asset-factory";
|
||||
case "freeCreate":
|
||||
return "/free-create";
|
||||
case "quickCreate":
|
||||
return "/quick-create";
|
||||
case "videoRemix":
|
||||
return "/video-remix";
|
||||
case "imageOptimize":
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
// 视频复刻 · 上传参考视频 → 视频提炼接口 → 可编辑提示词。
|
||||
// 拆解跟生成脚本同一套接口口径:模型下拉 + Gemini 3.1 Pro 官转看图。
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, FileVideo, Save, ScanLine, ScanSearch } from "lucide-react";
|
||||
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Clock3,
|
||||
FileVideo,
|
||||
FileVideo2,
|
||||
PanelsTopLeft,
|
||||
RectangleVertical,
|
||||
Save,
|
||||
ScanLine,
|
||||
ScanSearch,
|
||||
TextCursorInput,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { publicModelDisplayName } from "../model-display";
|
||||
import type { ModelConfig } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
|
||||
const REMIX_DRAFT_KEY = "vr-digest-draft";
|
||||
const VIDEO_DIGEST_POINTS = 30;
|
||||
|
||||
type RemixDraft = {
|
||||
prompt: string;
|
||||
duration: number;
|
||||
shots: number;
|
||||
rhythm: string;
|
||||
ratio: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
modelId: string;
|
||||
fileKind: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function loadDraft(): RemixDraft | null {
|
||||
@@ -43,24 +57,11 @@ function saveDraft(draft: RemixDraft) {
|
||||
|
||||
const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function shotCount(text: string, frames: number) {
|
||||
const marks = text.match(/【第\s*\d+\s*镜】/g);
|
||||
return marks?.length || frames || 0;
|
||||
}
|
||||
|
||||
function rhythmCopy(text: string) {
|
||||
if (/快节奏|紧凑/.test(text)) return "快节奏种草";
|
||||
if (/慢节奏|舒缓/.test(text)) return "慢节奏展示";
|
||||
const form = text.match(/形式[::]\s*([^\n]+)/);
|
||||
if (form?.[1]) return form[1].trim().slice(0, 12);
|
||||
return "按参考片节奏";
|
||||
}
|
||||
|
||||
function isGemini31(model: ModelConfig) {
|
||||
const blob = `${model.name} ${model.display_name}`.toLowerCase();
|
||||
return blob.includes("gemini-3.1") || blob.includes("gemini 3.1") || model.name === "gemini-3.1-pro-preview";
|
||||
@@ -76,6 +77,45 @@ function pickDigestModel(models: ModelConfig[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function fileKind(file: File | null, name: string) {
|
||||
if (file?.type === "video/quicktime" || /\.mov$/i.test(file?.name || name)) return "MOV";
|
||||
if (/\.webm$/i.test(file?.name || name)) return "WEBM";
|
||||
return "MP4";
|
||||
}
|
||||
|
||||
function ratioLabel(width: number, height: number) {
|
||||
if (!width || !height) return "—";
|
||||
const r = width / height;
|
||||
if (Math.abs(r - 9 / 16) < 0.08) return "9:16 竖屏";
|
||||
if (Math.abs(r - 16 / 9) < 0.08) return "16:9 横屏";
|
||||
if (Math.abs(r - 1) < 0.08) return "1:1";
|
||||
return width > height ? "横屏" : "竖屏";
|
||||
}
|
||||
|
||||
function readVideoMeta(file: File): Promise<{ duration: number; width: number; height: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => {
|
||||
const meta = { duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 };
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(meta);
|
||||
};
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ duration: 0, width: 0, height: 0 });
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function fileMetaCopy(kind: string, size: number, width: number, height: number) {
|
||||
if (width && height) return `${kind} · ${width} × ${height}`;
|
||||
if (size > 0) return `${kind} · ${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return kind;
|
||||
}
|
||||
|
||||
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
|
||||
textModels?: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -88,31 +128,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const [prompt, setPrompt] = useState(draft?.prompt || "");
|
||||
const [duration, setDuration] = useState(draft?.duration || 0);
|
||||
const [shots, setShots] = useState(draft?.shots || 0);
|
||||
const [rhythm, setRhythm] = useState(draft?.rhythm || "");
|
||||
const [ratio, setRatio] = useState(draft?.ratio || "");
|
||||
const [fileName, setFileName] = useState(draft?.fileName || "");
|
||||
const [fileSize, setFileSize] = useState(draft?.fileSize || 0);
|
||||
const [modelId, setModelId] = useState(draft?.modelId || "");
|
||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||
const done = prompt.length > 0;
|
||||
const [kind, setKind] = useState(draft?.fileKind || "MP4");
|
||||
const [width, setWidth] = useState(draft?.width || 0);
|
||||
const [height, setHeight] = useState(draft?.height || 0);
|
||||
const [hasResult, setHasResult] = useState(Boolean(draft?.prompt.trim()));
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const digestModels = useMemo(
|
||||
() => textModels.filter((model) => model.status === "active" && isGemini31(model)),
|
||||
[textModels]
|
||||
);
|
||||
const defaultModel = useMemo(() => pickDigestModel(digestModels), [digestModels]);
|
||||
const activeModelId = modelId || defaultModel?.id || "";
|
||||
const activeModel = digestModels.find((model) => model.id === activeModelId) || defaultModel;
|
||||
const activeModelName = publicModelDisplayName(activeModel, "Gemini 3.1 Pro 官转");
|
||||
const estimatedPoints = Math.round(Number(activeModel?.unit_price || 0));
|
||||
|
||||
const activeModel = useMemo(() => pickDigestModel(digestModels), [digestModels]);
|
||||
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||
useEffect(() => {
|
||||
if (!modelMenuOpen) return;
|
||||
const close = (event: MouseEvent) => {
|
||||
if (!(event.target as HTMLElement).closest(".vr-model-pick")) setModelMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("click", close);
|
||||
return () => document.removeEventListener("click", close);
|
||||
}, [modelMenuOpen]);
|
||||
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||||
}, []);
|
||||
const estimatedPoints = priceMultiplier === 1
|
||||
? VIDEO_DIGEST_POINTS
|
||||
: Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6))));
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt.trim()) return;
|
||||
@@ -120,14 +156,23 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
prompt,
|
||||
duration,
|
||||
shots,
|
||||
rhythm,
|
||||
ratio,
|
||||
fileName: file?.name || fileName,
|
||||
fileSize: file?.size || fileSize,
|
||||
modelId: activeModelId,
|
||||
fileKind: kind,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [prompt, duration, shots, rhythm, file, fileName, fileSize, activeModelId]);
|
||||
}, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]);
|
||||
|
||||
const pickFile = (next: File | null) => {
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
if (!el || !hasResult) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.max(132, el.scrollHeight)}px`;
|
||||
}, [prompt, hasResult]);
|
||||
|
||||
const pickFile = async (next: File | null) => {
|
||||
if (!next) return;
|
||||
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
|
||||
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
|
||||
@@ -137,9 +182,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
onNotify("error", "视频不能超过 200MB,请压缩后再传");
|
||||
return;
|
||||
}
|
||||
const meta = await readVideoMeta(next);
|
||||
setFile(next);
|
||||
setFileName(next.name);
|
||||
setFileSize(next.size);
|
||||
setKind(fileKind(next, next.name));
|
||||
setWidth(meta.width);
|
||||
setHeight(meta.height);
|
||||
setRatio(ratioLabel(meta.width, meta.height));
|
||||
if (meta.duration) setDuration(Math.round(meta.duration));
|
||||
setHasResult(false);
|
||||
};
|
||||
|
||||
const analyze = async () => {
|
||||
@@ -148,16 +200,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (activeModelId) fd.append("model_config_id", activeModelId);
|
||||
if (activeModel?.id) fd.append("model_config_id", activeModel.id);
|
||||
const digest = await api.extractVideoDigest(fd);
|
||||
const text = digest.text.trim();
|
||||
setPrompt(text);
|
||||
setDuration(digest.duration);
|
||||
setDuration(digest.duration || duration);
|
||||
setShots(shotCount(text, digest.frames));
|
||||
setRhythm(rhythmCopy(text));
|
||||
setFileName(file.name);
|
||||
setFileSize(file.size);
|
||||
onNotify("success", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧,请先逐镜核对`);
|
||||
setHasResult(true);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
} catch (error) {
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
@@ -184,140 +236,134 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
navigate("freeCreate");
|
||||
};
|
||||
|
||||
const analyzeLabel = busy
|
||||
? "正在拆解…"
|
||||
: `生成这个需要 ${estimatedPoints} 积分`;
|
||||
|
||||
return (
|
||||
<div className="vr-page">
|
||||
<div className="vr-inner">
|
||||
<header className="page-head">
|
||||
<div className="vr-title-row">
|
||||
<button type="button" className="btn btn-ghost vr-back" aria-label="返回上一入口页面" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
<div>
|
||||
<h1>视频复刻</h1>
|
||||
<div className="sub">
|
||||
上传参考视频,拆解镜头、动作、运镜和节奏,生成可编辑提示词
|
||||
<span className="mono">[ /remix ]</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="vr-grid">
|
||||
<section className="vr-panel">
|
||||
<div className="section-h">
|
||||
<h2>上传参考视频</h2>
|
||||
<span className="more">// 视频提炼</span>
|
||||
</div>
|
||||
<p className="vr-lead">建议使用主体清晰、镜头完整的电商短视频。拆解稿会落在右侧,先逐镜改完再去生成。</p>
|
||||
<div className="vr-step">
|
||||
<div className="vr-step-h">
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 3 分钟 · 200MB</span>
|
||||
</div>
|
||||
<label className={`vr-upload${file ? " has-file" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,.mp4,.mov,.m4v,.webm"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
|
||||
<small>
|
||||
{file
|
||||
? `${formatSize(file.size)} · 视频已就绪,可开始拆解`
|
||||
: fileName
|
||||
? `${formatSize(fileSize)} · 上次拆解还在,刷新不会丢。要重拆请再选一次文件`
|
||||
: "上传后抽帧拆解镜头结构与节奏"}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="vr-actions">
|
||||
<div className="vr-meta">
|
||||
{digestModels.length > 0 ? (
|
||||
<div className={`chip-wrap vr-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
<button type="button" className="chip" title="选择视频提炼模型" onClick={() => setModelMenuOpen((open) => !open)}>
|
||||
{activeModelName}
|
||||
<svg className="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div className="chip-menu">
|
||||
{digestModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`mi${model.id === activeModelId ? " selected" : ""}`}
|
||||
role="menuitemradio"
|
||||
aria-checked={model.id === activeModelId}
|
||||
tabIndex={0}
|
||||
onClick={() => { setModelId(model.id); setModelMenuOpen(false); }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setModelId(model.id);
|
||||
setModelMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{publicModelDisplayName(model)}
|
||||
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<span>{estimatedPoints > 0 ? `视频解析预计消耗 ${estimatedPoints} 积分` : "视频解析按一次文本模型计费"}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary" disabled={!file || busy} onClick={() => void analyze()}>
|
||||
{busy ? <span className="spinner" aria-hidden="true" /> : <ScanSearch />}
|
||||
<span>{busy ? "正在拆解…" : done ? "重新拆解" : "开始拆解"}</span>
|
||||
<section className="video-tool-page remix-page">
|
||||
<header className="page-header">
|
||||
<div className="image-title-row">
|
||||
<button type="button" className="image-back-button" aria-label="返回上一入口页面" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
<div className="page-heading">
|
||||
<h1>提炼提示词</h1>
|
||||
<p>上传参考视频,生成可编辑的视频提示词</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="video-flow-grid remix-flow-grid">
|
||||
<section className="video-flow-panel video-remix-upload-panel">
|
||||
<h2>上传参考视频</h2>
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
</div>
|
||||
<label className={`video-upload-field${file ? " has-file" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
|
||||
<small>
|
||||
{file
|
||||
? "视频已就绪,可开始拆解"
|
||||
: fileName
|
||||
? "上次拆解还在,刷新不会丢。要重拆请再选一次文件"
|
||||
: "上传后自动识别镜头结构与内容节奏"}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="video-flow-actions remix-analyze-actions">
|
||||
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
|
||||
<ScanSearch />
|
||||
<span>{analyzeLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className={`video-result-panel remix-information-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<div className="video-result-placeholder">
|
||||
<div>
|
||||
<span className="remix-placeholder-icon"><ScanLine /></span>
|
||||
<strong>等待视频解析</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-analysis-result">
|
||||
<div className="remix-info-heading">
|
||||
<span className="remix-status-icon"><BadgeCheck /></span>
|
||||
<div>
|
||||
<span className="remix-eyebrow">Analysis complete</span>
|
||||
<h2>参考视频信息</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="remix-info-list">
|
||||
<div className="remix-info-item">
|
||||
<span><Clock3 />视频时长</span>
|
||||
<strong>{duration ? `${duration} 秒` : "—"}</strong>
|
||||
</div>
|
||||
<div className="remix-info-item">
|
||||
<span><RectangleVertical />画面比例</span>
|
||||
<strong>{ratio || "—"}</strong>
|
||||
</div>
|
||||
<div className="remix-info-item">
|
||||
<span><PanelsTopLeft />镜头数量</span>
|
||||
<strong>{shots ? `${shots} 个镜头` : "—"}</strong>
|
||||
</div>
|
||||
<div className="remix-info-item remix-info-file">
|
||||
<span><FileVideo2 />已解析文件</span>
|
||||
<strong>{fileName || "参考视频.mp4"}</strong>
|
||||
<small>{fileMetaCopy(kind, fileSize, width, height)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className={`remix-prompt-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<div className="remix-prompt-placeholder">
|
||||
<span><TextCursorInput /></span>
|
||||
<div><strong>等待生成提示词</strong></div>
|
||||
</div>
|
||||
<div className="remix-prompt-result">
|
||||
<div className="remix-prompt-head">
|
||||
<div className="remix-prompt-title">
|
||||
<div><h2>提示词内容</h2></div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={promptRef}
|
||||
className="analysis-prompt"
|
||||
aria-label="复刻提示词"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
<div className="video-flow-actions remix-prompt-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => void savePrompt()}>
|
||||
<Save />
|
||||
保存提示词
|
||||
</button>
|
||||
<button type="button" className="primary-action" onClick={continueGenerate}>
|
||||
<span>生成视频</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className={`vr-result${done ? " has-result" : ""}`}>
|
||||
{!done ? (
|
||||
<div className="vr-placeholder">
|
||||
<div>
|
||||
<ScanLine />
|
||||
<strong>{busy ? "正在抽帧拆解这条视频" : "等待视频解析"}</strong>
|
||||
<span>{busy ? "大约需要半分钟。拆解稿会落在这里,请先逐镜核对再去生成。" : "完成后将在这里展示镜头摘要和复刻提示词"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="vr-analysis">
|
||||
<div className="section-h">
|
||||
<h2>视频拆解完成</h2>
|
||||
<span className="more">[ {shots} SHOTS ]</span>
|
||||
</div>
|
||||
<p className="vr-lead">已识别 {shots} 个镜头。拆解必然有错,先改「拆解存疑」再继续生成。</p>
|
||||
<div className="vr-summary">
|
||||
<span>视频时长<strong>{duration} 秒</strong></span>
|
||||
<span>镜头数量<strong>{shots} 镜</strong></span>
|
||||
<span>内容节奏<strong>{rhythm}</strong></span>
|
||||
</div>
|
||||
<textarea
|
||||
className="textarea vr-prompt"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
<div className="vr-actions">
|
||||
<button type="button" className="btn" onClick={() => void savePrompt()}>
|
||||
<Save />
|
||||
保存提示词
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={continueGenerate}>
|
||||
<span>继续生成视频</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user