优化住流程和添加复刻视频页面

This commit is contained in:
Azmat@qq.com
2026-08-26 18:44:03 +08:00
parent 0ee498d807
commit c25060c6c6
30 changed files with 2574 additions and 155 deletions
+81 -18
View File
@@ -4,6 +4,7 @@ import {
ArrowLeft,
Boxes,
Clapperboard,
ChevronRight,
ImagePlus,
LayoutPanelTop,
RefreshCw,
@@ -30,6 +31,12 @@ import {
type BillingRates,
} from "../components/free-create/constants";
import type { NavigateFn } from "./route-config";
import {
PC_CAT_MORE,
PC_CAT_MORE_LABEL,
PC_CAT_OPTIONS,
PC_CAT_PRIMARY,
} from "../product-business";
const PROGRESS_STEPS = [
{ label: "脚本", icon: ScrollText },
@@ -85,6 +92,10 @@ function historyBadge(item: QuickCreateJob) {
return "未完成";
}
function historyCover(item: QuickCreateJob) {
return item.product_images?.[0]?.url || "";
}
function historyVideoUrl(item: QuickCreateJob) {
return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
}
@@ -113,6 +124,8 @@ export function QuickCreatePage({
modelConfigs: ModelConfig[];
}) {
const [name, setName] = useState("");
const [category, setCategory] = useState("");
const [catMoreOpen, setCatMoreOpen] = useState(false);
const [images, setImages] = useState<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = useState("");
@@ -126,7 +139,7 @@ export function QuickCreatePage({
const [unavailableMessage, setUnavailableMessage] = useState("");
const [pollEpoch, setPollEpoch] = useState(0);
const [history, setHistory] = useState<QuickCreateJob[]>([]);
const [playing, setPlaying] = useState<{ url: string; poster: string; title: string } | null>(null);
const [playing, setPlaying] = useState<{ url: string; title: string } | null>(null);
const videoConfigs = useMemo(
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
[modelConfigs],
@@ -182,6 +195,10 @@ export function QuickCreatePage({
productPrefillDoneRef.current = true;
setName((current) => current || product.title || "");
setSourceProductId((current) => current || product.id);
if (product.category && PC_CAT_OPTIONS.includes(product.category)) {
setCategory((current) => current || product.category);
setCatMoreOpen(PC_CAT_MORE.includes(product.category));
}
setSavedImages((current) => {
if (current.length) return current;
return (product.images || [])
@@ -299,12 +316,12 @@ export function QuickCreatePage({
return () => window.removeEventListener("keydown", onKey);
}, [playing]);
function playClip(url?: string, poster?: string, title?: string) {
function playClip(url?: string, title?: string) {
if (!url) {
onNotify?.("info", "视频还不能播放,请稍后再试");
return;
}
setPlaying({ url, poster: poster || "", title: title || "预览视频" });
setPlaying({ url, title: title || "预览视频" });
}
function selectImages(files: FileList | null) {
@@ -372,6 +389,10 @@ export function QuickCreatePage({
onNotify?.("info", "请先填写商品名称并上传商品图片");
return;
}
if (!category) {
onNotify?.("info", "请选择商品品类,脚本会按品类来写");
return;
}
setSubmitting(true);
setJob(null);
setServiceUnavailable(false);
@@ -381,6 +402,7 @@ export function QuickCreatePage({
try {
const form = new FormData();
form.append("name", name.trim());
form.append("category", category);
form.append("aspect_ratio", aspectRatio);
form.append("resolution", resolution);
form.append("total_duration", String(totalDuration));
@@ -433,6 +455,8 @@ export function QuickCreatePage({
function clearDraft() {
setName("");
setCategory("");
setCatMoreOpen(false);
setImages([]);
setSavedImages([]);
setSourceProductId("");
@@ -501,6 +525,7 @@ export function QuickCreatePage({
const reviewBlocked = isReviewFailure(job?.error_message);
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length;
const canStart = Boolean(name.trim() && category && imageCount && selectedVideoModel) && !reviewBlocked;
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel) && !reviewBlocked;
const activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0));
const result = job?.result;
@@ -511,7 +536,6 @@ export function QuickCreatePage({
: [];
const clipUrls = new Set(videoClips.map((clip) => clip.video_url).filter(Boolean));
const mergedUrl = result?.final_video_url || (result?.video_url && !clipUrls.has(result.video_url) ? result.video_url : "");
const posterFallback = displayUrls[0] || "";
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
const videoEstimate = estimateCost(
selectedVideoModel,
@@ -547,6 +571,46 @@ export function QuickCreatePage({
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" disabled={isGenerating} />
</label>
<div className="quick-field">
<span className="quick-field-label"><span>商品品类</span><small>必选 · 写脚本时会用到</small></span>
<div className={`quick-cat-grid${catMoreOpen ? " is-more-open" : ""}`} role="listbox" aria-label="商品品类">
{PC_CAT_PRIMARY.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => { setCategory(option); setCatMoreOpen(false); }}
>
{option}
</button>
))}
<button
type="button"
className={`chip quick-cat-chip quick-cat-more${catMoreOpen ? " is-open" : ""}`}
disabled={isGenerating}
aria-expanded={catMoreOpen}
onClick={() => setCatMoreOpen((open) => !open)}
>
<span>{PC_CAT_MORE_LABEL}</span>
<ChevronRight />
</button>
{catMoreOpen ? PC_CAT_MORE.map((option) => (
<button
type="button"
className={`chip quick-cat-chip${category === option ? " active" : ""}`}
key={option}
disabled={isGenerating}
aria-selected={category === option}
onClick={() => setCategory(option)}
>
{option}
</button>
)) : null}
</div>
</div>
<div className="quick-field">
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9张</small></span>
<div className={`quick-upload${imageCount ? " has-images" : ""}`}>
@@ -613,7 +677,7 @@ export function QuickCreatePage({
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button>
) : (
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canRetry}>
<button type="button" className="quick-generate-button" onClick={() => void startGeneration()} disabled={!canStart}>
<WandSparkles /><span>{`立即生成视频 · 消耗 ${estimatedPoints} 积分`}</span>
</button>
)}
@@ -646,24 +710,23 @@ export function QuickCreatePage({
<div className="quick-state quick-state-complete">
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
{videoClips.map((clip, index) => (
{videoClips.map((clip, index) => {
const clipUrl = clip.video_url || result?.video_url || "";
return (
<button
key={clip.id}
type="button"
className="quick-video-result-card"
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || posterFallback, `第${index + 1}场视频`)}
onClick={() => playClip(clipUrl, `第${index + 1}场视频`)}
>
<span className="quick-video-result-thumb">
{clip.poster_url || result?.poster_url || posterFallback ? (
<img src={clip.poster_url || result?.poster_url || posterFallback} alt={`第${index + 1}场视频首帧`} />
) : clip.video_url ? (
<video src={clip.video_url} muted playsInline preload="metadata" />
) : null}
{clipUrl ? <video src={clipUrl} muted playsInline preload="metadata" /> : null}
<span className="quick-video-play" aria-hidden="true"><Play /></span>
</span>
<span className="quick-video-result-meta"><strong>第{index + 1}场视频</strong><small>{clip.duration_seconds || 15}秒</small></span>
</button>
))}
);
})}
</div>
<div className="quick-result-head">
<div>
@@ -676,7 +739,7 @@ export function QuickCreatePage({
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw />重新生成</button>
{videoClips.length > 1 ? (
mergedUrl ? (
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, result?.poster_url || posterFallback, "完整视频")}>
<button type="button" className="secondary-action" onClick={() => playClip(mergedUrl, "完整视频")}>
<Play />播放
</button>
) : (
@@ -743,7 +806,7 @@ export function QuickCreatePage({
{history.length ? (
<div className="quick-history-list">
{history.map((item) => {
const poster = item.result?.poster_url || "";
const cover = historyCover(item);
const videoUrl = historyVideoUrl(item);
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
@@ -754,11 +817,11 @@ export function QuickCreatePage({
<button
type="button"
className="quick-history-thumb"
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
onClick={() => playClip(videoUrl, historyTitle(item))}
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
disabled={!canPlay}
>
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{cover ? <img src={cover} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
<small>{formatClock(duration)}</small>
</button>
@@ -798,7 +861,7 @@ export function QuickCreatePage({
<strong>{playing.title}</strong>
<button type="button" onClick={() => setPlaying(null)} aria-label="关闭播放"><X /></button>
</div>
<video src={playing.url} poster={playing.poster || undefined} controls autoPlay playsInline controlsList="nodownload" />
<video src={playing.url} controls autoPlay playsInline controlsList="nodownload" />
</div>
</div>
) : null}