修复BGM规范化契约与基础校验
补全T0一键简化两轮输入、结构化结果和互斥状态规则 新增TypeScript Unicode White_Space canonicalizer与生成、补全、简化状态 新增Rust零分配canonicalizer并让计数和校验返回规范化Prompt 补齐跨语言Unicode及200/201字符边界测试
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -31,28 +31,33 @@
|
||||
|
||||
### 生成游戏背景音乐
|
||||
|
||||
- `gpt_description_prompt`:用户输入框当前显示的完整背景音乐提示词,是正式 BGM 生成的唯一最终 Prompt。
|
||||
- `gpt_description_prompt`:用户输入框当前文本按本文规则清理首尾 Unicode 空白后形成的背景音乐提示词,是正式 BGM 生成的唯一最终 Prompt。
|
||||
- `make_instrumental`:固定传 `true`,不在 UI 中展示为可改字段。
|
||||
- 提交到 VectorEngine 时映射为 Suno 纯音乐模式字段:`mv`、`gpt_description_prompt`、`make_instrumental: true`。`mv` 后端固定使用默认 Suno 模型,UI 以禁用态模型胶囊显示 `Suno`。
|
||||
- 前端请求继续使用 `gptDescriptionPrompt`,Rust DTO 继续使用 `gpt_description_prompt`;不得因“唯一最终 Prompt”语义把请求字段改名为 `actualPrompt`。响应中的 `actualPrompt` / `actual_prompt` 继续保留既有生成审计语义。
|
||||
- `gpt_description_prompt` 按 Apifox 契约限制 200 个 Unicode code point。前端、BFF 和 `platform-audio` 只校验,不执行 `trim`、Unicode 规范化、空白折叠、换行转换、标点替换或静默截断。
|
||||
- `gpt_description_prompt` 按 Apifox 契约限制 200 个 Unicode code point。前端、BFF 和 `platform-audio` 允许且必须执行同一套首尾 Unicode 空白清理;除此之外不得执行 Unicode 规范化、内部空白折叠、内部换行转换、标点替换或静默截断。
|
||||
|
||||
## BGM Prompt 优化 V1.0
|
||||
|
||||
### 唯一最终 Prompt 与字符口径
|
||||
### 唯一可见最终 Prompt、首尾空白与字符口径
|
||||
|
||||
- 预设和 AI 助手都只修改同一个 BGM 输入框。输入框当前显示的完整字符串是唯一最终 Prompt,不维护另一份“实际 Prompt”、选中预设集合或内部拼接结果。
|
||||
- 正式生成时,输入框、前端 BFF 请求、Suno body、生成记录 `prompt`、生成记录 `actual_prompt` 和结果响应中的最终文本必须逐 code point 完全一致。
|
||||
- 用户 Prompt 原样处理:不执行 `trim`,不转换或合并换行,不删除零宽字符,不执行 NFC 或其它 Unicode 规范化,不折叠空格,不替换标点,不静默截断。
|
||||
- 总字符数按输入框实际字符串的 Unicode code point 数计算。TypeScript 使用 `Array.from(prompt).length`,Rust 使用 `prompt.chars().count()`。
|
||||
- “有效字符”只用于动作可用性和参数校验,定义为非 Unicode 空白 code point;它不改变原字符串。标点、数字、字母、空格、换行和不可见 code point 都计入 200 字总数。
|
||||
- 预设和 AI 助手都只修改同一个 BGM 输入框。“唯一最终 Prompt”约束的是语义来源和用户可见性:不得在正式请求中额外拼入用户看不到的前缀、后缀、预设元数据、分组信息、内部模板或系统提示词;它不要求保留编辑缓冲区中的首尾空白。这里的“用户不可见内容”指应用额外注入的语义文本,不指用户输入或粘贴的零宽字符等不可见 Unicode code point。
|
||||
- 从当前字符串两端删除属于 Unicode `White_Space` 属性的 code point 后得到的文本,以下称 canonical Prompt(规范化 Prompt);内部空格、内部换行和其它 code point 保持不变。
|
||||
- 用户编辑期间输入框可以暂时保留首尾空格、Tab 和换行。字符计数与动作可用状态基于 canonical Prompt 的非写入式预览计算,不得在每次键入时改写输入框;点击预设、AI 补全、一键简化、撤销或正式生成时,前端才在同步操作阶段计算 canonical Prompt 并写回输入框。
|
||||
- 正式生成时,写回后的输入框、前端 BFF 请求、队列请求载荷、Suno body、生成记录 `prompt`、生成记录 `actual_prompt` 和结果响应必须逐 code point 完全一致。
|
||||
- 首尾空白清理必须跨语言一致,不能直接混用语义不同的原生函数。TypeScript 按 `\p{White_Space}` 删除两端 code point;Rust 使用 `char::is_whitespace` 删除两端 code point。`U+200B` 和 `U+FEFF` 不属于 Unicode `White_Space`,不得被这一步顺带删除。
|
||||
- 除首尾 Unicode 空白清理外,不删除零宽字符,不执行 NFC 或其它 Unicode 规范化,不折叠内部空格,不转换或合并内部换行,不替换标点,不静默截断。
|
||||
- 总字符数按规范化后最终 Prompt 的 Unicode code point 数计算。TypeScript 使用 `Array.from(finalPrompt).length`,Rust 使用 `final_prompt.chars().count()`;被删除的首尾 Unicode 空白不计入 200 字。
|
||||
- “有效字符”只用于动作可用性和参数校验,定义为最终 Prompt 中的非 Unicode 空白 code point。标点、数字、字母和不可见但不属于 Unicode `White_Space` 的 code point 均计为有效字符;内部空格、内部换行和全部不可见 code point 仍计入 200 字总数。
|
||||
|
||||
| 当前输入 | AI 补全 | 一键简化 | 正式生成 |
|
||||
| 规范化后的最终 Prompt | AI 补全 | 一键简化 | 正式生成 |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 个有效字符 | 禁止 | 禁止 | 禁止 |
|
||||
| 空字符串(总数 0、有效字符数 0) | 禁止 | 禁止 | 禁止 |
|
||||
| 1 个有效字符且总数不超过 200 | 禁止 | 禁止 | 允许 |
|
||||
| 至少 2 个有效字符且总数不超过 200 | 允许 | 禁止 | 允许 |
|
||||
| 总数超过 200 | 禁止 | 允许 | 禁止 |
|
||||
| 至少 1 个有效字符且总数超过 200 | 禁止 | 允许 | 禁止 |
|
||||
|
||||
- canonical Prompt 满足不变量:`有效字符数 = 0` 当且仅当 `总字符数 = 0`。原始输入即使完全由 201 个或更多 Unicode `White_Space` 组成,canonicalization 后仍为空字符串,AI 补全、一键简化和正式生成全部禁止;不得按规范化前的长度把全空白输入归入“可简化”状态。
|
||||
|
||||
### 预设库与追加规则
|
||||
|
||||
@@ -93,13 +98,14 @@
|
||||
|
||||
点击预设时:
|
||||
|
||||
1. 输入框原始字符串为空,直接写入该预设的完整可见文本。
|
||||
2. 输入框原始字符串非空,检查原文最后一个 Unicode code point。
|
||||
3. 最后一个字符属于 Unicode 标点类别时,直接追加预设文本;否则先追加中文句号 `。`,再追加预设文本。
|
||||
4. 不对原文执行 `trim`。原文以空格或换行结尾时,句号追加在这些原始字符之后。
|
||||
5. 允许重复点击同一预设,每次都按同一规则追加。
|
||||
6. 点击任意预设后清除旧撤销快照并隐藏撤销按钮。
|
||||
7. 追加后允许超过 200 字,完整文本必须保留;超限只复用现有通用 Prompt 过长状态,不增加预设专用警告。
|
||||
1. 先按统一规则删除输入框文本两端的 Unicode `White_Space` code point,并把结果写回输入框。
|
||||
2. 规范化后的字符串为空,直接写入该预设的完整可见文本。
|
||||
3. 规范化后的字符串非空,检查其最后一个 Unicode code point。
|
||||
4. 最后一个字符属于 Unicode 标点类别时,直接追加预设文本;否则先追加中文句号 `。`,再追加预设文本。
|
||||
5. 首尾空白在追加前已经删除;内部空格和内部换行保持原位,不得继续清理。写入输入框的规范化文本、句号和预设可见文本共同构成新的唯一最终 Prompt。
|
||||
6. 允许重复点击同一个预设,每次都按同一规则追加。
|
||||
7. 点击任意预设后清除旧撤销快照并隐藏撤销按钮。
|
||||
8. 追加后允许超过 200 字,完整文本必须保留;超限只复用现有通用 Prompt 过长状态,不增加预设专用警告。
|
||||
|
||||
预设滚动交互:
|
||||
|
||||
@@ -113,34 +119,36 @@
|
||||
|
||||
### AI 补全
|
||||
|
||||
- 至少 2 个有效字符且总字符数不超过 200 时允许调用。前端把输入框原始字符串传给登录态内部 BFF,不做前置清理。
|
||||
- 点击 AI 补全时先按统一规则规范化输入框首尾空白并同步写回;至少 2 个有效字符且总字符数不超过 200 时,前端才把这份可见最终 Prompt 传给登录态内部 BFF。
|
||||
- 默认模型使用现有编辑器 Agent LLM 配置中的 `gpt-5.4-mini`;Prompt 助手复用现有 `LlmClient`,不建立新的平台 LLM 能力。
|
||||
- 服务端模板必须要求:保留用户明确的主题、场景、风格、情绪、乐器、能量、韵律、时长、循环和避免项;按场景选择性补足场景、氛围、能量、韵律、乐器、旋律、声音设计、循环和避免项,不为凑全方向堆砌形容词。
|
||||
- 用户描述已足够完整时,只补充一至两个与主题匹配的具体声音细节。发现冲突时,优先级为“明确避免项和限制 > 明确玩法用途与场景 > 风格、情绪、能量与韵律 > AI 补充细节”。
|
||||
- 输出只允许一条可直接写回输入框的中文 BGM Prompt;不得包含解释、标题、Markdown、JSON、代码块、具体艺人或歌曲模仿要求。
|
||||
- 结果必须包含有效字符且不超过 200 个 Unicode code point。校验通过后整段替换输入框;调用失败、结果为空、格式非法或超限时保留原文,不写回部分结果。
|
||||
- AI 输出先执行同一套首尾 Unicode 空白规范化,再由程序计算字符数。结果必须包含有效字符且不超过 200 个 Unicode code point;校验通过后整段写回输入框并成为唯一最终 Prompt。调用失败、结果为空、格式非法或超限时保留请求前已经写回的规范化 Prompt,不写回部分结果。
|
||||
- 补全过程不调用 Suno、不创建正式生成任务、不触发正式音乐生成扣费。
|
||||
|
||||
### 一键简化
|
||||
|
||||
- 只在当前 Prompt 总字符数超过 200 时允许调用。点击前保存完整原文,AI 处理中保持输入框原文不变。
|
||||
- 第一次简化目标为 180 字;180 不是硬门槛,只要候选不超过 200 字、格式合法且 LLM 判断内容完整即可通过。
|
||||
- 第一次候选超过 200 字、格式非法、LLM 判断内容不完整或存在明显残句时,自动进行唯一一次重试,目标为 170 字。
|
||||
- 第二次仍不合格时返回失败并保留原文,提示用户手动精简;最多两次 LLM 调用,任何情况下都不得由程序截断到 200 字。
|
||||
- 点击一键简化时先按统一规则规范化输入框首尾空白并同步写回;只在规范化后的当前 Prompt 总字符数超过 200 时允许调用。点击前保存这份规范化后的完整 Prompt,AI 处理中保持输入框不变。
|
||||
- 服务端在本次简化中冻结 `originalPrompt` 为入站 canonical Prompt,最多两次调用期间始终不变,作为内容保真参照。第一次调用使用 `currentPrompt = originalPrompt`,目标为 180 字。这里的 `originalPrompt` / `currentPrompt` 是服务端组装 LLM 简化模板时的内部变量;客户端简化 BFF DTO 仍只提交一个 `currentPrompt`,该入站值经 canonicalization 后同时成为内部 `originalPrompt` 和第一次内部 `currentPrompt`。
|
||||
- 180 不是硬门槛。每次调用要求返回内部结构化 envelope:`prompt: string`、`isDirectWritebackFormat: boolean`、`isContentComplete: boolean`、`hasObviousFragment: boolean`。envelope 不属于候选文本,也不得写回输入框;响应不能解析为包含上述正确字段类型的对象时,本次候选不通过。
|
||||
- 候选“格式合法”专指 `isDirectWritebackFormat = true`:模型确认 canonical 候选只包含一条可直接写回输入框的中文 BGM Prompt,不包含解释、标题、Markdown、JSON、代码块、字数报告、处理过程或删改说明。它与“内部结构可解析”是两个独立校验项;程序不得另用关键词或未定义的正则推断标题、解释等语义格式。
|
||||
- 第一次候选未通过任一通过条件时,自动进行唯一一次重试,目标为 170 字。若第一次响应至少能解析为对象并提取字符串 `prompt`,且该字符串 canonicalize 后包含有效字符,第二次使用 `currentPrompt = canonicalize(第一次候选)`,即使第一次因其它字段缺失、超限、格式、完整性或残句判断而不通过;若第一次响应连对象或字符串 `prompt` 都无法提取,或候选 canonicalize 后不含有效字符,第二次回退使用 `currentPrompt = originalPrompt`。第二次调用中的 `originalPrompt` 始终仍是最初入站 canonical Prompt。
|
||||
- 每次候选都先执行同一套首尾 Unicode 空白规范化,再计算实际字符数。第二次仍不合格时返回失败并保留请求前已经写回的规范化 Prompt,提示用户手动精简;最多两次 LLM 调用,任何情况下都不得由程序截断到 200 字。
|
||||
- 简化模板必须优先保留明确限制、场景与用途、情绪与风格、核心乐器与速度、能量/韵律/旋律、循环结构、区分度较高的声音设计,再删除次要修饰细节;不得改变专有名词、BPM、调性、时长、数值、乐器和明确限制。
|
||||
- 程序不抽取关键词,不对原文与候选执行内容硬编码逐项比对。是否保留重要信息、内容是否完整、是否形成明显残句由 LLM 在同一次调用的内部结构化结果中判断。
|
||||
- 内部结构化结果同时包含候选 Prompt 与完整性判断;BFF 对外只返回已通过校验的 Prompt 和程序计算的字符数,不暴露内部判断字段。
|
||||
- 程序只负责确定性校验:结构可解析、候选包含有效字符、格式合法、实际字符数不超过 200。字符数必须由程序计算,不采信模型报告。
|
||||
- 程序不抽取关键词,不对规范化后的输入与候选执行内容硬编码逐项比对。是否为直接写回格式、是否保留重要信息且内容完整、是否形成明显残句由 LLM 在同一次调用的三个布尔字段中分别判断。
|
||||
- BFF 对外只返回已通过校验的 Prompt 和程序计算的字符数,不暴露三个内部判断字段。
|
||||
- 候选只有同时满足以下条件才通过:内部结构可解析且四个字段类型正确;canonical 候选包含有效字符;canonical 候选实际字符数不超过 200;`isDirectWritebackFormat = true`;`isContentComplete = true`;`hasObviousFragment = false`。程序负责解析和检查字段类型、canonicalization、有效字符与实际字符数,并执行三个布尔判断结果;字符数由程序计算且不采信模型报告,程序不自行推断三个语义判断。
|
||||
- 一键简化不维护或恢复“已选预设”元数据;预设已写入输入框的文本只是当前最终 Prompt 的一部分。
|
||||
|
||||
### 单层撤销
|
||||
|
||||
- AI 补全和一键简化成功都必须产生一层 Prompt 快照。发起 AI 操作前,以当时的当前 Prompt 建立本次临时快照。
|
||||
- AI 成功写回后,该快照成为可撤销版本;AI 失败且原文未变时清除本次临时快照,不生成新的可撤销版本。
|
||||
- 用户手动编辑 AI 结果后,撤销仍可用。再次发起 AI 操作时,以当时的当前 Prompt 替换旧快照。
|
||||
- 点击预设清除旧快照;正式提交被后端拒绝时,恢复并保留提交前已有快照。
|
||||
- 点击撤销时,当前 Prompt 与快照互换;按钮继续可用,允许用户在两个版本间来回切换。
|
||||
- 只保存一层,快照只包含 Prompt,不包含预设滚动位置、展开状态、模型、时长或其它参数。
|
||||
- AI 补全和一键简化成功都必须产生一层 Prompt 快照。发起 AI 操作前,先把当时输入规范化并写回,再以该 canonical Prompt 建立本次临时快照。
|
||||
- AI 成功写回后,该快照成为可撤销版本;AI 失败时输入框保留请求前已经写回的 canonical Prompt,清除本次临时快照,不生成新的可撤销版本,也不恢复发起本次操作时已经被替换的旧快照。
|
||||
- 用户手动编辑 AI 结果后,撤销仍可用。再次发起 AI 操作时,先规范化并写回当时的当前 Prompt,再以该 canonical Prompt 替换旧快照。
|
||||
- 点击预设时先规范化并写回,再清除旧快照;正式提交被后端拒绝时,保留已经写回的 canonical Prompt 和提交前已有快照。
|
||||
- 点击撤销时,先把当前输入规范化并写回,再让该 canonical Prompt 与 canonical 快照互换;按钮继续可用,允许用户在两个规范化版本间来回切换。
|
||||
- 只保存一层,快照只包含 canonical Prompt,不包含预设滚动位置、展开状态、模型、时长或其它参数。
|
||||
|
||||
### AI 操作与方案 A 提交锁
|
||||
|
||||
@@ -155,9 +163,9 @@ idle
|
||||
|
||||
- `completing` / `simplifying` 开始时以同步 operation ID 取得当前 dialog 操作权;同一操作的后续双击无效。新操作使旧 operation ID 失效,关闭 dialog 时中止请求,dialog ID 或 operation ID 不匹配的迟到响应必须丢弃。
|
||||
- AI 处理中输入框只读,禁用预设展开/收起、预设词条、滚动箭头、AI 补全、一键简化、撤销和生成。
|
||||
- 点击生成的同步事件内必须在任何 `await` 前把当前 BGM dialog 切换到 `submitting`。提交中锁定该 dialog 的输入框、预设、展开/收起、滚动箭头、AI 操作、撤销、参数控件和生成按钮,忽略后续重复点击。
|
||||
- 点击生成的同步事件内必须在任何 `await` 前依次完成:立即把当前 BGM dialog 切换到 `submitting`、计算 canonical Prompt 并写回输入框、按 canonical Prompt 校验 0 个有效字符和 200 字上限、冻结本次请求值。前端校验失败时立即解除锁并保留写回后的 canonical Prompt,不发送请求。提交中锁定该 dialog 的输入框、预设、展开/收起、滚动箭头、AI 操作、撤销、参数控件和生成按钮,忽略后续重复点击。
|
||||
- 锁只作用于当前 BGM dialog,不锁整个画布、其它 generation dialog、画布拖动、缩放、图层操作或其它编辑能力。
|
||||
- 后端拒绝正式提交时,解除锁,恢复当前面板并保留用户原始 Prompt 与提交前已有撤销快照,继续使用现有正式生成错误展示。
|
||||
- 后端拒绝正式提交时,解除锁,恢复当前面板并保留已经写回的 canonical Prompt 与提交前已有撤销快照,继续使用现有正式生成错误展示。
|
||||
- 后端接受请求并创建正式生成任务后,`submitting` 结束,当前 dialog 进入现有 `queued/generating` 占位;输入 composer 继续隐藏,同一任务不得再次提交。
|
||||
- “提交成功”仅表示后端已接受请求并创建正式任务,不表示 Suno 已完成音乐生成。
|
||||
|
||||
@@ -180,7 +188,7 @@ idle
|
||||
- generated 私有音频资源播放前必须通过 `/api/assets/read-url` 换签;画布卡片不得直接把 `/generated-*` 或 generated OSS 私有地址交给 `<audio>` 裸请求。
|
||||
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。
|
||||
- 音频图层右上角标签显示在信息按钮左侧,和其他素材卡右上角信息区保持一致。
|
||||
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。
|
||||
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段;BGM 快照保存输入框已经写回的 canonical Prompt,不保存助手系统模板、内部引导或预设元数据。
|
||||
- 音频图层上方浮动工具栏只保留 `改造` 和 `下载按钮`。点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。
|
||||
|
||||
## 前端与 BFF 契约
|
||||
@@ -231,9 +239,10 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
}
|
||||
```
|
||||
|
||||
- 客户端不得提交 `maxChars`、`targetChars`、模型名、预设 ID、分组、颜色、内部模板或完整性判断;这些由服务端固定。
|
||||
- 客户端不得提交 `maxChars`、`targetChars`、模型名、预设 ID、分组、颜色、内部模板或格式 / 完整性 / 残句判断;这些由服务端固定或由内部 LLM 结果产生。
|
||||
- 两个助手 BFF 不调用 Suno、钱包扣费、正式 generation queue、OSS、素材库或 SpacetimeDB。
|
||||
- BGM 正式提交必须使用输入框当前原始 Prompt,不再调用 `dialog.prompt.trim()`,也不再把空 Prompt 回退为“游戏背景音乐”。
|
||||
- 两个助手请求中的 `currentPrompt` 必须是前端已经写回输入框的 canonical Prompt;响应 `prompt` 也必须先执行同一 canonicalization,`charCount` 是响应 canonical Prompt 的 Unicode code point 数。
|
||||
- BGM 正式提交必须使用输入框已经写回的 canonical Prompt,不得额外拼接用户不可见内容,也不得把空 Prompt 回退为“游戏背景音乐”。
|
||||
|
||||
统一响应:
|
||||
|
||||
@@ -257,13 +266,16 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
}
|
||||
```
|
||||
|
||||
- BGM 成功响应必须同时填充 `prompt` 和 `actualPrompt`,两者都与本次 canonical Prompt 逐 code point 等值,不得携带助手模板、内部引导或隐藏前后缀;共享响应类型为兼容 SFX 与历史数据仍可保留 `actualPrompt` 可选。
|
||||
|
||||
## 后端实现
|
||||
|
||||
- 在 `shared-contracts/src/assets.rs` 增加编辑器音频请求 / 响应 DTO。
|
||||
- 在 `shared-contracts` 增加最小 BGM Prompt 助手请求 / 响应 DTO;前后端共享 `currentPrompt`、`prompt` 和 `charCount` 字段,不把内部 LLM 判断暴露给客户端。
|
||||
- 前端、`api-server` 与 `platform-audio` 必须实现同一 BGM canonicalization 语义并复用同一组跨语言测试向量:只删除首尾 Unicode `White_Space`,保留内部空白和全部其它 code point。该操作必须幂等,不得直接混用语义不同的 TypeScript / Rust 原生 `trim`。
|
||||
- 在 `platform-audio` 增加编辑器专用 body builder 和 submit 函数:
|
||||
- 背景音乐 body 使用 `mv`、`gpt_description_prompt`、`make_instrumental`。
|
||||
- 背景音乐增加“只校验、不清理”的 Prompt 校验:检查至少一个有效字符和最多 200 个 Unicode code point,校验通过后原样构造 Suno body。不得继续复用会执行 `trim` 的 `normalize_limited_text`。
|
||||
- 背景音乐在 body builder 边界防御性执行幂等 canonicalization,再按 canonical Prompt 检查至少一个有效字符和最多 200 个 Unicode code point;校验通过后用 canonical Prompt 构造 Suno body。不得复用语义不同的 `normalize_limited_text`,不得提供默认 Prompt。
|
||||
- Suno 音乐接口路径固定为 `/suno/submit/music`;`VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`。
|
||||
- 音效 body 使用 Vidu 文生音频契约:提交 `/ent/v2/text2audio`,请求体包含 `model: "audio1.0"`、`prompt`、`sound: prompt`、`duration` 和可选 `seed`;`model` 和 `prompt` 为文档必填,`sound` 用于兼容线上网关实际校验,`prompt` 最长 1500 字符,`duration` 按 Vidu 文档限制在 `2-10` 秒。
|
||||
- 编辑器音效轮询使用 Vidu 路径 `/ent/v2/tasks/{taskId}/creations`,不再使用 Suno `/suno/fetch/{taskId}`;Suno 文生音效 `task: "sound"` 暂不从编辑器入口暴露。
|
||||
@@ -275,9 +287,9 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
- 在 `api-server` 增加登录态内部 BGM Prompt 助手 BFF:
|
||||
- `POST /api/editor/audios/background-music/prompts/completions`
|
||||
- `POST /api/editor/audios/background-music/prompts/simplifications`
|
||||
- Prompt 助手 BFF 使用现有编辑器专用 LLM client 和 `gpt-5.4-mini` 默认配置;补全固定执行一次,简化按 `180 -> 170` 最多两次。服务端负责模板组装、原始字符校验、内部结构化完整性判断和现有 API 错误 envelope。
|
||||
- Prompt 助手 BFF 在入站和 LLM 候选出站边界执行 BGM canonicalization;服务端字符数、0 / 1 / 2 个有效字符规则和 200 字限制都基于 canonical Prompt。助手使用现有编辑器专用 LLM client 和 `gpt-5.4-mini` 默认配置;补全固定执行一次,简化按 `180 -> 170` 最多两次,并按“一键简化”章节冻结 `originalPrompt`、派生每次 `currentPrompt`。服务端负责模板组装、canonical 字符校验、内部 envelope 解析、执行格式 / 完整性 / 残句三个布尔判断和现有 API 错误 envelope;不自行猜测三个语义判断。
|
||||
- Prompt 助手继续复用 `LlmClient` 现有失败原文日志行为。本需求不增加请求级日志开关、脱敏、metadata-only 模式或相关上线门禁。
|
||||
- BGM generation BFF 必须改用“只校验、不清理”的原始 Prompt 校验,删除空 Prompt 默认回退;SFX 继续使用现有规范化、回退、Vidu body 和 1500 字限制。
|
||||
- BGM generation BFF 在入站时防御性执行同一幂等 canonicalization,规范化后校验有效字符和 200 字限制,并把 canonical Prompt 写入正式 generation queue 请求载荷、持久化记录和响应;删除空 Prompt 默认回退。助手模板只用于生成输入框可见候选,不得进入正式队列或 Suno 请求。SFX 继续使用现有规范化、回退、Vidu body 和 1500 字限制。
|
||||
- BFF 复用现有 `vector_engine_audio_generation` 的任务轮询、下载、OSS 持久化和计费包装;音效 10 泥点,背景音乐 5 泥点。
|
||||
- 生成音频持久化后返回 OSS `objectKey` 与 `assetObjectId`;前端保存素材库时继续使用 `audioSrc` 作为兼容路径,并把 OSS 身份写入素材记录。
|
||||
- 本切片不修改 SpacetimeDB schema,不新增 Prompt 助手持久化表,不向 `/api/external/v1` 暴露助手,也不修改 External v1 OpenAPI 或 `platform-llm` 日志策略。
|
||||
@@ -301,14 +313,15 @@ POST /api/editor/audios/background-music/prompts/simplifications
|
||||
- 音频素材浮动工具栏只显示 `改造` 与 `下载按钮`,`改造` 复用对应生成面板且没有参考图组件。
|
||||
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
|
||||
- 刷新后 layout 能恢复音频生成器和音频图层。
|
||||
- BGM 输入框按 Unicode code point 显示 `0 / 200` 计数;0 个有效字符不能生成,1 个有效字符可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,201 个 code point 时只能一键简化。
|
||||
- 三组 30 个 BGM 预设按本文固定文案写入或追加;重复点击、Unicode 标点结尾、空格 / 换行结尾和追加后超限都不清理或丢失原文。
|
||||
- BGM 输入框按 canonical Prompt 的 Unicode code point 显示 `0 / 200` 计数和动作状态,但编辑期间不因计数而改写输入框;canonical Prompt 为空时三个动作全部禁止,1 个有效字符且不超限时可以生成但不能 AI 补全,至少 2 个有效字符且不超限时可以 AI 补全,非空且达到 201 个 code point 时只能一键简化。200 / 201 个纯 Unicode `White_Space` 原始输入均先归一为空,不能简化。
|
||||
- 三组 30 个 BGM 预设按本文固定文案写入或追加;点击前先删除首尾 Unicode `White_Space` 并写回,再基于规范化结果判断空值和末尾标点。重复点击和追加后超限不得丢失 canonical Prompt,内部空格和内部换行保持原位。
|
||||
- 预设展开后默认无缝慢速循环,桌面端左 / 中 / 右区域分别加速向左、暂停、加速向右,左右箭头同步加速;组件在触摸环境被渲染时可横向滚动并选择词条,本需求不恢复移动端图片画布入口。
|
||||
- AI 补全成功后只写回一条不超过 200 字的中文 Prompt,失败、空值、格式错误或超限结果不修改输入框,也不调用 Suno 或扣除正式音乐生成泥点。
|
||||
- 一键简化只处理超过 200 字的 BGM Prompt,按 180 字目标尝试一次,必要时按 170 字目标重试一次;第二次仍失败时保留原文,程序不得截断。
|
||||
- AI 补全和简化成功均产生一层交换式撤销快照;手动编辑后仍可撤销,点击预设清除快照,点击撤销可在两个版本间反复互换。
|
||||
- AI 补全的输入和候选都先 canonicalize;成功后只写回一条不超过 200 字的中文 canonical Prompt,失败、空值、格式错误或超限结果保留请求前已经写回的 canonical Prompt,也不调用 Suno 或扣除正式音乐生成泥点。
|
||||
- 一键简化资格、180 / 170 目标后的实际字符数和候选校验都基于 canonical Prompt;第一次使用冻结的 `originalPrompt`,第二次优先处理第一次可提取的非空 canonical 候选,否则回退处理 `originalPrompt`,且两次都以同一 `originalPrompt` 作保真参照。候选必须同时通过结构、有效字符、200 字、格式、完整性和残句校验;第二次仍失败时保留请求前已经写回的 canonical Prompt,程序不得截断。
|
||||
- AI 补全和简化成功均产生一层 canonical Prompt 交换式撤销快照;手动编辑后仍可撤销,点击预设清除快照,点击撤销前先规范化当前输入并可在两个 canonical 版本间反复互换。
|
||||
- AI 操作的旧响应、关闭 dialog 后的响应或其它 dialog 的响应不得覆盖当前 Prompt;同一按钮双击只产生一个有效助手请求。
|
||||
- BGM 点击生成后在首个 `await` 前同步锁定当前 dialog;同一 dialog 快速重复点击只产生一次正式请求、一个生成任务和一次扣费,不锁整个画布或其它 dialog。
|
||||
- BGM 正式提交不再 `trim` 或回退默认 Prompt;包含首尾空格、LF / CRLF、零宽字符、组合字符或 ZWJ emoji 的合法 Prompt 在输入框、BFF、Suno body、生成记录和结果响应中完全一致。
|
||||
- BGM 正式提交会删除首尾 Unicode `White_Space` 并同步写回输入框,不回退默认 Prompt;首尾 U+0085 等 `White_Space` 被删除,内部空格和 LF / CRLF 原样保留,U+200B、U+FEFF、组合字符和 ZWJ emoji 不被误删。canonical Prompt 在输入框、BFF、队列载荷、Suno body、生成记录和结果响应中完全一致,且没有用户不可见的前缀、后缀或模板。
|
||||
- BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。
|
||||
- BGM Suno body 仍只包含 `mv`、`gpt_description_prompt`、`make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;SFX 的 Vidu body、默认 Prompt、时长与 1500 字限制无回归。
|
||||
- 本切片的定向前端、shared-contracts、`api-server`、`platform-audio` 和端到端 Prompt 等值测试通过,并执行 `npm run typecheck`、对应 Rust 定向测试、`npm run check:encoding` 与 `git diff --check`。
|
||||
|
||||
@@ -7,22 +7,33 @@ const BACKGROUND_MUSIC_COMPLETION_MIN_EFFECTIVE_CHARS: usize = 2;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ValidatedBackgroundMusicPrompt<'a> {
|
||||
/// The exact caller-provided string. Validation never trims or normalizes it.
|
||||
/// The caller-provided string after removing boundary Unicode whitespace.
|
||||
pub prompt: &'a str,
|
||||
pub char_count: usize,
|
||||
pub effective_char_count: usize,
|
||||
}
|
||||
|
||||
/// Counts Unicode scalar values, matching TypeScript `Array.from(prompt).length`.
|
||||
pub fn background_music_prompt_char_count(prompt: &str) -> usize {
|
||||
prompt.chars().count()
|
||||
/// Removes Unicode `White_Space` code points from both ends of a BGM prompt.
|
||||
///
|
||||
/// Internal whitespace and all other code points remain unchanged. In particular,
|
||||
/// U+200B and U+FEFF are preserved because Rust does not classify them as
|
||||
/// whitespace, while U+0085 is removed at either boundary.
|
||||
pub fn canonicalize_background_music_prompt(prompt: &str) -> &str {
|
||||
prompt.trim_matches(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// Counts code points outside the Unicode `White_Space` property.
|
||||
/// Counts Unicode scalar values in the canonical prompt, matching TypeScript
|
||||
/// `Array.from(canonicalPrompt).length`.
|
||||
pub fn background_music_prompt_char_count(prompt: &str) -> usize {
|
||||
canonicalize_background_music_prompt(prompt).chars().count()
|
||||
}
|
||||
|
||||
/// Counts code points outside the Unicode `White_Space` property in the
|
||||
/// canonical prompt.
|
||||
///
|
||||
/// U+0085 is whitespace, while U+200B and U+FEFF remain effective characters.
|
||||
pub fn background_music_prompt_effective_char_count(prompt: &str) -> usize {
|
||||
prompt
|
||||
canonicalize_background_music_prompt(prompt)
|
||||
.chars()
|
||||
.filter(|character| !character.is_whitespace())
|
||||
.count()
|
||||
@@ -53,20 +64,21 @@ fn validate_background_music_prompt<'a>(
|
||||
field: &'static str,
|
||||
min_effective_chars: usize,
|
||||
) -> Result<ValidatedBackgroundMusicPrompt<'a>, AudioError> {
|
||||
let prompt = canonicalize_background_music_prompt(prompt);
|
||||
let char_count = background_music_prompt_char_count(prompt);
|
||||
let effective_char_count = background_music_prompt_effective_char_count(prompt);
|
||||
|
||||
if effective_char_count < min_effective_chars {
|
||||
return Err(AudioError::invalid_request(format!(
|
||||
"{field} 至少需要 {min_effective_chars} 个有效字符"
|
||||
)));
|
||||
}
|
||||
if char_count > SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS {
|
||||
return Err(AudioError::invalid_request(format!(
|
||||
"{field} 超过 {} 字符",
|
||||
SUNO_GPT_DESCRIPTION_PROMPT_MAX_CHARS
|
||||
)));
|
||||
}
|
||||
if effective_char_count < min_effective_chars {
|
||||
return Err(AudioError::invalid_request(format!(
|
||||
"{field} 至少需要 {min_effective_chars} 个有效字符"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(ValidatedBackgroundMusicPrompt {
|
||||
prompt,
|
||||
|
||||
@@ -9,8 +9,8 @@ mod types;
|
||||
|
||||
pub use background_music_prompt::{
|
||||
ValidatedBackgroundMusicPrompt, background_music_prompt_char_count,
|
||||
background_music_prompt_effective_char_count, validate_background_music_completion_prompt,
|
||||
validate_background_music_generation_prompt,
|
||||
background_music_prompt_effective_char_count, canonicalize_background_music_prompt,
|
||||
validate_background_music_completion_prompt, validate_background_music_generation_prompt,
|
||||
};
|
||||
pub use client::{
|
||||
build_vector_engine_audio_http_client, resolve_audio_task_download_urls,
|
||||
|
||||
@@ -4,39 +4,85 @@ use platform_audio::{
|
||||
audio_mime_to_extension, background_music_prompt_char_count,
|
||||
background_music_prompt_effective_char_count, build_background_music_task_body,
|
||||
build_editor_background_music_task_body, build_editor_sound_effect_task_body,
|
||||
build_sound_effect_task_body, extract_audio_urls, is_failed_task_status,
|
||||
is_pending_task_status, normalize_audio_mime_type, normalize_task_status,
|
||||
validate_background_music_completion_prompt, validate_background_music_generation_prompt,
|
||||
build_sound_effect_task_body, canonicalize_background_music_prompt, extract_audio_urls,
|
||||
is_failed_task_status, is_pending_task_status, normalize_audio_mime_type,
|
||||
normalize_task_status, validate_background_music_completion_prompt,
|
||||
validate_background_music_generation_prompt,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_counts_unicode_code_points_and_effective_characters() {
|
||||
let prompt = " \t\n\u{00a0}\u{2003}\u{200b}😀";
|
||||
fn background_music_prompt_canonicalization_is_idempotent() {
|
||||
let prompt = " \t\u{0085}\u{00a0}音 \r\n\u{200b}\u{feff}😀\u{2003}\n ";
|
||||
let canonical = canonicalize_background_music_prompt(prompt);
|
||||
|
||||
assert_eq!(background_music_prompt_char_count(prompt), 7);
|
||||
assert_eq!(canonical, "音 \r\n\u{200b}\u{feff}😀");
|
||||
assert_eq!(canonicalize_background_music_prompt(canonical), canonical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_canonicalization_matches_unicode_white_space_test_vector() {
|
||||
let unicode_white_space: String = [
|
||||
'\u{0009}', '\u{000a}', '\u{000b}', '\u{000c}', '\u{000d}', '\u{0020}', '\u{0085}',
|
||||
'\u{00a0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}', '\u{2003}', '\u{2004}',
|
||||
'\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}', '\u{2009}', '\u{200a}', '\u{2028}',
|
||||
'\u{2029}', '\u{202f}', '\u{205f}', '\u{3000}',
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let internal_white_space = "A \t\r\n\u{0085}\u{00a0}\u{3000}B";
|
||||
let prompt = format!("{unicode_white_space}{internal_white_space}{unicode_white_space}");
|
||||
|
||||
assert_eq!(unicode_white_space.chars().count(), 25);
|
||||
assert!(unicode_white_space.chars().all(char::is_whitespace));
|
||||
assert_eq!(
|
||||
canonicalize_background_music_prompt(&prompt),
|
||||
internal_white_space
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_counts_canonical_unicode_code_points_and_effective_characters() {
|
||||
let prompt = " \t\n\u{00a0}\u{2003}\u{200b}😀\u{0085}";
|
||||
let internal_whitespace = "A \t\n\u{00a0}\u{2003}\u{0085} B";
|
||||
|
||||
assert_eq!(background_music_prompt_char_count(prompt), 2);
|
||||
assert_eq!(background_music_prompt_effective_char_count(prompt), 2);
|
||||
assert_eq!(background_music_prompt_char_count("\r\n"), 2);
|
||||
assert_eq!(background_music_prompt_char_count(internal_whitespace), 9);
|
||||
assert_eq!(
|
||||
background_music_prompt_effective_char_count(internal_whitespace),
|
||||
2
|
||||
);
|
||||
assert_eq!(background_music_prompt_char_count("\r\n"), 0);
|
||||
assert_eq!(background_music_prompt_effective_char_count("\r\n"), 0);
|
||||
assert_eq!(background_music_prompt_char_count("e\u{0301}"), 2);
|
||||
assert_eq!(background_music_prompt_effective_char_count("e\u{0301}"), 2);
|
||||
assert_eq!(background_music_prompt_char_count("👩💻"), 3);
|
||||
assert_eq!(background_music_prompt_effective_char_count("👩💻"), 3);
|
||||
assert_eq!(background_music_prompt_char_count("\u{0085}"), 1);
|
||||
assert_eq!(background_music_prompt_char_count("\u{0085}"), 0);
|
||||
assert_eq!(background_music_prompt_effective_char_count("\u{0085}"), 0);
|
||||
assert_eq!(background_music_prompt_char_count("\u{200b}"), 1);
|
||||
assert_eq!(background_music_prompt_effective_char_count("\u{200b}"), 1);
|
||||
assert_eq!(background_music_prompt_char_count("\u{feff}"), 1);
|
||||
assert_eq!(background_music_prompt_effective_char_count("\u{feff}"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_generation_prompt_requires_one_effective_character_without_cleaning() {
|
||||
for prompt in ["", " \t\r\n", "\u{00a0}\u{2003}"] {
|
||||
fn background_music_generation_prompt_requires_one_effective_character_after_canonicalization() {
|
||||
for prompt in [
|
||||
"",
|
||||
" \t\r\n",
|
||||
"\u{00a0}\u{2003}",
|
||||
&"\u{0085}".repeat(200),
|
||||
&"\u{0085}".repeat(201),
|
||||
] {
|
||||
let error = validate_background_music_generation_prompt(prompt)
|
||||
.expect_err("Unicode-whitespace-only prompts should be rejected");
|
||||
assert!(error.message().contains("至少需要 1 个有效字符"));
|
||||
assert!(!error.message().contains("超过 200 字符"));
|
||||
}
|
||||
|
||||
for prompt in ["乐", "\u{200b}", "😀"] {
|
||||
for prompt in ["乐", "\u{200b}", "\u{feff}", "😀"] {
|
||||
let validated = validate_background_music_generation_prompt(prompt)
|
||||
.expect("one non-whitespace code point should be accepted for generation");
|
||||
assert_eq!(validated.prompt, prompt);
|
||||
@@ -44,11 +90,11 @@ fn background_music_generation_prompt_requires_one_effective_character_without_c
|
||||
assert_eq!(validated.effective_char_count, 1);
|
||||
}
|
||||
|
||||
let prompt = " 音\n\u{200b}😀 ";
|
||||
let prompt = " \u{0085}\u{2003}音\n\u{200b}😀\u{00a0}\r\n";
|
||||
let validated = validate_background_music_generation_prompt(prompt)
|
||||
.expect("valid prompt should retain every original code point");
|
||||
assert_eq!(validated.prompt, prompt);
|
||||
assert_eq!(validated.char_count, 8);
|
||||
.expect("valid prompt should remove only boundary Unicode whitespace");
|
||||
assert_eq!(validated.prompt, "音\n\u{200b}😀");
|
||||
assert_eq!(validated.char_count, 4);
|
||||
assert_eq!(validated.effective_char_count, 3);
|
||||
}
|
||||
|
||||
@@ -61,21 +107,30 @@ fn background_music_completion_prompt_requires_two_effective_characters() {
|
||||
assert!(error.message().contains("至少需要 2 个有效字符"));
|
||||
}
|
||||
|
||||
for prompt in ["音乐", "\u{200b}乐", "😀🎵", "👩💻"] {
|
||||
for (prompt, canonical) in [
|
||||
("音乐", "音乐"),
|
||||
("\u{200b}乐", "\u{200b}乐"),
|
||||
("😀🎵", "😀🎵"),
|
||||
("👩💻", "👩💻"),
|
||||
("\u{0085} 音\n乐 \u{00a0}", "音\n乐"),
|
||||
] {
|
||||
let validated = validate_background_music_completion_prompt(prompt)
|
||||
.expect("completion should accept at least two effective code points");
|
||||
assert_eq!(validated.prompt, prompt);
|
||||
assert_eq!(validated.prompt, canonical);
|
||||
assert!(validated.effective_char_count >= 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_validation_accepts_200_and_rejects_201_code_points() {
|
||||
fn background_music_prompt_validation_uses_canonical_200_code_point_limit() {
|
||||
let max_prompt = "😀".repeat(200);
|
||||
let generation = validate_background_music_generation_prompt(&max_prompt)
|
||||
.expect("200 code points should be accepted for generation");
|
||||
let completion = validate_background_music_completion_prompt(&max_prompt)
|
||||
.expect("200 code points should be accepted for completion");
|
||||
let max_prompt_with_boundary_whitespace = format!("\u{0085} {max_prompt}\u{2003}\r\n");
|
||||
let generation =
|
||||
validate_background_music_generation_prompt(&max_prompt_with_boundary_whitespace)
|
||||
.expect("200 code points should be accepted for generation");
|
||||
let completion =
|
||||
validate_background_music_completion_prompt(&max_prompt_with_boundary_whitespace)
|
||||
.expect("200 code points should be accepted for completion");
|
||||
assert_eq!(generation.prompt, max_prompt);
|
||||
assert_eq!(generation.char_count, 200);
|
||||
assert_eq!(completion.char_count, 200);
|
||||
@@ -91,6 +146,57 @@ fn background_music_prompt_validation_accepts_200_and_rejects_201_code_points()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_validation_preserves_internal_whitespace_and_non_whitespace_invisibles()
|
||||
{
|
||||
let internal_whitespace_prompt = format!("A{}B", " ".repeat(199));
|
||||
assert_eq!(
|
||||
background_music_prompt_char_count(&internal_whitespace_prompt),
|
||||
201
|
||||
);
|
||||
assert_eq!(
|
||||
background_music_prompt_effective_char_count(&internal_whitespace_prompt),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
validate_background_music_generation_prompt(&internal_whitespace_prompt)
|
||||
.expect_err("201 canonical code points should be rejected")
|
||||
.message()
|
||||
.contains("超过 200 字符")
|
||||
);
|
||||
|
||||
for prompt in ["\u{200b}".repeat(201), "\u{feff}".repeat(201)] {
|
||||
assert_eq!(background_music_prompt_char_count(&prompt), 201);
|
||||
assert_eq!(background_music_prompt_effective_char_count(&prompt), 201);
|
||||
assert!(
|
||||
validate_background_music_generation_prompt(&prompt)
|
||||
.expect_err("U+200B and U+FEFF must remain subject to the 200 code point limit")
|
||||
.message()
|
||||
.contains("超过 200 字符")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_music_prompt_validation_ignores_any_amount_of_boundary_whitespace() {
|
||||
let one_effective_character = format!(
|
||||
"{}A{}",
|
||||
"\u{0085}\u{2003}".repeat(150),
|
||||
"\u{00a0}\r\n".repeat(150)
|
||||
);
|
||||
let generation = validate_background_music_generation_prompt(&one_effective_character)
|
||||
.expect("boundary Unicode whitespace should not count toward the generation limit");
|
||||
assert_eq!(generation.prompt, "A");
|
||||
assert_eq!(generation.char_count, 1);
|
||||
assert_eq!(generation.effective_char_count, 1);
|
||||
assert!(
|
||||
validate_background_music_completion_prompt(&one_effective_character)
|
||||
.expect_err("one canonical effective character is insufficient for completion")
|
||||
.message()
|
||||
.contains("至少需要 2 个有效字符")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_audio_mime_type_from_content_type_and_url() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,25 +6,86 @@ import {
|
||||
BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS,
|
||||
canCompleteBackgroundMusicPrompt,
|
||||
canGenerateBackgroundMusicFromPrompt,
|
||||
canSimplifyBackgroundMusicPrompt,
|
||||
canonicalizeBackgroundMusicPrompt,
|
||||
countEffectivePromptCodePoints,
|
||||
countPromptCodePoints,
|
||||
} from './ImageCanvasBackgroundMusicPromptModel';
|
||||
|
||||
describe('ImageCanvasBackgroundMusicPromptModel', () => {
|
||||
it('counts raw Unicode code points without normalizing the prompt', () => {
|
||||
const combiningText = 'e\u0301';
|
||||
const zwjEmoji = '👨👩👧👦';
|
||||
const prompt = ` \r\n${combiningText}${zwjEmoji}\u200B\uFEFF `;
|
||||
it('removes only leading and trailing Unicode White_Space code points', () => {
|
||||
const unicodeWhiteSpace = [
|
||||
'\u0009',
|
||||
'\u000A',
|
||||
'\u000B',
|
||||
'\u000C',
|
||||
'\u000D',
|
||||
'\u0020',
|
||||
'\u0085',
|
||||
'\u00A0',
|
||||
'\u1680',
|
||||
'\u2000',
|
||||
'\u2001',
|
||||
'\u2002',
|
||||
'\u2003',
|
||||
'\u2004',
|
||||
'\u2005',
|
||||
'\u2006',
|
||||
'\u2007',
|
||||
'\u2008',
|
||||
'\u2009',
|
||||
'\u200A',
|
||||
'\u2028',
|
||||
'\u2029',
|
||||
'\u202F',
|
||||
'\u205F',
|
||||
'\u3000',
|
||||
].join('');
|
||||
const internalWhiteSpace = 'A \t\r\n\u0085\u00A0\u3000B';
|
||||
|
||||
expect(countPromptCodePoints('')).toBe(0);
|
||||
expect(countPromptCodePoints('\r\n')).toBe(2);
|
||||
expect(countPromptCodePoints(combiningText)).toBe(2);
|
||||
expect(countPromptCodePoints(zwjEmoji)).toBe(7);
|
||||
expect(countPromptCodePoints(prompt)).toBe(15);
|
||||
expect(prompt).toBe(` \r\n${combiningText}${zwjEmoji}\u200B\uFEFF `);
|
||||
expect(
|
||||
canonicalizeBackgroundMusicPrompt(
|
||||
`${unicodeWhiteSpace}${internalWhiteSpace}${unicodeWhiteSpace}`,
|
||||
),
|
||||
).toBe(internalWhiteSpace);
|
||||
expect(
|
||||
canonicalizeBackgroundMusicPrompt('\u0085\u200B内容\uFEFF\u0085'),
|
||||
).toBe('\u200B内容\uFEFF');
|
||||
expect(canonicalizeBackgroundMusicPrompt('\u200B\uFEFF')).toBe(
|
||||
'\u200B\uFEFF',
|
||||
);
|
||||
});
|
||||
|
||||
it('counts only non-Unicode-White_Space code points as effective', () => {
|
||||
it('is idempotent without normalizing internal Unicode content', () => {
|
||||
const combiningText = 'e\u0301';
|
||||
const zwjEmoji = '👨👩👧👦';
|
||||
const prompt = ` \u0085${combiningText}\r\n${zwjEmoji}\u200B\uFEFF\u3000`;
|
||||
const canonicalPrompt = `${combiningText}\r\n${zwjEmoji}\u200B\uFEFF`;
|
||||
|
||||
expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe(canonicalPrompt);
|
||||
expect(canonicalizeBackgroundMusicPrompt(canonicalPrompt)).toBe(
|
||||
canonicalPrompt,
|
||||
);
|
||||
expect(canonicalPrompt).toContain(combiningText);
|
||||
expect(canonicalPrompt).toContain('\r\n');
|
||||
expect(canonicalPrompt).toContain(zwjEmoji);
|
||||
expect(canonicalPrompt).toContain('\u200B\uFEFF');
|
||||
});
|
||||
|
||||
it('counts code points from the canonical prompt', () => {
|
||||
const combiningText = 'e\u0301';
|
||||
const zwjEmoji = '👨👩👧👦';
|
||||
const prompt = ` \r\n${combiningText}\r\n${zwjEmoji}\u200B\uFEFF `;
|
||||
|
||||
expect(countPromptCodePoints('')).toBe(0);
|
||||
expect(countPromptCodePoints('\r\n')).toBe(0);
|
||||
expect(countPromptCodePoints(combiningText)).toBe(2);
|
||||
expect(countPromptCodePoints(zwjEmoji)).toBe(7);
|
||||
expect(countPromptCodePoints(prompt)).toBe(13);
|
||||
expect(prompt).toBe(` \r\n${combiningText}\r\n${zwjEmoji}\u200B\uFEFF `);
|
||||
});
|
||||
|
||||
it('counts only non-Unicode-White_Space code points in the canonical prompt as effective', () => {
|
||||
expect(countEffectivePromptCodePoints(' \t\r\n\u0085\u00A0\u3000')).toBe(0);
|
||||
expect(countEffectivePromptCodePoints('e\u0301')).toBe(2);
|
||||
expect(countEffectivePromptCodePoints('👨👩👧👦')).toBe(7);
|
||||
@@ -57,4 +118,91 @@ describe('ImageCanvasBackgroundMusicPromptModel', () => {
|
||||
expect(canCompleteBackgroundMusicPrompt('😀'.repeat(200))).toBe(true);
|
||||
expect(canCompleteBackgroundMusicPrompt('😀'.repeat(201))).toBe(false);
|
||||
});
|
||||
|
||||
it('allows simplification only for a non-empty canonical prompt over 200 code points', () => {
|
||||
expect(canSimplifyBackgroundMusicPrompt(' \r\n\u0085')).toBe(false);
|
||||
expect(canSimplifyBackgroundMusicPrompt('A'.repeat(200))).toBe(false);
|
||||
expect(canSimplifyBackgroundMusicPrompt('A'.repeat(201))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([200, 201])(
|
||||
'canonicalizes %i Unicode White_Space code points to an empty disabled prompt',
|
||||
(length) => {
|
||||
const prompt = '\u0085'.repeat(length);
|
||||
|
||||
expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe('');
|
||||
expect(countPromptCodePoints(prompt)).toBe(0);
|
||||
expect(countEffectivePromptCodePoints(prompt)).toBe(0);
|
||||
expect(canGenerateBackgroundMusicFromPrompt(prompt)).toBe(false);
|
||||
expect(canCompleteBackgroundMusicPrompt(prompt)).toBe(false);
|
||||
expect(canSimplifyBackgroundMusicPrompt(prompt)).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the canonical prompt for generation, completion, and simplification states', () => {
|
||||
const boundaryWhiteSpaceAroundA = `${'\u0085'.repeat(201)}A${'\u3000'.repeat(201)}`;
|
||||
const overLimitWithInternalWhiteSpace = `A${' '.repeat(199)}B`;
|
||||
const boundaryWhiteSpaceAround200Characters = `${'\u0085'.repeat(201)}${'A'.repeat(200)}${'\u3000'.repeat(201)}`;
|
||||
|
||||
expect(canonicalizeBackgroundMusicPrompt(boundaryWhiteSpaceAroundA)).toBe(
|
||||
'A',
|
||||
);
|
||||
expect(countPromptCodePoints(boundaryWhiteSpaceAroundA)).toBe(1);
|
||||
expect(countEffectivePromptCodePoints(boundaryWhiteSpaceAroundA)).toBe(1);
|
||||
expect(
|
||||
canGenerateBackgroundMusicFromPrompt(boundaryWhiteSpaceAroundA),
|
||||
).toBe(true);
|
||||
expect(canCompleteBackgroundMusicPrompt(boundaryWhiteSpaceAroundA)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(canSimplifyBackgroundMusicPrompt(boundaryWhiteSpaceAroundA)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
expect(countPromptCodePoints(overLimitWithInternalWhiteSpace)).toBe(201);
|
||||
expect(
|
||||
countEffectivePromptCodePoints(overLimitWithInternalWhiteSpace),
|
||||
).toBe(2);
|
||||
expect(
|
||||
canGenerateBackgroundMusicFromPrompt(overLimitWithInternalWhiteSpace),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canCompleteBackgroundMusicPrompt(overLimitWithInternalWhiteSpace),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canSimplifyBackgroundMusicPrompt(overLimitWithInternalWhiteSpace),
|
||||
).toBe(true);
|
||||
|
||||
expect(countPromptCodePoints(boundaryWhiteSpaceAround200Characters)).toBe(
|
||||
200,
|
||||
);
|
||||
expect(
|
||||
countEffectivePromptCodePoints(boundaryWhiteSpaceAround200Characters),
|
||||
).toBe(200);
|
||||
expect(
|
||||
canGenerateBackgroundMusicFromPrompt(
|
||||
boundaryWhiteSpaceAround200Characters,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canCompleteBackgroundMusicPrompt(boundaryWhiteSpaceAround200Characters),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canSimplifyBackgroundMusicPrompt(boundaryWhiteSpaceAround200Characters),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['\u200B', '\uFEFF'])(
|
||||
'keeps 201 %s code points and enables only simplification',
|
||||
(codePoint) => {
|
||||
const prompt = codePoint.repeat(201);
|
||||
|
||||
expect(canonicalizeBackgroundMusicPrompt(prompt)).toBe(prompt);
|
||||
expect(countPromptCodePoints(prompt)).toBe(201);
|
||||
expect(countEffectivePromptCodePoints(prompt)).toBe(201);
|
||||
expect(canGenerateBackgroundMusicFromPrompt(prompt)).toBe(false);
|
||||
expect(canCompleteBackgroundMusicPrompt(prompt)).toBe(false);
|
||||
expect(canSimplifyBackgroundMusicPrompt(prompt)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,14 +3,22 @@ export const BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS = 1;
|
||||
export const BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS = 2;
|
||||
|
||||
const UNICODE_WHITE_SPACE_CODE_POINT = /^\p{White_Space}$/u;
|
||||
const LEADING_UNICODE_WHITE_SPACE = /^\p{White_Space}+/u;
|
||||
const TRAILING_UNICODE_WHITE_SPACE = /\p{White_Space}+$/u;
|
||||
|
||||
export function canonicalizeBackgroundMusicPrompt(prompt: string) {
|
||||
return prompt
|
||||
.replace(LEADING_UNICODE_WHITE_SPACE, '')
|
||||
.replace(TRAILING_UNICODE_WHITE_SPACE, '');
|
||||
}
|
||||
|
||||
export function countPromptCodePoints(prompt: string) {
|
||||
return Array.from(prompt).length;
|
||||
return Array.from(canonicalizeBackgroundMusicPrompt(prompt)).length;
|
||||
}
|
||||
|
||||
export function countEffectivePromptCodePoints(prompt: string) {
|
||||
let effectiveCodePoints = 0;
|
||||
for (const codePoint of prompt) {
|
||||
for (const codePoint of canonicalizeBackgroundMusicPrompt(prompt)) {
|
||||
if (!UNICODE_WHITE_SPACE_CODE_POINT.test(codePoint)) {
|
||||
effectiveCodePoints += 1;
|
||||
}
|
||||
@@ -33,3 +41,11 @@ export function canCompleteBackgroundMusicPrompt(prompt: string) {
|
||||
BACKGROUND_MUSIC_PROMPT_COMPLETION_MIN_EFFECTIVE_CODE_POINTS
|
||||
);
|
||||
}
|
||||
|
||||
export function canSimplifyBackgroundMusicPrompt(prompt: string) {
|
||||
return (
|
||||
countPromptCodePoints(prompt) > BACKGROUND_MUSIC_PROMPT_MAX_CODE_POINTS &&
|
||||
countEffectivePromptCodePoints(prompt) >=
|
||||
BACKGROUND_MUSIC_PROMPT_GENERATION_MIN_EFFECTIVE_CODE_POINTS
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user