用户反馈:设置里选完模型,分辨率档不对——Seedance-2.5 只有 480P 和 720P;另外 2.5 的上游支持到 29 秒,故事板那边应该跟着放开。
第一句说得对,2.5 确实只有 480p 和 720p。第二句也对,它确实支持 4~29 秒。
两条都对,说明我们发出去的档位和上游支持的对不上。查下去发现,同一份能力表被抄了三份。
一、三处各自漂移
后端有一张权威表,写清了每个模型支持哪些分辨率、哪些时长:
const MODEL_RESOLUTIONS: Record<VideoModel, readonly VideoResolution[]> = {
"seedance-2": ["480p", "720p", "1080p", "4k"],
"seedance-2-fast": ["480p", "720p"],
"seedance-2-mini": ["480p", "720p"],
"seedance-2.5": ["480p", "720p"],
"kling-v3": ["720p", "1080p"],
"minimax-h3": ["2k", "768p"],
};
前端有一份手抄的副本。它早就漂了,而且漂在两个方向。
多出的档位。 设置页的取档逻辑是写死的规则:「H3 两档、其余一律四档」。于是 2.5、fast、mini 这三个只有 480p 和 720p 的模型,界面上给出 1080P 和 4K。用户选了,选中即报未配价,或者直接被上游拒。
少掉的档位。 Kling 只支持 720p 和 1080p,界面给的是四档。反过来的情况也存在:如果某个模型的档位比默认四档更多,界面也显示不出来。
时长那一处漂得更彻底。前端和后端各写了一份 4~15 的夹取:
// 前端
return Math.max(4, Math.min(15, value));
// 后端
return Math.max(4, Math.min(15, value));
2.5 传 29 秒,被悄悄砍成 15。用户看不出为什么变短——没有任何提示,界面上显示的就是 15 秒。
二、被砍掉的秒数后面跟着钱
时长那一处还不只是显示问题。
故事板按固定秒数切板。代码里是一个常量:
export const SHOT_SECONDS = 15;
按 15 秒一切。2.5 一板能放 29 秒,硬按 15 秒切,一集会被切成两倍数量的板。
板数翻倍就是出图与出片的费用翻倍。这不是理论推算——用户选 2.5 的动机就是长板数少切,结果切得和短时长模型一样多,还多花一倍钱。
comic-subshot.ts 里留着上限常量的注释,写明了它只是「模型未知时的保守默认」:
// 上限只是「模型未知时的保守默认」——真正的上限逐模型不同(seedance-2.5 能到 29 秒),
// 调用方应把该模型的最大秒数作为 maxBoardSec 传进来。写死 15 的话,选了 2.5 也只切 15 秒一板。
调用方没传。
三、提示词长度:四个数,一个是实测出来的
同一批里还有一个更贵的限制:提示词长度上限。
上游对提示词有硬上限,超过直接拒。各模型不一样,而这些值在很长一段时间里没有集中维护。表现是一条线上的完整失败:
模型 seedance-2.5 的提示词不能超过 5000 个字符,当前为 18545 个字符
接入本身没问题,是缺了长度约束。补完之后这张表长这样:
const MODEL_PROMPT_LIMITS: Partial<Record<VideoModel, number>> = {
"minimax-h3": 7000,
"seedance-2": 2500,
"seedance-2-fast": 2500,
"seedance-2-mini": 2500,
"seedance-2.5": 5000,
// 20260809 实测:发 2553 字被硬拒 `prompt: size must be between 0 and 2500`
"kling-v3": 2500,
};
export const PROMPT_LIMIT_FALLBACK = 50000;
这几个数的来源不同,注释里写清了哪个是实测的:
seedance-2.5 的 5000 是实测出来的,文档只字未提:线上一条 18545 字的脚本被拒,报文写「提示词不能超过 5000 个字符」。同批实测另两条渠道收 16500 字照样 200,所以这是 2.5 独有的限制,不能推广到整个系列。加新渠道前先拿超长 prompt 打一次,别等线上炸。
这段话是这张表里唯一带出处的一条。其余几个数是按上游文档填的,文档没提的只能等线上撞。
长度约束补齐之后,还有一个配套的预算计算。原先只有 H3 有预算,其余模型返回 null,等于完全不限:
/** 每秒成片对应的脚本篇幅。15 秒 → 7500 字,是人肉审稿与出片效果都合适的密度。 */
export const CHARS_PER_SECOND = 500;
/** 留给用户自己追加修改的余量:脚本刚好顶满上限时,用户加一句就被上游拒了。 */
const PROMPT_BUDGET_MARGIN = 500;
export function scriptCharBudget(targetModel: string | undefined, durationSec?: number): number | null {
const hardLimit = modelPromptHardLimit(targetModel);
if (!durationSec || !Number.isFinite(durationSec) || durationSec <= 0) return hardLimit;
const byDuration = Math.round(durationSec * CHARS_PER_SECOND);
return hardLimit ? Math.min(byDuration, hardLimit) : byDuration;
}
按 15 秒算出来是 7500 字——上限撤销之后,脚本直接写到 1.8 万字,那也是生成耗时 193 秒的原因。
四、抄一份是必要的,那就测试它
前端为什么不能直接读后端那张表:apps/web 不能 import apps/api。而节点面板必须知道「这个模型支持哪些分辨率、哪些时长」,才能只给出跑得通的选项。
抄一份是必要的。那就把「不许走样」变成断言。
新增的共享包文件头写明了它的性质和守卫:
/**
* 生图 / 视频的**上游真实能力**表。
*
* 这是 apps/api 里两张表的镜像……
*
* **为什么要抄一份**:前端(apps/web)不能 import apps/api,而节点面板必须知道
* 「这个模型支持哪些分辨率、哪些时长」才能只给出跑得通的选项。
* 抄一份就有走样的风险,所以 apps/api 里有一条对比测试逐项核对两边——
* 谁改了上游表而没同步这里,测试立刻红。
*
* 硬规矩:这里只允许出现上游真支持的取值。多给一个选项,用户就会选到一个必然失败的组合。
*/
同时把取值这件事收成单点:
/**
* 前端渲染面板、后端校验参数都走这一个函数——两边各写一份判断,
* 迟早出现「界面给得出、服务端不认」的组合。
*/
export function resolveDynamicOptions(kind: "videoResolution" | "videoDuration", params): readonly { value, label }[]
细粒度那一档也保留了两份清单,且是有意不同:
/**
* 注意它与下面的 MODEL_DURATION_OPTIONS 是两回事、且**故意不同**:
* 后者是「按钮行」的精简清单(seedance 只列 7 档,避免一排按钮太长),
* 而滑块是细粒度选择,应当覆盖服务端真正接受的全部档位——
* 否则用户拖不到 7/9/11/13/14 秒,白白削掉能力。
*/
五、假绿的七条用例
修的过程中还撞到一个测试问题,值得单独记。
改动完成后跑测试,有一组用例报了这个警告:
This might cause false positive tests
追下去发现是真的假绿。供应商迁移之后,配置加载在某些条件下会抛错,而那个抛错发生在 try 之外,成了未处理的 rejection。用例本身并不感知异常,于是照样通过。
具体是 7 条。
修法是让配置加载把异常收敛成一个明确的错误类型(缺 key 重试没有意义,归为永久失败),并在两个相关测试文件的模块加载阶段注入测试用 key。注入位置也有讲究——放 beforeEach 会因为跨文件执行顺序失效。
这件事和主题有关:同一份能力表抄三份会漂,同一份配置在三个地方加载也会。 假绿的七条用例,测的是「配置能加载」,而配置在测试环境里根本没被加载。
六、收口之后
同一批里还顺手修了两处同源问题:
- 漫剧设置页那份模型列表是前端手写的副本,早已和后端漂开:列出「Seedance-2.0 Pro」和「可灵 V2」两个根本不存在的 ID,而真实的 ID 是
seedance-2-fast和kling-v3。选中即被出片接口的 zod 拒掉。改成读取后端的模型清单接口。 - 长篇项目的设置页没有把
videoModel/videoResolution传给设置组件,onSave也没往上收。用户选完模型点保存,没有报错,刷新之后回退到原来的值。
三处问题的形式不同,成因是同一个:同一份事实存在多个副本,而且没有一处是权威。
收口之后的结构是三份,每一份都有明确职责:
| 位置 | 职责 | 守卫 |
|---|---|---|
| 后端能力表 | 权威来源 | 被镜像表逐项对比 |
| 共享包镜像表 | 跨端取值 | 对比测试(改一边不改另一边即红) |
| 前端 UI 清单 | 只影响展示形态 | 值必须来自共享包,不许写字面量 |
硬规矩只有一条,写在镜像表里:只允许出现上游真支持的取值。多给一个选项,用户就会选到一个必然失败的组合。
反过来说,少给一个选项的代价同样实在——2.5 的 29 秒被砍到 15,用户看到的是「这个模型没比别的强」,看不到的是我们没把它的能力交出去。
■