添加视频复刻和优化脚本旁白

This commit is contained in:
Azmat@qq.com
2026-08-24 10:48:01 +08:00
parent 368586d33e
commit 2f5b7304b0
33 changed files with 1172 additions and 109 deletions
+3
View File
@@ -29,6 +29,7 @@ import {
AuthScreen,
Dashboard,
FreeCreatePage,
VideoRemixPage,
ImageWorkbenchPage,
LibraryPage,
MessagesPage,
@@ -1045,6 +1046,8 @@ export function App() {
return <AssetFactoryPage navigate={navigate} />;
case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
case "videoRemix":
return <VideoRemixPage textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhoto":
+7
View File
@@ -348,6 +348,13 @@ export const api = {
{ method: "POST", body: formData }
);
},
// 视频复刻页:不绑项目,抽帧 + 多模态读帧出中文分镜稿。慢(30~60 秒),计一次文本费。
extractVideoDigest(formData: FormData) {
return request<{ name: string; chars: number; text: string; frames: number; duration: number; estimated_cost?: string }>(
"/api/ai/video-digest/",
{ method: "POST", body: formData }
);
},
// ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ──
// 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。
saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) {
+3 -1
View File
@@ -16,6 +16,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
{ id: "video-remix", group: "导航", label: "视频复刻", sub: "上传参考视频,拆解镜头并生成提示词", page: "videoRemix", icon: "scan", key: "R" },
{ id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
@@ -269,7 +270,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|| page === "modelPhotoDemoB"
|| page === "platformCover"
) return "image";
if (page === "projects" || page === "projectWizard" || page === "pipeline" || page === "freeCreate") return "video";
if (page === "projects" || page === "projectWizard" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video";
return null;
}
@@ -399,6 +400,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
modelPhotoDemoB: "assetFactory",
platformCover: "assetFactory",
freeCreate: "freeCreate",
videoRemix: "projects",
library: "library",
team: "team",
account: "account",
@@ -5,11 +5,6 @@ import type { FreeVideoTask } from "../../types";
import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants";
import { isPublicGenerationError, presentGenerationError } from "../../generation-error";
function ratioStyle(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" };
}
function ratioCopy(ratio: string) {
const [w, h] = ratio.split(":").map(Number);
if (!w || !h) return ratio;
@@ -45,7 +40,6 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
<article className={`fc-card${done ? " done" : ""}`}>
<div
className="fc-card-media"
style={ratioStyle(task.aspect_ratio)}
role={done ? "button" : undefined}
tabIndex={done ? 0 : undefined}
onClick={done ? onOpen : undefined}
+68 -8
View File
@@ -120,12 +120,12 @@
display: grid;
place-items: center;
}
.fc-page .fc-stage:has(.fc-empty) {
.fc-page .fc-stage:has(.fc-empty:not(.is-soft)) {
display: grid;
place-items: center;
overflow: hidden;
}
.fc-page .fc-stage:has(.fc-empty)::before {
.fc-page .fc-stage:has(.fc-empty:not(.is-soft))::before {
content: "";
position: absolute;
inset: 5% 7%;
@@ -165,11 +165,60 @@
font-size: 13px;
}
.fc-page .fc-feedbar {
display: flex;
flex-wrap: wrap;
gap: 7px;
flex-shrink: 0;
margin-bottom: 14px;
}
.fc-page .fc-filter {
height: 30px;
padding: 0 11px;
border: 0;
border-radius: 8px;
color: var(--fc-muted);
background: rgba(34, 42, 54, 0.055);
font: inherit;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.fc-page .fc-filter span {
font-variant-numeric: tabular-nums;
opacity: 0.72;
}
.fc-page .fc-filter:hover {
color: var(--accent-black);
background: rgba(34, 42, 54, 0.10);
}
.fc-page .fc-filter.active {
color: #fff;
background: var(--fc-black);
}
.fc-page .fc-group + .fc-group { margin-top: 28px; }
.fc-page .fc-group-h {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
color: var(--accent-black);
}
.fc-page .fc-group-h span {
font-weight: 400;
color: var(--fc-muted);
font-variant-numeric: tabular-nums;
}
.fc-page .fc-feed {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
align-items: start;
align-items: stretch;
padding-bottom: 8px;
}
.fc-page .fc-sentinel {
@@ -181,6 +230,8 @@
.fc-page .fc-card {
overflow: hidden;
min-width: 0;
height: 100%;
border: 1px solid var(--fc-line);
border-radius: 13px;
background: rgba(34, 42, 54, 0.05);
@@ -196,12 +247,14 @@
}
.fc-page .fc-card-media {
position: relative;
width: 100%;
aspect-ratio: 16 / 10;
background: #e5e6e9;
max-height: 280px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex: 0 0 auto;
}
.fc-page .fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-page .fc-dur {
@@ -314,15 +367,18 @@
.fc-page .fc-hover-btn:hover { color: var(--accent-black); }
.fc-page .fc-hover-btn.fav { color: var(--klein); }
.fc-page .fc-hover-btn.danger:hover { color: var(--accent-crimson); }
.fc-page .fc-card-meta { padding: 15px 16px 17px; }
.fc-page .fc-card-meta { padding: 15px 16px 17px; flex: 1 1 auto; }
.fc-page .fc-card-meta h3 {
margin: 0 0 6px;
min-height: calc(1.35em * 2);
font-size: 15px;
font-weight: 600;
line-height: 1.35;
color: var(--accent-black);
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.fc-page .fc-card-meta p {
margin: 0;
@@ -979,9 +1035,13 @@
.fc-lib-model-picks .btn { height: 24px; padding: 0 8px; font-size: 11.5px; }
.fc-lib-more { display: flex; justify-content: center; padding-top: 14px; }
@media (max-width: 1500px) {
.fc-page .fc-feed { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.fc-page .fc-head { flex-direction: column; align-items: stretch; }
.fc-page .fc-material .fc-ghost { min-width: 0; }
.fc-page .fc-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.fc-page .fc-feed { grid-template-columns: 1fr; }
+1
View File
@@ -14,6 +14,7 @@ import "./messages-page.css";
import "./settings-page.css";
import "./ai-tools-page.css";
import "./free-create-page.css";
import "./video-remix-page.css";
import "./product-create-page.css";
import "./project-wizard-page.css";
import "./admin-page.css";
+91 -14
View File
@@ -1547,10 +1547,10 @@
.tri-cand-card .tri-cand-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
/* ── 流程步骤4 · 人物/场景详情弹窗(对齐设计稿 asset-detail-grid)── */
.asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.asset-detail-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; }
.asset-detail-lead .ad-lead-wrap { position: relative; }
.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 9/16; border-radius: var(--r-md); }
.asset-detail-grid { display: flex; align-items: flex-start; gap: 24px; width: 100%; min-width: 0; }
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; flex: 0 1 240px; width: 240px; max-width: 34%; min-width: 0; }
.asset-detail-lead .ad-lead-wrap { position: relative; width: 100%; min-width: 0; overflow: hidden; }
.asset-detail-lead .placeholder.ad-lead-img { width: 100%; max-width: 100%; min-width: 0; aspect-ratio: 9/16; max-height: min(420px, 48vh); height: auto; box-sizing: border-box; border-radius: var(--r-md); }
.ad-zoom-btn { position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px; padding: 0; background: rgba(21,20,15,.7); color: #fff; border: 0; border-radius: var(--r-sm); display: grid; place-items: center; cursor: pointer; opacity: 0; transition: opacity var(--t-base), background var(--t-base); z-index: 3; }
.ad-zoom-btn:hover { background: rgba(21,20,15,.92); }
.ad-zoom-btn svg { width: 14px; height: 14px; }
@@ -1570,7 +1570,8 @@
.asset-detail-section-h .ad-icon-btn:hover { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }
.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }
.asset-detail-section-h .ad-icon-gap { margin-left: 8px; }
.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); }
.asset-detail-tri-row { width: 100%; min-width: 0; }
.asset-detail-tri-row .placeholder { width: 100%; max-width: 100%; min-width: 0; aspect-ratio: 16 / 9; box-sizing: border-box; border-radius: var(--r-md); }
.asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; gap: 8px; }
.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; }
.md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }
@@ -1578,13 +1579,16 @@
.md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
.md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
.md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; }
.asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; }
.asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; flex-wrap: wrap; gap: 8px; line-height: 1.5; min-width: 0; }
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.asset-detail-tip span { min-width: 0; flex: 1 1 160px; }
.asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 12px; cursor: pointer; font-family: inherit; flex-shrink: 0; }
.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }
.asset-modal-f .ad-stat-btn { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); }
.asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; }
.asset-detail-lead { flex: 0 1 240px; width: 240px; max-width: 34%; min-width: 0; contain: inline-size; }
.asset-detail-right { flex: 1 1 0%; min-width: min(280px, 52%); overflow: hidden; }
/* 固定大小、不可拖拽(对齐设计稿:描述角色立绘的提示词栏固定) */
.ad-detail-prompt { width: 100%; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; outline: none; resize: none; height: 88px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
/* 版本缩略图(非 .placeholder,需自己接 --mock-media-url 才显图) */
@@ -1631,11 +1635,15 @@
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: 12px;
width: min(960px, 100%);
width: 960px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px);
max-height: calc(100dvh - 48px);
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
box-sizing: border-box;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
}
.asset-modal-h {
@@ -1673,19 +1681,57 @@
margin-left: auto;
}
.asset-modal-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }
.asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; min-height: 0; }
.asset-modal-body { padding: 20px 24px 24px; overflow-x: hidden; overflow-y: auto; flex: 1 1 auto; min-width: 0; min-height: 0; }
.asset-modal-f {
padding: 14px 20px;
border-top: 1px solid var(--border-faint);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
flex-shrink: 0;
}
.asset-detail-grid { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 24px; align-items: start; }
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; }
.asset-detail-lead .ad-lead-wrap { position: relative; }
.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 9/16; border-radius: var(--r-md); }
.asset-detail-grid {
display: flex;
align-items: flex-start;
gap: 24px;
width: 100%;
min-width: 0;
}
.asset-detail-lead {
display: flex;
flex-direction: column;
gap: 10px;
flex: 0 1 240px;
width: 240px;
max-width: 34%;
min-width: 0;
contain: inline-size;
}
.asset-detail-right {
flex: 1 1 0%;
min-width: min(280px, 52%);
overflow: hidden;
}
.asset-detail-lead .ad-lead-wrap {
position: relative;
width: 100%;
min-width: 0;
overflow: hidden;
}
.asset-detail-lead .placeholder.ad-lead-img {
width: 100%;
max-width: 100%;
min-width: 0;
aspect-ratio: 9 / 16;
max-height: min(420px, 48vh);
height: auto;
box-sizing: border-box;
border-radius: var(--r-md);
}
@supports not (aspect-ratio: 9 / 16) {
.asset-detail-lead .placeholder.ad-lead-img { height: min(420px, 48vh); }
}
.ad-zoom-btn {
position: absolute;
right: 8px;
@@ -1708,7 +1754,15 @@
.ad-zoom-btn svg { width: 14px; height: 14px; }
.asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn,
.asset-detail-tri-row .ad-tri-wrap:hover .ad-zoom-btn { opacity: 1; }
.asset-detail-tri-row .ad-tri-wrap { position: relative; }
.asset-detail-tri-row {
width: 100%;
min-width: 0;
}
.asset-detail-tri-row .ad-tri-wrap {
position: relative;
width: 100%;
min-width: 0;
}
.asset-detail-lead .ad-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
.asset-detail-lead .ad-thumbs .thumb {
flex: 0 0 56px;
@@ -1760,7 +1814,15 @@
.asset-detail-section-h .ad-icon-btn:hover { background: var(--klein); color: #fff; border-color: var(--klein); }
.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }
.asset-detail-section-h .ad-icon-gap { margin-left: 8px; }
.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); }
.asset-detail-right .ad-section { min-width: 0; }
.asset-detail-tri-row .placeholder {
width: 100%;
max-width: 100%;
min-width: 0;
aspect-ratio: 16 / 9;
box-sizing: border-box;
border-radius: var(--r-md);
}
.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; }
.md-view-versions .v-thumb {
flex: 0 0 auto;
@@ -1790,10 +1852,13 @@
color: var(--accent-black);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
line-height: 1.5;
min-width: 0;
}
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.asset-detail-tip span { min-width: 0; flex: 1 1 160px; }
.asset-detail-tip .ai-gen-btn {
margin-left: auto;
height: 26px;
@@ -1807,6 +1872,18 @@
font-family: inherit;
flex-shrink: 0;
}
@media (max-width: 720px) {
.asset-detail-grid { flex-direction: column; }
.asset-detail-lead,
.asset-detail-right {
flex: none;
width: 100%;
max-width: none;
min-width: 0;
contain: none;
}
.asset-detail-lead { max-width: 200px; }
}
.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }
.asset-modal-f .ad-stat-btn {
height: 32px;
+1 -1
View File
@@ -36,7 +36,7 @@ const CREATE_GROUPS: Array<{
label: "从参考视频开始",
hint: "复用成熟视频的镜头表达与节奏",
cards: [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "freeCreate", icon: "scan" },
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
],
},
+110 -14
View File
@@ -27,10 +27,47 @@ import { PlatformLibraryModal } from "../components/free-create/platform-library
import type { PromptInputHandle } from "../components/free-create/prompt-input";
import { SystemLoading } from "../components/loading";
import { ConfirmModal } from "../components/overlays";
import { REMIX_PROMPT_KEY } from "./video-remix";
const PAGE_SIZE = 20;
const PROGRESS_KEY = "fc-progress";
type FeedFilter = "all" | "inflight" | "succeeded" | "failed";
const FEED_FILTERS: Array<{ key: FeedFilter; label: string }> = [
{ key: "all", label: "全部" },
{ key: "inflight", label: "生成中" },
{ key: "succeeded", label: "已完成" },
{ key: "failed", label: "失败" }
];
const GROUP_LABEL: Record<"today" | "yesterday" | "earlier", string> = {
today: "今天",
yesterday: "昨天",
earlier: "更早"
};
function chinaDay(iso: string | null) {
if (!iso) return "";
return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso));
}
function dayBucket(iso: string | null): "today" | "yesterday" | "earlier" {
const day = chinaDay(iso);
if (!day) return "earlier";
const today = chinaDay(new Date().toISOString());
if (day === today) return "today";
const yesterday = chinaDay(new Date(Date.now() - 86400000).toISOString());
return day === yesterday ? "yesterday" : "earlier";
}
function matchesFeedFilter(task: FreeVideoTask, filter: FeedFilter) {
if (filter === "all") return true;
if (filter === "inflight") return isInFlight(task.status);
if (filter === "succeeded") return task.status === "succeeded";
return task.status === "failed" || task.status === "cancelled";
}
function loadProgress(): Record<string, number> {
try {
return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record<string, number>;
@@ -97,12 +134,21 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
const [submitting, setSubmitting] = useState(false);
const promptRef = useRef<PromptInputHandle | null>(null);
useEffect(() => {
const draft = sessionStorage.getItem(REMIX_PROMPT_KEY);
if (!draft) return;
sessionStorage.removeItem(REMIX_PROMPT_KEY);
window.setTimeout(() => promptRef.current?.setContent(draft, []), 0);
notify("info", "已填入复刻提示词,可改完再生成");
}, [notify]);
// —— 弹窗 ——
const [detailId, setDetailId] = useState<string | null>(null);
const [libraryOpen, setLibraryOpen] = useState(false);
const [platformOpen, setPlatformOpen] = useState(false);
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
const [feedFilter, setFeedFilter] = useState<FeedFilter>("all");
// —— 轮询/进度 ——
const pollTimersRef = useRef(new Map<string, number>());
@@ -522,6 +568,26 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
setRefs([]);
}, []);
const filterCounts = useMemo(() => ({
all: tasks.length,
inflight: tasks.filter((t) => isInFlight(t.status)).length,
succeeded: tasks.filter((t) => t.status === "succeeded").length,
failed: tasks.filter((t) => t.status === "failed" || t.status === "cancelled").length
}), [tasks]);
const feedGroups = useMemo(() => {
const visible = tasks.filter((t) => matchesFeedFilter(t, feedFilter));
const groups: Array<{ key: "today" | "yesterday" | "earlier"; items: FreeVideoTask[] }> = [
{ key: "today", items: [] },
{ key: "yesterday", items: [] },
{ key: "earlier", items: [] }
];
visible.forEach((task) => {
groups.find((g) => g.key === dayBucket(task.created_at))?.items.push(task);
});
return groups.filter((g) => g.items.length > 0);
}, [tasks, feedFilter]);
// 详情弹窗的上一条/下一条(只在已完成的任务间切换)
const doneTasks = tasks.filter((t) => t.status === "succeeded" && t.video_url);
const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null;
@@ -554,6 +620,22 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
</div>
</header>
{tasks.length > 0 && (
<div className="fc-feedbar">
{FEED_FILTERS.map((item) => (
<button
key={item.key}
type="button"
className={`fc-filter${feedFilter === item.key ? " active" : ""}`}
onClick={() => setFeedFilter(item.key)}
>
{item.label}
<span>{filterCounts[item.key]}</span>
</button>
))}
</div>
)}
<div className="fc-stage" ref={stageRef}>
{loading ? (
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
@@ -563,21 +645,35 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
<strong></strong>
<p></p>
</div>
) : (
<div className="fc-feed">
{tasks.map((task) => (
<GenerationCard
key={task.id}
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
onRetry={() => handleRetry(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
/>
))}
) : feedGroups.length === 0 ? (
<div className={`fc-empty${hasMore ? " is-soft" : ""}`}>
<div className="fc-empty-icon"><Clapperboard /></div>
<strong></strong>
<p>{hasMore ? "当前已加载的记录里没有,下滑可继续查找" : "换一个状态筛选,或继续在下方生成新视频"}</p>
</div>
) : (
feedGroups.map((group) => (
<section className="fc-group" key={group.key}>
<div className="fc-group-h">
{GROUP_LABEL[group.key]}
<span>{group.items.length}</span>
</div>
<div className="fc-feed">
{group.items.map((task) => (
<GenerationCard
key={task.id}
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
onRetry={() => handleRetry(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
/>
))}
</div>
</section>
))
)}
{hasMore && <div ref={sentinelRef} className="fc-sentinel"></div>}
</div>
+1
View File
@@ -11,4 +11,5 @@ export { TeamPage } from "./team";
export { MessagesPage } from "./messages";
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
export { FreeCreatePage } from "./free-create";
export { VideoRemixPage } from "./video-remix";
export { SettingsPage } from "./settings";
+2 -1
View File
@@ -34,7 +34,7 @@ import {
import { isLocalLife } from "../product-business";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url("${url.replace(/"/g, "")}")` } as CSSProperties);
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
// 脚本来源 → 「来源」brief pill 文案
@@ -1773,6 +1773,7 @@ export function PipelinePage(props: {
try {
const fd = new FormData();
fd.append("file", file);
if (activeScriptModelId) fd.append("model_config_id", activeScriptModelId);
const digest = await api.extractScriptVideo(project.id, fd);
const text = digest.text.trim();
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
+1 -1
View File
@@ -440,7 +440,7 @@ 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: "freeCreate", image: "/assets/yz/video-remix.jpg" },
{ title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
{ title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
];
+6 -1
View File
@@ -28,6 +28,7 @@ export type Page =
| "messages"
| "assetFactory"
| "freeCreate"
| "videoRemix"
| "imageOptimize"
| "modelPhoto"
| "modelPhotoDemoA"
@@ -101,6 +102,7 @@ export const routeLabels: Record<Page, string> = {
messages: "消息",
assetFactory: "图片工具",
freeCreate: "自由创作",
videoRemix: "视频复刻",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
@@ -118,7 +120,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", "pipeline"].includes(page)) return "projects";
if (["projectWizard", "freeCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory";
}
@@ -161,6 +163,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 === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
if (path === "/image-optimize") {
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash };
}
@@ -204,6 +207,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
return "/asset-factory";
case "freeCreate":
return "/free-create";
case "videoRemix":
return "/video-remix";
case "imageOptimize":
return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize";
case "modelPhoto":
+324
View File
@@ -0,0 +1,324 @@
// 视频复刻 · 上传参考视频 → 视频提炼接口 → 可编辑提示词。
// 拆解跟生成脚本同一套接口口径:模型下拉 + Gemini 3.1 Pro 官转看图。
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, ArrowRight, FileVideo, Save, ScanLine, ScanSearch } 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";
type RemixDraft = {
prompt: string;
duration: number;
shots: number;
rhythm: string;
fileName: string;
fileSize: number;
modelId: string;
};
function loadDraft(): RemixDraft | null {
try {
const raw = localStorage.getItem(REMIX_DRAFT_KEY);
if (!raw) return null;
const draft = JSON.parse(raw) as RemixDraft;
if (typeof draft.prompt !== "string" || !draft.prompt.trim()) return null;
return draft;
} catch {
return null;
}
}
function saveDraft(draft: RemixDraft) {
try {
localStorage.setItem(REMIX_DRAFT_KEY, JSON.stringify(draft));
localStorage.setItem(REMIX_PROMPT_KEY, draft.prompt);
} catch {
/* 隐私模式写满时静默,内存态仍可用 */
}
}
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";
}
function pickDigestModel(models: ModelConfig[]) {
const active = models.filter((model) => model.status === "active" && isGemini31(model));
return (
active.find((model) => /官转/.test(model.display_name))
|| active.find((model) => model.name === "gemini-3.1-pro-preview")
|| active[0]
|| null
);
}
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
textModels?: ModelConfig[];
onNotify: (type: "success" | "error" | "info", text: string) => void;
onBack: () => void;
navigate: NavigateFn;
}) {
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [draft] = useState(loadDraft);
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 [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 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));
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]);
useEffect(() => {
if (!prompt.trim()) return;
saveDraft({
prompt,
duration,
shots,
rhythm,
fileName: file?.name || fileName,
fileSize: file?.size || fileSize,
modelId: activeModelId,
});
}, [prompt, duration, shots, rhythm, file, fileName, fileSize, activeModelId]);
const pickFile = (next: File | null) => {
if (!next) return;
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
return;
}
if (next.size > VIDEO_DIGEST_MAX_BYTES) {
onNotify("error", "视频不能超过 200MB,请压缩后再传");
return;
}
setFile(next);
setFileName(next.name);
setFileSize(next.size);
};
const analyze = async () => {
if (!file || busy) return;
setBusy(true);
try {
const fd = new FormData();
fd.append("file", file);
if (activeModelId) fd.append("model_config_id", activeModelId);
const digest = await api.extractVideoDigest(fd);
const text = digest.text.trim();
setPrompt(text);
setDuration(digest.duration);
setShots(shotCount(text, digest.frames));
setRhythm(rhythmCopy(text));
setFileName(file.name);
setFileSize(file.size);
onNotify("success", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧,请先逐镜核对`);
} catch (error) {
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
} finally {
setBusy(false);
}
};
const savePrompt = async () => {
const text = prompt.trim();
if (!text) return;
sessionStorage.setItem(REMIX_PROMPT_KEY, text);
try {
await navigator.clipboard.writeText(text);
onNotify("success", "提示词已保存,可继续生成或直接粘贴");
} catch {
onNotify("success", "提示词已保存,可继续生成视频");
}
};
const continueGenerate = () => {
const text = prompt.trim();
if (!text) return;
sessionStorage.setItem(REMIX_PROMPT_KEY, text);
navigate("freeCreate");
};
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>
</button>
</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>
</div>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
/* 视频复刻页 · 两栏工作台。不重写共享 .btn/.pill/.input/.chip/.textarea。 */
.vr-page {
width: 100%;
max-width: none;
box-sizing: border-box;
}
.vr-page .vr-inner {
width: min(1320px, 100%);
margin: 0 auto;
}
.vr-page .vr-title-row {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.vr-page .vr-back {
width: 36px;
height: 36px;
padding: 0;
flex: 0 0 auto;
margin-top: 2px;
}
.vr-page .vr-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
gap: 16px;
}
.vr-page .vr-panel,
.vr-page .vr-result {
position: relative;
padding: 22px 24px;
border-radius: var(--r-md);
background: var(--surface);
}
.vr-page .vr-panel::before,
.vr-page .vr-result::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--border-faint);
border-radius: inherit;
pointer-events: none;
}
.vr-page .vr-lead {
margin: 0 0 18px;
color: var(--black-alpha-56);
font-size: 13px;
line-height: 1.65;
}
.vr-page .vr-step { margin-top: 4px; }
.vr-page .vr-step-h {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.vr-page .vr-step-h strong {
font-size: 13px;
font-weight: 500;
color: var(--accent-black);
}
.vr-page .vr-step-h span {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: .04em;
}
.vr-page .vr-upload {
min-height: 148px;
display: grid;
place-items: center;
padding: 22px;
border: 1px dashed var(--border-muted);
border-radius: var(--r-md);
color: var(--black-alpha-56);
background: var(--background-lighter);
text-align: center;
cursor: pointer;
}
.vr-page .vr-upload:hover,
.vr-page .vr-upload.has-file {
border-color: var(--heat-40);
background: var(--heat-12);
color: var(--heat);
}
.vr-page .vr-upload > span {
display: grid;
place-items: center;
gap: 8px;
min-width: 0;
}
.vr-page .vr-upload svg {
width: 22px;
height: 22px;
}
.vr-page .vr-upload strong {
color: var(--accent-black);
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.vr-page .vr-upload small {
color: var(--black-alpha-48);
font-size: 12px;
}
.vr-page .vr-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 22px;
}
.vr-page .vr-meta {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.vr-page .vr-meta > span {
color: var(--black-alpha-48);
font-size: 12px;
}
.vr-page .vr-placeholder,
.vr-page .vr-analysis { min-height: 360px; }
.vr-page .vr-placeholder {
display: grid;
place-items: center;
color: var(--black-alpha-48);
text-align: left;
}
.vr-page .vr-placeholder > div {
display: grid;
justify-items: start;
gap: 8px;
max-width: 260px;
}
.vr-page .vr-placeholder svg {
width: 22px;
height: 22px;
color: var(--black-alpha-32);
}
.vr-page .vr-placeholder strong {
font-size: 14px;
font-weight: 500;
color: var(--accent-black);
}
.vr-page .vr-placeholder span {
font-size: 13px;
line-height: 1.65;
color: var(--black-alpha-56);
}
.vr-page .vr-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin: 0 0 16px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
}
.vr-page .vr-summary span {
padding: 12px 14px;
color: var(--black-alpha-48);
background: var(--background-lighter);
font-size: 11px;
}
.vr-page .vr-summary span + span {
border-left: 1px solid var(--border-faint);
}
.vr-page .vr-summary strong {
display: block;
margin-top: 4px;
color: var(--accent-black);
font-size: 14px;
font-weight: 500;
}
.vr-page .vr-prompt {
min-height: 174px;
}
.vr-page .vr-analysis .vr-actions {
justify-content: flex-end;
}
@media (max-width: 1100px) {
.vr-page .vr-grid { grid-template-columns: 1fr; }
.vr-page .vr-summary { grid-template-columns: 1fr; }
.vr-page .vr-summary span + span { border-left: 0; border-top: 1px solid var(--border-faint); }
.vr-page .vr-actions { flex-wrap: wrap; }
}