完成的商品复刻
This commit is contained in:
@@ -272,6 +272,38 @@ function modelImageCount(model: ModelEntity) {
|
||||
return [model.portrait, model.triview].filter(Boolean).length;
|
||||
}
|
||||
|
||||
interface ModeFormState {
|
||||
videoFile: File | null;
|
||||
videoPreview: string;
|
||||
videoRef: FreeVideoRef | null;
|
||||
videoUploading: boolean;
|
||||
videoMeta: { duration: number; width: number; height: number };
|
||||
source: ProductSource;
|
||||
selectedProduct: Product | null;
|
||||
selectedModel: ModelEntity | null;
|
||||
tempFiles: File[];
|
||||
tempAssetRefs: FreeVideoRef[];
|
||||
filledSubjectName: string;
|
||||
job: FreeVideoTask | null;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
const createInitialModeState = (): ModeFormState => ({
|
||||
videoFile: null,
|
||||
videoPreview: "",
|
||||
videoRef: null,
|
||||
videoUploading: false,
|
||||
videoMeta: { duration: 0, width: 0, height: 0 },
|
||||
source: "",
|
||||
selectedProduct: null,
|
||||
selectedModel: null,
|
||||
tempFiles: [],
|
||||
tempAssetRefs: [],
|
||||
filledSubjectName: "",
|
||||
job: null,
|
||||
jobId: "",
|
||||
});
|
||||
|
||||
export function VideoReplacePage({
|
||||
products: initialProducts = [],
|
||||
modelConfigs = [],
|
||||
@@ -289,26 +321,17 @@ export function VideoReplacePage({
|
||||
const [products, setProducts] = useState(initialProducts);
|
||||
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 [forms, setForms] = useState<Record<ReplaceMode, ModeFormState>>({
|
||||
product: createInitialModeState(),
|
||||
character: createInitialModeState(),
|
||||
});
|
||||
// 进页面先向后端确认有没有在跑的任务,确认完再决定显示表单还是进度
|
||||
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);
|
||||
const [selectedModel, setSelectedModel] = useState<ModelEntity | null>(null);
|
||||
const [tempFiles, setTempFiles] = useState<File[]>([]);
|
||||
const [tempPreviews, setTempPreviews] = useState<string[]>([]);
|
||||
const [tempAssetRefs, setTempAssetRefs] = useState<FreeVideoRef[]>([]);
|
||||
const [filledSubjectName, setFilledSubjectName] = useState("");
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
const [pendingProductId, setPendingProductId] = useState("");
|
||||
const [pendingModelId, setPendingModelId] = useState("");
|
||||
const [jobId, setJobId] = useState(readJobId);
|
||||
const [job, setJob] = useState<FreeVideoTask | null>(null);
|
||||
const [history, setHistory] = useState<FreeVideoTask[]>([]);
|
||||
const [expandedHistoryId, setExpandedHistoryId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -318,6 +341,47 @@ export function VideoReplacePage({
|
||||
const completedNoticeRef = useRef("");
|
||||
const wasReviewingRef = useRef(false);
|
||||
const wasDigestingRef = useRef(false);
|
||||
const formsRef = useRef(forms);
|
||||
formsRef.current = forms;
|
||||
|
||||
const currentForm = forms[replaceMode];
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
videoRef,
|
||||
videoUploading,
|
||||
videoMeta,
|
||||
source,
|
||||
selectedProduct,
|
||||
selectedModel,
|
||||
tempFiles,
|
||||
tempAssetRefs,
|
||||
filledSubjectName,
|
||||
job,
|
||||
jobId,
|
||||
} = currentForm;
|
||||
|
||||
const updateCurrentForm = (updater: Partial<ModeFormState> | ((prev: ModeFormState) => Partial<ModeFormState>)) => {
|
||||
setForms((prev) => {
|
||||
const cur = prev[replaceMode];
|
||||
const patch = typeof updater === "function" ? updater(cur) : updater;
|
||||
return {
|
||||
...prev,
|
||||
[replaceMode]: { ...cur, ...patch },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateModeForm = (mode: ReplaceMode, updater: Partial<ModeFormState> | ((prev: ModeFormState) => Partial<ModeFormState>)) => {
|
||||
setForms((prev) => {
|
||||
const cur = prev[mode];
|
||||
const patch = typeof updater === "function" ? updater(cur) : updater;
|
||||
return {
|
||||
...prev,
|
||||
[mode]: { ...cur, ...patch },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const videoConfigs = useMemo(
|
||||
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
|
||||
@@ -335,8 +399,8 @@ export function VideoReplacePage({
|
||||
: source === "temporary"
|
||||
? (tempFiles[0]
|
||||
? (tempFiles.length > 1
|
||||
? `${tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback}(${tempFiles.length}张参考图)`
|
||||
: (tempFiles[0].name.replace(/\.[^.]+$/, "") || copy.temporaryFallback))
|
||||
? `${tempFiles[0].name.replace(/\\.[^.]+$/, "") || copy.temporaryFallback}(${tempFiles.length}张参考图)`
|
||||
: (tempFiles[0].name.replace(/\\.[^.]+$/, "") || copy.temporaryFallback))
|
||||
: (filledSubjectName || copy.temporaryFallback))
|
||||
: "";
|
||||
const productReady = source === "library"
|
||||
@@ -413,20 +477,22 @@ export function VideoReplacePage({
|
||||
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"),
|
||||
updateModeForm(mode, {
|
||||
job: running,
|
||||
jobId: running.id,
|
||||
videoPreview: video?.url || "",
|
||||
videoRef: video ? { ...video, type: "video", role: "reference_video", label: video.label || "参考视频" } : null,
|
||||
filledSubjectName: subjectNameFromTask(running),
|
||||
videoMeta: {
|
||||
duration: Number(video?.duration || running.duration || 0),
|
||||
...sizeFromRatio(running.aspect_ratio || "9:16"),
|
||||
},
|
||||
});
|
||||
rememberJob(running.id);
|
||||
} else {
|
||||
forgetJob();
|
||||
}
|
||||
@@ -444,6 +510,14 @@ export function VideoReplacePage({
|
||||
}))
|
||||
.catch(() => undefined);
|
||||
void restoreInflight();
|
||||
|
||||
return () => {
|
||||
Object.values(formsRef.current).forEach((f) => {
|
||||
if (f.videoPreview.startsWith("blob:")) {
|
||||
try { URL.revokeObjectURL(f.videoPreview); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -459,20 +533,18 @@ export function VideoReplacePage({
|
||||
|
||||
useBodyScrollLock(libraryOpen);
|
||||
|
||||
const productJobId = forms.product.jobId;
|
||||
const characterJobId = forms.character.jobId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return;
|
||||
if (!productJobId) return;
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await api.pollVideoReplace(jobId);
|
||||
const data = await api.pollVideoReplace(productJobId);
|
||||
if (cancelled) return;
|
||||
setJob(data.task);
|
||||
if (isInFlight(data.task.status)) {
|
||||
const jobMode = modeFromTask(data.task);
|
||||
setReplaceMode(jobMode);
|
||||
rememberReplaceMode(jobMode);
|
||||
}
|
||||
updateModeForm("product", { job: data.task });
|
||||
const stillDigesting = isDigesting(data.task);
|
||||
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
|
||||
if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current;
|
||||
@@ -489,21 +561,20 @@ export function VideoReplacePage({
|
||||
if (data.task.status === "succeeded") {
|
||||
if (completedNoticeRef.current !== data.task.id) {
|
||||
completedNoticeRef.current = data.task.id;
|
||||
onNotify("success", "视频复刻成片已生成");
|
||||
onNotify("success", "商品复刻成片已生成");
|
||||
onTaskSettled?.();
|
||||
}
|
||||
void loadHistory();
|
||||
} else if (data.task.status === "failed") {
|
||||
onNotify("error", data.task.error_message || "视频复刻未完成,请重试");
|
||||
onNotify("error", data.task.error_message || "商品复刻未完成,请重试");
|
||||
onTaskSettled?.();
|
||||
}
|
||||
setJobId("");
|
||||
updateModeForm("product", { jobId: "" });
|
||||
forgetJob();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setJob(null);
|
||||
setJobId("");
|
||||
updateModeForm("product", { job: null, jobId: "" });
|
||||
forgetJob();
|
||||
return;
|
||||
}
|
||||
@@ -515,7 +586,59 @@ export function VideoReplacePage({
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [jobId, onNotify, onTaskSettled]);
|
||||
}, [productJobId, onNotify, onTaskSettled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!characterJobId) return;
|
||||
let cancelled = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await api.pollVideoReplace(characterJobId);
|
||||
if (cancelled) return;
|
||||
updateModeForm("character", { job: data.task });
|
||||
const stillDigesting = isDigesting(data.task);
|
||||
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
|
||||
if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current;
|
||||
if (stillReviewing) wasReviewingRef.current = true;
|
||||
else if (wasReviewingRef.current && isInFlight(data.task.status)) {
|
||||
wasReviewingRef.current = false;
|
||||
onNotify("success", wasDigestingRef.current ? "分镜稿已提炼完成,正在复刻" : "素材审核已通过,正在复刻");
|
||||
wasDigestingRef.current = false;
|
||||
}
|
||||
if (isInFlight(data.task.status)) {
|
||||
timer = window.setTimeout(poll, stillReviewing ? 2000 : 2500);
|
||||
return;
|
||||
}
|
||||
if (data.task.status === "succeeded") {
|
||||
if (completedNoticeRef.current !== data.task.id) {
|
||||
completedNoticeRef.current = data.task.id;
|
||||
onNotify("success", "角色复刻成片已生成");
|
||||
onTaskSettled?.();
|
||||
}
|
||||
void loadHistory();
|
||||
} else if (data.task.status === "failed") {
|
||||
onNotify("error", data.task.error_message || "角色复刻未完成,请重试");
|
||||
onTaskSettled?.();
|
||||
}
|
||||
updateModeForm("character", { jobId: "" });
|
||||
forgetJob();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
updateModeForm("character", { job: null, jobId: "" });
|
||||
forgetJob();
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(poll, 8000);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [characterJobId, onNotify, onTaskSettled]);
|
||||
|
||||
const pickVideo = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
@@ -531,38 +654,49 @@ export function VideoReplacePage({
|
||||
onNotify("error", "只支持 mp4 / mov 视频");
|
||||
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 previewUrl = URL.createObjectURL(file);
|
||||
updateCurrentForm((cur) => {
|
||||
if (cur.videoPreview.startsWith("blob:")) {
|
||||
try { URL.revokeObjectURL(cur.videoPreview); } catch { /* ignore */ }
|
||||
}
|
||||
return {
|
||||
videoFile: file,
|
||||
videoPreview: previewUrl,
|
||||
videoUploading: true,
|
||||
job: cur.job && isInFlight(cur.job.status) ? cur.job : null,
|
||||
};
|
||||
});
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("purpose", PRODUCT_SOURCE_PURPOSE);
|
||||
try {
|
||||
const uploaded = await api.uploadFreeVideoRef(form);
|
||||
setVideoRef({
|
||||
url: uploaded.url,
|
||||
type: "video",
|
||||
role: "reference_video",
|
||||
label: "参考视频",
|
||||
thumb_url: uploaded.thumb_url,
|
||||
duration: uploaded.duration || check.duration,
|
||||
asset_id: uploaded.asset_id,
|
||||
source: "upload",
|
||||
});
|
||||
setVideoMeta({
|
||||
duration: uploaded.duration || check.duration || 0,
|
||||
width: uploaded.width || 0,
|
||||
height: uploaded.height || 0,
|
||||
updateCurrentForm({
|
||||
videoRef: {
|
||||
url: uploaded.url,
|
||||
type: "video",
|
||||
role: "reference_video",
|
||||
label: "参考视频",
|
||||
thumb_url: uploaded.thumb_url,
|
||||
duration: uploaded.duration || check.duration,
|
||||
asset_id: uploaded.asset_id,
|
||||
source: "upload",
|
||||
},
|
||||
videoMeta: {
|
||||
duration: uploaded.duration || check.duration || 0,
|
||||
width: uploaded.width || 0,
|
||||
height: uploaded.height || 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
setVideoFile(null);
|
||||
setVideoRef(null);
|
||||
setVideoPreview("");
|
||||
updateCurrentForm({
|
||||
videoFile: null,
|
||||
videoRef: null,
|
||||
videoPreview: "",
|
||||
});
|
||||
onNotify("error", error instanceof Error ? error.message : "参考视频上传失败");
|
||||
} finally {
|
||||
setVideoUploading(false);
|
||||
updateCurrentForm({ videoUploading: false });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -572,78 +706,42 @@ export function VideoReplacePage({
|
||||
onNotify("error", "请选择 JPG、PNG 或 WebP 图片");
|
||||
return;
|
||||
}
|
||||
setTempFiles((current) => {
|
||||
const existing = new Set(current.map(fileKey));
|
||||
updateCurrentForm((cur) => {
|
||||
const existing = new Set(cur.tempFiles.map(fileKey));
|
||||
const unique = incoming.filter((file) => {
|
||||
const key = fileKey(file);
|
||||
if (existing.has(key)) return false;
|
||||
existing.add(key);
|
||||
return true;
|
||||
});
|
||||
const room = Math.max(0, MAX_IMAGES - current.length);
|
||||
const room = Math.max(0, MAX_IMAGES - cur.tempFiles.length);
|
||||
if (room === 0) {
|
||||
onNotify("info", `最多上传9张${copy.temporaryNoun}`);
|
||||
return current;
|
||||
return {};
|
||||
}
|
||||
if (!unique.length) {
|
||||
onNotify("info", "所选图片已在九宫格中");
|
||||
return current;
|
||||
return {};
|
||||
}
|
||||
if (unique.length > room) onNotify("info", `最多上传9张图片,已保留前${room}张`);
|
||||
return [...current, ...unique.slice(0, room)];
|
||||
return {
|
||||
tempFiles: [...cur.tempFiles, ...unique.slice(0, room)],
|
||||
source: "temporary",
|
||||
selectedProduct: null,
|
||||
selectedModel: null,
|
||||
tempAssetRefs: [],
|
||||
filledSubjectName: "",
|
||||
job: cur.job && !isInFlight(cur.job.status) ? null : cur.job,
|
||||
};
|
||||
});
|
||||
setSource("temporary");
|
||||
setSelectedProduct(null);
|
||||
setSelectedModel(null);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
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;
|
||||
// 两种模式都按 30 秒收口。带着超长视频切过去会一路走到提交才报错,这里直接清掉。
|
||||
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("");
|
||||
setSelectedProduct(null);
|
||||
setSelectedModel(null);
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
setPendingProductId("");
|
||||
setPendingModelId("");
|
||||
setLibraryOpen(false);
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify(
|
||||
"info",
|
||||
tooLongForNext
|
||||
? `已切换为${REPLACE_MODE_COPY[next].modeLabel},参考视频最长 ${REF_SECONDS_MAX[next]} 秒,请重新上传`
|
||||
: `已切换为${REPLACE_MODE_COPY[next].modeLabel}`
|
||||
);
|
||||
onNotify("info", `已切换为${REPLACE_MODE_COPY[next].modeLabel}`);
|
||||
};
|
||||
|
||||
const confirmLibrarySelection = () => {
|
||||
@@ -657,15 +755,17 @@ export function VideoReplacePage({
|
||||
onNotify("error", "这个角色还没有可用图片");
|
||||
return;
|
||||
}
|
||||
setSelectedModel(model);
|
||||
setSelectedProduct(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
updateModeForm("character", (cur) => ({
|
||||
selectedModel: model,
|
||||
selectedProduct: null,
|
||||
source: "library",
|
||||
tempFiles: [],
|
||||
tempAssetRefs: [],
|
||||
filledSubjectName: "",
|
||||
job: cur.job && isInFlight(cur.job.status) ? cur.job : null,
|
||||
}));
|
||||
setLibraryOpen(false);
|
||||
setPendingModelId("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("success", `${copy.pickToast}:${model.name}`);
|
||||
return;
|
||||
}
|
||||
@@ -674,41 +774,44 @@ export function VideoReplacePage({
|
||||
onNotify("info", "请先选择或上传商品素材");
|
||||
return;
|
||||
}
|
||||
setSelectedProduct(product);
|
||||
setSelectedModel(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
updateModeForm("product", (cur) => ({
|
||||
selectedProduct: product,
|
||||
selectedModel: null,
|
||||
source: "library",
|
||||
tempFiles: [],
|
||||
tempAssetRefs: [],
|
||||
filledSubjectName: "",
|
||||
job: cur.job && isInFlight(cur.job.status) ? cur.job : null,
|
||||
}));
|
||||
setLibraryOpen(false);
|
||||
setPendingProductId("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
onNotify("success", `${copy.pickToast}:${product.title}`);
|
||||
};
|
||||
|
||||
const startGeneration = async () => {
|
||||
const current = forms[replaceMode];
|
||||
if (!videoReady || !productReady || generating) return;
|
||||
if (!preferredModel) {
|
||||
onNotify("error", "暂无可用视频模型");
|
||||
return;
|
||||
}
|
||||
if (!videoRef?.asset_id) {
|
||||
if (!current.videoRef?.asset_id) {
|
||||
onNotify("error", "请先上传参考视频");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let imageAssetIds: string[] = [];
|
||||
if (source === "temporary") {
|
||||
if (tempFiles.length) {
|
||||
for (const file of tempFiles.slice(0, MAX_IMAGES)) {
|
||||
if (current.source === "temporary") {
|
||||
if (current.tempFiles.length) {
|
||||
for (const file of current.tempFiles.slice(0, MAX_IMAGES)) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const data = await api.uploadFreeVideoRef(form);
|
||||
if (data.asset_id) imageAssetIds.push(data.asset_id);
|
||||
}
|
||||
} else {
|
||||
imageAssetIds = tempAssetRefs.map((item) => item.asset_id || "").filter(Boolean);
|
||||
imageAssetIds = current.tempAssetRefs.map((item) => item.asset_id || "").filter(Boolean);
|
||||
}
|
||||
if (!imageAssetIds.length) {
|
||||
onNotify("error", replaceMode === "character" ? "请上传角色参考图" : "请上传商品参考图");
|
||||
@@ -717,19 +820,22 @@ export function VideoReplacePage({
|
||||
}
|
||||
const data = await api.submitVideoReplace({
|
||||
replace_mode: replaceMode,
|
||||
video_asset_id: videoRef.asset_id,
|
||||
product_id: source === "library" && replaceMode === "product" ? selectedProduct?.id : undefined,
|
||||
model_id: source === "library" && replaceMode === "character" ? selectedModel?.id : undefined,
|
||||
image_asset_ids: source === "temporary" ? imageAssetIds : undefined,
|
||||
video_asset_id: current.videoRef.asset_id,
|
||||
product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined,
|
||||
model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined,
|
||||
image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined,
|
||||
model: preferredModel.name,
|
||||
aspect_ratio: aspectRatio,
|
||||
resolution: "720p",
|
||||
duration: outputDuration,
|
||||
});
|
||||
setJob(data.task);
|
||||
setJobId(data.task.id);
|
||||
const taskMode = modeFromTask(data.task) || replaceMode;
|
||||
updateModeForm(taskMode, {
|
||||
job: data.task,
|
||||
jobId: data.task.id,
|
||||
});
|
||||
rememberJob(data.task.id);
|
||||
rememberReplaceMode(modeFromTask(data.task) || replaceMode);
|
||||
rememberReplaceMode(taskMode);
|
||||
completedNoticeRef.current = "";
|
||||
wasReviewingRef.current = data.task.review_stage === "reviewing" || data.task.status === "created";
|
||||
wasDigestingRef.current = isDigesting(data.task);
|
||||
@@ -750,8 +856,11 @@ export function VideoReplacePage({
|
||||
// 后端单飞闸:已有复刻在跑。直接接上它,别让用户对着报错干瞪眼。
|
||||
const running = (error.payload as { inflight?: FreeVideoTask } | undefined)?.inflight;
|
||||
if (running) {
|
||||
setJob(running);
|
||||
setJobId(running.id);
|
||||
const taskMode = modeFromTask(running);
|
||||
updateModeForm(taskMode, {
|
||||
job: running,
|
||||
jobId: running.id,
|
||||
});
|
||||
rememberJob(running.id);
|
||||
}
|
||||
onNotify("info", error.message || "已有一个视频正在复刻中");
|
||||
@@ -764,8 +873,8 @@ export function VideoReplacePage({
|
||||
};
|
||||
|
||||
const fillFormFromTask = (task: FreeVideoTask) => {
|
||||
if (generating) return;
|
||||
const mode = modeFromTask(task);
|
||||
if (forms[mode].job && isInFlight(forms[mode].job.status)) return;
|
||||
const video = videoRefFromTask(task);
|
||||
if (!video?.asset_id) {
|
||||
onNotify("error", "这条记录没有可用的参考视频,请重新上传");
|
||||
@@ -775,54 +884,55 @@ export function VideoReplacePage({
|
||||
const subject = subjectNameFromTask(task);
|
||||
setReplaceMode(mode);
|
||||
rememberReplaceMode(mode);
|
||||
setVideoFile(null);
|
||||
// 从历史「重新生成」回填时也要把原视频放进预览框,否则第 1 步看起来像没选
|
||||
setVideoPreview(video.url || "");
|
||||
setVideoRef({
|
||||
...video,
|
||||
type: "video",
|
||||
role: "reference_video",
|
||||
label: video.label || "参考视频",
|
||||
});
|
||||
setVideoMeta({
|
||||
duration: Number(video.duration || task.duration || 0),
|
||||
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
||||
});
|
||||
setFilledSubjectName(subject);
|
||||
|
||||
if (mode === "character") {
|
||||
const model = models.find((item) => item.id === task.model_id)
|
||||
|| models.find((item) => item.name === subject);
|
||||
if (model) {
|
||||
setSelectedModel(model);
|
||||
setSelectedProduct(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
} else {
|
||||
setSelectedModel(null);
|
||||
setSelectedProduct(null);
|
||||
setSource(images.length ? "temporary" : "");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs(images);
|
||||
if (!model && task.subject_source === "library") onNotify("info", "原角色已不在人物库,请重新选择");
|
||||
}
|
||||
updateModeForm("character", {
|
||||
videoFile: null,
|
||||
videoPreview: video.url || "",
|
||||
videoRef: {
|
||||
...video,
|
||||
type: "video",
|
||||
role: "reference_video",
|
||||
label: video.label || "参考视频",
|
||||
},
|
||||
videoMeta: {
|
||||
duration: Number(video.duration || task.duration || 0),
|
||||
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
||||
},
|
||||
filledSubjectName: subject,
|
||||
selectedModel: model || null,
|
||||
selectedProduct: null,
|
||||
source: model ? "library" : (images.length ? "temporary" : ""),
|
||||
tempFiles: [],
|
||||
tempAssetRefs: model ? [] : images,
|
||||
});
|
||||
if (!model && task.subject_source === "library") onNotify("info", "原角色已不在人物库,请重新选择");
|
||||
} else {
|
||||
const product = products.find((item) => item.id === task.product_id)
|
||||
|| products.find((item) => item.title === subject);
|
||||
if (product) {
|
||||
setSelectedProduct(product);
|
||||
setSelectedModel(null);
|
||||
setSource("library");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
} else {
|
||||
setSelectedProduct(null);
|
||||
setSelectedModel(null);
|
||||
setSource(images.length ? "temporary" : "");
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs(images);
|
||||
if (!product && task.subject_source === "library") onNotify("info", "原商品已不在商品库,请重新选择");
|
||||
}
|
||||
updateModeForm("product", {
|
||||
videoFile: null,
|
||||
videoPreview: video.url || "",
|
||||
videoRef: {
|
||||
...video,
|
||||
type: "video",
|
||||
role: "reference_video",
|
||||
label: video.label || "参考视频",
|
||||
},
|
||||
videoMeta: {
|
||||
duration: Number(video.duration || task.duration || 0),
|
||||
...sizeFromRatio(task.aspect_ratio || "9:16"),
|
||||
},
|
||||
filledSubjectName: subject,
|
||||
selectedProduct: product || null,
|
||||
selectedModel: null,
|
||||
source: product ? "library" : (images.length ? "temporary" : ""),
|
||||
tempFiles: [],
|
||||
tempAssetRefs: product ? [] : images,
|
||||
});
|
||||
if (!product && task.subject_source === "library") onNotify("info", "原商品已不在商品库,请重新选择");
|
||||
}
|
||||
onNotify("success", "已填入上次素材,确认后可再次生成");
|
||||
document.querySelector(".replace-flow-panel")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
@@ -844,6 +954,16 @@ export function VideoReplacePage({
|
||||
setExpandedHistoryId((current) => (current === id ? "" : id));
|
||||
};
|
||||
|
||||
const tempDrop = useFileDrop(
|
||||
(files) => addTempImages(files),
|
||||
{ disabled: generating }
|
||||
);
|
||||
|
||||
const videoDrop = useFileDrop(
|
||||
(files) => { void pickVideo(files[0] || null); },
|
||||
{ disabled: generating || videoUploading }
|
||||
);
|
||||
|
||||
const cells = Array.from({ length: 9 }, (_, index) => tempDisplay[index] || null);
|
||||
|
||||
return (
|
||||
@@ -1036,12 +1156,17 @@ export function VideoReplacePage({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (generating) return;
|
||||
if (tempFiles.length) {
|
||||
setTempFiles((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
} else {
|
||||
setTempAssetRefs((current) => current.filter((_, itemIndex) => itemIndex !== index));
|
||||
}
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
updateCurrentForm((cur) => {
|
||||
const nextFiles = cur.tempFiles.filter((_, itemIndex) => itemIndex !== index);
|
||||
const nextAssetRefs = cur.tempAssetRefs.filter((_, itemIndex) => itemIndex !== index);
|
||||
const nextSource = (nextFiles.length > 0 || nextAssetRefs.length > 0) ? cur.source : "";
|
||||
return {
|
||||
tempFiles: nextFiles,
|
||||
tempAssetRefs: nextAssetRefs,
|
||||
source: nextSource,
|
||||
job: cur.job && !isInFlight(cur.job.status) ? null : cur.job,
|
||||
};
|
||||
});
|
||||
onNotify("info", `已删除临时${copy.temporaryNoun}`);
|
||||
}}
|
||||
>
|
||||
@@ -1064,12 +1189,15 @@ export function VideoReplacePage({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (generating) return;
|
||||
if (!tempFiles.length && !tempAssetRefs.length) return;
|
||||
setTempFiles([]);
|
||||
setTempAssetRefs([]);
|
||||
setFilledSubjectName("");
|
||||
if (source === "temporary") setSource("");
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
const cur = forms[replaceMode];
|
||||
if (!cur.tempFiles.length && !cur.tempAssetRefs.length) return;
|
||||
updateCurrentForm((c) => ({
|
||||
tempFiles: [],
|
||||
tempAssetRefs: [],
|
||||
filledSubjectName: "",
|
||||
source: c.source === "temporary" ? "" : c.source,
|
||||
job: c.job && !isInFlight(c.job.status) ? null : c.job,
|
||||
}));
|
||||
onNotify("info", `已清空临时${copy.temporaryNoun}`);
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user