206 lines
8.7 KiB
TypeScript
206 lines
8.7 KiB
TypeScript
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
|
|
// 模型下拉列后端返回的全部视频模型(FC_MODELS 只提供好看的标签)。
|
|
// 约束联动(与后端校验一致):分辨率与时长档位取自所选模型的 metadata.capabilities,
|
|
// 换档时把超出新模型能力的选择夹回去。
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ArrowRight, ChevronDown } from "lucide-react";
|
|
import type { ModelConfig } from "../../types";
|
|
import {
|
|
DEFAULT_BILLING_RATES,
|
|
FC_MODELS,
|
|
FC_RATIOS,
|
|
FC_RESOLUTIONS,
|
|
MODE_LABELS,
|
|
estimateCost,
|
|
modelDurations,
|
|
modelResolutions,
|
|
type BillingRates,
|
|
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);
|
|
const menuRef = 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]);
|
|
// 菜单封了高会滚动(时长有 27 档),打开时默认停在顶部会看不到当前选中项。
|
|
// 直接改菜单的 scrollTop,不用 scrollIntoView —— 后者会连带把整个页面滚一下。
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const menu = menuRef.current;
|
|
const active = menu?.querySelector<HTMLElement>(".fc-dd-item.selected");
|
|
if (!menu || !active) return;
|
|
menu.scrollTop = active.offsetTop - menu.clientHeight / 2 + active.offsetHeight / 2;
|
|
}, [open]);
|
|
return (
|
|
<div className={`fc-dd${open ? " open" : ""}`} ref={wrapRef}>
|
|
<button type="button" className="fc-chip" disabled={disabled} onClick={() => setOpen((v) => !v)} title={label}>
|
|
{label} <strong>{display}</strong>
|
|
<ChevronDown />
|
|
</button>
|
|
{open && (
|
|
<div className="fc-dd-menu" ref={menuRef}>
|
|
{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">{item.desc}</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, refs, videoConfigs, billingRates, 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[];
|
|
billingRates?: BillingRates;
|
|
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 config = videoConfigs.find((c) => c.name === model);
|
|
// 可选的分辨率/时长按所选模型的能力来,不再写死 —— Seedance 2.5 能出 30 秒,2.0 只有 15。
|
|
const allowedResolutions = modelResolutions(config);
|
|
const allowedDurations = modelDurations(config);
|
|
const { tokens, points } = estimateCost(config, { ratio, resolution, duration, refs }, billingRates || DEFAULT_BILLING_RATES);
|
|
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-chips">
|
|
<FcDropdown
|
|
label="模型"
|
|
display={FC_MODELS.find((m) => m.name === model)?.label || config?.display_name || model}
|
|
items={videoConfigs.map((c) => {
|
|
const known = FC_MODELS.find((m) => m.name === c.name);
|
|
return { value: c.name, label: known?.label || c.display_name || c.name, desc: known?.desc };
|
|
})}
|
|
onSelect={(value) => {
|
|
onModelChange(value);
|
|
// 换档后把分辨率/时长夹回新模型支持的范围,否则会带着上一档的选择提交然后被后端拒
|
|
const next = videoConfigs.find((c) => c.name === value);
|
|
const resolutions = modelResolutions(next);
|
|
if (!resolutions.includes(resolution)) {
|
|
onResolutionChange(resolutions.includes("720p") ? "720p" : resolutions[0]);
|
|
}
|
|
const durations = modelDurations(next);
|
|
if (!durations.includes(duration)) {
|
|
onDurationChange(durations.includes(5) ? 5 : durations[0]);
|
|
}
|
|
}}
|
|
/>
|
|
<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: !allowedResolutions.includes(r),
|
|
hint: "当前模型不支持这一档"
|
|
}))}
|
|
onSelect={onResolutionChange}
|
|
/>
|
|
<FcDropdown
|
|
label="时长"
|
|
display={`${duration}s`}
|
|
items={allowedDurations.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-chip" onClick={() => setSeedOpen((v) => !v)} title="种子值(-1 随机;相同种子 + 相同参数可复现相似结果)">
|
|
种子 <strong>{seed === -1 ? "随机" : seed}</strong>
|
|
</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">-1 = 随机;相同种子可复现相似结果</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="fc-toolbar-r">
|
|
<span className="fc-estimate" title="预估消耗(实际按真实用量结算,多退少不补超)">
|
|
≈ {tokens.toLocaleString()} tokens · {points.toLocaleString()} 积分
|
|
</span>
|
|
<button type="button" className="fc-clear" onClick={onClear}>清空</button>
|
|
<button type="button" className="fc-gen" disabled={!canSend} onClick={onSend} title="Ctrl/Cmd + Enter">
|
|
{submitting ? "提交中…" : uploading ? "素材上传中…" : "生成"}
|
|
<ArrowRight />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|