添加seedance2.5

This commit is contained in:
Azmat@qq.com
2026-08-28 16:03:23 +08:00
parent c0a0b9603b
commit a618f02315
16 changed files with 416 additions and 80 deletions
+13 -1
View File
@@ -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]
);
+12 -2
View File
@@ -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" : ""}`}>
+1 -1
View File
@@ -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
+12 -10
View File
@@ -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({