feat(ai): 自由创作视频生成全量移植(/free-create)——Seedance 文/图生视频+按时长计费+人物素材库
后端: - free_video.py: 提交/轮询/收藏/软删全链路,复用 AITask(新增 is_deleted/is_favorited, migration 0022) - video_pricing.py: 按时长 token 计费(×1.10 buffer+clamp); video_errors.py 错误归一; media_probe.py 时长探测 - catalog/volcano: Seedance free-video 模型接入+seed(migration 0023) - 素材库: FreeAssetGroup/FreeAsset(火山 Assets API 引用登记, migration 0009)+ 上传/轮询/删除接口 - settings: FREE_VIDEO_MAX_CONCURRENT 团队并发闸(默认3); CELERY_TASK_ALWAYS_EAGER 本地联调开关(生产恒关) - 测试: test_free_video.py 新增; billing/products/projects tests 配套调整 前端: - /free-create 页面+components/free-create/ 全套(输入栏/@mention 素材引用/生成卡/视频详情弹窗/素材库弹窗) - api.ts/types.ts 扩展 free-video 与 free-assets 接口; 路由/侧边栏入口接入 bug/: 测试清单 (11)(12) 与截图归档 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
|
||||
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ModelConfig } from "../../types";
|
||||
import {
|
||||
FC_DURATIONS,
|
||||
FC_MODELS,
|
||||
FC_RATIOS,
|
||||
FC_RESOLUTIONS,
|
||||
FC_STANDARD_MODEL,
|
||||
MODE_LABELS,
|
||||
estimateCost,
|
||||
type FreeMode,
|
||||
type LocalRef
|
||||
} from "./constants";
|
||||
|
||||
type MenuItem = { value: string; label: string; desc?: string; disabled?: boolean; hint?: string };
|
||||
|
||||
function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
label: string;
|
||||
display: string;
|
||||
items: MenuItem[];
|
||||
onSelect: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
|
||||
}, [open]);
|
||||
return (
|
||||
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
|
||||
<button type="button" className="fc-dd-btn" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
|
||||
<span className="fc-dd-lbl mono">{label}</span>
|
||||
<span className="fc-dd-val">{display}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m18 15-6-6-6 6" /></svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="fc-dd-menu">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`fc-dd-item${item.value === display || item.label === display ? " selected" : ""}${item.disabled ? " disabled" : ""}`}
|
||||
disabled={item.disabled}
|
||||
title={item.disabled ? item.hint : undefined}
|
||||
onClick={() => { if (!item.disabled) { onSelect(item.value); setOpen(false); } }}
|
||||
>
|
||||
<span className="ti">{item.label}</span>
|
||||
{item.desc && <span className="de mono">{item.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, hasPrompt, submitting, onModeChange, onModelChange, onRatioChange, onResolutionChange, onDurationChange, onSeedChange, onClear, onSend }: {
|
||||
mode: FreeMode;
|
||||
model: string;
|
||||
ratio: string;
|
||||
resolution: string;
|
||||
duration: number;
|
||||
seed: number;
|
||||
refs: LocalRef[];
|
||||
videoConfigs: ModelConfig[];
|
||||
hasPrompt: boolean;
|
||||
submitting: boolean;
|
||||
onModeChange: (mode: FreeMode) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onRatioChange: (ratio: string) => void;
|
||||
onResolutionChange: (resolution: string) => void;
|
||||
onDurationChange: (duration: number) => void;
|
||||
onSeedChange: (seed: number) => void;
|
||||
onClear: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
const isStandard = model === FC_STANDARD_MODEL;
|
||||
const config = videoConfigs.find((c) => c.name === model);
|
||||
const { tokens, cost } = estimateCost(config, { ratio, resolution, duration, refs });
|
||||
const [seedOpen, setSeedOpen] = useState(false);
|
||||
const seedRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!seedOpen) return;
|
||||
const onDown = (event: MouseEvent) => { if (!seedRef.current?.contains(event.target as Node)) setSeedOpen(false); };
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
}, [seedOpen]);
|
||||
|
||||
const uploading = refs.some((r) => r.uploading);
|
||||
const canSend = hasPrompt && !submitting && !uploading;
|
||||
|
||||
return (
|
||||
<div className="fc-toolbar">
|
||||
<div className="fc-toolbar-l">
|
||||
<FcDropdown
|
||||
label="模型"
|
||||
display={FC_MODELS.find((m) => m.name === model)?.label || model}
|
||||
items={FC_MODELS.map((m) => ({ value: m.name, label: m.label, desc: m.desc }))}
|
||||
onSelect={(value) => {
|
||||
onModelChange(value);
|
||||
// 非标准档不支持 1080P/4K:自动回落 720P(与 jimeng 行为一致)
|
||||
if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p");
|
||||
}}
|
||||
/>
|
||||
<FcDropdown
|
||||
label="模式"
|
||||
display={MODE_LABELS[mode]}
|
||||
items={[
|
||||
{ value: "universal", label: "全能参考", desc: "文生 / 图·视频·音频参考" },
|
||||
{ value: "keyframe", label: "首尾帧", desc: "首帧必填 · 尾帧可选" }
|
||||
]}
|
||||
onSelect={(value) => onModeChange(value as FreeMode)}
|
||||
/>
|
||||
<FcDropdown label="比例" display={ratio} items={FC_RATIOS.map((r) => ({ value: r, label: r }))} onSelect={onRatioChange} />
|
||||
<FcDropdown
|
||||
label="分辨率"
|
||||
display={resolution.toUpperCase()}
|
||||
items={FC_RESOLUTIONS.map((r) => ({
|
||||
value: r,
|
||||
label: r.toUpperCase(),
|
||||
disabled: (r === "1080p" || r === "4k") && !isStandard,
|
||||
hint: "仅 Seedance 2.0 标准档支持"
|
||||
}))}
|
||||
onSelect={onResolutionChange}
|
||||
/>
|
||||
<FcDropdown
|
||||
label="时长"
|
||||
display={`${duration}s`}
|
||||
items={FC_DURATIONS.map((d) => ({ value: String(d), label: `${d}s` }))}
|
||||
onSelect={(value) => onDurationChange(Number(value))}
|
||||
/>
|
||||
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
|
||||
<button type="button" className="fc-dd-btn" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
|
||||
<span className="fc-dd-lbl mono">种子</span>
|
||||
<span className="fc-dd-val">{seed === -1 ? "随机" : seed}</span>
|
||||
</button>
|
||||
{seedOpen && (
|
||||
<div className="fc-dd-menu fc-seed-menu">
|
||||
<div className="fc-seed-row">
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={seed}
|
||||
min={-1}
|
||||
onChange={(event) => {
|
||||
const v = parseInt(event.target.value, 10);
|
||||
onSeedChange(Number.isNaN(v) ? -1 : v);
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="btn btn-sm" onClick={() => { onSeedChange(-1); setSeedOpen(false); }}>随机</button>
|
||||
</div>
|
||||
<div className="fc-seed-hint mono">// -1 = 随机;相同种子可复现相似结果</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="fc-toolbar-r">
|
||||
<span className="fc-estimate mono" title="预估消耗(实际按火山返回用量结算)">
|
||||
≈ {tokens.toLocaleString()} tokens · ¥{cost.toFixed(2)}
|
||||
</span>
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={onClear}>清空</button>
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
||||
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成 →"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user