完善视频提炼和视频复刻完善提交测试
This commit is contained in:
@@ -3456,3 +3456,10 @@
|
||||
.yz-image .result-panel { height: auto; min-height: 420px; }
|
||||
.yz-image .studio-layout { grid-template-columns: 1fr; height: auto; }
|
||||
}
|
||||
|
||||
/* 参考图可直接拖进输入区 */
|
||||
.yz-image .image-composer.is-dragover {
|
||||
outline: 1.5px dashed var(--heat);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--r-md, 8px);
|
||||
}
|
||||
|
||||
@@ -127,11 +127,14 @@ export function setRemember(username: string | null, remember: boolean) {
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
/** 解析成功的响应体。409 这类「冲突」要把在跑的任务带回给调用方接上,只有 message 不够用。 */
|
||||
payload?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
constructor(status: number, message: string, payload?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,8 +172,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const text = await response.text();
|
||||
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
|
||||
let message = text || `${response.status} ${response.statusText}`;
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
try {
|
||||
const data = JSON.parse(text) as Record<string, unknown>;
|
||||
payload = data;
|
||||
const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0];
|
||||
if (typeof first === "string") message = first;
|
||||
else if (Array.isArray(first) && typeof first[0] === "string") message = first[0];
|
||||
@@ -191,7 +196,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
if (response.status === 413) {
|
||||
message = "文件太大,服务器没接收。视频请压到 200MB 以内再传";
|
||||
}
|
||||
throw new ApiError(response.status, message);
|
||||
throw new ApiError(response.status, message, payload);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
@@ -379,7 +384,10 @@ export const api = {
|
||||
return request<QuickCreateJob>(`/api/projects/quick-create-retry/${jobId}/`, { method: "POST" });
|
||||
},
|
||||
quickCreateHistory() {
|
||||
return request<{ count: number; results: QuickCreateJob[] }>("/api/projects/quick-create-history/");
|
||||
// inflight = 正在跑的那条:刷新/重进页面据此恢复进度,不再显示成空表单
|
||||
return request<{ count: number; results: QuickCreateJob[]; inflight?: QuickCreateJob | null }>(
|
||||
"/api/projects/quick-create-history/"
|
||||
);
|
||||
},
|
||||
// 整体替换 metadata —— 调用方务必先展开现有 project.metadata 再合并,别把别的 key 冲掉
|
||||
updateProject(id: string, payload: { name?: string; metadata?: Record<string, unknown> }) {
|
||||
@@ -932,7 +940,8 @@ export const api = {
|
||||
return request<{ task: FreeVideoTask }>("/api/ai/video-replace/", { method: "POST", body: JSON.stringify(payload) });
|
||||
},
|
||||
videoReplaceTasks(offset = 0, pageSize = 20) {
|
||||
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean }>(
|
||||
// inflight 只在首页返回:刷新/重进页面据此恢复「进行中」,不用自己在列表里翻状态
|
||||
return request<{ results: FreeVideoTask[]; total: number; has_more: boolean; inflight?: FreeVideoTask | null }>(
|
||||
`/api/ai/video-replace/?offset=${offset}&page_size=${pageSize}`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
|
||||
import { api } from "../../api";
|
||||
import { useFileDrop } from "../use-file-drop";
|
||||
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
|
||||
import { useBodyScrollLock } from "../overlays";
|
||||
|
||||
@@ -131,6 +132,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
}
|
||||
};
|
||||
|
||||
// 素材库弹窗:整块 body 都能接住拖进来的文件。在组内传素材,在组外快速建图。
|
||||
const libDrop = useFileDrop(
|
||||
(files) => {
|
||||
const file = files[0];
|
||||
if (!file) return;
|
||||
if (activeGroup) void uploadAsset(file);
|
||||
else void quickUploadImage(file);
|
||||
},
|
||||
{ disabled: uploading }
|
||||
);
|
||||
|
||||
const quickUploadImage = async (file: File) => {
|
||||
setQuickUploading(true);
|
||||
try {
|
||||
@@ -278,7 +290,11 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
|
||||
</div>
|
||||
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-b fc-lib-body" onClick={() => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}>
|
||||
<div
|
||||
className={`modal-b fc-lib-body${libDrop.dragging ? " is-dragover" : ""}`}
|
||||
{...libDrop.dropProps}
|
||||
onClick={() => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}
|
||||
>
|
||||
{!activeGroup ? (
|
||||
<>
|
||||
<div className="fc-lib-toolbar">
|
||||
|
||||
@@ -137,7 +137,7 @@ function probeImage(file: File): Promise<FileCheck> {
|
||||
});
|
||||
}
|
||||
|
||||
function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
|
||||
function probeMedia(file: File, kind: "video" | "audio", maxSeconds = MAX_VIDEO_TOTAL_SECONDS): Promise<FileCheck> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const el = document.createElement(kind);
|
||||
@@ -146,24 +146,34 @@ function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
|
||||
URL.revokeObjectURL(url);
|
||||
const duration = el.duration;
|
||||
if (!isFinite(duration)) resolve({ ok: true, type: kind });
|
||||
else if (duration < 2 - 0.05 || duration > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) {
|
||||
resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` });
|
||||
} else resolve({ ok: true, type: kind, duration: Math.min(MAX_VIDEO_TOTAL_SECONDS, Math.round(duration * 10) / 10) });
|
||||
else if (duration < 2 - 0.05 || duration > maxSeconds + VIDEO_DURATION_SLACK) {
|
||||
resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-${maxSeconds} 秒之间` });
|
||||
} else resolve({ ok: true, type: kind, duration: Math.min(maxSeconds, Math.round(duration * 10) / 10) });
|
||||
};
|
||||
el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); };
|
||||
el.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkRefFile(file: File): Promise<FileCheck> {
|
||||
/** 参考素材前置校验。
|
||||
* 默认按自由创作那套(15 秒 / 50MB)——参考视频要直传火山,是火山的硬限制。
|
||||
* 视频复刻·商品这类「视频只喂给提炼模型、不进火山」的入口,用 options 放宽。 */
|
||||
export async function checkRefFile(
|
||||
file: File,
|
||||
options?: { maxSeconds?: number; maxVideoBytes?: number }
|
||||
): Promise<FileCheck> {
|
||||
const type = (file.type || "").toLowerCase();
|
||||
const maxSeconds = options?.maxSeconds ?? MAX_VIDEO_TOTAL_SECONDS;
|
||||
const maxVideoBytes = options?.maxVideoBytes ?? VIDEO_MAX_BYTES;
|
||||
if (IMAGE_TYPES.includes(type)) {
|
||||
if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" };
|
||||
return probeImage(file);
|
||||
}
|
||||
if (VIDEO_TYPES.includes(type)) {
|
||||
if (file.size > VIDEO_MAX_BYTES) return { ok: false, error: "视频大小不能超过 50MB" };
|
||||
return probeMedia(file, "video");
|
||||
if (file.size > maxVideoBytes) {
|
||||
return { ok: false, error: `视频大小不能超过 ${Math.round(maxVideoBytes / 1024 / 1024)}MB` };
|
||||
}
|
||||
return probeMedia(file, "video", maxSeconds);
|
||||
}
|
||||
if (AUDIO_TYPES.includes(type)) {
|
||||
if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { CSSProperties } from "react";
|
||||
import { api } from "../api";
|
||||
import { useFileDrop } from "./use-file-drop";
|
||||
import type { Asset, ModelEntity } from "../types";
|
||||
import { useBodyScrollLock, MediaLightbox } from "./overlays";
|
||||
|
||||
@@ -94,6 +95,11 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
// 本地上传 → 直接作为选中立绘进右侧栏
|
||||
const candidateDrop = useFileDrop(
|
||||
(files) => { void uploadCandidate(files[0]); },
|
||||
{ disabled: busy, accept: (f) => f.type.startsWith("image/") }
|
||||
);
|
||||
|
||||
async function uploadCandidate(file?: File | null) {
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
@@ -156,9 +162,9 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
|
||||
) : (
|
||||
<div className="actorlib-studio-upload">
|
||||
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 上传本地模特形象图 → 右侧命名并保存</div>
|
||||
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
||||
<div className={`actorlib-drop${candidateDrop.dragging ? " is-dragover" : ""}`} {...candidateDrop.dropProps} role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地模特图片上传"}</div>
|
||||
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : candidateDrop.dragging ? "松开即可上传" : "点击或拖拽本地模特图片上传"}</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>JPG / PNG / WEBP</div>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
/** 全站上传区共用的拖拽投放。各页只管拿 dragging 加自己的高亮类,逻辑不再各写一份。 */
|
||||
export function useFileDrop(
|
||||
onFiles: (files: File[]) => void,
|
||||
options?: { disabled?: boolean; accept?: (file: File) => boolean }
|
||||
) {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
// 拖过子元素时浏览器会连发 dragenter/dragleave,只看 leave 会让高亮疯狂闪。用进出计数兜住。
|
||||
const depth = useRef(0);
|
||||
const disabled = Boolean(options?.disabled);
|
||||
const accept = options?.accept;
|
||||
|
||||
const reset = useCallback(() => {
|
||||
depth.current = 0;
|
||||
setDragging(false);
|
||||
}, []);
|
||||
|
||||
const hasFiles = (event: React.DragEvent) =>
|
||||
Array.from(event.dataTransfer?.types || []).includes("Files");
|
||||
|
||||
const onDragEnter = useCallback((event: React.DragEvent) => {
|
||||
if (disabled || !hasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
depth.current += 1;
|
||||
setDragging(true);
|
||||
}, [disabled]);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
if (disabled || !hasFiles(event)) return;
|
||||
// 不 preventDefault 浏览器会把文件当导航打开,drop 根本不触发
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
||||
}, [disabled]);
|
||||
|
||||
const onDragLeave = useCallback((event: React.DragEvent) => {
|
||||
if (disabled) return;
|
||||
event.preventDefault();
|
||||
depth.current = Math.max(0, depth.current - 1);
|
||||
if (depth.current === 0) setDragging(false);
|
||||
}, [disabled]);
|
||||
|
||||
const onDrop = useCallback((event: React.DragEvent) => {
|
||||
if (disabled) return;
|
||||
event.preventDefault();
|
||||
reset();
|
||||
const files = Array.from(event.dataTransfer?.files || []);
|
||||
const picked = accept ? files.filter(accept) : files;
|
||||
if (picked.length) onFiles(picked);
|
||||
}, [disabled, accept, onFiles, reset]);
|
||||
|
||||
return { dragging, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } };
|
||||
}
|
||||
@@ -1052,3 +1052,10 @@
|
||||
.fc-player-nav.prev { left: 6px; }
|
||||
.fc-player-nav.next { right: 6px; }
|
||||
}
|
||||
|
||||
/* 素材库弹窗:整块 body 接住拖进来的文件 */
|
||||
.fc-lib-body.is-dragover {
|
||||
outline: 1.5px dashed var(--heat);
|
||||
outline-offset: -8px;
|
||||
background: var(--heat-8);
|
||||
}
|
||||
|
||||
@@ -338,3 +338,16 @@
|
||||
.model-detail-b { grid-template-columns: 1fr; padding: 20px; }
|
||||
.model-detail-left { width: min(100%, 320px); margin: 0 auto; }
|
||||
}
|
||||
|
||||
/* 拖图进列表即添加模特 */
|
||||
.models-page .ml-grid.is-dragover {
|
||||
outline: 1.5px dashed var(--heat);
|
||||
outline-offset: 8px;
|
||||
border-radius: var(--r-md, 8px);
|
||||
}
|
||||
|
||||
/* 模特详情:拖图替换形象图 */
|
||||
.model-detail-portrait.is-dragover {
|
||||
outline: 1.5px solid var(--heat);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -2187,3 +2187,10 @@
|
||||
.chat-model-menu.chip-menu .mi.selected .mi-check {
|
||||
color: var(--klein) !important;
|
||||
}
|
||||
|
||||
/* 模特立绘上传区:拖拽投放高亮 */
|
||||
.actorlib-drop.is-dragover {
|
||||
border-style: solid;
|
||||
border-color: var(--heat);
|
||||
background: var(--heat-8);
|
||||
}
|
||||
|
||||
@@ -939,3 +939,11 @@
|
||||
.task-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
}
|
||||
|
||||
/* 拖拽投放高亮:实线 + 主橙,和 hover 区分 */
|
||||
.img-upload.is-dragover {
|
||||
border-style: solid;
|
||||
border-color: var(--heat);
|
||||
color: var(--heat);
|
||||
background: var(--heat-8);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,18 @@
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-panel { opacity: .82; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-form-footer { pointer-events: auto; opacity: 1; }
|
||||
.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); display: none; }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
|
||||
/* 读取任务状态:确认完之前不给看空表单,左侧整块也不能操作 —— 否则用户以为没任务又提交一次 */
|
||||
.quick-create-page .quick-create-shell.is-restoring .quick-state-restoring { display: grid; }
|
||||
.quick-create-page .quick-create-shell.is-restoring .quick-state-ready,
|
||||
.quick-create-page .quick-create-shell.is-restoring .quick-state-generating,
|
||||
.quick-create-page .quick-create-shell.is-restoring .quick-state-complete,
|
||||
.quick-create-page .quick-create-shell.is-restoring .quick-state-failed { display: none; }
|
||||
.quick-create-page .quick-state-restoring { width: min(460px,100%); justify-items: center; text-align: center; gap: 14px; }
|
||||
.quick-create-page .quick-state-restoring h2 { margin: 0; font-size: 23px; }
|
||||
.quick-create-page .quick-state-restoring p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-restoring-spinner { width: 34px; height: 34px; border-radius: 50%; border: 2px solid rgba(0,47,167,.16); border-top-color: var(--quick-blue); animation: quick-spinner-rotate 0.8s linear infinite; }
|
||||
.quick-create-page .quick-form-panel.is-locked { pointer-events: none; opacity: .55; filter: saturate(.85); transition: opacity 160ms ease; }
|
||||
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-state-ready,.quick-create-page .quick-create-shell.is-complete .quick-state-ready,.quick-create-page .quick-create-shell.is-failed .quick-state-ready { display: none; }
|
||||
.quick-create-page .quick-create-shell.is-generating .quick-state-generating { display: grid; }
|
||||
.quick-create-page .quick-create-shell.is-complete .quick-state-complete { display: grid; }
|
||||
@@ -149,3 +161,10 @@
|
||||
@media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } }
|
||||
@media (max-width: 980px) { .quick-create-page { padding-inline: 18px; }.quick-create-page .quick-create-shell { grid-template-columns: 1fr; }.quick-create-page .quick-create-panel { min-height: 620px; }.quick-create-page .quick-result-actions { grid-template-columns: 1fr; } }
|
||||
@media (max-width: 620px) { .quick-create-page .quick-parameter-grid { grid-template-columns: 1fr; }.quick-create-page .quick-upload-filled { width: min(100%,240px); grid-template-columns: 1fr; }.quick-create-page .quick-upload-more { min-height: 148px; } }
|
||||
|
||||
/* 拖拽投放高亮 */
|
||||
.quick-upload.is-dragover {
|
||||
border-style: solid;
|
||||
border-color: var(--heat);
|
||||
background: var(--heat-8);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConfig, ModelEntity, Product, WorkbenchTask } from "../types";
|
||||
import { api } from "../api";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import { imageModelPickerOptions } from "../model-display";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { SkeletonRows, SystemLoading } from "../components/loading";
|
||||
@@ -711,14 +712,19 @@ export function ImageWorkbenchPage({
|
||||
// YYX#14:无真封面时回退到按商品名匹配的 mock 图,与商品库一致显图,不再露灰占位
|
||||
return p.cover_preview_url || primary?.preview_url || productMockCoverUrl(p.title);
|
||||
};
|
||||
function pickReference(event: ChangeEvent<HTMLInputElement>) {
|
||||
const files = Array.from(event.target.files || []);
|
||||
function acceptReferences(files: File[]) {
|
||||
if (!files.length) return;
|
||||
// 追加到已选(支持多次点 + 累加),逐张生成本地预览;file 留着提交时上传
|
||||
setRefImages((prev) => [...prev, ...files.map((f) => ({ name: f.name, url: URL.createObjectURL(f), file: f }))]);
|
||||
}
|
||||
|
||||
function pickReference(event: ChangeEvent<HTMLInputElement>) {
|
||||
acceptReferences(Array.from(event.target.files || []));
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
const refDrop = useFileDrop(acceptReferences, { accept: (f) => f.type.startsWith("image/") });
|
||||
|
||||
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
|
||||
// 团队价格系数(差异化调价):预估所见即所扣;拉不到按标准价 1
|
||||
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||
@@ -1741,7 +1747,7 @@ export function ImageWorkbenchPage({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="image-composer">
|
||||
<div className={`image-composer${refDrop.dragging ? " is-dragover" : ""}`} {...refDrop.dropProps}>
|
||||
<div className="image-composer-main">
|
||||
<div>
|
||||
<button type="button" className="image-reference-button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChangeEvent, CSSProperties, KeyboardEvent } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, RefreshCw, Settings2, Trash2, Upload, User, UserPlus, X } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import { generationErrorText } from "../generation-error";
|
||||
import type { ModelEntity } from "../types";
|
||||
import { SystemLoading } from "../components/loading";
|
||||
@@ -120,6 +121,11 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
const portraitDrop = useFileDrop(
|
||||
(files) => selectPortrait(files[0]),
|
||||
{ disabled: saving, accept: (f) => f.type.startsWith("image/") }
|
||||
);
|
||||
|
||||
function selectPortrait(file?: File) {
|
||||
if (!file || currentModel.is_official || saving) return;
|
||||
clearPendingPortrait();
|
||||
@@ -203,7 +209,8 @@ function ModelDetailModal({ model, close, onZoom, onSaved, onNotify, onBillingCh
|
||||
</div>
|
||||
<input ref={portraitFileRef} type="file" accept="image/*" hidden onChange={(event) => { const file = event.target.files?.[0]; event.target.value = ""; selectPortrait(file); }} />
|
||||
<div
|
||||
className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}`}
|
||||
className={`placeholder model-detail-portrait${portraitUrl ? " has-mock-media" : ""}${portraitDrop.dragging ? " is-dragover" : ""}`}
|
||||
{...(model.is_official ? {} : portraitDrop.dropProps)}
|
||||
style={portraitUrl ? mediaStyle(portraitUrl) : undefined}
|
||||
role={portraitUrl ? "button" : undefined}
|
||||
tabIndex={portraitUrl ? 0 : undefined}
|
||||
@@ -307,9 +314,18 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
|
||||
useEffect(() => { setSelected(new Set()); }, [tab]);
|
||||
|
||||
const modelDrop = useFileDrop(
|
||||
(files) => { void acceptModelFile(files[0]); },
|
||||
{ disabled: uploading, accept: (f) => f.type.startsWith("image/") }
|
||||
);
|
||||
|
||||
async function onPick(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
await acceptModelFile(file);
|
||||
}
|
||||
|
||||
async function acceptModelFile(file?: File | null) {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
@@ -357,7 +373,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
</button>
|
||||
<button className="ml-primary" type="button" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||
<UserPlus />
|
||||
<span>{uploading ? "上传中…" : "添加模特"}</span>
|
||||
<span>{uploading ? "上传中…" : modelDrop.dragging ? "松开即可添加" : "添加模特"}</span>
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onPick} />
|
||||
</div>
|
||||
@@ -388,7 +404,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
<span>点右上「添加模特」上传一张形象图,或在图片/视频流程里生成</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-grid">
|
||||
<div className={`ml-grid${modelDrop.dragging ? " is-dragover" : ""}`} {...modelDrop.dropProps}>
|
||||
{items.map((m) => {
|
||||
const selectable = !m.is_official;
|
||||
const isSelected = selected.has(m.id);
|
||||
|
||||
@@ -888,9 +888,33 @@ export function PipelinePage(props: {
|
||||
delBusy(busyKey);
|
||||
}
|
||||
}
|
||||
// 流程步骤4 · 生成人物立绘(App 层自动接力三视图)
|
||||
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string) {
|
||||
return await genBaseAsset("person", prompt, label, busyKey);
|
||||
// 流程步骤4 · 生成人物立绘 → 出图后自动接力三视图(用户不必再进详情弹窗手点「AI 生成三视图」;
|
||||
// 弹窗里那个按钮保留,用于重跑 / 补生成)。后端 generate-base-asset 已带 auto_triview 自动接力,
|
||||
// 这里只做「保持转圈 + 兜底」:立绘落库后先确认后端已把三视图任务排上,排上了就把 loading 交给
|
||||
// pending-assets 轮询;真没排上(旧后端 / 接力异常)才补发一次,避免重复出图重复扣费。
|
||||
async function genPersonPortrait(prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string) {
|
||||
const res = await genBaseAsset("person", prompt, label, busyKey, referenceAssetId);
|
||||
const portraitId = res?.adopted_asset || "";
|
||||
if (!portraitId) return res;
|
||||
// 立绘的 busy 已在 genBaseAsset 里落回,这里立刻把三视图的 busy 顶上,卡片不闪「已就绪」
|
||||
const triKeys = [...new Set([busyKey, ...entityGenKeys("person", label)])].map((k) => `${k}:tri`);
|
||||
triKeys.forEach(addBusy);
|
||||
try {
|
||||
let chained = false;
|
||||
for (let i = 0; i < 3 && !chained; i += 1) {
|
||||
try {
|
||||
const pending = (await api.pendingAssets(project.id)).pending || [];
|
||||
chained = pending.some((p) => p.is_triview && p.triview_of === portraitId);
|
||||
} catch {
|
||||
/* 网络抖动:下一轮再看 */
|
||||
}
|
||||
if (!chained && i < 2) await new Promise((resolve) => window.setTimeout(resolve, 3000));
|
||||
}
|
||||
if (!chained) await onGenerateTriview(portraitId);
|
||||
} finally {
|
||||
triKeys.forEach(delBusy);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// ── 流程步骤4 · 实体提取闸门(进资产趴入口):不自动花钱,用户点按钮才提取/生成 ──
|
||||
// idle=露三按钮 / running=露 loading;提取后 metadata.script_entities 落库、或已有资产 → 闸门由渲染层自动隐藏
|
||||
@@ -906,8 +930,7 @@ export function PipelinePage(props: {
|
||||
for (const e of entities) {
|
||||
const bk = `seed:${e.type === "character" ? "person" : "scene"}:${e.name}`;
|
||||
if (e.type === "character") {
|
||||
if (mode === "full") await genPersonPortrait(e.visual_prompt, e.name, bk);
|
||||
else await genBaseAsset("person", e.visual_prompt, e.name, bk);
|
||||
await genPersonPortrait(e.visual_prompt, e.name, bk); // 立绘出完自动接力三视图
|
||||
} else {
|
||||
await genBaseAsset("scene", e.visual_prompt, e.name, bk);
|
||||
}
|
||||
@@ -2944,6 +2967,7 @@ export function PipelinePage(props: {
|
||||
if (activeDot !== 2 && viewStage !== 2) return;
|
||||
let stopped = false;
|
||||
let timer = 0;
|
||||
let idleTicks = 0; // 连续「无在途」次数:立绘刚出片、后端接力的三视图任务可能晚半拍才入库,别一空就停
|
||||
const tick = async () => {
|
||||
if (document.hidden) { if (!stopped) timer = window.setTimeout(tick, 4000); return; } // 后台不空跑(纯读),保留心跳回前台即恢复
|
||||
try {
|
||||
@@ -2961,7 +2985,9 @@ export function PipelinePage(props: {
|
||||
prevPendingIdsRef.current = list.map((pending) => pending.id);
|
||||
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview, triview_of: p.triview_of })));
|
||||
// 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。
|
||||
if (list.length === 0 && genBusy.size === 0) { stopped = true; return; }
|
||||
// 连空两轮(~8s)才停:立绘落库瞬间后端才接力建三视图任务,一空就停会把「三视图生成中」整条漏掉。
|
||||
idleTicks = list.length === 0 && genBusy.size === 0 ? idleTicks + 1 : 0;
|
||||
if (idleTicks >= 2) { stopped = true; return; }
|
||||
} catch {
|
||||
/* 忽略,下一轮再试 */
|
||||
}
|
||||
@@ -3722,7 +3748,7 @@ export function PipelinePage(props: {
|
||||
{kind === "scene" ? <Image /> : <UsersRound />}
|
||||
<span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span>
|
||||
</button>
|
||||
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void genBaseAsset(kind, p, tag, seedKey); }}>
|
||||
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[seedKey] ?? tagPrompt(tag)).trim() || tagPrompt(tag); void (kind === "person" ? genPersonPortrait(p, tag, seedKey) : genBaseAsset(kind, p, tag, seedKey)); }}>
|
||||
<Sparkles /><span>{busy ? "生成中…" : "AI 生成"}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3738,7 +3764,6 @@ export function PipelinePage(props: {
|
||||
const portraitBusy = genKeys.some((k) => isBusy(k)) || (!mainUrl && pendingHas(kind, entity.name));
|
||||
const triBusy = kind === "person" && (genKeys.some((k) => isBusy(`${k}:tri`)) || pendingTriFor(kind, entity));
|
||||
const busy = portraitBusy || triBusy;
|
||||
const loadingText = kind === "person" && mainUrl ? "三视图生成中…" : "生成中…";
|
||||
const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
|
||||
const previewState = `${mainUrl ? " ready" : busy ? " generating" : " pending"}${busy ? " generating" : ""}`;
|
||||
return (
|
||||
@@ -3763,7 +3788,7 @@ export function PipelinePage(props: {
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}
|
||||
>
|
||||
{kind === "person" && mainUrl ? <img className="as-gen-photo" src={mainUrl} alt="" /> : null}
|
||||
{busy ? <span className={`asset-card-loading${mainUrl ? " veil" : ""}`}><span className="asset-spinner" aria-hidden="true"></span><span>{loadingText}</span></span> : null}
|
||||
{busy ? <span className={`asset-card-loading${mainUrl ? " veil" : ""}`}><span className="asset-spinner" aria-hidden="true"></span><span>生成中…</span></span> : null}
|
||||
</div>
|
||||
<div className="as-gen-meta">
|
||||
<h4 onClick={() => openAssetDetail(kind, entity)}>{entity.name}</h4>
|
||||
@@ -3789,7 +3814,7 @@ export function PipelinePage(props: {
|
||||
{kind === "scene" ? <Image /> : <UsersRound />}
|
||||
<span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span>
|
||||
</button>
|
||||
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}>
|
||||
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void (kind === "person" ? genPersonPortrait(p, entity.name, entBK, ref) : genBaseAsset(kind, p, entity.name, entBK)); }}>
|
||||
<Sparkles /><span>{busy ? "生成中…" : mainUrl ? "重新生成" : "AI 生成"}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -4743,7 +4768,8 @@ export function PipelinePage(props: {
|
||||
// 重跑立绘 → 追加新立绘并自动接力该版三视图。
|
||||
// 角色重跑:若该角色已有当前立绘,传它作参考图 → 后端走 image_edit 参考立绘+提示词,保持同一人物一致(不重抽随机人)。
|
||||
const ref = isPerson && viewPortraitAsset ? viewPortraitAsset : undefined;
|
||||
await genBaseAsset(isPerson ? "person" : "scene", prompt, entity!.name, pBK, ref);
|
||||
if (isPerson) await genPersonPortrait(prompt, entity!.name, pBK, ref);
|
||||
else await genBaseAsset("scene", prompt, entity!.name, pBK);
|
||||
setAdPortraitId(null); setAdTriId(null);
|
||||
}
|
||||
async function regenTri() {
|
||||
@@ -4770,7 +4796,7 @@ export function PipelinePage(props: {
|
||||
{/* 同三视图:大图随数据(portraitUrl)走,出图完成即显示,不被在途 busyPortrait 卡转圈 */}
|
||||
<div className={`placeholder ad-lead-img${portraitUrl ? " has-mock-media" : ""}`} style={portraitUrl ? mediaStyle(portraitUrl) : undefined}>
|
||||
{!portraitUrl && (busyPortrait
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">立绘生成中…</span></div>
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">生成中…</span></div>
|
||||
: <span className="ph-frame">立绘</span>)}
|
||||
</div>
|
||||
{portraitUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: portraitUrl, kind: "image", name: `${entity.name} · 立绘` })}>{zoomSvg}</button>}
|
||||
@@ -4805,7 +4831,7 @@ export function PipelinePage(props: {
|
||||
避免「缩略图已出、大图还在转圈」——busyTri 只在「还没任何结果」时显示生成中占位 */}
|
||||
<div className={`placeholder${triUrl ? " has-mock-media" : ""}`} style={triUrl ? mediaStyle(triUrl) : undefined}>
|
||||
{!triUrl && (busyTri
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">三视图生成中…</span></div>
|
||||
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame">生成中…</span></div>
|
||||
: <span className="ph-frame">正 / 侧 / 背 · 三视图</span>)}
|
||||
{triUrl && busyTri && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
|
||||
import { ArrowLeft, Check, ChevronDown, Grid2X2, List, PackagePlus, PackageX, Search, Settings2, Trash2, X } from "lucide-react";
|
||||
import { ConfirmModal, MediaLightbox, SuccessModal } from "../components/overlays";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import { ProductCreateDrawer } from "../components/product-create-drawer";
|
||||
import {
|
||||
@@ -786,9 +787,15 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
return () => { alive = false; };
|
||||
}, [product?.id, product.cover_asset, assetReload]);
|
||||
|
||||
const imgDrop = useFileDrop((files) => { void uploadProductImage(files[0]); }, { disabled: uploading });
|
||||
|
||||
async function onPickProductImage(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
await uploadProductImage(file);
|
||||
}
|
||||
|
||||
async function uploadProductImage(file?: File | null) {
|
||||
if (!file || !onUploadImage) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
@@ -1163,7 +1170,7 @@ export function ProductDetailPage({ product, projects, initialTab = "assets", na
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
|
||||
<div className={`img-upload${imgDrop.dragging ? " is-dragover" : ""}`} {...imgDrop.dropProps} id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
|
||||
{uploading ? (
|
||||
<span className="ph-frame" style={{ fontSize: 12 }}>上传中…</span>
|
||||
) : (
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, ApiError, type QuickCreateJob } from "../api";
|
||||
import { ConfirmModal, OverlayPortal, useBodyScrollLock } from "../components/overlays";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import { forgetQuickCreateJob, readQuickCreateJobId, rememberQuickCreateJob, stripQuickCreateSuffix } from "../quick-create-lock";
|
||||
import type { ModelConfig } from "../types";
|
||||
import {
|
||||
@@ -135,6 +136,9 @@ export function QuickCreatePage({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
// 进页面先向后端确认有没有在跑的任务。localStorage 可能被清、也可能换了浏览器,
|
||||
// 只认它会让刷新后看到一张空表单,用户以为没任务又提交一次 —— 那会重复建商品、重复扣费。
|
||||
const [restoring, setRestoring] = useState(true);
|
||||
const [serviceUnavailable, setServiceUnavailable] = useState(false);
|
||||
const [unavailableMessage, setUnavailableMessage] = useState("");
|
||||
const [pollEpoch, setPollEpoch] = useState(0);
|
||||
@@ -183,8 +187,19 @@ export function QuickCreatePage({
|
||||
}))
|
||||
.catch(() => undefined);
|
||||
void api.quickCreateHistory()
|
||||
.then((payload) => setHistory((payload.results || []).filter(jobIsComplete)))
|
||||
.catch(() => undefined);
|
||||
.then((payload) => {
|
||||
setHistory((payload.results || []).filter(jobIsComplete));
|
||||
const running = payload.inflight || null;
|
||||
if (running) {
|
||||
setJob(running);
|
||||
setJobId(running.id);
|
||||
rememberQuickCreateJob(running.id);
|
||||
} else if (!savedJobId()) {
|
||||
forgetQuickCreateJob();
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setRestoring(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -325,7 +340,7 @@ export function QuickCreatePage({
|
||||
setPlaying({ url, title: title || "预览视频" });
|
||||
}
|
||||
|
||||
function selectImages(files: FileList | null) {
|
||||
function selectImages(files: FileList | File[] | null) {
|
||||
const selected = Array.from(files || []).filter((file) => file.type.startsWith("image/"));
|
||||
setImages((current) => {
|
||||
const room = Math.max(0, 9 - savedImages.length - current.length);
|
||||
@@ -386,6 +401,10 @@ export function QuickCreatePage({
|
||||
}
|
||||
|
||||
async function startGeneration() {
|
||||
if (restoring) {
|
||||
onNotify?.("info", "正在读取任务状态,请稍候");
|
||||
return;
|
||||
}
|
||||
if (!name.trim() || (!images.length && !savedImages.length)) {
|
||||
onNotify?.("info", "请先填写商品名称并上传商品图片");
|
||||
return;
|
||||
@@ -433,7 +452,16 @@ export function QuickCreatePage({
|
||||
onNotify?.("success", "一键成片任务已启动");
|
||||
onProjectCreated?.();
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 503) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// 后端单飞闸:已有任务在跑。直接接上它,别让用户对着报错干瞪眼。
|
||||
const running = (error.payload as { inflight?: QuickCreateJob } | undefined)?.inflight;
|
||||
if (running) {
|
||||
setJob(running);
|
||||
setJobId(running.id);
|
||||
rememberQuickCreateJob(running.id);
|
||||
}
|
||||
onNotify?.("info", error.message || "已有一个一键成片正在进行中");
|
||||
} else if (error instanceof ApiError && error.status === 503) {
|
||||
setServiceUnavailable(true);
|
||||
setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试");
|
||||
onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试");
|
||||
@@ -520,6 +548,7 @@ export function QuickCreatePage({
|
||||
}
|
||||
|
||||
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
|
||||
const imageDrop = useFileDrop((files) => selectImages(files), { disabled: isGenerating });
|
||||
const isComplete = job?.status === "succeeded";
|
||||
const isCancelled = job?.status === "cancelled";
|
||||
const isFailed = !isGenerating && !isComplete && (job?.status === "failed" || isCancelled || serviceUnavailable);
|
||||
@@ -547,6 +576,7 @@ export function QuickCreatePage({
|
||||
const estimatedPoints = videoEstimate.points > 0 ? videoEstimate.points + sceneCount * 60 : sceneCount * 240;
|
||||
const shellClass = [
|
||||
"quick-create-shell",
|
||||
restoring ? "is-restoring" : "",
|
||||
isGenerating ? "is-generating" : "",
|
||||
isComplete ? "is-complete" : "",
|
||||
isFailed ? "is-failed" : "",
|
||||
@@ -562,7 +592,11 @@ export function QuickCreatePage({
|
||||
</header>
|
||||
|
||||
<div className={shellClass} id="quickCreateShell">
|
||||
<section className="quick-create-panel quick-form-panel">
|
||||
<section
|
||||
className={`quick-create-panel quick-form-panel${restoring ? " is-locked" : ""}`}
|
||||
aria-busy={restoring}
|
||||
inert={restoring}
|
||||
>
|
||||
<div className="quick-form-copy">
|
||||
<h2>告诉我们这是什么商品</h2>
|
||||
</div>
|
||||
@@ -614,7 +648,10 @@ export function QuickCreatePage({
|
||||
|
||||
<div className="quick-field">
|
||||
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9张</small></span>
|
||||
<div className={`quick-upload${imageCount ? " has-images" : ""}`}>
|
||||
<div
|
||||
className={`quick-upload${imageCount ? " has-images" : ""}${imageDrop.dragging ? " is-dragover" : ""}`}
|
||||
{...imageDrop.dropProps}
|
||||
>
|
||||
<input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => { selectImages(event.target.files); event.currentTarget.value = ""; }} />
|
||||
{imageCount ? (
|
||||
<div className="quick-upload-filled">
|
||||
@@ -686,6 +723,12 @@ export function QuickCreatePage({
|
||||
</section>
|
||||
|
||||
<section className="quick-create-panel quick-status-panel" aria-live="polite">
|
||||
<div className="quick-state quick-state-restoring" role="status">
|
||||
<div className="quick-restoring-spinner" aria-hidden="true" />
|
||||
<h2>正在读取任务状态</h2>
|
||||
<p>确认有没有正在进行的一键成片,稍候</p>
|
||||
</div>
|
||||
|
||||
<div className="quick-state quick-state-ready">
|
||||
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
|
||||
<h2>核心参数可选,其余自动完成</h2>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { LoginSession, Team, User, UserPreference } from "../types";
|
||||
import { TeamModal } from "../components/overlays";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
|
||||
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
||||
|
||||
@@ -346,13 +347,18 @@ export function SettingsPage({
|
||||
setModal("avatar");
|
||||
}
|
||||
|
||||
function onPickAvatar(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
function acceptAvatar(file?: File | null) {
|
||||
if (!file) return;
|
||||
setAvatarFile(file);
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
}
|
||||
|
||||
function onPickAvatar(event: ChangeEvent<HTMLInputElement>) {
|
||||
acceptAvatar(event.target.files?.[0]);
|
||||
}
|
||||
|
||||
const avatarDrop = useFileDrop((files) => acceptAvatar(files[0]), { disabled: savingAvatar });
|
||||
|
||||
async function handleUploadAvatar() {
|
||||
if (!avatarFile || savingAvatar) return;
|
||||
setSavingAvatar(true);
|
||||
@@ -717,7 +723,8 @@ export function SettingsPage({
|
||||
onChange={onPickAvatar}
|
||||
/>
|
||||
<div
|
||||
className="upload-zone"
|
||||
className={`upload-zone${avatarDrop.dragging ? " dragover" : ""}`}
|
||||
{...avatarDrop.dropProps}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="点击选择图片上传"
|
||||
@@ -732,7 +739,7 @@ export function SettingsPage({
|
||||
<span className="uz-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
|
||||
</span>
|
||||
<div><strong>点击选择</strong> · 图片文件</div>
|
||||
<div><strong>{avatarDrop.dragging ? "松开即可上传" : "点击选择"}</strong> · 图片文件</div>
|
||||
<span className="uz-hint">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
PanelsTopLeft,
|
||||
Play,
|
||||
RectangleVertical,
|
||||
RefreshCw,
|
||||
Save,
|
||||
ScanLine,
|
||||
ScanSearch,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import type { ModelConfig, VideoDigestHistory, VideoDigestJob } from "../types";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
|
||||
@@ -167,6 +169,8 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const [openHistoryId, setOpenHistoryId] = useState("");
|
||||
const [playing, setPlaying] = useState<VideoDigestHistory | null>(null);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
// 进页面先向后端确认有没有在跑的提炼,确认完再决定显示表单还是进度
|
||||
const [restoring, setRestoring] = useState(true);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const completedNoticeRef = useRef("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -236,21 +240,31 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
await loadHistory();
|
||||
const listed = await loadHistory();
|
||||
if (cancelled) return;
|
||||
// 优先信服务端:列表接口会回本团队在跑的那条。localStorage 可能被清、
|
||||
// 也可能换了浏览器/标签页,只认它会让「退出再进来任务就没了」。
|
||||
const inflight = listed?.inflight || null;
|
||||
const stored = readJobId();
|
||||
if (!stored) return;
|
||||
const restoreId = (inflight ? jobIdOf(inflight) : "") || stored;
|
||||
if (!restoreId) {
|
||||
forgetJob();
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const job = await api.getVideoDigest(stored);
|
||||
const job = inflight && jobIdOf(inflight) === restoreId
|
||||
? inflight
|
||||
: await api.getVideoDigest(restoreId);
|
||||
if (cancelled) return;
|
||||
if (job.status === "processing") {
|
||||
if (!job.video_url && !job.cover_url) {
|
||||
forgetJob();
|
||||
void api.cancelVideoDigest(stored).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
// 之前这里在「拿不到封面/原片直链」时把任务取消掉 —— 那是拿渲染缩略图的
|
||||
// 能力去判活,TOS 慢一点或签名失败就误杀正在跑的任务。一律恢复并续上轮询,
|
||||
// 真死掉的由后端 expire_stale_team_digests(15 分钟)回收。
|
||||
applyJobMeta(job);
|
||||
setJobId(stored);
|
||||
setJobId(restoreId);
|
||||
rememberJob(restoreId);
|
||||
setRestoring(false);
|
||||
return;
|
||||
}
|
||||
if (job.status === "succeeded") {
|
||||
@@ -260,7 +274,9 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
/* 过期 / 已取消 / 不存在:当没任务 */
|
||||
}
|
||||
forgetJob();
|
||||
})();
|
||||
})().finally(() => {
|
||||
if (!cancelled) setRestoring(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -322,6 +338,11 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
};
|
||||
}, [pollId, onNotify]);
|
||||
|
||||
const videoDrop = useFileDrop(
|
||||
(files) => { void pickFile(files[0] || null); },
|
||||
{ disabled: analyzing }
|
||||
);
|
||||
|
||||
const pickFile = async (next: File | null) => {
|
||||
if (!next || analyzing) return;
|
||||
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
|
||||
@@ -391,6 +412,19 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
setJobId(id);
|
||||
} catch (error) {
|
||||
if (userCancelledRef.current || (error instanceof DOMException && error.name === "AbortError")) return;
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// 后端单飞闸:已有提炼在跑。直接接上它,别让用户对着报错干瞪眼。
|
||||
const running = (error.payload as { inflight?: VideoDigestJob } | undefined)?.inflight;
|
||||
if (running) {
|
||||
const id = jobIdOf(running);
|
||||
applyJobMeta(running);
|
||||
rememberJob(id);
|
||||
setWatchId("");
|
||||
setJobId(id);
|
||||
}
|
||||
onNotify("info", error.message || "已有一个视频正在提炼中");
|
||||
return;
|
||||
}
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -460,6 +494,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
const stage: ProgressStage = analyzing ? "analyze" : hasResult ? "prompt" : file || remoteVideoUrl ? "analyze" : "upload";
|
||||
const panelClass = [
|
||||
"video-result-panel remix-information-panel",
|
||||
restoring ? "is-restoring" : "",
|
||||
hasResult ? "has-result" : "",
|
||||
analyzing ? "is-analyzing" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
@@ -496,32 +531,54 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</div>
|
||||
|
||||
<div className="video-flow-grid remix-flow-grid">
|
||||
<section className="video-flow-panel video-remix-upload-panel">
|
||||
<section
|
||||
className={`video-flow-panel video-remix-upload-panel${restoring ? " is-locked" : ""}`}
|
||||
aria-busy={restoring}
|
||||
inert={restoring}
|
||||
>
|
||||
<h2>上传参考视频</h2>
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
<span>MP4 / MOV · 最长 3 分钟 · ≤200MB</span>
|
||||
</div>
|
||||
{previewUrl ? (
|
||||
<div className="video-upload-field has-file has-preview">
|
||||
<video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
|
||||
<label className={`remix-replace-video${analyzing ? " is-disabled" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
disabled={analyzing}
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
更换视频
|
||||
</label>
|
||||
<div
|
||||
className={`video-upload-field has-file has-preview${videoDrop.dragging ? " is-dragover" : ""}`}
|
||||
{...videoDrop.dropProps}
|
||||
>
|
||||
<div className="video-upload-preview">
|
||||
<video src={previewUrl} poster={coverUrl || undefined} controls playsInline preload="metadata" />
|
||||
<div className="video-upload-preview-meta">
|
||||
<strong>{fileName || file?.name || "参考视频"}</strong>
|
||||
<small>
|
||||
{analyzing
|
||||
? "正在提炼分镜稿…"
|
||||
: [duration ? `${duration} 秒` : "", ratio, fileMetaCopy(kind, fileSize, width, height)]
|
||||
.filter(Boolean).join(" · ")}
|
||||
</small>
|
||||
</div>
|
||||
<label className={`video-upload-change${analyzing ? " is-disabled" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
hidden
|
||||
disabled={analyzing}
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<RefreshCw />
|
||||
更换视频
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<label className="video-upload-field">
|
||||
<label
|
||||
className={`video-upload-field${videoDrop.dragging ? " is-dragover" : ""}`}
|
||||
{...videoDrop.dropProps}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime,video/webm"
|
||||
@@ -534,7 +591,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>点击上传参考视频</strong>
|
||||
<strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
|
||||
<small>上传后自动识别镜头结构与内容节奏</small>
|
||||
</span>
|
||||
</label>
|
||||
@@ -544,7 +601,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
disabled={analyzing || !canAnalyze}
|
||||
disabled={restoring || analyzing || !canAnalyze}
|
||||
onClick={() => void analyze()}
|
||||
>
|
||||
<ScanSearch />
|
||||
@@ -554,6 +611,13 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</section>
|
||||
|
||||
<aside className={panelClass} aria-live="polite">
|
||||
<div className="remix-restoring-state" role="status" aria-live="polite">
|
||||
<div className="remix-restoring-content">
|
||||
<span className="remix-restoring-spinner" aria-hidden="true" />
|
||||
<strong>正在读取任务状态</strong>
|
||||
<span>确认有没有正在进行的提炼,稍候</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-result-placeholder">
|
||||
<div>
|
||||
<span className="remix-placeholder-icon"><ScanLine /></span>
|
||||
@@ -568,6 +632,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
</div>
|
||||
<strong>正在提炼提示词</strong>
|
||||
<span>离开页面也不会中断,稍后回来即可查看结果</span>
|
||||
<div className="remix-generating-bar" aria-hidden="true"><span /></div>
|
||||
<button type="button" className="secondary-action remix-cancel-analyze" onClick={() => setConfirmCancel(true)}>
|
||||
<X />
|
||||
取消
|
||||
@@ -616,6 +681,10 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<div><h2>提示词内容</h2></div>
|
||||
</div>
|
||||
<div className="remix-prompt-head-actions">
|
||||
<button type="button" className="primary-action remix-prompt-head-btn" onClick={() => continueGenerate()}>
|
||||
<span>生成视频</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
<button type="button" className="secondary-action remix-prompt-head-btn" onClick={() => void savePrompt()}>
|
||||
<Save />
|
||||
保存提示词
|
||||
@@ -629,12 +698,6 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
<div className="video-flow-actions remix-prompt-actions">
|
||||
<button type="button" className="primary-action" onClick={() => continueGenerate()}>
|
||||
<span>生成视频</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import { OverlayPortal, useBodyScrollLock } from "../components/overlays";
|
||||
import { useFileDrop } from "../components/use-file-drop";
|
||||
import {
|
||||
DEFAULT_BILLING_RATES,
|
||||
FC_MODELS,
|
||||
@@ -37,6 +38,13 @@ const JOB_KEY = "airshelf:video-replace-job";
|
||||
const MODE_KEY = "airshelf:video-replace-mode";
|
||||
const CHARACTER_MARK = "[视频复刻·角色]";
|
||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
||||
// 商品复刻的参考视频只用来提炼分镜稿,不发火山,所以能到 60 秒;
|
||||
// 角色复刻仍把参考视频直传火山,卡死在火山的 15 秒。
|
||||
const PRODUCT_SOURCE_PURPOSE = "video_replace_product";
|
||||
const REF_SECONDS_MAX = { product: 60, character: 15 } as const;
|
||||
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 50 * 1024 * 1024 } as const;
|
||||
// 火山单次出片上限。参考视频更长时不报错,成片按这个截断,提示词里会要求模型压缩改编。
|
||||
const SEEDANCE_MAX_OUTPUT_SECONDS = 15;
|
||||
|
||||
type ProductSource = "library" | "temporary" | "";
|
||||
type ReplaceMode = "product" | "character";
|
||||
@@ -50,7 +58,7 @@ const REPLACE_MODE_COPY = {
|
||||
videoReady: "视频已就绪,将先提炼分镜稿再复刻",
|
||||
libraryTitle: "从商品库选择",
|
||||
libraryEmpty: "选择已创建的商品",
|
||||
temporaryTitle: "临时上传商品",
|
||||
temporaryTitle: "临时上传素材",
|
||||
temporaryEmpty: "仅用于本次任务 · 最多 9 张",
|
||||
temporaryNoun: "商品图",
|
||||
temporaryFallback: "临时商品素材",
|
||||
@@ -170,8 +178,9 @@ function formatClock(seconds: number) {
|
||||
}
|
||||
|
||||
function clampDuration(seconds: number) {
|
||||
const rounded = Math.round(Number(seconds) || 15);
|
||||
return Math.min(15, Math.max(4, rounded || 15));
|
||||
const rounded = Math.round(Number(seconds) || SEEDANCE_MAX_OUTPUT_SECONDS);
|
||||
// 60 秒参考视频不是错误:成片取火山能出的最长,分镜稿由模型压缩改编。
|
||||
return Math.min(SEEDANCE_MAX_OUTPUT_SECONDS, Math.max(4, rounded || SEEDANCE_MAX_OUTPUT_SECONDS));
|
||||
}
|
||||
|
||||
function isCharacterRemix(task?: Partial<FreeVideoTask> | null) {
|
||||
@@ -240,6 +249,24 @@ function productImageCount(product: Product) {
|
||||
return product.images?.filter((image) => image.asset || image.preview_url).length || 0;
|
||||
}
|
||||
|
||||
/** 选中后这次实际会带给模型的参考图。三视图是 standalone 资产,不在 images 里,单独点名。 */
|
||||
function librarySelectionDetail(mode: ReplaceMode, product: Product | null, model: ModelEntity | null) {
|
||||
if (mode === "character") {
|
||||
if (!model) return "";
|
||||
const parts = [model.portrait ? "定妆照" : "", model.triview ? "三视图" : ""].filter(Boolean);
|
||||
return parts.length ? `将带上 ${parts.join(" + ")}` : "";
|
||||
}
|
||||
if (!product) return "";
|
||||
const count = productImageCount(product) || (product.cover_preview_url ? 1 : 0);
|
||||
const parts = [count ? `${count} 张商品图` : ""];
|
||||
parts.push(product.triview_preview_url ? "三视图" : "");
|
||||
const kept = parts.filter(Boolean);
|
||||
if (!kept.length) return "";
|
||||
return product.triview_preview_url
|
||||
? `将带上 ${kept.join(" + ")}`
|
||||
: `将带上 ${kept.join("")} · 该商品还没有三视图`;
|
||||
}
|
||||
|
||||
function modelCover(model: ModelEntity) {
|
||||
return model.portrait || model.triview || "";
|
||||
}
|
||||
@@ -266,8 +293,12 @@ export function VideoReplacePage({
|
||||
const [models, setModels] = useState<ModelEntity[]>([]);
|
||||
const [replaceMode, setReplaceMode] = useState<ReplaceMode>(readReplaceMode);
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
// 选完立刻用本地 objectURL 放预览,不等上传回来 —— 用户要先看见自己选的片子
|
||||
const [videoPreview, setVideoPreview] = useState("");
|
||||
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
|
||||
const [videoUploading, setVideoUploading] = useState(false);
|
||||
// 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度
|
||||
const [restoring, setRestoring] = useState(true);
|
||||
const [videoMeta, setVideoMeta] = useState({ duration: 0, width: 0, height: 0 });
|
||||
const [source, setSource] = useState<ProductSource>("");
|
||||
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
|
||||
@@ -314,6 +345,7 @@ export function VideoReplacePage({
|
||||
const productReady = source === "library"
|
||||
? (replaceMode === "character" ? Boolean(selectedModel) : Boolean(selectedProduct))
|
||||
: (tempFiles.length > 0 || tempAssetRefs.length > 0);
|
||||
const librarySelected = source === "library" && Boolean(replaceMode === "character" ? selectedModel : selectedProduct);
|
||||
const libraryPreview = replaceMode === "character"
|
||||
? (selectedModel ? modelCover(selectedModel) : "")
|
||||
: (selectedProduct ? productCover(selectedProduct) : "");
|
||||
@@ -345,7 +377,9 @@ export function VideoReplacePage({
|
||||
],
|
||||
}, billingRates);
|
||||
const points = estimated.points || 220;
|
||||
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading;
|
||||
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
|
||||
// 生成按钮不会因此被误点 —— 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")));
|
||||
@@ -353,6 +387,7 @@ export function VideoReplacePage({
|
||||
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
|
||||
const panelClass = [
|
||||
"video-result-panel replace-result-panel",
|
||||
restoring ? "is-restoring" : "",
|
||||
generating ? "is-generating" : "",
|
||||
reviewing || digesting ? "is-reviewing" : "",
|
||||
hasResult ? "has-result" : "",
|
||||
@@ -367,11 +402,40 @@ export function VideoReplacePage({
|
||||
try {
|
||||
const data = await api.videoReplaceTasks(0, 50);
|
||||
setHistory((data.results || []).filter((item) => item.status === "succeeded"));
|
||||
return data;
|
||||
} catch {
|
||||
/* 历史失败不挡当前复刻 */
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复在跑的任务。拉到之前一律显示「读取中」——否则用户以为没任务,又传一次。
|
||||
const restoreInflight = async () => {
|
||||
const data = await loadHistory();
|
||||
const running = data?.inflight || null;
|
||||
if (running) {
|
||||
// 静默恢复:只把进度接上。不要走 fillFormFromTask —— 它会弹 toast、滚动页面,
|
||||
// 还依赖商品/模特列表加载完,拿来做刷新恢复会又吵又不稳。
|
||||
setJob(running);
|
||||
setJobId(running.id);
|
||||
rememberJob(running.id);
|
||||
const mode = modeFromTask(running);
|
||||
setReplaceMode(mode);
|
||||
rememberReplaceMode(mode);
|
||||
const video = videoRefFromTask(running);
|
||||
if (video?.url) setVideoPreview(video.url);
|
||||
setVideoRef(video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null);
|
||||
setFilledSubjectName(subjectNameFromTask(running));
|
||||
setVideoMeta({
|
||||
duration: Number(video?.duration || running.duration || 0),
|
||||
...sizeFromRatio(running.aspect_ratio || "9:16"),
|
||||
});
|
||||
} else {
|
||||
forgetJob();
|
||||
}
|
||||
setRestoring(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void api.products(100).then((payload) => setProducts(payload.results || [])).catch(() => undefined);
|
||||
void api.listModels({ pageSize: 200 }).then((payload) => setModels((payload.results || []).filter((item) => !item.is_deleted))).catch(() => undefined);
|
||||
@@ -382,7 +446,7 @@ export function VideoReplacePage({
|
||||
multiplier: Number(config.team_price_multiplier) || 1,
|
||||
}))
|
||||
.catch(() => undefined);
|
||||
void loadHistory();
|
||||
void restoreInflight();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -455,7 +519,10 @@ export function VideoReplacePage({
|
||||
|
||||
const pickVideo = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
const check = await checkRefFile(file);
|
||||
const check = await checkRefFile(file, {
|
||||
maxSeconds: REF_SECONDS_MAX[replaceMode],
|
||||
maxVideoBytes: REF_BYTES_MAX[replaceMode],
|
||||
});
|
||||
if (!check.ok) {
|
||||
onNotify("error", check.error);
|
||||
return;
|
||||
@@ -465,11 +532,13 @@ export function VideoReplacePage({
|
||||
return;
|
||||
}
|
||||
setVideoFile(file);
|
||||
setVideoPreview(URL.createObjectURL(file));
|
||||
setVideoUploading(true);
|
||||
setJob((current) => (current && isInFlight(current.status) ? current : null));
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (replaceMode === "product") form.append("purpose", PRODUCT_SOURCE_PURPOSE);
|
||||
try {
|
||||
const uploaded = await api.uploadFreeVideoRef(form);
|
||||
setVideoRef({
|
||||
@@ -490,13 +559,14 @@ export function VideoReplacePage({
|
||||
} catch (error) {
|
||||
setVideoFile(null);
|
||||
setVideoRef(null);
|
||||
setVideoPreview("");
|
||||
onNotify("error", error instanceof Error ? error.message : "参考视频上传失败");
|
||||
} finally {
|
||||
setVideoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTempImages = (files: FileList | null) => {
|
||||
const addTempImages = (files: FileList | File[] | null) => {
|
||||
const incoming = [...(files || [])].filter((file) => IMAGE_TYPES.includes(file.type));
|
||||
if (!incoming.length) {
|
||||
onNotify("error", "请选择 JPG、PNG 或 WebP 图片");
|
||||
@@ -530,8 +600,33 @@ export function VideoReplacePage({
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 换一条预览或离开页面时释放 objectURL,不然一路选下去会攒一堆 blob
|
||||
if (!videoPreview.startsWith("blob:")) return;
|
||||
return () => URL.revokeObjectURL(videoPreview);
|
||||
}, [videoPreview]);
|
||||
|
||||
const tempDrop = useFileDrop(
|
||||
(files) => addTempImages(files),
|
||||
{ disabled: generating }
|
||||
);
|
||||
|
||||
const videoDrop = useFileDrop(
|
||||
(files) => { void pickVideo(files[0] || null); },
|
||||
{ disabled: generating || videoUploading }
|
||||
);
|
||||
|
||||
const switchReplaceMode = (next: ReplaceMode) => {
|
||||
if (next === replaceMode || generating) return;
|
||||
// 商品复刻能传 60 秒,角色复刻只能 15 秒。带着超长视频切过去会一路走到提交才报错,
|
||||
// 这里直接清掉并说明原因。
|
||||
const tooLongForNext = (videoMeta.duration || 0) > REF_SECONDS_MAX[next] + 0.5;
|
||||
if (tooLongForNext) {
|
||||
setVideoFile(null);
|
||||
setVideoRef(null);
|
||||
setVideoPreview("");
|
||||
setVideoMeta({ duration: 0, width: 0, height: 0 });
|
||||
}
|
||||
setReplaceMode(next);
|
||||
rememberReplaceMode(next);
|
||||
setSource("");
|
||||
@@ -544,7 +639,12 @@ export function VideoReplacePage({
|
||||
setPendingModelId("");
|
||||
setLibraryOpen(false);
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`);
|
||||
onNotify(
|
||||
"info",
|
||||
tooLongForNext
|
||||
? `已切换为${REPLACE_MODE_COPY[next].modeLabel},参考视频最长 ${REF_SECONDS_MAX[next]} 秒,请重新上传`
|
||||
: `已切换为${REPLACE_MODE_COPY[next].modeLabel}`
|
||||
);
|
||||
};
|
||||
|
||||
const confirmLibrarySelection = () => {
|
||||
@@ -647,6 +747,17 @@ export function VideoReplacePage({
|
||||
forgetJob();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// 后端单飞闸:已有复刻在跑。直接接上它,别让用户对着报错干瞪眼。
|
||||
const running = (error.payload as { inflight?: FreeVideoTask } | undefined)?.inflight;
|
||||
if (running) {
|
||||
setJob(running);
|
||||
setJobId(running.id);
|
||||
rememberJob(running.id);
|
||||
}
|
||||
onNotify("info", error.message || "已有一个视频正在复刻中");
|
||||
return;
|
||||
}
|
||||
onNotify("error", error instanceof Error ? error.message : "视频复刻提交失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -666,6 +777,8 @@ export function VideoReplacePage({
|
||||
setReplaceMode(mode);
|
||||
rememberReplaceMode(mode);
|
||||
setVideoFile(null);
|
||||
// 从历史「重新生成」回填时也要把原视频放进预览框,否则第 1 步看起来像没选
|
||||
setVideoPreview(video.url || "");
|
||||
setVideoRef({
|
||||
...video,
|
||||
type: "video",
|
||||
@@ -750,7 +863,11 @@ export function VideoReplacePage({
|
||||
</header>
|
||||
|
||||
<div className="video-flow-grid">
|
||||
<section className="video-flow-panel replace-flow-panel">
|
||||
<section
|
||||
className={`video-flow-panel replace-flow-panel${restoring ? " is-locked" : ""}`}
|
||||
aria-busy={restoring}
|
||||
inert={restoring}
|
||||
>
|
||||
<h2>准备复刻素材</h2>
|
||||
<div className="replace-mode-switch" role="tablist" aria-label="选择视频复刻功能">
|
||||
<button
|
||||
@@ -781,9 +898,17 @@ export function VideoReplacePage({
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>1. 参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 15 秒</span>
|
||||
<span>MP4 / MOV · 最长 {REF_SECONDS_MAX[replaceMode]} 秒</span>
|
||||
</div>
|
||||
<label className={`video-upload-field${videoReady ? " has-file" : ""}`}>
|
||||
<div
|
||||
className={[
|
||||
"video-upload-field",
|
||||
videoReady ? "has-file" : "",
|
||||
videoPreview ? "has-preview" : "",
|
||||
videoDrop.dragging ? "is-dragover" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
{...videoDrop.dropProps}
|
||||
>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
@@ -794,12 +919,40 @@ export function VideoReplacePage({
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<FileVideo2 />
|
||||
<strong>{videoFile?.name || (videoReady ? "参考视频已就绪" : "点击上传参考视频")}</strong>
|
||||
<small>{videoReady ? copy.videoReady : copy.videoEmpty}</small>
|
||||
</span>
|
||||
</label>
|
||||
{videoPreview ? (
|
||||
<div className="video-upload-preview">
|
||||
<video src={videoPreview} controls playsInline preload="metadata" />
|
||||
<div className="video-upload-preview-meta">
|
||||
<strong>{videoFile?.name || filledSubjectName || "参考视频"}</strong>
|
||||
<small>
|
||||
{videoUploading
|
||||
? "正在上传参考视频…"
|
||||
: `${formatClock(videoMeta.duration)} · ${ratioCopy(aspectRatio)} · ${copy.videoReady}`}
|
||||
</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="video-upload-change"
|
||||
disabled={generating || videoUploading}
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
>
|
||||
<RefreshCw />
|
||||
更换视频
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="video-upload-trigger"
|
||||
disabled={generating}
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
>
|
||||
<FileVideo2 />
|
||||
<strong>{videoDrop.dragging ? "松开即可上传" : "点击或拖拽上传参考视频"}</strong>
|
||||
<small>{copy.videoEmpty}</small>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-flow-step">
|
||||
@@ -810,30 +963,34 @@ export function VideoReplacePage({
|
||||
<div className="product-replace-options">
|
||||
<button
|
||||
type="button"
|
||||
className={`replace-product-method${source === "library" ? " active" : ""}${libraryPreview ? " has-product-preview" : ""}`}
|
||||
className={`replace-product-method${source === "library" ? " active" : ""}${librarySelected ? " has-product-preview" : ""}`}
|
||||
onClick={() => {
|
||||
if (replaceMode === "character") setPendingModelId(selectedModel?.id || "");
|
||||
else setPendingProductId(selectedProduct?.id || "");
|
||||
setLibraryOpen(true);
|
||||
}}
|
||||
>
|
||||
{libraryPreview ? <img className="replace-product-method-background" src={libraryPreview} alt="" aria-hidden="true" /> : <img className="replace-product-method-background" alt="" aria-hidden="true" />}
|
||||
<span className="replace-product-method-icon"><LibraryBig /></span>
|
||||
<span className="replace-product-method-icon">
|
||||
{librarySelected && libraryPreview
|
||||
? <img src={libraryPreview} alt="" aria-hidden="true" />
|
||||
: <LibraryBig />}
|
||||
</span>
|
||||
<span className="replace-product-method-copy">
|
||||
<strong>{copy.libraryTitle}</strong>
|
||||
<small>
|
||||
{source === "library" && (replaceMode === "character" ? selectedModel : selectedProduct)
|
||||
? `已选择 · ${replaceMode === "character" ? selectedModel?.name : selectedProduct?.title}`
|
||||
: copy.libraryEmpty}
|
||||
</small>
|
||||
<strong>
|
||||
{librarySelected
|
||||
? (replaceMode === "character" ? selectedModel?.name : selectedProduct?.title)
|
||||
: copy.libraryTitle}
|
||||
</strong>
|
||||
<small>{librarySelected ? librarySelectionDetail(replaceMode, selectedProduct, selectedModel) || copy.libraryTitle : copy.libraryEmpty}</small>
|
||||
</span>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}`}
|
||||
className={`replace-product-method replace-temporary-method${source === "temporary" ? " active" : ""}${tempDisplay.length ? " has-images" : ""}${tempDrop.dragging ? " is-dragover" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
{...tempDrop.dropProps}
|
||||
onClick={() => tempInputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -846,7 +1003,13 @@ export function VideoReplacePage({
|
||||
<span className="replace-product-method-icon"><Upload /></span>
|
||||
<span className="replace-product-method-copy">
|
||||
<strong>{copy.temporaryTitle}</strong>
|
||||
<small>{tempDisplay.length ? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}` : copy.temporaryEmpty}</small>
|
||||
<small>
|
||||
{tempDrop.dragging
|
||||
? "松开即可添加"
|
||||
: tempDisplay.length
|
||||
? `已上传 ${tempDisplay.length} 张${copy.temporaryNoun}`
|
||||
: copy.temporaryEmpty}
|
||||
</small>
|
||||
</span>
|
||||
<ImagePlus />
|
||||
{tempDisplay.length ? (
|
||||
@@ -924,7 +1087,7 @@ export function VideoReplacePage({
|
||||
<button
|
||||
type="button"
|
||||
className="primary-action"
|
||||
disabled={!videoReady || !productReady || generating}
|
||||
disabled={restoring || !videoReady || !productReady || generating}
|
||||
onClick={() => void startGeneration()}
|
||||
>
|
||||
<Replace />
|
||||
@@ -934,6 +1097,13 @@ export function VideoReplacePage({
|
||||
</section>
|
||||
|
||||
<aside className={panelClass} aria-live="polite">
|
||||
<div className="replace-restoring-state" role="status" aria-live="polite">
|
||||
<div className="replace-restoring-content">
|
||||
<span className="replace-restoring-spinner" aria-hidden="true" />
|
||||
<strong>正在读取任务状态</strong>
|
||||
<span>确认有没有正在进行的复刻,稍候</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-result-placeholder">
|
||||
<div>
|
||||
<div className="replace-placeholder-visual">
|
||||
|
||||
@@ -255,6 +255,8 @@ export type Product = {
|
||||
purged_at?: string | null;
|
||||
cover_asset?: string | null;
|
||||
cover_preview_url?: string;
|
||||
/** 商品三视图(白底多角度单张 16:9)。是 standalone 资产,不在 images 里。 */
|
||||
triview_preview_url?: string;
|
||||
images?: Array<{ id: string; asset: string; preview_url?: string; sort_order: number; is_primary: boolean }>;
|
||||
selling_points: Array<{ id: string; title: string; detail: string; sort_order: number }>;
|
||||
created_at: string;
|
||||
|
||||
@@ -358,11 +358,17 @@
|
||||
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn {
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 「生成视频」是这一步的主动作,放按钮组最左边 */
|
||||
.vr-page .remix-prompt-head-actions .primary-action.remix-prompt-head-btn {
|
||||
order: -1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-head-actions .remix-prompt-head-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -408,11 +414,6 @@
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.08);
|
||||
}
|
||||
|
||||
.vr-page .video-flow-actions.remix-prompt-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vr-page .video-flow-actions.remix-analyze-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
@@ -481,11 +482,18 @@
|
||||
}
|
||||
|
||||
.vr-page .video-upload-field:hover,
|
||||
.vr-page .video-upload-field.has-file {
|
||||
.vr-page .video-upload-field.has-file,
|
||||
.vr-page .video-upload-field.is-dragover {
|
||||
border-color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
|
||||
/* 拖拽悬停:实线 + 略深底,和「已选好」区分开 */
|
||||
.vr-page .video-upload-field.is-dragover {
|
||||
border-style: solid;
|
||||
background: rgba(0, 47, 167, 0.11);
|
||||
}
|
||||
|
||||
.vr-page .video-upload-field > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -689,6 +697,71 @@
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* 读取任务状态期间左侧整块不可操作:只锁提交按钮不够,用户照样能点/拖上传 */
|
||||
.vr-page .video-remix-upload-panel.is-locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
filter: saturate(0.85);
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
/* 进页面先确认后端有没有在跑的任务,确认完之前不给看空表单也不让提交 */
|
||||
.vr-page .remix-restoring-state { display: none; place-items: center; text-align: center; }
|
||||
|
||||
.vr-page .remix-information-panel.is-restoring .remix-restoring-state { display: grid; }
|
||||
|
||||
.vr-page .remix-information-panel.is-restoring .video-result-placeholder,
|
||||
.vr-page .remix-information-panel.is-restoring .remix-generating-state,
|
||||
.vr-page .remix-information-panel.is-restoring .video-analysis-result { display: none; }
|
||||
|
||||
.vr-page .remix-restoring-content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.vr-page .remix-restoring-content strong { color: var(--text); font-size: 16px; font-weight: 600; }
|
||||
.vr-page .remix-restoring-content span:not(.remix-restoring-spinner) {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.vr-page .remix-restoring-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(0, 47, 167, 0.16);
|
||||
border-top-color: var(--klein);
|
||||
animation: remix-restoring-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes remix-restoring-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.vr-page .remix-generating-bar {
|
||||
width: 210px;
|
||||
height: 4px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 16, 18, 0.08);
|
||||
}
|
||||
|
||||
.vr-page .remix-generating-bar span {
|
||||
width: 44%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: inherit;
|
||||
background: var(--klein);
|
||||
animation: remix-progress-slide 1.15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes remix-progress-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(330%); }
|
||||
}
|
||||
|
||||
@keyframes remix-frame-scan {
|
||||
0% { transform: translateX(-130%); }
|
||||
100% { transform: translateX(130%); }
|
||||
@@ -815,7 +888,8 @@
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field:hover,
|
||||
.vr-page .remix-page .video-upload-field:focus-within {
|
||||
.vr-page .remix-page .video-upload-field:focus-within,
|
||||
.vr-page .remix-page .video-upload-field.is-dragover {
|
||||
border-color: var(--klein);
|
||||
background: #f0f5ff;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 47, 167, 0.05);
|
||||
@@ -841,56 +915,74 @@
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 预览卡与「视频复刻」页保持同一套:缩略图 + 文件信息 + 更换按钮,不再整块铺黑底 */
|
||||
.vr-page .remix-page .video-upload-field.has-preview {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
place-items: stretch;
|
||||
text-align: left;
|
||||
border-style: solid;
|
||||
cursor: default;
|
||||
background: #0f1728;
|
||||
border-color: rgba(0, 47, 167, 0.28);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview:hover,
|
||||
.vr-page .remix-page .video-upload-field.has-preview:focus-within {
|
||||
background: #0f1728;
|
||||
border-color: rgba(0, 47, 167, 0.28);
|
||||
box-shadow: none;
|
||||
.vr-page .video-upload-preview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 132px) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field.has-preview video {
|
||||
width: 100%;
|
||||
height: 196px;
|
||||
height: auto;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 168px;
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
background: #0f1728;
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
.vr-page .video-upload-preview-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vr-page .video-upload-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 13px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: rgba(13, 19, 31, 0.72);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
color: var(--klein);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video:hover {
|
||||
background: rgba(0, 47, 167, 0.92);
|
||||
.vr-page .video-upload-change:hover {
|
||||
border-color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
|
||||
.vr-page .remix-replace-video.is-disabled {
|
||||
.vr-page .video-upload-change.is-disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.vr-page .video-upload-change svg { width: 15px; height: 15px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vr-page .video-upload-preview { grid-template-columns: minmax(0, 96px) minmax(0, 1fr); }
|
||||
.vr-page .video-upload-change { grid-column: 1 / -1; justify-content: center; }
|
||||
}
|
||||
|
||||
.vr-page .remix-page .video-upload-field strong {
|
||||
color: #1d2940;
|
||||
font-size: 15px;
|
||||
@@ -903,7 +995,8 @@
|
||||
|
||||
.vr-page .remix-page .remix-information-panel .video-result-placeholder,
|
||||
.vr-page .remix-page .remix-information-panel .video-analysis-result,
|
||||
.vr-page .remix-page .remix-information-panel .remix-generating-state {
|
||||
.vr-page .remix-page .remix-information-panel .remix-generating-state,
|
||||
.vr-page .remix-page .remix-information-panel .remix-restoring-state {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -935,10 +1028,6 @@
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.vr-page .remix-page .remix-prompt-actions {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
@keyframes remixReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -213,24 +213,104 @@
|
||||
color: #42506a;
|
||||
background: rgba(0, 47, 167, 0.04);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-field:hover,
|
||||
.vrep-page .video-upload-field.has-file {
|
||||
.vrep-page .video-upload-field.has-file,
|
||||
.vrep-page .video-upload-field.is-dragover {
|
||||
border-color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-field > span {
|
||||
/* 拖拽悬停:实线 + 略深底,和「已选好」区分开 */
|
||||
.vrep-page .video-upload-field.is-dragover {
|
||||
border-style: solid;
|
||||
background: rgba(0, 47, 167, 0.11);
|
||||
}
|
||||
|
||||
/* 已有预览时容器让位给视频本身 */
|
||||
.vrep-page .video-upload-field.has-preview {
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
border-style: solid;
|
||||
place-items: stretch;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-trigger {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-field svg {
|
||||
.vrep-page .video-upload-trigger:disabled { cursor: default; opacity: .6; }
|
||||
|
||||
.vrep-page .video-upload-preview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 132px) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-preview video {
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 168px;
|
||||
border-radius: 8px;
|
||||
background: #0f1728;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-preview-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 13px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.3);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--klein);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-change:hover:not(:disabled) {
|
||||
border-color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
|
||||
.vrep-page .video-upload-change:disabled { opacity: .5; cursor: default; }
|
||||
.vrep-page .video-upload-change svg { width: 15px; height: 15px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vrep-page .video-upload-preview {
|
||||
grid-template-columns: minmax(0, 96px) minmax(0, 1fr);
|
||||
}
|
||||
.vrep-page .video-upload-change { grid-column: 1 / -1; justify-content: center; }
|
||||
}
|
||||
|
||||
/* 只给空态大图标撑到 28px:更换按钮里的小图标不能被这条盖掉 */
|
||||
.vrep-page .video-upload-trigger > svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--klein);
|
||||
@@ -251,6 +331,63 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 临时上传商品图:拖拽悬停沿用「已选中」的蓝框,再压一层底色区分 */
|
||||
.vrep-page .replace-temporary-method.is-dragover {
|
||||
border-color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.11);
|
||||
}
|
||||
|
||||
/* 进页面先确认后端有没有在跑的任务。确认完之前不给看空表单,也不让提交 ——
|
||||
否则用户以为没任务,又传一遍,重复扣费。 */
|
||||
/* 面板是 flex column,不给 flex:1 + min-height 就会顶在最上面、下面留一大块空 */
|
||||
.vrep-page .replace-restoring-state {
|
||||
min-height: 470px;
|
||||
flex: 1;
|
||||
display: none;
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 读取任务状态期间左侧整块不可操作:只锁提交按钮不够,用户照样能点/拖上传 */
|
||||
.vrep-page .replace-flow-panel.is-locked,
|
||||
.vrep-page .video-flow-panel.is-locked {
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
filter: saturate(0.85);
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .replace-result-panel.is-restoring .replace-restoring-state { display: grid; }
|
||||
|
||||
.vrep-page .replace-result-panel.is-restoring .video-result-placeholder,
|
||||
.vrep-page .replace-result-panel.is-restoring .replace-generating-state,
|
||||
.vrep-page .replace-result-panel.is-restoring .video-analysis-result { display: none; }
|
||||
|
||||
.vrep-page .replace-restoring-content {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
.vrep-page .replace-restoring-content strong { color: var(--text); font-size: 16px; font-weight: 600; }
|
||||
.vrep-page .replace-restoring-content span:not(.replace-restoring-spinner) {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.vrep-page .replace-restoring-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(0, 47, 167, 0.16);
|
||||
border-top-color: var(--klein);
|
||||
animation: replace-restoring-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes replace-restoring-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.vrep-page .video-flow-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -347,48 +484,23 @@
|
||||
transition: border-color 170ms ease, background-color 170ms ease, box-shadow 170ms ease, transform 170ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .replace-product-method::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.24), rgba(255, 255, 255, 0.08));
|
||||
transition: opacity 180ms ease;
|
||||
/* 选中商品后不再整块铺商品图:图一花文字就读不了,active 的蓝底也被盖没。
|
||||
改成图标位直接放缩略图,文字保持全对比度。 */
|
||||
.vrep-page .replace-product-method-background { display: none; }
|
||||
|
||||
.vrep-page .replace-product-method.has-product-preview .replace-product-method-icon {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-color: rgba(0, 47, 167, 0.22);
|
||||
background: #f1f5ff;
|
||||
}
|
||||
|
||||
.vrep-page .replace-product-method.has-product-preview::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vrep-page .replace-product-method > :not(.replace-product-method-background) {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.vrep-page .replace-product-method-background {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
.vrep-page .replace-product-method-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
|
||||
.vrep-page .replace-product-method.has-product-preview .replace-product-method-background {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.vrep-page .replace-product-method.has-product-preview .replace-product-method-copy {
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
backdrop-filter: blur(5px);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.vrep-page .replace-product-method:hover {
|
||||
|
||||
Reference in New Issue
Block a user