优化视频复刻和商品内容大小

This commit is contained in:
Azmat@qq.com
2026-08-28 18:42:28 +08:00
parent a618f02315
commit 6b6146e595
20 changed files with 1557 additions and 382 deletions
@@ -32,6 +32,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [tab, setTab] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
@@ -42,6 +43,11 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setAssets(res.results);
setCount(res.count);
if (!silent) setSelected(new Set());
else {
const ids = new Set(res.results.map((a) => a.id));
setSelected((s) => new Set([...s].filter((id) => ids.has(id))));
}
} catch {
if (!silent) notify("error", "加载审核队列失败");
} finally {
@@ -71,15 +77,27 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
};
}, [load]);
async function retry(id: string) {
if (busy) return;
function toggleOne(id: string) {
setSelected((s) => {
const next = new Set(s);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function toggleAll() {
setSelected((s) => (s.size === assets.length ? new Set() : new Set(assets.map((a) => a.id))));
}
async function submit(ids: string[]) {
if (busy || ids.length === 0) return;
setBusy(true);
try {
await adminApi.submitReviews([id]);
notify("success", "已重新送审");
const res = await adminApi.submitReviews(ids);
notify("success", `已提交 ${res.submitted} 个资产送审`);
setSelected(new Set());
await load({ silent: true });
} catch {
notify("error", "重试失败");
notify("error", "送审失败");
} finally {
setBusy(false);
}
@@ -113,7 +131,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">{count} </span> · </div>
<div className="sub"><span className="mono">{count} </span> · </div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={busy} onClick={() => void poll()}>
@@ -139,6 +157,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<table className="t admin-table">
<thead>
<tr>
<th className="col-check"><input type="checkbox" checked={selected.size === assets.length && assets.length > 0} onChange={toggleAll} aria-label="全选" /></th>
<th></th>
<th></th>
<th></th>
@@ -149,6 +168,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<tbody>
{assets.map((a) => (
<tr key={a.id}>
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
<td>
{a.preview_url
? (
@@ -171,11 +191,11 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
{a.review_status === "failed" && a.review_error && <span className="admin-review-err mono" title={a.review_error}>!</span>}
</td>
<td className="col-actions">
{a.review_status === "failed" ? (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void retry(a.id)}>
{(a.review_status === "failed" || a.review_status === "") && (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void submit([a.id])}>
{a.review_status === "failed" ? "重试" : "送审"}
</button>
) : null}
)}
</td>
</tr>
))}
@@ -185,6 +205,14 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
</div>
)}
{selected.size > 0 && (
<div className="admin-bulk-bar" role="toolbar" aria-label="批量送审">
<span className="admin-bulk-count"> {selected.size} </span>
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}></button>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}></button>
</div>
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</>
);
+16 -16
View File
@@ -54,7 +54,7 @@ const REPLACE_MODE_COPY = {
targetLabel: "商品",
targetStep: "2. 选择自己的商品",
videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的商品",
videoReady: "视频已就绪,将先提炼分镜稿再复刻",
videoReady: "视频已就绪,将先提炼分镜稿再逐镜复刻",
libraryTitle: "从商品库选择",
libraryEmpty: "选择已创建的商品",
temporaryTitle: "临时上传素材",
@@ -62,9 +62,9 @@ const REPLACE_MODE_COPY = {
temporaryNoun: "商品图",
temporaryFallback: "临时商品素材",
generatingTitle: "正在进行商品复刻",
generatingCopy: "正在照着分镜稿还原镜头,并换上你的商品",
generatingCopy: "正在按分镜一镜一镜还原,并换上你的商品",
reviewingTitle: "正在审核参考素材",
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
reviewingCopy: "参考图需先通过合规审核,通过后自动开始复刻",
digestingTitle: "正在提炼参考视频",
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
resultTitle: "商品复刻已完成",
@@ -80,8 +80,8 @@ const REPLACE_MODE_COPY = {
modeLabel: "角色复刻",
targetLabel: "角色",
targetStep: "2. 选择自己的角色",
videoEmpty: "系统将自动识别需要替换的原片角色",
videoReady: "视频已就绪,将自动识别原片角色",
videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的角色",
videoReady: "视频已就绪,将先提炼分镜稿再逐镜复刻",
libraryTitle: "从人物库选择",
libraryEmpty: "选择已创建的人物",
temporaryTitle: "临时上传角色",
@@ -89,9 +89,9 @@ const REPLACE_MODE_COPY = {
temporaryNoun: "角色参考图",
temporaryFallback: "临时角色素材",
generatingTitle: "正在进行角色复刻",
generatingCopy: "正在匹配角色外观、表情与原片动作",
generatingCopy: "正在按分镜一镜一镜还原,并换上你的角色",
reviewingTitle: "正在审核参考素材",
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
reviewingCopy: "参考图需先通过合规审核,通过后自动开始复刻",
digestingTitle: "正在提炼参考视频",
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
resultTitle: "角色复刻已完成",
@@ -365,10 +365,6 @@ export function VideoReplacePage({
resolution: "720p",
duration: outputDuration,
refs: [
// 商品复刻的参考视频只用来提炼分镜稿,不会发给火山,算进估价会让显示积分高于实际扣费。
...(videoRef && replaceMode === "character"
? [{ type: "video", duration: videoRef.duration || videoMeta.duration }]
: []),
...((source === "library"
? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const }))
: (tempFiles.length ? tempFiles : tempAssetRefs)
@@ -379,10 +375,14 @@ export function VideoReplacePage({
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
const generating = Boolean(job && isInFlight(job.status)) || submitting;
const digesting = Boolean(job && isDigesting(job));
// 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。
const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created")));
const digesting = Boolean((job && isDigesting(job)) || (submitting && !job));
const reviewing = !digesting && Boolean(job && (job.review_stage === "reviewing" || job.status === "created"));
const hasResult = Boolean(job && job.status === "succeeded" && job.video_url);
const shotTotal = Number(job?.shot_total || 0);
const shotIndex = Number(job?.shot_index || 0);
const generatingCopy = shotTotal > 1 && shotIndex > 0
? `正在生成第 ${shotIndex}/${shotTotal} 镜,逐镜还原后再拼成完整片子`
: copy.generatingCopy;
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
const panelClass = [
"video-result-panel replace-result-panel",
@@ -392,7 +392,7 @@ export function VideoReplacePage({
hasResult ? "has-result" : "",
].filter(Boolean).join(" ");
const generateLabel = generating
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : "正在复刻…")
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…"))
: hasResult
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
@@ -1121,7 +1121,7 @@ export function VideoReplacePage({
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
</div>
<strong>{digesting ? copy.digestingTitle : reviewing ? copy.reviewingTitle : copy.generatingTitle}</strong>
<span>{digesting ? copy.digestingCopy : reviewing ? copy.reviewingCopy : copy.generatingCopy}</span>
<span>{digesting ? copy.digestingCopy : reviewing ? copy.reviewingCopy : generatingCopy}</span>
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>