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
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:
parent
92701ed558
commit
0eeefe88d6
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 项权限矩阵全过
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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"]'));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user