fix(generation): count upload + deduped asset images against 9-image cap
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 13m54s

参考图 9 张上限改为「上传图 + 去重后的素材库引用图」合并计数,
同一张素材图 @ 多次按 1 张算。旧逻辑只算上传图、@ 素材完全不计入,
导致可 9 张上传 + 无限 @ 素材超标。

- assetMentions.ts: 抽出 collectAssetMentionStats,新增按 assetId 去重的 imageAssetIds
- inputBar.ts: addReferences / _validateAndAddImages 并入素材去重数卡 9 张
- PromptInput.tsx: insertAssetMention 补 image 分支,已 @ 过的同一素材放行
- views.py: 上限校验移到循环后用 image_n(seen_urls 已去重)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc 2026-06-04 14:12:32 +08:00
parent 92701ed558
commit 0eeefe88d6
6 changed files with 98 additions and 57 deletions

View File

@ -473,6 +473,7 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
| 2026-03-18 | v0.9.1: 团队管理 — 预期登录城市(必填) + 自动学习 + disabled_by 来源标签 | Full stack |
| 2026-03-18 | v0.9.1: 前端拦截器 — user_disabled/team_disabled 错误码处理,弹窗提示后跳登录 | Frontend |
| 2026-03-19 | fix: LoginRecord 创建时显式传 geo 空字段,修复 MySQL 严格模式 IntegrityError | Backend |
| 2026-06-04 | fix: 参考图 9 张上限改为「上传图 + 去重素材库引用图」合并计数(同一素材按 1 张算)| Full stack |
### Phase 4 Details (2026-03-13)

View File

@ -359,18 +359,9 @@ def video_generate_view(request):
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# 构建参考素材
# 直接上传的参考图片最多 9 张;素材库 asset:// 引用不计入该上传槽位限制。
image_count = sum(
1 for r in references
if r.get('type', 'image') == 'image'
and not str(r.get('url', '')).startswith('asset://')
)
if image_count > 9:
return Response({
'error': 'too_many_references',
'message': f'参考图片最多 9 张,当前 {image_count} 张,请减少后重试',
}, status=status.HTTP_400_BAD_REQUEST)
# 参考图上限 9 张 = 上传图 + 素材库引用图,合并去重计数(同一张素材按 1 张算)。
# 实际校验放在构建 content_items 之后,用最终的 image_n 判断——seen_urls 已对
# 完全相同的 URL含重复 @ 的同一素材)去重,故 image_n 即去重后的图片总数。
reference_snapshots = []
content_items = []
seen_urls = set() # 去重:同一个素材只引用一次
@ -560,6 +551,13 @@ def video_generate_view(request):
logger.info('Video generate: %d content_items built (prompt=%s...)', len(content_items), prompt[:60])
# 参考图上限:上传图 + 素材库引用图(已按 URL 去重,同一素材按 1 张算)合计 ≤ 9
if image_n > 9:
return Response({
'error': 'too_many_references',
'message': f'参考图片最多 9 张(含素材库引用,同一素材按 1 张计算),当前 {image_n} 张,请减少后重试',
}, status=status.HTTP_400_BAD_REQUEST)
# 冻结(不扣余额)
record = GenerationRecord.objects.create(
user=user,

View File

@ -4,6 +4,30 @@
---
## 2026-06-04 — fix: 参考图 9 张上限改为「上传图 + 素材库引用图」合并去重计数
**状态**: ✅ 本地完成 | **验收**: `tsc -b` 0 报错 + 后端 syntax OK + vitest 相对基线 0 回归inputBarStore.test.ts 7 个失败为改前既有的陈旧用例)
### 变更内容
旧逻辑9 张上限只算直接上传图片,@ 素材库引用图片完全不计入(可 9 张上传 + 无限 @ 素材 → 超标)。
新逻辑:**上传图片 + 去重后的 @ 素材库引用图片 合计 ≤ 9同一张素材图 @ 多次按 1 张计算**。视频(3)/音频(3)上限不变。
| 层 | 文件 | 改动 |
|----|------|------|
| 前端 | `web/src/lib/assetMentions.ts` | 抽出 `collectAssetMentionStats`,返回新增 `imageAssetIds: Set`(按 `assetId` 去重的素材图片集合)|
| 前端 | `web/src/store/inputBar.ts` | `addReferences` / `_validateAndAddImages` 上传图校验并入 `imageAssetIds.size` 一起卡 9 张 |
| 前端 | `web/src/components/PromptInput.tsx` | `insertAssetMention` 补 image 分支:已 @ 过的同一素材放行,否则 `上传图+去重素材图 >= 9` 拦截;新增 `MAX_TOTAL_IMAGES=9` |
| 后端 | `backend/apps/generation/views.py` | 上限校验从循环前(仅算上传图、排除 asset://)移到循环后,改用最终 `image_n > 9` 判断——`seen_urls` 已对相同 URL含重复 @ 同一素材)去重,`image_n` 即去重后图片总数 |
### 关键设计要点
- **后端天然去重**`seen_urls` 早已跳过完全相同的 `asset://local-{id}` URL故把上限检查挪到循环后用 `image_n` 判断,一处改动同时满足「合并计数 + 去重」。
- **重复 @ 同一素材放行**:前端按 `assetId` 判断,编辑器里已存在该素材时再次 @ 不增加去重计数、不拦截。
- 前后端文案统一:「最多 9 张图片(含素材库引用,同一素材按 1 张计算)」。
---
## 2026-05-12 — v0.20.1: 7 批次小修复 + 中等功能(主管bug/封面帧/api_prompt/站内通知/团管重置密码/reEdit prompt/Safari 自适应根因)
**状态**: ✅ 本地完成 | **验收**: vitest 71/162 基线 0 回归 + 3 套 smoke (25+8+11) 全过 + 后端 curl 验证 4 通知 endpoint 全过 + 团管重置 6 项权限矩阵全过

View File

@ -12,6 +12,9 @@ const placeholders: Record<string, string> = {
keyframe: '输入描述,定义首帧到尾帧的运动过程',
};
// 参考图上限:上传图 + 素材库引用图(去重)合计 ≤ 9与后端 views.py 一致)
const MAX_TOTAL_IMAGES = 9;
export function PromptInput() {
const prompt = useInputBarStore((s) => s.prompt);
const setPrompt = useInputBarStore((s) => s.setPrompt);
@ -416,16 +419,22 @@ export function PromptInput() {
}, [extractText]);
const insertAssetMention = useCallback((asset: AssetSearchResult) => {
// Instant check: count limit. Image assets from the library do not consume
// the 9 direct-upload image slots.
const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 } };
// Instant check: count limit. 图片上限为「上传图 + 素材库引用图」合并去重 ≤ 9
// 同一张素材图片已在编辑器里 @ 过则可重复 @(不增加去重计数);视频/音频仍为各 3。
const stats = editorRef.current ? parseAssetMentionsFromDOM(editorRef.current) : { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 }, imageAssetIds: new Set<string>() };
const refs = useInputBarStore.getState().references;
const refCounts = { image: 0, video: 0, audio: 0 };
refs.forEach((r) => refCounts[r.type]++);
const typeKey = asset.asset_type === 'Video' ? 'video' : asset.asset_type === 'Audio' ? 'audio' : 'image';
const maxMap = { video: 3, audio: 3 };
if (typeKey !== 'image' && refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : asset.asset_type === 'Audio' ? '音频' : '图片';
if (typeKey === 'image') {
const alreadyReferenced = stats.imageAssetIds.has(String(asset.id));
if (!alreadyReferenced && refCounts.image + stats.imageAssetIds.size >= MAX_TOTAL_IMAGES) {
showToast(`最多 ${MAX_TOTAL_IMAGES} 张图片(含上传图片,同一素材按 1 张计算)`);
return;
}
} else if (refCounts[typeKey] + stats.counts[typeKey] >= maxMap[typeKey]) {
const typeLabel = asset.asset_type === 'Video' ? '视频' : '音频';
showToast(`${typeLabel}已达上限`);
return;
}

View File

@ -1,44 +1,49 @@
export interface AssetMentionStats {
counts: { image: number; video: number; audio: number };
durations: { video: number; audio: number };
/**
* assetId @ 1
* + 9
* size
*/
imageAssetIds: Set<string>;
}
/** Shared core: walk asset mention spans and aggregate counts/durations/distinct images. */
function collectAssetMentionStats(spans: ArrayLike<Element>): AssetMentionStats {
const counts = { image: 0, video: 0, audio: 0 };
const durations = { video: 0, audio: 0 };
const imageAssetIds = new Set<string>();
Array.from(spans).forEach((span) => {
const el = span as HTMLElement;
const t = el.dataset.assetType || 'Image';
const rawDur = parseFloat(el.dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur; // null/undefined → NaN → 0, ffprobe 失败不计入时长
if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else {
counts.image++;
// 无 assetId 的历史 span如 group 引用)退化为唯一键,按独立图片计数
imageAssetIds.add(el.dataset.assetId || `__noid_${counts.image}`);
}
});
return { counts, durations, imageAssetIds };
}
/**
* Parse asset mention spans directly from a DOM element (real-time, no stale state).
* Use this when you have access to the editor DOM element.
*/
export function parseAssetMentionsFromDOM(el: HTMLElement): {
counts: { image: number; video: number; audio: number };
durations: { video: number; audio: number };
} {
const counts = { image: 0, video: 0, audio: 0 };
const durations = { video: 0, audio: 0 };
el.querySelectorAll('[data-ref-type="asset"]').forEach((span) => {
const t = (span as HTMLElement).dataset.assetType || 'Image';
const rawDur = parseFloat((span as HTMLElement).dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur;
if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else { counts.image++; }
});
return { counts, durations };
export function parseAssetMentionsFromDOM(el: HTMLElement): AssetMentionStats {
return collectAssetMentionStats(el.querySelectorAll('[data-ref-type="asset"]'));
}
/**
* Parse asset mention spans from editor HTML string.
* Use this when you only have the HTML string (e.g., from store state).
*/
export function parseAssetMentions(html: string): {
counts: { image: number; video: number; audio: number };
durations: { video: number; audio: number };
} {
const counts = { image: 0, video: 0, audio: 0 };
const durations = { video: 0, audio: 0 };
if (!html) return { counts, durations };
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
doc.querySelectorAll('[data-ref-type="asset"]').forEach((el) => {
const t = (el as HTMLElement).dataset.assetType || 'Image';
const rawDur = parseFloat((el as HTMLElement).dataset.duration || '0');
const dur = isNaN(rawDur) ? 0 : rawDur; // null/undefined → NaN → 0, ffprobe 失败不计入时长
if (t === 'Video') { counts.video++; durations.video += dur; }
else if (t === 'Audio') { counts.audio++; durations.audio += dur; }
else { counts.image++; }
});
return { counts, durations };
export function parseAssetMentions(html: string): AssetMentionStats {
if (!html) return { counts: { image: 0, video: 0, audio: 0 }, durations: { video: 0, audio: 0 }, imageAssetIds: new Set() };
const doc = new DOMParser().parseFromString(html, 'text/html');
return collectAssetMentionStats(doc.querySelectorAll('[data-ref-type="asset"]'));
}

View File

@ -261,10 +261,11 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
prevReferences: [],
addReferences: (files) => {
const state = get();
// Count direct uploaded references by type. Asset library mentions do not
// consume direct upload slots.
// Count direct uploaded references by type. 图片上限为「上传图 + 素材库引用图」
// 合并去重后 ≤ 9同一张素材图按 1 张计算);视频/音频上限仍只算直接上传。
const counts = { image: 0, video: 0, audio: 0 };
for (const ref of state.references) counts[ref.type]++;
const assetImageCount = parseAssetMentions(state.editorHtml).imageAssetIds.size;
// Separate images (sync) from audio/video (need async duration check)
const imageFiles: File[] = [];
@ -278,8 +279,10 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
: 'image';
const max = type === 'image' ? MAX_IMAGES : type === 'video' ? MAX_VIDEOS : MAX_AUDIO;
if (counts[type] >= max) {
const label = type === 'image' ? `最多上传${MAX_IMAGES}张图片` : type === 'video' ? `最多上传${MAX_VIDEOS}个视频` : `最多上传${MAX_AUDIO}个音频`;
// 图片把素材库去重引用数并入当前占用,一起卡 9 张上限
const effectiveCount = type === 'image' ? counts.image + assetImageCount : counts[type];
if (effectiveCount >= max) {
const label = type === 'image' ? `最多 ${MAX_IMAGES} 张图片(含素材库引用,同一素材按 1 张计算)` : type === 'video' ? `最多上传${MAX_VIDEOS}个视频` : `最多上传${MAX_AUDIO}个音频`;
showToast(label);
continue;
}
@ -546,11 +549,12 @@ async function _validateAndAddImages(files: File[]) {
continue;
}
// Re-check count
// Re-check count(合并素材库去重引用图,一起卡 9 张上限)
const state = useInputBarStore.getState();
const currentCount = state.references.filter((r) => r.type === 'image').length;
if (currentCount >= MAX_IMAGES) {
showToast(`最多上传${MAX_IMAGES}张图片`);
const assetImageCount = parseAssetMentions(state.editorHtml).imageAssetIds.size;
if (currentCount + assetImageCount >= MAX_IMAGES) {
showToast(`最多 ${MAX_IMAGES} 张图片(含素材库引用,同一素材按 1 张计算)`);
continue;
}