From 1d88edb9907b249f0296d347e3cd13e92a3d445a Mon Sep 17 00:00:00 2001 From: "WIN-UGQIHHLSKBB\\EDY" <1790703832@qq.com> Date: Mon, 15 Jun 2026 14:30:25 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=8F=82=E8=80=83@=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E7=A9=BAnull=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/global-prompt-create-gen-task-payload.md | 279 ++++++++++++++++++ .../globalPromptPanel/composerRangeParam.ts | 39 ++- .../ReferenceUploadPromptSection.tsx | 5 +- .../globalPromptPanel/globalPromptPanel.tsx | 10 +- 4 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 docs/global-prompt-create-gen-task-payload.md diff --git a/docs/global-prompt-create-gen-task-payload.md b/docs/global-prompt-create-gen-task-payload.md new file mode 100644 index 0000000..8a1b181 --- /dev/null +++ b/docs/global-prompt-create-gen-task-payload.md @@ -0,0 +1,279 @@ +# createGenTaskPayload 梳理 + +本文只说明 `src/layouts/globalPromptPanel/globalPromptPanel.tsx` 里的 `createGenTaskPayload`。 +它是创建任务接口 `createGenTask()` 的请求体。 +它也会传给 `useGenerateCost()`,作为普通任务价格计算的同源参数。 + +## 1. 它解决什么问题 + +`createGenTaskPayload` 把创作面板当前状态整理成后端需要的任务参数。 + +它主要合并 4 类数据: + +- 当前任务类型:`activeTaskSubType` +- 当前模型:`activeModelItem` +- 当前参数:`modelParamFields` 和 `selectedParamValues` +- 当前输入内容:`chatPrompt`、上传媒体、音色、动作模仿示例、参考主体 + +最终结果会用于两个地方: + +- `useGenerateCost({ taskPayload: createGenTaskPayload })` +- `createGenTask(createGenTaskPayload)` + +## 2. 整体流程 + +```txt +mediaItems + └─ splitUploadMediaItems() + ├─ uploadImages + ├─ uploadVideos + └─ uploadVoice + +activeModelItem + activeTaskSubType + modelParamFields + selectedParamValues + └─ buildComposerTaskSubmitPayload() + ├─ subType + ├─ type + ├─ 模型字段 + ├─ 普通参数 + └─ extraTaskParams + +createGenTaskPayload + ├─ 基础任务参数 + ├─ chatPrompt + ├─ images / videos / voices + ├─ voiceId + ├─ templateVideoMaterialId + └─ referenceSubjectList +``` + +## 3. 基础任务参数从哪里来 + +基础参数来自 `buildComposerTaskSubmitPayload()`。 + +这个函数会返回: + +- `subType`:当前任务子类型 +- `type`:任务大类。当前代码里 `1` 是图片类,`2` 是视频类,`3` 是音频类 +- 模型字段:来自 `buildGenTaskModelPayload(activeModelItem)` +- 普通参数:来自模型参数和固定参数 +- `extraTaskParams`:滑块参数,比如 `speed / vol / pitch` + +### 3.1 模型字段 + +`buildGenTaskModelPayload()` 会把同一个模型信息写成多个字段。 +这是后端接口约定,不是前端重复写错。 + +当前会写入: + +- `aiModelName` +- `aiPlatform` +- `aiModelId` +- `model` +- `aiModelCode` +- `aiModelCodeAlias` + +其中 `aiPlatform`、`model`、`aiModelCode` 都来自 `activeModelItem.code`。 + +### 3.2 参数字段 + +参数字段来自 `buildComposerTaskParamPayload()`。 + +它会做这些事: + +- 用 `buildPreferredParamValues()` 给缺失参数补默认值 +- 找到比例字段,比如 `ratio` +- 找到分辨率字段,比如 `resolution` +- 根据比例和分辨率推导 `width / height` +- 保证请求体里有接口约定的 `ratio` 字段 + +### 3.3 滑块参数 + +`splitRangeParamsForTaskSubmit()` 会把滑块参数从顶层移到 `extraTaskParams`。 + +例子: + +```json +{ + "resolution": "2K", + "ratio": "9:16", + "extraTaskParams": { + "speed": 1, + "vol": 0.8, + "pitch": 0 + } +} +``` + +这样做是为了让普通参数留在顶层,同时让 `speed / vol / pitch` 这类范围值走 `extraTaskParams`。 + +## 4. 媒体字段怎么来 + +上传媒体来自 `splitUploadMediaItems(mediaItems, composerUploadLimits)`。 + +它会按类型拆成三列: + +- 图片:`uploadImages`,最后写入 `images` +- 视频:`uploadVideos`,最后写入 `videos` +- 音频:`uploadVoice`,最后写入 `voices` + +它也会按模型上传上限截断对应类型。 +例如模型最多只收 1 个视频时,后面的上传视频不会进入请求体。 + +空数组不会提交。 +所以代码最后会删除这些空字段: + +- `images` +- `videos` +- `voices` + +## 5. 不同任务的额外处理 + +`createGenTaskPayload` 在基础参数之外,会按任务类型补几个字段。 + +### 5.1 通用任务 + +所有任务都会写入: + +- `chatPrompt: chatPrompt.trim()` +- `images` +- `videos` +- `voices` + +如果 `chatPrompt` 为空,创建任务前会有校验。 +动作模仿任务例外,因为它可以只依赖动作示例。 + +### 5.2 文本转语音任务 + +当 `isVoiceTask` 为 `true` 时,会写入: + +- `voiceId` + +取值优先级是: + +```txt +selectedVoiceItem?.voiceId > voiceId +``` + +如果最终值为空,代码会删除 `voiceId`。 +创建任务前也会再校验一次,确保文本转语音必须有音色。 + +### 5.3 动作模仿任务 + +当 `isActionMimicTask` 为 `true` 时,`videos` 会特殊处理。 + +如果用户选了动作模仿示例,代码会把示例视频放到 `videos` 第一位: + +```txt +[selectedActionMimicItem.url, ...uploadMediaPayload.uploadVideos] +``` + +如果选中的是官方示例,还会写入: + +- `templateVideoMaterialId` + +它来自 `selectedActionMimicItem.id`。 +如果不是官方示例,这个字段会被删除。 + +### 5.4 参考生视频任务 + +当 `isReferenceTask` 为 `true` 时,会写入: + +- `referenceSubjectList` + +这个字段来自 `referenceStructure.referenceSubjectList`。 +它保存参考主体列表,不只是普通上传图片。 + +## 6. 当前请求体大概长什么样 + +一个普通图像或视频任务的请求体大概是: + +```json +{ + "subType": 102, + "type": 1, + "aiModelName": "模型名", + "aiPlatform": "模型 code", + "aiModelId": 123, + "model": "模型 code", + "aiModelCode": "模型 code", + "aiModelCodeAlias": "别名", + "resolution": "2K", + "ratio": "9:16", + "width": 720, + "height": 1280, + "batchSize": 1, + "chatPrompt": "用户输入的提示词", + "images": ["https://example.com/a.png"], + "videos": ["https://example.com/a.mp4"], + "voices": ["https://example.com/a.mp3"], + "extraTaskParams": { + "speed": 1 + } +} +``` + +实际字段会根据任务类型、模型参数、上传内容变化。 +空的 `images / videos / voices` 不会出现在最终请求体里。 + +## 7. 创建任务前还有哪些校验 + +`createGenTaskPayload` 只负责组装请求体。 +它不负责全部业务校验。 + +点击创建任务时,`handleCreateGenTaskClick()` 还会检查: + +- 非动作模仿任务必须有提示词 +- 当前任务是否满足最少上传数量 +- 音频素材不能单独提交,必须搭配图片或视频 +- 动作模仿任务必须选择示例 +- 文本转语音任务必须选择音色 +- 用户积分是否足够 + +这些校验通过后,才会调用: + +```ts +await createGenTask(createGenTaskPayload); +``` + +## 8. 阅读这段代码时的顺序 + +建议按这个顺序看: + +1. 先看 `createGenTaskPayload` 本体,知道最终字段有哪些。 +2. 再看 `buildComposerTaskSubmitPayload()`,理解模型和参数怎么进请求体。 +3. 再看 `buildComposerTaskParamPayload()`,理解默认参数、比例、分辨率、尺寸。 +4. 再看 `splitRangeParamsForTaskSubmit()`,理解滑块参数为什么进 `extraTaskParams`。 +5. 最后看 `handleCreateGenTaskClick()`,理解请求体提交前的校验。 + +## 9. 为什么现在看起来杂乱 + +这段逻辑承担了两个职责: + +- 生成创建任务的请求体 +- 给价格计算提供同一份任务参数 + +同时它还要兼容多种任务类型。 +所以它会同时碰到模型、参数、媒体、音色、动作示例、参考主体。 + +如果后续要继续整理,可以把它拆成一个纯函数,例如: + +```ts +buildCreateGenTaskPayload({ + activeTaskSubType, + activeModelItem, + modelParamFields, + selectedParamValues, + chatPrompt, + uploadMediaPayload, + selectedVoiceItem, + voiceId, + selectedActionMimicItem, + referenceSubjectList, + isVoiceTask, + isActionMimicTask, + isReferenceTask, +}) +``` + +这样 `globalPromptPanel.tsx` 只负责传状态。 +请求体规则可以放到 `utils.ts` 或单独文件里测试。 diff --git a/src/layouts/globalPromptPanel/composerRangeParam.ts b/src/layouts/globalPromptPanel/composerRangeParam.ts index ec09212..5f9c5ac 100644 --- a/src/layouts/globalPromptPanel/composerRangeParam.ts +++ b/src/layouts/globalPromptPanel/composerRangeParam.ts @@ -6,16 +6,23 @@ export type ComposerRangeParamBounds = { defaultValue: number; }; +/** 滑块型参数统一使用 0.1 步进。pitch 在 UI 层会单独改成整数步进。 */ export const RANGE_PARAM_STEP = 0.1; +/** 避免浮点数出现 0.30000000000000004 这类展示和提交问题。 */ function roundRangeParamValue(value: number): number { return Math.round(value * 10) / 10; } +/** UI 展示统一保留 1 位小数,比如 speed / vol 显示为 1.0。 */ export function formatRangeParamDisplay(value: number): string { return roundRangeParamValue(value).toFixed(1); } +/** + * 后端的 displayDimensions 既会返回普通选项,也会返回滑块范围。 + * 当前约定是:options[0] 有合法 startValue / endValue 时,这个字段就是滑块参数。 + */ function isRangeParamOption(option: ModelDimensionOption | undefined): boolean { if (!option) return false; const start = Number(option.startValue); @@ -23,11 +30,17 @@ function isRangeParamOption(option: ModelDimensionOption | undefined): boolean { return Number.isFinite(start) && Number.isFinite(end) && start < end; } +/** 把用户输入、历史任务值或草稿值限制在接口给定范围内。 */ function clampRangeParamValue(value: number, bounds: ComposerRangeParamBounds): number { const clamped = Math.min(bounds.end, Math.max(bounds.start, value)); return roundRangeParamValue(clamped); } +/** + * 从模型参数字段中提取滑块边界。 + * 默认值优先取 0,因为 speed / vol / pitch 这类参数通常以 0 表示不偏移。 + * 如果范围不包含 0,就取起止值中点。 + */ export function getRangeParamBounds(field: ComposerParamField): ComposerRangeParamBounds | null { const option = field.options[0]; if (!isRangeParamOption(option)) return null; @@ -41,7 +54,10 @@ export function normalizeRangeParamValue(value: number, bounds: ComposerRangePar return clampRangeParamValue(value, bounds); } -/** 仅用于滑块填充条视觉比例,展示值仍用 startValue~endValue 原值。 */ +/** + * 计算滑块填充条宽度。 + * 这个百分比只用于视觉位置,实际展示和提交仍使用原始范围值。 + */ export function getRangeParamFillPercent(value: number, bounds: ComposerRangeParamBounds): number { const clamped = clampRangeParamValue(value, bounds); const span = bounds.end - bounds.start; @@ -49,7 +65,10 @@ export function getRangeParamFillPercent(value: number, bounds: ComposerRangePar return ((clamped - bounds.start) / span) * 100; } -/** 兼容 extraTaskParams 为对象或 JSON 字符串。 */ +/** + * 历史任务里的 extraTaskParams 可能是对象,也可能是 JSON 字符串。 + * 这里统一解析成对象,方便“制作同款 / 再次生成”回填滑块参数。 + */ export function parseExtraTaskParams(raw: unknown): Record | null { if (raw == null || raw === "") return null; if (typeof raw === "string") { @@ -69,6 +88,10 @@ export function parseExtraTaskParams(raw: unknown): Record | nu return null; } +/** + * 读取任务里的参数值。 + * 滑块参数优先从 extraTaskParams 取;没有时再读顶层字段,兼容旧数据。 + */ export function getExtraTaskParamValue(taskInfo: Record | null | undefined, fieldKey: string): unknown { const extra = parseExtraTaskParams(taskInfo?.extraTaskParams); if (extra) { @@ -78,7 +101,7 @@ export function getExtraTaskParamValue(taskInfo: Record | null return taskInfo?.[fieldKey]; } -/** 制作同款 / 再次生成:统一把 extraTaskParams 解析为对象,便于区间参数回填。 */ +/** 制作同款 / 再次生成入口:先把 extraTaskParams 规整成对象,后续逻辑就不用重复判断类型。 */ export function normalizeTaskInfoForComposer(taskInfo: Record): Record { const extra = parseExtraTaskParams(taskInfo.extraTaskParams); if (!extra) return taskInfo; @@ -89,7 +112,10 @@ function readRangeParamFromTaskInfo(taskInfo: Record, fieldKey: return getExtraTaskParamValue(taskInfo, fieldKey); } -/** 为 speed / vol / pitch 等区间参数补齐或校正 selectedParamValues。 */ +/** + * 为 speed / vol / pitch 等滑块参数补齐 selectedParamValues。 + * 回填优先级:历史任务值 > 已有选择值 > 当前范围默认值。 + */ export function applyRangeParamValues( fields: ComposerParamField[], values: Record, @@ -117,7 +143,10 @@ export function applyRangeParamValues( return next; } -/** 提交任务:区间参数写入 extraTaskParams,其余参数保留在顶层。 */ +/** + * 创建任务和价格计算都要用同一套参数格式。 + * 普通参数保留在顶层,滑块参数移入 extraTaskParams,避免和后端顶层字段约定冲突。 + */ export function splitRangeParamsForTaskSubmit( fields: ComposerParamField[], paramValues: Record, diff --git a/src/layouts/globalPromptPanel/composerSections/ReferenceUploadPromptSection.tsx b/src/layouts/globalPromptPanel/composerSections/ReferenceUploadPromptSection.tsx index 76023b2..f07677a 100644 --- a/src/layouts/globalPromptPanel/composerSections/ReferenceUploadPromptSection.tsx +++ b/src/layouts/globalPromptPanel/composerSections/ReferenceUploadPromptSection.tsx @@ -444,6 +444,8 @@ export function ReferenceUploadPromptSection(props: CommonUploadPromptSectionPro >(), ); const [showPicker, setShowPicker] = useState(false); + const showPickerRef = useRef(showPicker); + showPickerRef.current = showPicker; const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; @@ -916,7 +918,7 @@ export function ReferenceUploadPromptSection(props: CommonUploadPromptSectionPro role: "textbox", }, handleKeyDown: (_view, event) => { - if (event.key === "Escape" && showPicker) { + if (event.key === "Escape" && showPickerRef.current) { setShowPicker(false); return true; } @@ -947,7 +949,6 @@ export function ReferenceUploadPromptSection(props: CommonUploadPromptSectionPro updatePickerPosition(currentEditor); }, }, - [handleEditorAstChange, handleEditorCopy, handleEditorCut, handleEditorPaste, showPicker, t, updatePickerPosition], ); useEffect(() => { diff --git a/src/layouts/globalPromptPanel/globalPromptPanel.tsx b/src/layouts/globalPromptPanel/globalPromptPanel.tsx index 76e69e7..168189a 100644 --- a/src/layouts/globalPromptPanel/globalPromptPanel.tsx +++ b/src/layouts/globalPromptPanel/globalPromptPanel.tsx @@ -1718,7 +1718,9 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false, const rangeBounds = getRangeParamBounds(field); if (rangeBounds) { const selectedValue = selectedParamValues[field.key]; - const rangeValue = normalizeRangeParamValue(Number(selectedValue ?? rangeBounds.defaultValue), rangeBounds); + const isPitchRange = field.key.trim().toLowerCase() === "pitch"; + const normalizedRangeValue = normalizeRangeParamValue(Number(selectedValue ?? rangeBounds.defaultValue), rangeBounds); + const rangeValue = isPitchRange ? Math.round(normalizedRangeValue) : normalizedRangeValue; const fillPercent = getRangeParamFillPercent(rangeValue, rangeBounds); const thumbAtStart = fillPercent <= 0; const thumbAtEnd = fillPercent >= 100; @@ -1727,7 +1729,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false,

{field.name}

@@ -1756,7 +1758,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false, className="layoutShell__composerParamRangeSliderInput" min={rangeBounds.start} max={rangeBounds.end} - step={RANGE_PARAM_STEP} + step={isPitchRange ? 1 : RANGE_PARAM_STEP} value={rangeValue} aria-label={field.name} aria-valuemin={rangeBounds.start} @@ -1766,7 +1768,7 @@ export function GlobalPromptPanel({ mode = "floating", embeddedVisible = false, const nextValue = Number(event.target.value); setSelectedParamValues((prev) => ({ ...prev, - [field.key]: normalizeRangeParamValue(nextValue, rangeBounds), + [field.key]: isPitchRange ? Math.round(normalizeRangeParamValue(nextValue, rangeBounds)) : normalizeRangeParamValue(nextValue, rangeBounds), })); }} />