diff --git a/CLAUDE.md b/CLAUDE.md index 918689f..7028830 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index ed9db0d..c0e9546 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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, diff --git a/docs/changelog.md b/docs/changelog.md index 6f4b6a7..c0c854b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 项权限矩阵全过 diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index 7666071..09ee8b0 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -12,6 +12,9 @@ const placeholders: Record = { 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() }; 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; } diff --git a/web/src/lib/assetMentions.ts b/web/src/lib/assetMentions.ts index 46c04bf..c67b64b 100644 --- a/web/src/lib/assetMentions.ts +++ b/web/src/lib/assetMentions.ts @@ -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; +} + +/** Shared core: walk asset mention spans and aggregate counts/durations/distinct images. */ +function collectAssetMentionStats(spans: ArrayLike): AssetMentionStats { + const counts = { image: 0, video: 0, audio: 0 }; + const durations = { video: 0, audio: 0 }; + const imageAssetIds = new Set(); + 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"]')); } diff --git a/web/src/store/inputBar.ts b/web/src/store/inputBar.ts index 796012c..8108651 100644 --- a/web/src/store/inputBar.ts +++ b/web/src/store/inputBar.ts @@ -261,10 +261,11 @@ export const useInputBarStore = create((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((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; }