添加seedance2.5
This commit is contained in:
@@ -9,17 +9,44 @@ export type LocalRef = FreeVideoRef & {
|
||||
uploading?: boolean; // 上传中(url 是 blob: 预览,禁止提交)
|
||||
};
|
||||
|
||||
// 只提供好看的标签/副标题;能出现哪些模型由后端返回的 modelConfigs 决定(见 free-create.tsx videoConfigs)。
|
||||
// 新模型上线只要后端有数据就会出现在下拉里,不再需要发一次前端。
|
||||
export const FC_MODELS = [
|
||||
{ name: "doubao-seedance-2-5-260628", label: "Seedance 2.5", desc: "最长 30 秒 · 支持 1080P / 4K" },
|
||||
{ name: "doubao-seedance-2-0-260128", label: "Seedance 2.0", desc: "标准档 · 支持 1080P / 4K" },
|
||||
{ name: "doubao-seedance-2-0-fast-260128", label: "Seedance 2.0 Fast", desc: "更快出片 · 480P / 720P" },
|
||||
{ name: "doubao-seedance-2-0-mini-260615", label: "Seedance 2.0 Mini", desc: "轻量便宜 · 480P / 720P" }
|
||||
] as const;
|
||||
export const FC_STANDARD_MODEL = FC_MODELS[0].name;
|
||||
// 默认选中的模型。按名字写死,不跟 FC_MODELS 的排序走 —— 调换顺序不该改默认档。
|
||||
export const FC_STANDARD_MODEL = "doubao-seedance-2-0-260128";
|
||||
|
||||
export const FC_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const;
|
||||
export const FC_RESOLUTIONS = ["480p", "720p", "1080p", "4k"] as const;
|
||||
export const FC_DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] as const;
|
||||
|
||||
type ModelCapabilities = { resolutions?: string[]; durations?: number[] };
|
||||
|
||||
function capabilitiesOf(config: ModelConfig | undefined): ModelCapabilities {
|
||||
const meta = (config?.metadata || {}) as Record<string, unknown>;
|
||||
const caps = (meta.capabilities || {}) as ModelCapabilities;
|
||||
return {
|
||||
resolutions: caps.resolutions || (meta.resolutions as string[] | undefined),
|
||||
durations: caps.durations || (meta.durations as number[] | undefined)
|
||||
};
|
||||
}
|
||||
|
||||
/** 该模型支持的分辨率档。后端没配就回落全集(由后端提交校验兜底)。 */
|
||||
export function modelResolutions(config: ModelConfig | undefined): string[] {
|
||||
const list = capabilitiesOf(config).resolutions;
|
||||
return list?.length ? list : [...FC_RESOLUTIONS];
|
||||
}
|
||||
|
||||
/** 该模型支持的出片时长。Seedance 2.5 能到 30 秒,2.0 只有 15 —— 写死会把新模型卡住。 */
|
||||
export function modelDurations(config: ModelConfig | undefined): number[] {
|
||||
const list = capabilitiesOf(config).durations;
|
||||
return list?.length ? list : [...FC_DURATIONS];
|
||||
}
|
||||
|
||||
export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
|
||||
|
||||
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// 自由创作·输入条工具栏:模型/模式/比例/分辨率/时长/种子 下拉 + 预估消耗 + 清空 + 生成。
|
||||
// 约束联动(与后端校验一致):1080P/4K 仅标准档;切非标准档时分辨率自动回落 720P。
|
||||
// 模型下拉列后端返回的全部视频模型(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_DURATIONS,
|
||||
FC_MODELS,
|
||||
FC_RATIOS,
|
||||
FC_RESOLUTIONS,
|
||||
FC_STANDARD_MODEL,
|
||||
MODE_LABELS,
|
||||
estimateCost,
|
||||
modelDurations,
|
||||
modelResolutions,
|
||||
type BillingRates,
|
||||
type FreeMode,
|
||||
type LocalRef
|
||||
@@ -28,6 +30,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDown = (event: MouseEvent) => {
|
||||
@@ -38,6 +41,15 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
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}>
|
||||
@@ -45,7 +57,7 @@ function FcDropdown({ label, display, items, onSelect, disabled }: {
|
||||
<ChevronDown />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="fc-dd-menu">
|
||||
<div className="fc-dd-menu" ref={menuRef}>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
@@ -86,8 +98,10 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
onClear: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
const isStandard = model === FC_STANDARD_MODEL;
|
||||
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);
|
||||
@@ -106,11 +120,23 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
<div className="fc-chips">
|
||||
<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 }))}
|
||||
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);
|
||||
if (value !== FC_STANDARD_MODEL && (resolution === "1080p" || resolution === "4k")) onResolutionChange("720p");
|
||||
// 换档后把分辨率/时长夹回新模型支持的范围,否则会带着上一档的选择提交然后被后端拒
|
||||
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
|
||||
@@ -129,15 +155,15 @@ export function FreeToolbar({ mode, model, ratio, resolution, duration, seed, re
|
||||
items={FC_RESOLUTIONS.map((r) => ({
|
||||
value: r,
|
||||
label: r.toUpperCase(),
|
||||
disabled: (r === "1080p" || r === "4k") && !isStandard,
|
||||
hint: "仅 Seedance 2.0 标准档支持"
|
||||
disabled: !allowedResolutions.includes(r),
|
||||
hint: "当前模型不支持这一档"
|
||||
}))}
|
||||
onSelect={onResolutionChange}
|
||||
/>
|
||||
<FcDropdown
|
||||
label="时长"
|
||||
display={`${duration}s`}
|
||||
items={FC_DURATIONS.map((d) => ({ value: String(d), label: `${d}s` }))}
|
||||
items={allowedDurations.map((d) => ({ value: String(d), label: `${d}s` }))}
|
||||
onSelect={(value) => onDurationChange(Number(value))}
|
||||
/>
|
||||
<div className={`fc-dd${seedOpen ? " open" : ""}`} ref={seedRef}>
|
||||
|
||||
@@ -717,7 +717,20 @@
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 30px rgba(20, 27, 38, 0.12);
|
||||
padding: 6px;
|
||||
/* 时长档随模型走(Seedance 2.5 有 4–30 共 27 项),菜单会顶穿视口。
|
||||
封顶后内部滚动;上限跟着视口收,矮屏也不会被切掉。 */
|
||||
max-height: min(320px, calc(100vh - 160px));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* 细滚动条:菜单本来就窄,系统默认那条会压掉一截可读宽度 */
|
||||
.fc-page .fc-dd-menu::-webkit-scrollbar { width: 6px; }
|
||||
.fc-page .fc-dd-menu::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(34, 42, 54, 0.18);
|
||||
}
|
||||
.fc-page .fc-dd-menu::-webkit-scrollbar-track { background: transparent; }
|
||||
.fc-page .fc-dd-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -102,8 +102,20 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
const onTaskSettledRef = useRef(onTaskSettled);
|
||||
onTaskSettledRef.current = onTaskSettled;
|
||||
const refreshGlobalShell = useCallback(() => onTaskSettledRef.current(), []);
|
||||
// 后端返回什么视频模型就给什么,不再拿 FC_MODELS 当白名单过滤 ——
|
||||
// 之前后台新加的模型(如 Seedance 2.5)因为不在这份写死的清单里,永远不会出现在下拉中。
|
||||
// FC_MODELS 现在只用来给已知模型配好看的标签,认不出的回落 display_name。
|
||||
// 排序按 FC_MODELS 的先后(2.5 在最前),不在清单里的模型排到后面、保持后端顺序。
|
||||
const videoConfigs = useMemo(
|
||||
() => modelConfigs.filter((c) => c.capability === "video" && FC_MODELS.some((m) => m.name === c.name)),
|
||||
() => modelConfigs
|
||||
.filter((c) => c.capability === "video")
|
||||
.sort((a, b) => {
|
||||
const rank = (name: string) => {
|
||||
const index = FC_MODELS.findIndex((m) => m.name === name);
|
||||
return index === -1 ? FC_MODELS.length : index;
|
||||
};
|
||||
return rank(a.name) - rank(b.name);
|
||||
}),
|
||||
[modelConfigs]
|
||||
);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色"
|
||||
// 脚本来源 → 「来源」brief pill 文案
|
||||
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
|
||||
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
|
||||
// 上传自有脚本 / 上传视频提炼入口暂隐,改 true 即可恢复
|
||||
const SHOW_SCRIPT_IMPORT = false;
|
||||
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
||||
const VO_VOICES = [
|
||||
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
|
||||
@@ -3175,7 +3177,7 @@ export function PipelinePage(props: {
|
||||
<div className="shots-empty">
|
||||
<div className="empty-ico"><LayoutList /></div>
|
||||
<div className="empty-title">还没有镜头脚本</div>
|
||||
<div className="empty-hint">从右侧选择辅助生成,或上传已有脚本,镜头内容会展示在这里。</div>
|
||||
<div className="empty-hint">{SHOW_SCRIPT_IMPORT ? "从右侧选择辅助生成,或上传已有脚本,镜头内容会展示在这里。" : "从右侧选择辅助生成,镜头内容会展示在这里。"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3262,11 +3264,15 @@ export function PipelinePage(props: {
|
||||
) : (
|
||||
<div className="chat-empty">
|
||||
<div className="ce-title">选择脚本创建方式</div>
|
||||
<div className="ce-hint">使用商品信息快速辅助生成,或导入你已经准备好的脚本。</div>
|
||||
<div className="ce-hint">{SHOW_SCRIPT_IMPORT ? "使用商品信息快速辅助生成,或导入你已经准备好的脚本。" : "使用商品信息快速辅助生成第一版脚本。"}</div>
|
||||
<div className="chat-modes">
|
||||
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><Sparkles /><span>辅助生成脚本</span></button>
|
||||
{SHOW_SCRIPT_IMPORT && (
|
||||
<>
|
||||
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("manual")}><Upload /><span>上传自有脚本</span></button>
|
||||
<button className={`chat-mode${chatMode === "video" ? " primary" : ""}`} type="button" data-mode="video" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("video")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg><span>上传视频提炼</span></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -3305,6 +3311,8 @@ export function PipelinePage(props: {
|
||||
<input ref={chatFileRef} type="file" accept=".docx,.txt" style={{ display: "none" }} onChange={(event) => void onPickScriptFile(event)} />
|
||||
<input ref={videoFileRef} type="file" accept=".mp4,.mov,.m4v,.webm" style={{ display: "none" }} onChange={(event) => void onPickVideoFile(event)} />
|
||||
<div className="chat-input-foot">
|
||||
{SHOW_SCRIPT_IMPORT && (
|
||||
<>
|
||||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本(docx / txt)" aria-label="上传脚本(docx / txt)" disabled={scriptFileBusy} onClick={() => chatFileRef.current?.click()}>
|
||||
{scriptFileBusy
|
||||
? <span className="spinner" aria-hidden="true"></span>
|
||||
@@ -3316,6 +3324,8 @@ export function PipelinePage(props: {
|
||||
? <span className="spinner" aria-hidden="true"></span>
|
||||
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
|
||||
{scriptPickerModels.length > 0 ? (
|
||||
<div ref={modelPickRef} className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
|
||||
@@ -540,7 +540,7 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 3 分钟 · ≤200MB</span>
|
||||
<span>MP4 / MOV · 最长 30 秒 · ≤200MB</span>
|
||||
</div>
|
||||
{previewUrl ? (
|
||||
<div
|
||||
|
||||
@@ -38,13 +38,12 @@ 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 秒。
|
||||
// 复刻(商品 / 角色)统一走 Seedance 2.5,单次能出 30 秒,参考视频也按 30 秒收口。
|
||||
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;
|
||||
const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
|
||||
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const;
|
||||
// Seedance 2.5 单次出片上限。参考视频更长时不报错,成片按这个截断。
|
||||
const SEEDANCE_MAX_OUTPUT_SECONDS = 30;
|
||||
|
||||
type ProductSource = "library" | "temporary" | "";
|
||||
type ReplaceMode = "product" | "character";
|
||||
@@ -471,9 +470,11 @@ export function VideoReplacePage({
|
||||
const data = await api.pollVideoReplace(jobId);
|
||||
if (cancelled) return;
|
||||
setJob(data.task);
|
||||
const jobMode = modeFromTask(data.task);
|
||||
setReplaceMode(jobMode);
|
||||
rememberReplaceMode(jobMode);
|
||||
if (isInFlight(data.task.status)) {
|
||||
const jobMode = modeFromTask(data.task);
|
||||
setReplaceMode(jobMode);
|
||||
rememberReplaceMode(jobMode);
|
||||
}
|
||||
const stillDigesting = isDigesting(data.task);
|
||||
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
|
||||
if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current;
|
||||
@@ -498,6 +499,7 @@ export function VideoReplacePage({
|
||||
onNotify("error", data.task.error_message || "视频复刻未完成,请重试");
|
||||
onTaskSettled?.();
|
||||
}
|
||||
setJobId("");
|
||||
forgetJob();
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
@@ -538,7 +540,7 @@ export function VideoReplacePage({
|
||||
if (job && !isInFlight(job.status)) setJob(null);
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (replaceMode === "product") form.append("purpose", PRODUCT_SOURCE_PURPOSE);
|
||||
form.append("purpose", PRODUCT_SOURCE_PURPOSE);
|
||||
try {
|
||||
const uploaded = await api.uploadFreeVideoRef(form);
|
||||
setVideoRef({
|
||||
|
||||
Reference in New Issue
Block a user