diff --git a/portal-ui/src/components/VideoComposeCard.vue b/portal-ui/src/components/VideoComposeCard.vue index 143a4df..e88d935 100644 --- a/portal-ui/src/components/VideoComposeCard.vue +++ b/portal-ui/src/components/VideoComposeCard.vue @@ -162,8 +162,8 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue' import { Message } from '@arco-design/web-vue' import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file' -/** 图生参考:不同参考图最多 4 张(图1–图4),同一 URL 可多次 @ */ -const MAX_REFERENCE_UNIQUE = 4 +/** 富文本内「不同素材」种类上限(图/视频/音频合计);同一 URL 可多次 @,提交时 reference_* 去重后最多 9 条 */ +const MAX_REFERENCE_CONTENT_SLOTS = 9 const props = defineProps({ modelValue: { @@ -603,7 +603,7 @@ const getUniqueRefUrlsInDoc = () => { return s } -/** 按文档顺序为不同 URL 分配 [图n]/[视频n]/[音频n],并同步 data-token / 展示 */ +/** 按文档顺序为不同 URL 分配 [图n]/[视频n]/[音频n];不同素材合计最多 MAX_REFERENCE_CONTENT_SLOTS,同 URL 复用同一序号 */ const renumberAllReferenceMentions = () => { if (!editorRef.value || !isReference.value) return const refs = Array.from(editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]')) @@ -613,6 +613,7 @@ const renumberAllReferenceMentions = () => { let imgNext = 1 let vidNext = 1 let audNext = 1 + let globalDistinct = 0 let droppedExtra = false for (const el of refs) { const u = el.getAttribute('data-reference-url') || '' @@ -624,31 +625,34 @@ const renumberAllReferenceMentions = () => { let token = '' if (kind === 'video') { if (!vidMap.has(u)) { - if (vidNext > MAX_REFERENCE_UNIQUE) { + if (globalDistinct >= MAX_REFERENCE_CONTENT_SLOTS) { el.remove() droppedExtra = true continue } + globalDistinct++ vidMap.set(u, vidNext++) } token = `[视频${vidMap.get(u)}]` } else if (kind === 'audio') { if (!audMap.has(u)) { - if (audNext > MAX_REFERENCE_UNIQUE) { + if (globalDistinct >= MAX_REFERENCE_CONTENT_SLOTS) { el.remove() droppedExtra = true continue } + globalDistinct++ audMap.set(u, audNext++) } token = `[音频${audMap.get(u)}]` } else { if (!imgMap.has(u)) { - if (imgNext > MAX_REFERENCE_UNIQUE) { + if (globalDistinct >= MAX_REFERENCE_CONTENT_SLOTS) { el.remove() droppedExtra = true continue } + globalDistinct++ imgMap.set(u, imgNext++) } token = `[图${imgMap.get(u)}]` @@ -658,7 +662,7 @@ const renumberAllReferenceMentions = () => { if (imgEl) imgEl.setAttribute('alt', token) } if (droppedExtra) { - Message.warning(`每类参考最多 ${MAX_REFERENCE_UNIQUE} 个不同素材,已移除多余引用`) + Message.warning(`富文本内不同素材最多 ${MAX_REFERENCE_CONTENT_SLOTS} 个(图/视频/音频合计),已移除多余引用`) } } @@ -697,21 +701,30 @@ const collectReferenceMentionsInDocOrder = () => { } /** - * 参考图模式提交用:首条 text;其后按文中 [图n]/[视频n]/[音频n] 顺序对应各 reference_*。 + * 参考图模式提交用:首条 text;其后为 reference_*。 + * 文中多次出现同一 [图n]/[视频n]/[音频n](同一 URL)时,只输出一条记录(按文中首次出现顺序),最多 9 条。 */ const getImageReferenceContentItems = () => { const text = getEditorPlainText() const first = { type: 'text', text: text || '' } const mentions = collectReferenceMentionsInDocOrder() - const rest = mentions.map(({ url, kind }) => { + const seen = new Set() + const rest = [] + for (const { url, kind } of mentions) { + const u = String(url || '').trim() + if (!u) continue + const key = `${kind}::${u}` + if (seen.has(key)) continue + seen.add(key) + if (rest.length >= MAX_REFERENCE_CONTENT_SLOTS) break if (kind === 'video') { - return { type: 'video_url', video_url: { url }, role: 'reference_video' } + rest.push({ type: 'video_url', video_url: { url: u }, role: 'reference_video' }) + } else if (kind === 'audio') { + rest.push({ type: 'audio_url', audio_url: { url: u }, role: 'reference_audio' }) + } else { + rest.push({ type: 'image_url', image_url: { url: u }, role: 'reference_image' }) } - if (kind === 'audio') { - return { type: 'audio_url', audio_url: { url }, role: 'reference_audio' } - } - return { type: 'image_url', image_url: { url }, role: 'reference_image' } - }) + } return [first, ...rest] } @@ -1001,15 +1014,17 @@ const applyReferenceFromHistory = ({ text, contentItems }) => { const selectMentionItem = (item) => { if (!item?.url || !editorRef.value) return const kind = item.mediaType === 'video' ? 'video' : item.mediaType === 'audio' ? 'audio' : 'image' - const uniqueByKind = { image: new Set(), video: new Set(), audio: new Set() } + const refSlotKey = `${kind}::${item.url}` + const keysInDoc = new Set() editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => { const u = el.getAttribute('data-reference-url') const k = el.getAttribute('data-reference-kind') || 'image' - if (u) uniqueByKind[k]?.add(u) + if (u) keysInDoc.add(`${k}::${u}`) }) - const keySet = uniqueByKind[kind] || uniqueByKind.image - if (!keySet.has(item.url) && keySet.size >= MAX_REFERENCE_UNIQUE) { - Message.warning(`该类型最多 ${MAX_REFERENCE_UNIQUE} 个不同素材`) + if (!keysInDoc.has(refSlotKey) && keysInDoc.size >= MAX_REFERENCE_CONTENT_SLOTS) { + Message.warning( + `富文本内不同素材最多 ${MAX_REFERENCE_CONTENT_SLOTS} 个(图/视频/音频合计),同一素材可重复插入` + ) mentionVisible.value = false return } diff --git a/portal-ui/src/views/VideoGen.vue b/portal-ui/src/views/VideoGen.vue index 7c921d4..a876cc4 100644 --- a/portal-ui/src/views/VideoGen.vue +++ b/portal-ui/src/views/VideoGen.vue @@ -270,7 +270,7 @@ export default { maxMediaCount() { if (this.videoMode === 'image-first-frame') return 1 if (this.videoMode === 'image-first-last-frame') return 2 - if (this.videoMode === 'image-reference') return 9 + if (this.videoMode === 'image-reference') return 12 return 12 }, allowedMediaTypes() { diff --git a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java index 994bb34..4d726fe 100644 --- a/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java +++ b/web-api/ruoyi-admin/src/main/java/com/ruoyi/api/PortalVideoController.java @@ -33,6 +33,7 @@ import org.springframework.web.bind.annotation.*; import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -305,7 +306,8 @@ public class PortalVideoController extends BaseController { filtered.add(it); } } - contentList = filtered; + // 文中多次出现同一 [图n]/[视频n]/[音频n](同一 URL)时,只保留一条 reference_* 记录 + contentList = dedupeReferenceContentAfterText(filtered); String firstRef = contentList.stream() .skip(1) @@ -343,6 +345,42 @@ public class PortalVideoController extends BaseController { return submitOrderAndCreate(request, "image-reference", body); } + /** + * 文中多次出现同一素材(同一 role + URL)时,只保留一条 reference_*,顺序为首次出现顺序。 + */ + private static List dedupeReferenceContentAfterText(List filtered) { + if (filtered == null || filtered.isEmpty()) { + return filtered; + } + List out = new ArrayList<>(); + out.add(filtered.get(0)); + Set seen = new LinkedHashSet<>(); + for (int i = 1; i < filtered.size(); i++) { + ContentItem it = filtered.get(i); + String key = referenceItemDedupeKey(it); + if (StringUtils.isEmpty(key)) { + continue; + } + if (seen.add(key)) { + out.add(it); + } + } + return out; + } + + private static String referenceItemDedupeKey(ContentItem item) { + if (isReferenceImageContentItem(item) && item.getImageUrl() != null) { + return "reference_image::" + StringUtils.trim(item.getImageUrl().getUrl()); + } + if (isReferenceVideoContentItem(item) && item.getVideoUrl() != null) { + return "reference_video::" + StringUtils.trim(item.getVideoUrl().getUrl()); + } + if (isReferenceAudioContentItem(item) && item.getAudioUrl() != null) { + return "reference_audio::" + StringUtils.trim(item.getAudioUrl().getUrl()); + } + return null; + } + private static String firstReferenceUrlFromItem(ContentItem item) { if (isReferenceImageContentItem(item)) { return item.getImageUrl().getUrl();