Compare commits
4 Commits
592c2595d6
...
0d5d58a86e
| Author | SHA1 | Date |
|---|---|---|
|
|
0d5d58a86e | |
|
|
c78fc762f4 | |
|
|
93ed944c14 | |
|
|
fec303b68c |
|
|
@ -1,5 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="vg-compose-card">
|
<div
|
||||||
|
class="vg-compose-card"
|
||||||
|
:class="{ 'vg-compose-card--drag': dragOver }"
|
||||||
|
@paste.capture="onComposePaste"
|
||||||
|
@dragover.prevent="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop.prevent="onDrop">
|
||||||
<!-- 左侧面板 - 根据模式动态渲染 -->
|
<!-- 左侧面板 - 根据模式动态渲染 -->
|
||||||
<div class="vg-compose-left" v-if="!isTextToVideo">
|
<div class="vg-compose-left" v-if="!isTextToVideo">
|
||||||
<!-- 首帧模式 -->
|
<!-- 首帧模式 -->
|
||||||
|
|
@ -57,13 +63,13 @@
|
||||||
参考图({{ mediaList.length }}/{{ maxMediaCount }})
|
参考图({{ mediaList.length }}/{{ maxMediaCount }})
|
||||||
</div>
|
</div>
|
||||||
<mf-button class="vg-compose-left-upload" size="small" type="primary" @click="openFilePicker">
|
<mf-button class="vg-compose-left-upload" size="small" type="primary" @click="openFilePicker">
|
||||||
添加图片
|
添加素材
|
||||||
</mf-button>
|
</mf-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mediaList.length === 0" class="vg-compose-empty" @click="openFilePicker">
|
<div v-if="mediaList.length === 0" class="vg-compose-empty" @click="openFilePicker">
|
||||||
<div class="vg-compose-empty-icon" aria-hidden="true">+</div>
|
<div class="vg-compose-empty-icon" aria-hidden="true">+</div>
|
||||||
<div class="vg-compose-empty-text">点击添加参考图</div>
|
<div class="vg-compose-empty-text">点击、粘贴或拖入图片 / 视频 / 音频</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="vg-compose-media-scroll">
|
<div v-else class="vg-compose-media-scroll">
|
||||||
|
|
@ -73,7 +79,14 @@
|
||||||
class="vg-compose-media-item"
|
class="vg-compose-media-item"
|
||||||
:title="item.name || ''">
|
:title="item.name || ''">
|
||||||
<div class="vg-compose-media-preview">
|
<div class="vg-compose-media-preview">
|
||||||
<img :src="item.url" alt="" />
|
<img v-if="item.mediaType === 'image'" :src="item.url" alt="" />
|
||||||
|
<video
|
||||||
|
v-else-if="item.mediaType === 'video'"
|
||||||
|
:src="item.url"
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata" />
|
||||||
|
<div v-else-if="item.mediaType === 'audio'" class="vg-compose-audio-badge">♪</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -115,9 +128,18 @@
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
:class="['vg-mention-item', { active: idx === mentionActiveIndex }]"
|
:class="['vg-mention-item', { active: idx === mentionActiveIndex }]"
|
||||||
@mousedown.prevent="selectMentionItem(item)">
|
@mousedown.prevent="selectMentionItem(item)">
|
||||||
<img :src="item.url" alt="" class="vg-mention-thumb" />
|
<img v-if="item.mediaType === 'image'" :src="item.url" alt="" class="vg-mention-thumb" />
|
||||||
|
<video
|
||||||
|
v-else-if="item.mediaType === 'video'"
|
||||||
|
:src="item.url"
|
||||||
|
class="vg-mention-thumb vg-mention-thumb-video"
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata" />
|
||||||
|
<div v-else class="vg-mention-thumb vg-mention-thumb-audio">♪</div>
|
||||||
|
<span class="vg-mention-kind">{{ mentionKindLabel(item.mediaType) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="mentionCandidates.length === 0" class="vg-mention-empty">暂无可引用参考图</div>
|
<div v-if="mentionCandidates.length === 0" class="vg-mention-empty">暂无可引用素材(请先上传)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -140,8 +162,8 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
import { uploadFile, extractUploadUrlFromResponse, PORTAL_TENCENT_COS_UPLOAD_URL } from '@/utils/file'
|
||||||
|
|
||||||
/** 图生参考:不同参考图最多 4 张(图1–图4),同一 URL 可多次 @ */
|
/** 富文本内「不同素材」种类上限(图/视频/音频合计);同一 URL 可多次 @,提交时 reference_* 去重后最多 9 条 */
|
||||||
const MAX_REFERENCE_UNIQUE = 4
|
const MAX_REFERENCE_CONTENT_SLOTS = 9
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
|
|
@ -174,6 +196,7 @@ const emit = defineEmits(['update:modelValue', 'update:mediaList'])
|
||||||
|
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
const editorRef = ref(null)
|
const editorRef = ref(null)
|
||||||
|
const dragOver = ref(false)
|
||||||
const localPrompt = ref(props.modelValue || '')
|
const localPrompt = ref(props.modelValue || '')
|
||||||
const internalMediaList = ref(Array.isArray(props.mediaList) ? [...props.mediaList] : [])
|
const internalMediaList = ref(Array.isArray(props.mediaList) ? [...props.mediaList] : [])
|
||||||
const currentUploadIndex = ref(-1) // for first/last frame mode
|
const currentUploadIndex = ref(-1) // for first/last frame mode
|
||||||
|
|
@ -224,14 +247,22 @@ const mentionCandidates = computed(() =>
|
||||||
(mediaList.value || [])
|
(mediaList.value || [])
|
||||||
.filter((i) => {
|
.filter((i) => {
|
||||||
const u = String(i?.url || '').trim()
|
const u = String(i?.url || '').trim()
|
||||||
return i?.mediaType === 'image' && !i?.isUploading && /^https?:\/\//i.test(u)
|
const mt = i?.mediaType
|
||||||
|
return ['image', 'video', 'audio'].includes(mt) && !i?.isUploading && /^https?:\/\//i.test(u)
|
||||||
})
|
})
|
||||||
.map((i, idx) => ({
|
.map((i, idx) => ({
|
||||||
key: i.id || i.url || String(idx),
|
key: i.id || i.url || String(idx),
|
||||||
url: i.url
|
url: i.url,
|
||||||
|
mediaType: i.mediaType || 'image'
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const mentionKindLabel = (mt) => {
|
||||||
|
if (mt === 'video') return '视频'
|
||||||
|
if (mt === 'audio') return '音频'
|
||||||
|
return '图'
|
||||||
|
}
|
||||||
|
|
||||||
const isTextToVideo = computed(() => props.videoMode === 'text-to-video')
|
const isTextToVideo = computed(() => props.videoMode === 'text-to-video')
|
||||||
const isFirstFrame = computed(() => props.videoMode === 'image-first-frame')
|
const isFirstFrame = computed(() => props.videoMode === 'image-first-frame')
|
||||||
const isFirstLastFrame = computed(() => props.videoMode === 'image-first-last-frame')
|
const isFirstLastFrame = computed(() => props.videoMode === 'image-first-last-frame')
|
||||||
|
|
@ -259,19 +290,22 @@ const openFilePickerFor = (index) => {
|
||||||
|
|
||||||
const acceptAttr = computed(() => {
|
const acceptAttr = computed(() => {
|
||||||
const types = new Set(props.allowedMediaTypes || [])
|
const types = new Set(props.allowedMediaTypes || [])
|
||||||
if (types.has('image') && types.has('video')) return 'image/*,video/*'
|
const parts = []
|
||||||
if (types.has('image')) return 'image/*'
|
if (types.has('image')) parts.push('image/*')
|
||||||
if (types.has('video')) return 'video/*'
|
if (types.has('video')) parts.push('video/*')
|
||||||
return 'image/*,video/*'
|
if (types.has('audio')) parts.push('audio/*')
|
||||||
|
return parts.length ? parts.join(',') : 'image/*,video/*,audio/*'
|
||||||
})
|
})
|
||||||
|
|
||||||
const detectMediaType = (file) => {
|
const detectMediaType = (file) => {
|
||||||
const mime = (file?.type || '').toLowerCase()
|
const mime = (file?.type || '').toLowerCase()
|
||||||
if (mime.startsWith('image/')) return 'image'
|
if (mime.startsWith('image/')) return 'image'
|
||||||
if (mime.startsWith('video/')) return 'video'
|
if (mime.startsWith('video/')) return 'video'
|
||||||
|
if (mime.startsWith('audio/')) return 'audio'
|
||||||
const lowerName = (file?.name || '').toLowerCase()
|
const lowerName = (file?.name || '').toLowerCase()
|
||||||
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lowerName)) return 'image'
|
if (/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(lowerName)) return 'image'
|
||||||
if (/\.(mp4|mov|webm|m4v|avi|mkv|ogg)$/.test(lowerName)) return 'video'
|
if (/\.(mp4|mov|webm|m4v|avi|mkv)$/.test(lowerName)) return 'video'
|
||||||
|
if (/\.(mp3|wav|m4a|aac|ogg|flac)$/.test(lowerName)) return 'audio'
|
||||||
return 'image'
|
return 'image'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -313,40 +347,48 @@ const clearAll = () => {
|
||||||
mentionVisible.value = false
|
mentionVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectFiles = async (event) => {
|
const ingestFileList = async (files) => {
|
||||||
const input = event.target
|
if (!files || !files.length) return
|
||||||
const files = Array.from(input.files || [])
|
const fileArr = Array.from(files)
|
||||||
if (!files.length) return
|
|
||||||
|
if (isFirstLastFrame.value && currentUploadIndex.value < 0) {
|
||||||
|
if (!mediaList.value[0]) currentUploadIndex.value = 0
|
||||||
|
else if (!mediaList.value[1]) currentUploadIndex.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
const isReferenceMode = isReference.value
|
const isReferenceMode = isReference.value
|
||||||
let targetList = [...mediaList.value]
|
let targetList = [...mediaList.value]
|
||||||
|
|
||||||
// 首尾帧模式特殊处理索引
|
|
||||||
if (isFirstLastFrame.value && currentUploadIndex.value >= 0) {
|
if (isFirstLastFrame.value && currentUploadIndex.value >= 0) {
|
||||||
const idx = currentUploadIndex.value
|
const idx = currentUploadIndex.value
|
||||||
if (files.length > 0) {
|
if (fileArr.length > 0) {
|
||||||
const file = files[0]
|
const file = fileArr[0]
|
||||||
const mediaType = detectMediaType(file)
|
const mediaType = detectMediaType(file)
|
||||||
if (mediaType !== 'image') {
|
if (mediaType !== 'image') {
|
||||||
Message.warning('首尾帧仅支持图片')
|
Message.warning('首尾帧仅支持图片')
|
||||||
input.value = ''
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const id = `frame_${Date.now()}`
|
const id = `frame_${Date.now()}`
|
||||||
const localPreview = URL.createObjectURL(file)
|
const localPreview = URL.createObjectURL(file)
|
||||||
const entry = { id, url: localPreview, mediaType: 'image', name: file.name, _fileRef: file, isUploading: true, label: idx === 0 ? '[首帧]' : '[尾帧]' }
|
const entry = {
|
||||||
|
id,
|
||||||
|
url: localPreview,
|
||||||
|
mediaType: 'image',
|
||||||
|
name: file.name,
|
||||||
|
_fileRef: file,
|
||||||
|
isUploading: true,
|
||||||
|
label: idx === 0 ? '[首帧]' : '[尾帧]'
|
||||||
|
}
|
||||||
targetList[idx] = entry
|
targetList[idx] = entry
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 其他模式正常处理
|
|
||||||
const remain = props.maxMediaCount - mediaList.value.length
|
const remain = props.maxMediaCount - mediaList.value.length
|
||||||
if (remain <= 0 && !isReferenceMode) {
|
if (remain <= 0 && !isReferenceMode) {
|
||||||
Message.warning(`最多添加 ${props.maxMediaCount} 个参考素材`)
|
Message.warning(`最多添加 ${props.maxMediaCount} 个参考素材`)
|
||||||
input.value = ''
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = files.slice(0, remain || files.length)
|
const selected = fileArr.slice(0, remain || fileArr.length)
|
||||||
const uploadingEntries = []
|
const uploadingEntries = []
|
||||||
|
|
||||||
for (const file of selected) {
|
for (const file of selected) {
|
||||||
|
|
@ -378,8 +420,7 @@ const clearAll = () => {
|
||||||
setMediaList(targetList)
|
setMediaList(targetList)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// 上传处理
|
const toUpload = targetList.filter((item) => item.isUploading)
|
||||||
const toUpload = targetList.filter(item => item.isUploading)
|
|
||||||
for (const entry of toUpload) {
|
for (const entry of toUpload) {
|
||||||
try {
|
try {
|
||||||
const res = await uploadFile({
|
const res = await uploadFile({
|
||||||
|
|
@ -393,9 +434,7 @@ const clearAll = () => {
|
||||||
const localPreview = entry.url
|
const localPreview = entry.url
|
||||||
setMediaList(
|
setMediaList(
|
||||||
mediaList.value.map((x) =>
|
mediaList.value.map((x) =>
|
||||||
normalizeItemKey(x) === normalizeItemKey(entry)
|
normalizeItemKey(x) === normalizeItemKey(entry) ? { ...x, url, isUploading: false } : x
|
||||||
? { ...x, url, isUploading: false }
|
|
||||||
: x
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (isReferenceMode) {
|
if (isReferenceMode) {
|
||||||
|
|
@ -411,9 +450,53 @@ const clearAll = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentUploadIndex.value = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectFiles = async (event) => {
|
||||||
|
const input = event.target
|
||||||
|
const files = Array.from(input.files || [])
|
||||||
|
if (!files.length) return
|
||||||
|
await ingestFileList(files)
|
||||||
input.value = ''
|
input.value = ''
|
||||||
currentUploadIndex.value = -1
|
currentUploadIndex.value = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onComposePaste = (e) => {
|
||||||
|
const files = []
|
||||||
|
if (e.clipboardData?.files?.length) {
|
||||||
|
for (const f of e.clipboardData.files) {
|
||||||
|
if (f) files.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!files.length && e.clipboardData?.items) {
|
||||||
|
for (const item of e.clipboardData.items) {
|
||||||
|
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||||
|
const f = item.getAsFile()
|
||||||
|
if (f) files.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!files.length) return
|
||||||
|
e.preventDefault()
|
||||||
|
ingestFileList(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeave = (e) => {
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget)) return
|
||||||
|
dragOver.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOver = (e) => {
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
dragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDrop = (e) => {
|
||||||
|
dragOver.value = false
|
||||||
|
const files = Array.from(e.dataTransfer?.files || [])
|
||||||
|
if (files.length) ingestFileList(files)
|
||||||
|
}
|
||||||
|
|
||||||
const onPromptInput = (e) => {
|
const onPromptInput = (e) => {
|
||||||
setPrompt(e.target.value)
|
setPrompt(e.target.value)
|
||||||
|
|
@ -520,42 +603,73 @@ const getUniqueRefUrlsInDoc = () => {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按文档顺序为不同 URL 分配 图1–图4,并同步 data-token / 展示 */
|
/** 按文档顺序为不同 URL 分配 [图n]/[视频n]/[音频n];不同素材合计最多 MAX_REFERENCE_CONTENT_SLOTS,同 URL 复用同一序号 */
|
||||||
const renumberAllReferenceMentions = () => {
|
const renumberAllReferenceMentions = () => {
|
||||||
if (!editorRef.value || !isReference.value) return
|
if (!editorRef.value || !isReference.value) return
|
||||||
const refs = Array.from(editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]'))
|
const refs = Array.from(editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]'))
|
||||||
const urlToNo = new Map()
|
const imgMap = new Map()
|
||||||
let next = 1
|
const vidMap = new Map()
|
||||||
|
const audMap = new Map()
|
||||||
|
let imgNext = 1
|
||||||
|
let vidNext = 1
|
||||||
|
let audNext = 1
|
||||||
|
let globalDistinct = 0
|
||||||
let droppedExtra = false
|
let droppedExtra = false
|
||||||
for (const el of refs) {
|
for (const el of refs) {
|
||||||
const u = el.getAttribute('data-reference-url') || ''
|
const u = el.getAttribute('data-reference-url') || ''
|
||||||
|
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||||
if (!u) {
|
if (!u) {
|
||||||
el.remove()
|
el.remove()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!urlToNo.has(u)) {
|
let token = ''
|
||||||
if (next > MAX_REFERENCE_UNIQUE) {
|
if (kind === 'video') {
|
||||||
|
if (!vidMap.has(u)) {
|
||||||
|
if (globalDistinct >= MAX_REFERENCE_CONTENT_SLOTS) {
|
||||||
el.remove()
|
el.remove()
|
||||||
droppedExtra = true
|
droppedExtra = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
urlToNo.set(u, next++)
|
globalDistinct++
|
||||||
|
vidMap.set(u, vidNext++)
|
||||||
|
}
|
||||||
|
token = `[视频${vidMap.get(u)}]`
|
||||||
|
} else if (kind === 'audio') {
|
||||||
|
if (!audMap.has(u)) {
|
||||||
|
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 (globalDistinct >= MAX_REFERENCE_CONTENT_SLOTS) {
|
||||||
|
el.remove()
|
||||||
|
droppedExtra = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
globalDistinct++
|
||||||
|
imgMap.set(u, imgNext++)
|
||||||
|
}
|
||||||
|
token = `[图${imgMap.get(u)}]`
|
||||||
}
|
}
|
||||||
const n = urlToNo.get(u)
|
|
||||||
const token = `[图${n}]`
|
|
||||||
el.setAttribute('data-token', token)
|
el.setAttribute('data-token', token)
|
||||||
const imgEl = el.querySelector('.vg-inline-ref-image')
|
const imgEl = el.querySelector('.vg-inline-ref-image, .vg-inline-ref-video')
|
||||||
if (imgEl) imgEl.alt = token
|
if (imgEl) imgEl.setAttribute('alt', token)
|
||||||
}
|
}
|
||||||
if (droppedExtra) {
|
if (droppedExtra) {
|
||||||
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,已移除多余引用`)
|
Message.warning(`富文本内不同素材最多 ${MAX_REFERENCE_CONTENT_SLOTS} 个(图/视频/音频合计),已移除多余引用`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reconcileReferenceMentions = (nextList) => {
|
const reconcileReferenceMentions = (nextList) => {
|
||||||
if (!editorRef.value) return
|
if (!editorRef.value) return
|
||||||
const allowed = new Set(
|
const allowed = new Set(
|
||||||
(nextList || []).filter((x) => x?.mediaType === 'image' && x?.url).map((x) => x.url)
|
(nextList || []).filter((x) => x?.url && ['image', 'video', 'audio'].includes(x?.mediaType)).map((x) => x.url)
|
||||||
)
|
)
|
||||||
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
editorRef.value.querySelectorAll('.vg-inline-ref[data-mention-reference="1"]').forEach((el) => {
|
||||||
const u = el.getAttribute('data-reference-url') || ''
|
const u = el.getAttribute('data-reference-url') || ''
|
||||||
|
|
@ -565,43 +679,52 @@ const reconcileReferenceMentions = (nextList) => {
|
||||||
setPrompt(getEditorPlainText())
|
setPrompt(getEditorPlainText())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 文档顺序下首次出现的参考图 URL(对应 图1、图2…) */
|
/** 文档顺序下的参考素材(与占位符顺序一致) */
|
||||||
const collectReferenceUrlsInDocOrder = () => {
|
const collectReferenceMentionsInDocOrder = () => {
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
if (!editor) return []
|
if (!editor) return []
|
||||||
const urls = []
|
const out = []
|
||||||
const seen = new Set()
|
|
||||||
const walk = (node) => {
|
const walk = (node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) return
|
if (node.nodeType === Node.TEXT_NODE) return
|
||||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||||
const el = node
|
const el = node
|
||||||
if (el.dataset?.mentionReference === '1') {
|
if (el.dataset?.mentionReference === '1') {
|
||||||
const url = el.getAttribute('data-reference-url') || ''
|
const url = el.getAttribute('data-reference-url') || ''
|
||||||
if (url && !seen.has(url)) {
|
const kind = el.getAttribute('data-reference-kind') || 'image'
|
||||||
seen.add(url)
|
if (url) out.push({ url, kind })
|
||||||
urls.push(url)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Array.from(el.childNodes).forEach(walk)
|
Array.from(el.childNodes).forEach(walk)
|
||||||
}
|
}
|
||||||
Array.from(editor.childNodes).forEach(walk)
|
Array.from(editor.childNodes).forEach(walk)
|
||||||
return urls
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 参考图模式提交用:第 1 条必须是 text(整段文案,仅含 [图n],不含 URL);
|
* 参考图模式提交用:首条 text;其后为 reference_*。
|
||||||
* 其后按 图1→图n 顺序各一条 image_url + reference_image。
|
* 文中多次出现同一 [图n]/[视频n]/[音频n](同一 URL)时,只输出一条记录(按文中首次出现顺序),最多 9 条。
|
||||||
*/
|
*/
|
||||||
const getImageReferenceContentItems = () => {
|
const getImageReferenceContentItems = () => {
|
||||||
const text = getEditorPlainText()
|
const text = getEditorPlainText()
|
||||||
const first = { type: 'text', text: text || '' }
|
const first = { type: 'text', text: text || '' }
|
||||||
const urls = collectReferenceUrlsInDocOrder()
|
const mentions = collectReferenceMentionsInDocOrder()
|
||||||
const rest = urls.map((url) => ({
|
const seen = new Set()
|
||||||
type: 'image_url',
|
const rest = []
|
||||||
image_url: { url },
|
for (const { url, kind } of mentions) {
|
||||||
role: 'reference_image'
|
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') {
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
return [first, ...rest]
|
return [first, ...rest]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -789,11 +912,119 @@ const onEditorKeyup = (e) => {
|
||||||
mentionActiveIndex.value = mentionVisible.value && mentionCandidates.value.length ? 0 : -1
|
mentionActiveIndex.value = mentionVisible.value && mentionCandidates.value.length ? 0 : -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildMentionHolder = (url, kind) => {
|
||||||
|
const holder = document.createElement('span')
|
||||||
|
holder.className = 'vg-inline-ref'
|
||||||
|
holder.setAttribute('data-mention-reference', '1')
|
||||||
|
holder.setAttribute('data-token', '[?]')
|
||||||
|
holder.setAttribute('data-reference-url', url)
|
||||||
|
holder.setAttribute('data-reference-kind', kind)
|
||||||
|
holder.setAttribute('contenteditable', 'false')
|
||||||
|
|
||||||
|
if (kind === 'image') {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.src = url
|
||||||
|
img.alt = ''
|
||||||
|
img.setAttribute('draggable', 'false')
|
||||||
|
img.setAttribute('contenteditable', 'false')
|
||||||
|
img.className = 'vg-inline-ref-image'
|
||||||
|
holder.appendChild(img)
|
||||||
|
} else if (kind === 'video') {
|
||||||
|
const v = document.createElement('video')
|
||||||
|
v.src = url
|
||||||
|
v.className = 'vg-inline-ref-image vg-inline-ref-video'
|
||||||
|
v.setAttribute('muted', '')
|
||||||
|
v.setAttribute('playsinline', '')
|
||||||
|
v.setAttribute('preload', 'metadata')
|
||||||
|
v.setAttribute('draggable', 'false')
|
||||||
|
holder.appendChild(v)
|
||||||
|
} else {
|
||||||
|
const badge = document.createElement('span')
|
||||||
|
badge.className = 'vg-inline-ref-audio'
|
||||||
|
badge.textContent = '♪'
|
||||||
|
badge.setAttribute('title', '音频')
|
||||||
|
holder.appendChild(badge)
|
||||||
|
}
|
||||||
|
return holder
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyReferenceFromHistory = ({ text, contentItems }) => {
|
||||||
|
if (!editorRef.value || !isReference.value) return
|
||||||
|
const items = Array.isArray(contentItems) ? contentItems : []
|
||||||
|
const media = []
|
||||||
|
let nid = 0
|
||||||
|
const pickImageUrl = (it) => it?.image_url?.url || it?.imageUrl?.url
|
||||||
|
const pickVideoUrl = (it) => it?.video_url?.url || it?.videoUrl?.url
|
||||||
|
const pickAudioUrl = (it) => it?.audio_url?.url || it?.audioUrl?.url
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it || it.type === 'text') continue
|
||||||
|
if (it.type === 'image_url' && it.role === 'reference_image') {
|
||||||
|
const u = pickImageUrl(it)
|
||||||
|
if (u) media.push({ id: `hist_${nid++}`, url: u, mediaType: 'image', name: '参考图' })
|
||||||
|
}
|
||||||
|
if (it.type === 'video_url' && it.role === 'reference_video') {
|
||||||
|
const u = pickVideoUrl(it)
|
||||||
|
if (u) media.push({ id: `hist_${nid++}`, url: u, mediaType: 'video', name: '参考视频' })
|
||||||
|
}
|
||||||
|
if (it.type === 'audio_url' && it.role === 'reference_audio') {
|
||||||
|
const u = pickAudioUrl(it)
|
||||||
|
if (u) media.push({ id: `hist_${nid++}`, url: u, mediaType: 'audio', name: '参考音频' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMediaList(media)
|
||||||
|
|
||||||
|
const pools = { image: [], video: [], audio: [] }
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it || it.type === 'text') continue
|
||||||
|
if (it.type === 'image_url' && it.role === 'reference_image' && pickImageUrl(it)) pools.image.push(pickImageUrl(it))
|
||||||
|
if (it.type === 'video_url' && it.role === 'reference_video' && pickVideoUrl(it)) pools.video.push(pickVideoUrl(it))
|
||||||
|
if (it.type === 'audio_url' && it.role === 'reference_audio' && pickAudioUrl(it)) pools.audio.push(pickAudioUrl(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTok = (tok) => {
|
||||||
|
const a = /\[图(\d+)\]/.exec(tok)
|
||||||
|
if (a) return { kind: 'image', url: pools.image[parseInt(a[1], 10) - 1] }
|
||||||
|
const b = /\[视频(\d+)\]/.exec(tok)
|
||||||
|
if (b) return { kind: 'video', url: pools.video[parseInt(b[1], 10) - 1] }
|
||||||
|
const c = /\[音频(\d+)\]/.exec(tok)
|
||||||
|
if (c) return { kind: 'audio', url: pools.audio[parseInt(c[1], 10) - 1] }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = text || ''
|
||||||
|
const re = /(\[图\d+\]|\[视频\d+\]|\[音频\d+\])/g
|
||||||
|
editorRef.value.innerHTML = ''
|
||||||
|
const frag = document.createDocumentFragment()
|
||||||
|
let last = 0
|
||||||
|
let m
|
||||||
|
while ((m = re.exec(s)) !== null) {
|
||||||
|
if (m.index > last) frag.appendChild(document.createTextNode(s.slice(last, m.index)))
|
||||||
|
const tok = m[0]
|
||||||
|
const r = resolveTok(tok)
|
||||||
|
if (r?.url) frag.appendChild(buildMentionHolder(r.url, r.kind))
|
||||||
|
else frag.appendChild(document.createTextNode(tok))
|
||||||
|
last = m.index + m[0].length
|
||||||
|
}
|
||||||
|
if (last < s.length) frag.appendChild(document.createTextNode(s.slice(last)))
|
||||||
|
editorRef.value.appendChild(frag)
|
||||||
|
renumberAllReferenceMentions()
|
||||||
|
setPrompt(getEditorPlainText())
|
||||||
|
}
|
||||||
|
|
||||||
const selectMentionItem = (item) => {
|
const selectMentionItem = (item) => {
|
||||||
if (!item?.url || !editorRef.value) return
|
if (!item?.url || !editorRef.value) return
|
||||||
const urls = getUniqueRefUrlsInDoc()
|
const kind = item.mediaType === 'video' ? 'video' : item.mediaType === 'audio' ? 'audio' : 'image'
|
||||||
if (!urls.has(item.url) && urls.size >= MAX_REFERENCE_UNIQUE) {
|
const refSlotKey = `${kind}::${item.url}`
|
||||||
Message.warning(`最多 ${MAX_REFERENCE_UNIQUE} 张不同参考图,无法再插入新图`)
|
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) keysInDoc.add(`${k}::${u}`)
|
||||||
|
})
|
||||||
|
if (!keysInDoc.has(refSlotKey) && keysInDoc.size >= MAX_REFERENCE_CONTENT_SLOTS) {
|
||||||
|
Message.warning(
|
||||||
|
`富文本内不同素材最多 ${MAX_REFERENCE_CONTENT_SLOTS} 个(图/视频/音频合计),同一素材可重复插入`
|
||||||
|
)
|
||||||
mentionVisible.value = false
|
mentionVisible.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -807,25 +1038,9 @@ const selectMentionItem = (item) => {
|
||||||
const range = selection.getRangeAt(0)
|
const range = selection.getRangeAt(0)
|
||||||
if (!editorRef.value.contains(range.commonAncestorContainer)) return
|
if (!editorRef.value.contains(range.commonAncestorContainer)) return
|
||||||
|
|
||||||
const token = '[图?]'
|
const holder = buildMentionHolder(item.url, kind)
|
||||||
const holder = document.createElement('span')
|
|
||||||
holder.className = 'vg-inline-ref'
|
|
||||||
holder.setAttribute('data-mention-reference', '1')
|
|
||||||
holder.setAttribute('data-token', token)
|
|
||||||
holder.setAttribute('data-reference-url', item.url)
|
|
||||||
holder.setAttribute('contenteditable', 'false')
|
|
||||||
|
|
||||||
const img = document.createElement('img')
|
|
||||||
img.src = item.url
|
|
||||||
img.alt = ''
|
|
||||||
img.setAttribute('draggable', 'false')
|
|
||||||
img.setAttribute('contenteditable', 'false')
|
|
||||||
img.className = 'vg-inline-ref-image'
|
|
||||||
|
|
||||||
holder.appendChild(img)
|
|
||||||
|
|
||||||
range.insertNode(holder)
|
range.insertNode(holder)
|
||||||
// 不在引用后自动加空格,保证导出 text 为 「描述[图1]描述[图2]…」,占位与描述紧挨在用户输入的位置
|
|
||||||
range.setStartAfter(holder)
|
range.setStartAfter(holder)
|
||||||
range.collapse(true)
|
range.collapse(true)
|
||||||
selection.removeAllRanges()
|
selection.removeAllRanges()
|
||||||
|
|
@ -841,6 +1056,7 @@ const selectMentionItem = (item) => {
|
||||||
defineExpose({
|
defineExpose({
|
||||||
getEditorPlainText,
|
getEditorPlainText,
|
||||||
getImageReferenceContentItems,
|
getImageReferenceContentItems,
|
||||||
|
applyReferenceFromHistory,
|
||||||
clearPromptOnly: () => {
|
clearPromptOnly: () => {
|
||||||
setPrompt('')
|
setPrompt('')
|
||||||
if (editorRef.value) editorRef.value.innerHTML = ''
|
if (editorRef.value) editorRef.value.innerHTML = ''
|
||||||
|
|
@ -861,6 +1077,13 @@ defineExpose({
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
|
transition: outline 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-compose-card--drag {
|
||||||
|
outline: 2px dashed rgba(0, 202, 224, 0.55);
|
||||||
|
outline-offset: 2px;
|
||||||
|
background: rgba(0, 202, 224, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vg-compose-left {
|
.vg-compose-left {
|
||||||
|
|
@ -1052,6 +1275,17 @@ defineExpose({
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vg-compose-audio-badge {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
color: rgba(0, 202, 224, 0.95);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.vg-compose-remove-btn {
|
.vg-compose-remove-btn {
|
||||||
|
|
@ -1179,7 +1413,8 @@ defineExpose({
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vg-rich-editor :deep(.vg-inline-ref-image) {
|
.vg-rich-editor :deep(.vg-inline-ref-image),
|
||||||
|
.vg-rich-editor :deep(.vg-inline-ref-video) {
|
||||||
display: block;
|
display: block;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
@ -1187,10 +1422,43 @@ defineExpose({
|
||||||
max-height: 72px;
|
max-height: 72px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
vertical-align: middle;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vg-rich-editor :deep(.vg-inline-ref-audio) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(0, 202, 224, 0.95);
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-mention-kind {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-mention-thumb-video {
|
||||||
|
object-fit: cover;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-mention-thumb-audio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 202, 224, 0.12);
|
||||||
|
color: rgba(0, 202, 224, 0.95);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.vg-mention-panel {
|
.vg-mention-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
|
@ -1208,6 +1476,7 @@ defineExpose({
|
||||||
.vg-mention-item {
|
.vg-mention-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
||||||
|
|
@ -23,18 +23,36 @@
|
||||||
<template v-for="(seg, idx) in getRowPromptSegments(row)" :key="`${row.id || 'row'}_${idx}`">
|
<template v-for="(seg, idx) in getRowPromptSegments(row)" :key="`${row.id || 'row'}_${idx}`">
|
||||||
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
|
<span v-if="seg.type === 'text'">{{ seg.text }}</span>
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else-if="seg.type === 'image'"
|
||||||
class="vg-chat-inline-ref-image"
|
class="vg-chat-inline-ref-image"
|
||||||
:src="seg.url"
|
:src="seg.url"
|
||||||
:alt="seg.token || ''" />
|
:alt="seg.token || ''" />
|
||||||
|
<video
|
||||||
|
v-else-if="seg.type === 'video'"
|
||||||
|
class="vg-chat-inline-ref-video"
|
||||||
|
:src="seg.url"
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata" />
|
||||||
|
<span v-else-if="seg.type === 'audio'" class="vg-chat-inline-audio" title="音频">♪</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="taskParamLine(row)" class="vg-chat-task-params">
|
||||||
|
{{ taskParamLine(row) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vg-chat-user-actions">
|
||||||
|
<button type="button" class="vg-chat-action-btn" @click="reEditTask(row)">重新编辑</button>
|
||||||
|
<button type="button" class="vg-chat-action-btn vg-chat-action-primary" @click="regenerateTask(row)">
|
||||||
|
重新生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="vg-chat-time">{{ formatCreateTime(row.createTime) }}</div>
|
<div class="vg-chat-time">{{ formatCreateTime(row.createTime) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分隔线 -->
|
<div class="vg-chat-divider" aria-hidden="true"></div>
|
||||||
<div class="vg-chat-divider"></div>
|
|
||||||
|
|
||||||
<!-- AI响应部分(视频结果) -->
|
<!-- AI响应部分(视频结果) -->
|
||||||
<div class="vg-chat-ai-section">
|
<div class="vg-chat-ai-section">
|
||||||
|
|
@ -60,6 +78,9 @@
|
||||||
<div v-else class="vg-chat-result-link">
|
<div v-else class="vg-chat-result-link">
|
||||||
<a :href="row.result" target="_blank" rel="noreferrer">查看结果</a>
|
<a :href="row.result" target="_blank" rel="noreferrer">查看结果</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isVideoUrl(row.result)" class="vg-chat-download-row">
|
||||||
|
<button type="button" class="vg-download-btn" @click="downloadVideoUrl(row.result)">下载视频</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -249,12 +270,13 @@ export default {
|
||||||
maxMediaCount() {
|
maxMediaCount() {
|
||||||
if (this.videoMode === 'image-first-frame') return 1
|
if (this.videoMode === 'image-first-frame') return 1
|
||||||
if (this.videoMode === 'image-first-last-frame') return 2
|
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
|
return 12
|
||||||
},
|
},
|
||||||
allowedMediaTypes() {
|
allowedMediaTypes() {
|
||||||
if (this.videoMode === 'image-first-frame' || this.videoMode === 'image-first-last-frame') return ['image']
|
if (this.videoMode === 'image-first-frame' || this.videoMode === 'image-first-last-frame') return ['image']
|
||||||
if (this.videoMode === 'image-reference') return ['image']
|
if (this.videoMode === 'image-reference') return ['image', 'video', 'audio']
|
||||||
|
if (this.videoMode === 'text-to-video') return ['image', 'video', 'audio']
|
||||||
return ['image', 'video']
|
return ['image', 'video']
|
||||||
},
|
},
|
||||||
posterUrl() {
|
posterUrl() {
|
||||||
|
|
@ -589,34 +611,220 @@ export default {
|
||||||
return segs
|
return segs
|
||||||
}
|
}
|
||||||
|
|
||||||
// 参考图模式:按 [图n] 替换为对应 reference_image
|
if (mode === 'image-reference') {
|
||||||
|
const { imgs, vids, auds } = this.getRowReferencePools(row)
|
||||||
|
const combined = /\[图(\d+)\]|\[视频(\d+)\]|\[音频(\d+)\]/g
|
||||||
|
const segments = []
|
||||||
|
let last = 0
|
||||||
|
let m
|
||||||
|
while ((m = combined.exec(text)) !== null) {
|
||||||
|
if (m.index > last) {
|
||||||
|
segments.push({ type: 'text', text: text.slice(last, m.index) })
|
||||||
|
}
|
||||||
|
if (m[1]) {
|
||||||
|
const idx = parseInt(m[1], 10) - 1
|
||||||
|
const url = imgs[idx]
|
||||||
|
if (url) segments.push({ type: 'image', url, token: m[0] })
|
||||||
|
else segments.push({ type: 'text', text: m[0] })
|
||||||
|
} else if (m[2]) {
|
||||||
|
const idx = parseInt(m[2], 10) - 1
|
||||||
|
const url = vids[idx]
|
||||||
|
if (url) segments.push({ type: 'video', url, token: m[0] })
|
||||||
|
else segments.push({ type: 'text', text: m[0] })
|
||||||
|
} else if (m[3]) {
|
||||||
|
const idx = parseInt(m[3], 10) - 1
|
||||||
|
const url = auds[idx]
|
||||||
|
if (url) segments.push({ type: 'audio', url, token: m[0] })
|
||||||
|
else segments.push({ type: 'text', text: m[0] })
|
||||||
|
}
|
||||||
|
last = m.index + m[0].length
|
||||||
|
}
|
||||||
|
if (last < text.length) {
|
||||||
|
segments.push({ type: 'text', text: text.slice(last) })
|
||||||
|
}
|
||||||
|
if (segments.length) return segments
|
||||||
const refs = this.getRowReferenceImageUrls(row)
|
const refs = this.getRowReferenceImageUrls(row)
|
||||||
if (!refs.length) {
|
if (!refs.length) {
|
||||||
return [{ type: 'text', text }]
|
return [{ type: 'text', text }]
|
||||||
}
|
}
|
||||||
const tokenReg = /\[图(\d+)\]/g
|
const tokenReg = /\[图(\d+)\]/g
|
||||||
const segments = []
|
const segments2 = []
|
||||||
let last = 0
|
last = 0
|
||||||
let m
|
|
||||||
while ((m = tokenReg.exec(text)) !== null) {
|
while ((m = tokenReg.exec(text)) !== null) {
|
||||||
const token = m[0]
|
const token = m[0]
|
||||||
const idx = Number(m[1]) - 1
|
const idx = Number(m[1]) - 1
|
||||||
const start = m.index
|
const start = m.index
|
||||||
if (start > last) {
|
if (start > last) {
|
||||||
segments.push({ type: 'text', text: text.slice(last, start) })
|
segments2.push({ type: 'text', text: text.slice(last, start) })
|
||||||
}
|
}
|
||||||
const url = idx >= 0 ? refs[idx] : ''
|
const url = idx >= 0 ? refs[idx] : ''
|
||||||
if (url) {
|
if (url) {
|
||||||
segments.push({ type: 'image', url, token })
|
segments2.push({ type: 'image', url, token })
|
||||||
} else {
|
} else {
|
||||||
segments.push({ type: 'text', text: token })
|
segments2.push({ type: 'text', text: token })
|
||||||
}
|
}
|
||||||
last = start + token.length
|
last = start + token.length
|
||||||
}
|
}
|
||||||
if (last < text.length) {
|
if (last < text.length) {
|
||||||
segments.push({ type: 'text', text: text.slice(last) })
|
segments2.push({ type: 'text', text: text.slice(last) })
|
||||||
}
|
}
|
||||||
return segments.length ? segments : [{ type: 'text', text }]
|
return segments2.length ? segments2 : [{ type: 'text', text }]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ type: 'text', text }]
|
||||||
|
},
|
||||||
|
|
||||||
|
getRowReferencePools(row) {
|
||||||
|
const imgs = []
|
||||||
|
const vids = []
|
||||||
|
const auds = []
|
||||||
|
try {
|
||||||
|
const vp = typeof row.videoParams === 'string' ? JSON.parse(row.videoParams) : row.videoParams
|
||||||
|
const content = Array.isArray(vp?.content) ? vp.content : []
|
||||||
|
for (const it of content) {
|
||||||
|
if (!it || it.type === 'text') continue
|
||||||
|
if (it.type === 'image_url' && it.role === 'reference_image') {
|
||||||
|
const u = it.image_url?.url || it.imageUrl?.url
|
||||||
|
if (u) imgs.push(u)
|
||||||
|
}
|
||||||
|
if (it.type === 'video_url' && it.role === 'reference_video') {
|
||||||
|
const u = it.video_url?.url || it.videoUrl?.url
|
||||||
|
if (u) vids.push(u)
|
||||||
|
}
|
||||||
|
if (it.type === 'audio_url' && it.role === 'reference_audio') {
|
||||||
|
const u = it.audio_url?.url || it.audioUrl?.url
|
||||||
|
if (u) auds.push(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return { imgs, vids, auds }
|
||||||
|
},
|
||||||
|
|
||||||
|
safeParseVideoParams(raw) {
|
||||||
|
if (raw == null) return null
|
||||||
|
if (typeof raw === 'object') return raw
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch (_) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseRowParams(row) {
|
||||||
|
let model = row.model
|
||||||
|
let ratio = row.ratio
|
||||||
|
let duration = row.duration
|
||||||
|
let resolution = row.resolution
|
||||||
|
const vp = this.safeParseVideoParams(row.videoParams)
|
||||||
|
if (vp) {
|
||||||
|
if (vp.model) model = vp.model
|
||||||
|
if (vp.ratio) ratio = vp.ratio
|
||||||
|
if (vp.duration != null) duration = vp.duration
|
||||||
|
if (vp.resolution) resolution = vp.resolution
|
||||||
|
}
|
||||||
|
return { model, ratio, duration, resolution }
|
||||||
|
},
|
||||||
|
|
||||||
|
taskParamLine(row) {
|
||||||
|
const p = this.parseRowParams(row)
|
||||||
|
const parts = []
|
||||||
|
if (p.model) parts.push(`模型 ${p.model}`)
|
||||||
|
if (p.ratio) parts.push(`比例 ${p.ratio}`)
|
||||||
|
if (p.duration != null && p.duration !== '') parts.push(`时长 ${p.duration}s`)
|
||||||
|
if (p.resolution) parts.push(`分辨率 ${p.resolution}`)
|
||||||
|
return parts.join(' · ')
|
||||||
|
},
|
||||||
|
|
||||||
|
buildMediaListFromVideoParams(row) {
|
||||||
|
const out = []
|
||||||
|
try {
|
||||||
|
const vp = typeof row.videoParams === 'string' ? JSON.parse(row.videoParams) : row.videoParams
|
||||||
|
const content = Array.isArray(vp?.content) ? vp.content : []
|
||||||
|
for (const it of content) {
|
||||||
|
if (!it || it.type === 'text') continue
|
||||||
|
if (it.type === 'image_url' && it.role === 'reference_image') {
|
||||||
|
const u = it.image_url?.url || it.imageUrl?.url
|
||||||
|
if (u) out.push({ id: `c_${out.length}`, url: u, mediaType: 'image' })
|
||||||
|
}
|
||||||
|
if (it.type === 'video_url' && it.role === 'reference_video') {
|
||||||
|
const u = it.video_url?.url || it.videoUrl?.url
|
||||||
|
if (u) out.push({ id: `c_${out.length}`, url: u, mediaType: 'video' })
|
||||||
|
}
|
||||||
|
if (it.type === 'audio_url' && it.role === 'reference_audio') {
|
||||||
|
const u = it.audio_url?.url || it.audioUrl?.url
|
||||||
|
if (u) out.push({ id: `c_${out.length}`, url: u, mediaType: 'audio' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
|
||||||
|
async reEditTask(row, opts = {}) {
|
||||||
|
const silent = !!opts.silent
|
||||||
|
const mode = row.mode || 'text-to-video'
|
||||||
|
this.videoMode = mode
|
||||||
|
await this.$nextTick()
|
||||||
|
const p = this.parseRowParams(row)
|
||||||
|
if (p.model) this.selectedModel = p.model
|
||||||
|
if (p.ratio) this.selectedRatio = p.ratio
|
||||||
|
if (p.duration != null && p.duration !== '') this.selectedDuration = Number(p.duration)
|
||||||
|
if (p.resolution) this.selectedResolution = p.resolution
|
||||||
|
|
||||||
|
if (mode === 'image-reference') {
|
||||||
|
const vp = this.safeParseVideoParams(row.videoParams)
|
||||||
|
this.promptText = row.text || ''
|
||||||
|
await this.$nextTick()
|
||||||
|
this.$refs.videoComposeRef?.applyReferenceFromHistory?.({
|
||||||
|
text: row.text || '',
|
||||||
|
contentItems: vp?.content || []
|
||||||
|
})
|
||||||
|
if (!silent) this.$message?.success?.('已载入该条任务到编辑区')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'image-first-frame') {
|
||||||
|
this.promptText = row.text || ''
|
||||||
|
this.mediaList = row.img1 ? [{ id: 'h1', url: row.img1, mediaType: 'image' }] : []
|
||||||
|
if (!silent) this.$message?.success?.('已载入该条任务到编辑区')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'image-first-last-frame') {
|
||||||
|
this.promptText = row.text || ''
|
||||||
|
const ml = []
|
||||||
|
if (row.img1) ml.push({ id: 'h1', url: row.img1, mediaType: 'image' })
|
||||||
|
if (row.img2) ml.push({ id: 'h2', url: row.img2, mediaType: 'image' })
|
||||||
|
this.mediaList = ml
|
||||||
|
if (!silent) this.$message?.success?.('已载入该条任务到编辑区')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.promptText = row.text || ''
|
||||||
|
this.mediaList = this.buildMediaListFromVideoParams(row)
|
||||||
|
if (!silent) this.$message?.success?.('已载入该条任务到编辑区')
|
||||||
|
},
|
||||||
|
|
||||||
|
async regenerateTask(row) {
|
||||||
|
await this.reEditTask(row, { silent: true })
|
||||||
|
await this.$nextTick()
|
||||||
|
await this.generateVideo()
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadVideoUrl(url) {
|
||||||
|
const u = String(url || '').trim()
|
||||||
|
if (!u) return
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = u
|
||||||
|
link.download = `video_${Date.now()}.mp4`
|
||||||
|
link.target = '_blank'
|
||||||
|
link.rel = 'noreferrer'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
},
|
},
|
||||||
|
|
||||||
async cancelRowTask(row) {
|
async cancelRowTask(row) {
|
||||||
|
|
@ -719,11 +927,28 @@ export default {
|
||||||
}
|
}
|
||||||
if (attachments.length) {
|
if (attachments.length) {
|
||||||
contentItems.push(
|
contentItems.push(
|
||||||
...attachments.map((item) => ({
|
...attachments.map((item) => {
|
||||||
|
const mt = item.mediaType
|
||||||
|
if (mt === 'video') {
|
||||||
|
return {
|
||||||
|
type: 'video_url',
|
||||||
|
video_url: { url: item.url },
|
||||||
|
role: 'reference_video'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mt === 'audio') {
|
||||||
|
return {
|
||||||
|
type: 'audio_url',
|
||||||
|
audio_url: { url: item.url },
|
||||||
|
role: 'reference_audio'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
type: 'image_url',
|
type: 'image_url',
|
||||||
image_url: { url: item.url },
|
image_url: { url: item.url },
|
||||||
role: 'reference_image'
|
role: 'reference_image'
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (contentItems.length) {
|
if (contentItems.length) {
|
||||||
|
|
@ -1457,6 +1682,96 @@ export default {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vg-chat-inline-ref-video {
|
||||||
|
display: inline-block;
|
||||||
|
width: 88px;
|
||||||
|
height: 50px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin: 0 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-inline-audio {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 202, 224, 0.15);
|
||||||
|
border: 1px solid rgba(0, 202, 224, 0.35);
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--vg-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-task-params {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--vg-muted);
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--vg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-user-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-action-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--vg-border);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--vg-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-action-btn:hover {
|
||||||
|
color: var(--vg-text);
|
||||||
|
border-color: rgba(0, 202, 224, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-action-primary {
|
||||||
|
color: var(--vg-cyan);
|
||||||
|
border-color: rgba(0, 202, 224, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-divider {
|
||||||
|
height: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-chat-download-row {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-download-btn {
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(0, 202, 224, 0.45);
|
||||||
|
background: rgba(0, 202, 224, 0.12);
|
||||||
|
color: var(--vg-cyan);
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vg-download-btn:hover {
|
||||||
|
background: rgba(0, 202, 224, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.vg-chat-time {
|
.vg-chat-time {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import com.ruoyi.ai.domain.*;
|
||||||
import com.ruoyi.ai.service.IAiManagerService;
|
import com.ruoyi.ai.service.IAiManagerService;
|
||||||
import com.ruoyi.ai.service.IAiOrderService;
|
import com.ruoyi.ai.service.IAiOrderService;
|
||||||
import com.ruoyi.ai.service.IAiTagService;
|
import com.ruoyi.ai.service.IAiTagService;
|
||||||
|
import com.ruoyi.ai.service.IByteDeptApiKeyService;
|
||||||
import com.ruoyi.ai.service.IByteService;
|
import com.ruoyi.ai.service.IByteService;
|
||||||
import com.ruoyi.api.request.ByteApiRequest;
|
import com.ruoyi.api.request.ByteApiRequest;
|
||||||
import com.ruoyi.common.annotation.Anonymous;
|
import com.ruoyi.common.annotation.Anonymous;
|
||||||
|
|
@ -40,12 +41,10 @@ public class ByteApiController extends BaseController {
|
||||||
private final IAiOrderService aiOrderService;
|
private final IAiOrderService aiOrderService;
|
||||||
private final IAiManagerService managerService;
|
private final IAiManagerService managerService;
|
||||||
private final IAiTagService aiTagService;
|
private final IAiTagService aiTagService;
|
||||||
|
private final IByteDeptApiKeyService byteDeptApiKeyService;
|
||||||
@Value("${byteapi.callBackUrl}")
|
@Value("${byteapi.callBackUrl}")
|
||||||
private String url;
|
private String url;
|
||||||
|
|
||||||
// 火山引擎配置
|
|
||||||
@Value("${volcengine.ark.apiKey}")
|
|
||||||
private String volcApiKey;
|
|
||||||
@Value("${volcengine.ark.baseUrl}")
|
@Value("${volcengine.ark.baseUrl}")
|
||||||
private String volcBaseUrl;
|
private String volcBaseUrl;
|
||||||
@Value("${volcengine.ark.callbackUrl}")
|
@Value("${volcengine.ark.callbackUrl}")
|
||||||
|
|
@ -98,14 +97,15 @@ public class ByteApiController extends BaseController {
|
||||||
ByteBodyReq byteBodyReq = new ByteBodyReq();
|
ByteBodyReq byteBodyReq = new ByteBodyReq();
|
||||||
// model由前端传入,默认为Seedance 2.0
|
// model由前端传入,默认为Seedance 2.0
|
||||||
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
|
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
|
||||||
request.getModel() : "ep-20260326165811-dlkth");
|
request.getModel() : "doubao-seedance-2.0");
|
||||||
byteBodyReq.setPrompt(text);
|
byteBodyReq.setPrompt(text);
|
||||||
byteBodyReq.setSequential_image_generation("disabled");
|
byteBodyReq.setSequential_image_generation("disabled");
|
||||||
byteBodyReq.setResponse_format("url");
|
byteBodyReq.setResponse_format("url");
|
||||||
byteBodyReq.setSize("2K");
|
byteBodyReq.setSize("2K");
|
||||||
byteBodyReq.setStream(false);
|
byteBodyReq.setStream(false);
|
||||||
byteBodyReq.setWatermark(false);
|
byteBodyReq.setWatermark(false);
|
||||||
ByteBodyRes byteBodyRes = byteService.promptToImg(byteBodyReq);
|
String arkApiKey = byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||||
|
ByteBodyRes byteBodyRes = byteService.promptToImg(byteBodyReq, arkApiKey);
|
||||||
List<ByteDataRes> data = byteBodyRes.getData();
|
List<ByteDataRes> data = byteBodyRes.getData();
|
||||||
ByteDataRes byteDataRes = data.get(0);
|
ByteDataRes byteDataRes = data.get(0);
|
||||||
String url = byteDataRes.getUrl();
|
String url = byteDataRes.getUrl();
|
||||||
|
|
@ -184,7 +184,8 @@ public class ByteApiController extends BaseController {
|
||||||
byteBodyReq.setSize("2K");
|
byteBodyReq.setSize("2K");
|
||||||
byteBodyReq.setStream(false);
|
byteBodyReq.setStream(false);
|
||||||
byteBodyReq.setWatermark(false);
|
byteBodyReq.setWatermark(false);
|
||||||
ByteBodyRes byteBodyRes = byteService.promptToImg(byteBodyReq);
|
String arkApiKey = byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||||
|
ByteBodyRes byteBodyRes = byteService.promptToImg(byteBodyReq, arkApiKey);
|
||||||
List<ByteDataRes> data = byteBodyRes.getData();
|
List<ByteDataRes> data = byteBodyRes.getData();
|
||||||
ByteDataRes byteDataRes = data.get(0);
|
ByteDataRes byteDataRes = data.get(0);
|
||||||
String url = byteDataRes.getUrl();
|
String url = byteDataRes.getUrl();
|
||||||
|
|
@ -256,7 +257,7 @@ public class ByteApiController extends BaseController {
|
||||||
ByteBodyReq byteBodyReq = new ByteBodyReq();
|
ByteBodyReq byteBodyReq = new ByteBodyReq();
|
||||||
// model由前端传入,默认为Seedance2.0
|
// model由前端传入,默认为Seedance2.0
|
||||||
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
|
byteBodyReq.setModel(StringUtils.isNotEmpty(request.getModel()) ?
|
||||||
request.getModel() : "ep-20260326165811-dlkth");
|
request.getModel() : "doubao-seedance-2.0");
|
||||||
byteBodyReq.setCallback_url(volcCallbackUrl);
|
byteBodyReq.setCallback_url(volcCallbackUrl);
|
||||||
|
|
||||||
// 构建符合火山引擎格式的content
|
// 构建符合火山引擎格式的content
|
||||||
|
|
@ -295,7 +296,8 @@ public class ByteApiController extends BaseController {
|
||||||
byteBodyReq.setResolution("720p");
|
byteBodyReq.setResolution("720p");
|
||||||
byteBodyReq.setRatio("3:4");
|
byteBodyReq.setRatio("3:4");
|
||||||
|
|
||||||
ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq);
|
String arkApiKey = byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||||
|
ByteBodyRes byteBodyRes = byteService.imgToVideo(byteBodyReq, arkApiKey);
|
||||||
String id = byteBodyRes.getId();
|
String id = byteBodyRes.getId();
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
aiOrderService.orderFailure(aiOrder);
|
aiOrderService.orderFailure(aiOrder);
|
||||||
|
|
@ -313,7 +315,8 @@ public class ByteApiController extends BaseController {
|
||||||
@GetMapping(value = "/{id}")
|
@GetMapping(value = "/{id}")
|
||||||
@ApiOperation("视频下载")
|
@ApiOperation("视频下载")
|
||||||
public AjaxResult getInfo(@PathVariable("id") String id) throws Exception {
|
public AjaxResult getInfo(@PathVariable("id") String id) throws Exception {
|
||||||
ByteBodyRes byteBodyRes = byteService.uploadVideo(id);
|
String arkApiKey = byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||||
|
ByteBodyRes byteBodyRes = byteService.uploadVideo(id, arkApiKey);
|
||||||
if ("succeeded".equals(byteBodyRes.getStatus())) {
|
if ("succeeded".equals(byteBodyRes.getStatus())) {
|
||||||
content content = byteBodyRes.getContent();
|
content content = byteBodyRes.getContent();
|
||||||
String videoUrl = content.getVideo_url();
|
String videoUrl = content.getVideo_url();
|
||||||
|
|
@ -356,7 +359,8 @@ public class ByteApiController extends BaseController {
|
||||||
@PostMapping(value = "/{id}/cancel")
|
@PostMapping(value = "/{id}/cancel")
|
||||||
@ApiOperation("取消视频生成任务")
|
@ApiOperation("取消视频生成任务")
|
||||||
public AjaxResult cancelTask(@PathVariable("id") String id) throws Exception {
|
public AjaxResult cancelTask(@PathVariable("id") String id) throws Exception {
|
||||||
return byteService.cancelVideoTask(id);
|
String arkApiKey = byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||||
|
return byteService.cancelVideoTask(id, arkApiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import com.ruoyi.common.utils.TencentCosUtil;
|
||||||
import com.ruoyi.config.PortalVideoProperties;
|
import com.ruoyi.config.PortalVideoProperties;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
@ -32,6 +33,7 @@ import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
@ -44,6 +46,7 @@ import java.util.stream.Collectors;
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/portal/video")
|
@RequestMapping("/api/portal/video")
|
||||||
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
@RequiredArgsConstructor(onConstructor_ = @Autowired)
|
||||||
|
@Slf4j
|
||||||
public class PortalVideoController extends BaseController {
|
public class PortalVideoController extends BaseController {
|
||||||
|
|
||||||
private final IByteService byteService;
|
private final IByteService byteService;
|
||||||
|
|
@ -61,6 +64,13 @@ public class PortalVideoController extends BaseController {
|
||||||
return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String maskKey(String k) {
|
||||||
|
if (StringUtils.isEmpty(k)) return "";
|
||||||
|
String t = k.trim();
|
||||||
|
if (t.length() <= 8) return "***";
|
||||||
|
return t.substring(0, 4) + "***" + t.substring(t.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
/** 与 ai_manager.type、portal.video.function-type 对齐,用于扣费 */
|
/** 与 ai_manager.type、portal.video.function-type 对齐,用于扣费 */
|
||||||
private String resolveFunctionType(PortalVideoGenRequest req) {
|
private String resolveFunctionType(PortalVideoGenRequest req) {
|
||||||
if (StringUtils.isNotEmpty(req.getFunctionType())) {
|
if (StringUtils.isNotEmpty(req.getFunctionType())) {
|
||||||
|
|
@ -201,6 +211,18 @@ public class PortalVideoController extends BaseController {
|
||||||
aiOrderService.orderSuccess(aiOrder);
|
aiOrderService.orderSuccess(aiOrder);
|
||||||
return AjaxResult.success(byteBodyRes);
|
return AjaxResult.success(byteBodyRes);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
try {
|
||||||
|
log.error(
|
||||||
|
"portal video submit failed: endpoint=/api/portal/video/{}, mode={}, req={}, byteBodyReq={}, resolvedKeyMasked={}",
|
||||||
|
mode,
|
||||||
|
mode,
|
||||||
|
OM.writeValueAsString(req),
|
||||||
|
OM.writeValueAsString(byteBodyReq),
|
||||||
|
maskKey(apiKey())
|
||||||
|
);
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
log.error("portal video submit failed: endpoint=/api/portal/video/{}, mode={}", mode, mode);
|
||||||
|
}
|
||||||
aiOrderService.orderFailure(aiOrder);
|
aiOrderService.orderFailure(aiOrder);
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
|
|
@ -284,7 +306,8 @@ public class PortalVideoController extends BaseController {
|
||||||
filtered.add(it);
|
filtered.add(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
contentList = filtered;
|
// 文中多次出现同一 [图n]/[视频n]/[音频n](同一 URL)时,只保留一条 reference_* 记录
|
||||||
|
contentList = dedupeReferenceContentAfterText(filtered);
|
||||||
|
|
||||||
String firstRef = contentList.stream()
|
String firstRef = contentList.stream()
|
||||||
.skip(1)
|
.skip(1)
|
||||||
|
|
@ -322,6 +345,42 @@ public class PortalVideoController extends BaseController {
|
||||||
return submitOrderAndCreate(request, "image-reference", body);
|
return submitOrderAndCreate(request, "image-reference", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文中多次出现同一素材(同一 role + URL)时,只保留一条 reference_*,顺序为首次出现顺序。
|
||||||
|
*/
|
||||||
|
private static List<ContentItem> dedupeReferenceContentAfterText(List<ContentItem> filtered) {
|
||||||
|
if (filtered == null || filtered.isEmpty()) {
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
List<ContentItem> out = new ArrayList<>();
|
||||||
|
out.add(filtered.get(0));
|
||||||
|
Set<String> 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) {
|
private static String firstReferenceUrlFromItem(ContentItem item) {
|
||||||
if (isReferenceImageContentItem(item)) {
|
if (isReferenceImageContentItem(item)) {
|
||||||
return item.getImageUrl().getUrl();
|
return item.getImageUrl().getUrl();
|
||||||
|
|
|
||||||
|
|
@ -224,14 +224,14 @@ tencentCos:
|
||||||
# domain: https://images.iqyjsnwv.com/
|
# domain: https://images.iqyjsnwv.com/
|
||||||
|
|
||||||
byteapi:
|
byteapi:
|
||||||
url: https://ark.ap-southeast.bytepluses.com/api/v3
|
url: http://zlhub.xiaowaiyou.cn/zhonglian
|
||||||
apiKey:
|
apiKey:
|
||||||
callBackUrl: https://undressing.top
|
callBackUrl: https://undressing.top
|
||||||
|
|
||||||
# 火山引擎 Ark API (Seedance 2.0)
|
# 火山引擎 Ark API (Seedance 2.0)
|
||||||
volcengine:
|
volcengine:
|
||||||
ark:
|
ark:
|
||||||
baseUrl: https://ark.cn-beijing.volces.com
|
baseUrl: http://zlhub.xiaowaiyou.cn/zhonglian
|
||||||
apiKey:
|
apiKey:
|
||||||
callbackUrl: https://undressing.top/api/ai/volcCallback
|
callbackUrl: https://undressing.top/api/ai/volcCallback
|
||||||
|
|
||||||
|
|
@ -241,15 +241,13 @@ portal:
|
||||||
# 与库表 ai_manager.type 一致(用于扣费);若报错 functionType does not exist,请插入对应 type 或改此处与库一致
|
# 与库表 ai_manager.type 一致(用于扣费);若报错 functionType does not exist,请插入对应 type 或改此处与库一致
|
||||||
function-type: "21"
|
function-type: "21"
|
||||||
defaults:
|
defaults:
|
||||||
model: ep-20260326165811-dlkth
|
model: doubao-seedance-2.0
|
||||||
duration: 4
|
duration: 4
|
||||||
resolution: 720p
|
resolution: 720p
|
||||||
ratio: "3:4"
|
ratio: "3:4"
|
||||||
models:
|
models:
|
||||||
- label: Seedance 2.0
|
- label: Seedance 2.0
|
||||||
value: ep-20260326165811-dlkth
|
value: doubao-seedance-2.0
|
||||||
- label: Seedance 2.0 Fast
|
|
||||||
value: ep-20260326170056-dkj9m
|
|
||||||
ratios:
|
ratios:
|
||||||
- "16:9"
|
- "16:9"
|
||||||
- "9:16"
|
- "9:16"
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,13 @@ public interface IByteService {
|
||||||
* 文生图
|
* 文生图
|
||||||
*/
|
*/
|
||||||
ByteBodyRes promptToImg(ByteBodyReq req) throws Exception;
|
ByteBodyRes promptToImg(ByteBodyReq req) throws Exception;
|
||||||
|
ByteBodyRes promptToImg(ByteBodyReq req, String arkApiKey) throws Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图生图
|
* 图生图
|
||||||
*/
|
*/
|
||||||
ByteBodyRes imgToImg(ByteBodyReq req) throws Exception;
|
ByteBodyRes imgToImg(ByteBodyReq req) throws Exception;
|
||||||
|
ByteBodyRes imgToImg(ByteBodyReq req, String arkApiKey) throws Exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首尾帧图生视频(使用全局配置的 Ark API Key)
|
* 首尾帧图生视频(使用全局配置的 Ark API Key)
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ import com.ruoyi.common.core.redis.RedisCache;
|
||||||
import com.ruoyi.common.exception.ServiceException;
|
import com.ruoyi.common.exception.ServiceException;
|
||||||
import com.ruoyi.common.utils.StringUtils;
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
import com.ruoyi.system.service.ISysDeptService;
|
import com.ruoyi.system.service.ISysDeptService;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService {
|
public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService {
|
||||||
|
|
||||||
private static final String NO_DEPT_MSG = "用户未分配部门:请在后台为门户用户设置 ai_user.dept_id(关联 sys_dept.dept_id)";
|
private static final String NO_DEPT_MSG = "用户未分配部门:请在后台为门户用户设置 ai_user.dept_id(关联 sys_dept.dept_id)";
|
||||||
|
|
@ -49,12 +51,14 @@ public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService {
|
||||||
throw new ServiceException(NO_DEPT_ROW_MSG);
|
throw new ServiceException(NO_DEPT_ROW_MSG);
|
||||||
}
|
}
|
||||||
// 优先使用用户直接归属部门的 Key;多数业务把用户挂在分公司(如 101)并在该节点配 byte_api_key
|
// 优先使用用户直接归属部门的 Key;多数业务把用户挂在分公司(如 101)并在该节点配 byte_api_key
|
||||||
|
Long keyDeptId = userDept.getDeptId();
|
||||||
String apiKey = trimKey(userDept.getByteApiKey());
|
String apiKey = trimKey(userDept.getByteApiKey());
|
||||||
if (StringUtils.isEmpty(apiKey)) {
|
if (StringUtils.isEmpty(apiKey)) {
|
||||||
Long fallbackDeptId = resolveSecondLevelDeptId(userDept);
|
Long fallbackDeptId = resolveSecondLevelDeptId(userDept);
|
||||||
if (!fallbackDeptId.equals(userDept.getDeptId())) {
|
if (!fallbackDeptId.equals(userDept.getDeptId())) {
|
||||||
SysDept keyDept = sysDeptService.selectDeptById(fallbackDeptId);
|
SysDept keyDept = sysDeptService.selectDeptById(fallbackDeptId);
|
||||||
if (keyDept != null) {
|
if (keyDept != null) {
|
||||||
|
keyDeptId = keyDept.getDeptId();
|
||||||
apiKey = trimKey(keyDept.getByteApiKey());
|
apiKey = trimKey(keyDept.getByteApiKey());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,12 +66,48 @@ public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService {
|
||||||
if (StringUtils.isEmpty(apiKey)) {
|
if (StringUtils.isEmpty(apiKey)) {
|
||||||
throw new ServiceException(NO_API_KEY_MSG);
|
throw new ServiceException(NO_API_KEY_MSG);
|
||||||
}
|
}
|
||||||
|
log.info(
|
||||||
|
"resolveVolcApiKey ok: aiUserId={}, userDeptId={}, keyDeptId={}, keyLen={}, keyMasked={}, keyFingerprint={}",
|
||||||
|
aiUserId,
|
||||||
|
aiUser.getDeptId(),
|
||||||
|
keyDeptId,
|
||||||
|
apiKey.length(),
|
||||||
|
maskKey(apiKey),
|
||||||
|
fingerprint(apiKey)
|
||||||
|
);
|
||||||
redisCache.setCacheObject(cacheKey, apiKey, CACHE_HOURS, TimeUnit.HOURS);
|
redisCache.setCacheObject(cacheKey, apiKey, CACHE_HOURS, TimeUnit.HOURS);
|
||||||
return apiKey;
|
return apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String maskKey(String key) {
|
||||||
|
if (StringUtils.isEmpty(key)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (key.length() <= 8) {
|
||||||
|
return "***";
|
||||||
|
}
|
||||||
|
return key.substring(0, 4) + "***" + key.substring(key.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fingerprint(String key) {
|
||||||
|
if (StringUtils.isEmpty(key)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return Integer.toHexString(key.hashCode());
|
||||||
|
}
|
||||||
|
|
||||||
private static String trimKey(String raw) {
|
private static String trimKey(String raw) {
|
||||||
return raw == null ? null : raw.trim();
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String k = raw.trim();
|
||||||
|
if (k.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||||
|
k = k.substring(7).trim();
|
||||||
|
}
|
||||||
|
if ((k.startsWith("\"") && k.endsWith("\"")) || (k.startsWith("'") && k.endsWith("'"))) {
|
||||||
|
k = k.substring(1, k.length() - 1).trim();
|
||||||
|
}
|
||||||
|
return k;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,42 @@ import com.ruoyi.ai.service.IByteService;
|
||||||
import com.ruoyi.common.core.domain.AjaxResult;
|
import com.ruoyi.common.core.domain.AjaxResult;
|
||||||
import com.ruoyi.common.utils.StringUtils;
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
import com.ruoyi.common.utils.http.OkHttpUtils;
|
import com.ruoyi.common.utils.http.OkHttpUtils;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import okhttp3.HttpUrl;
|
import okhttp3.HttpUrl;
|
||||||
import okhttp3.*;
|
import okhttp3.*;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class ByteService implements IByteService {
|
public class ByteService implements IByteService {
|
||||||
|
private static String normalizeArkApiKey(String rawKey) {
|
||||||
|
if (StringUtils.isBlank(rawKey)) {
|
||||||
|
return rawKey;
|
||||||
|
}
|
||||||
|
String k = rawKey.trim();
|
||||||
|
if (k.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||||
|
k = k.substring(7).trim();
|
||||||
|
}
|
||||||
|
if ((k.startsWith("\"") && k.endsWith("\"")) || (k.startsWith("'") && k.endsWith("'"))) {
|
||||||
|
k = k.substring(1, k.length() - 1).trim();
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskBearer(String authHeader) {
|
||||||
|
if (StringUtils.isBlank(authHeader)) {
|
||||||
|
return authHeader;
|
||||||
|
}
|
||||||
|
String prefix = "Bearer ";
|
||||||
|
String token = authHeader.startsWith(prefix) ? authHeader.substring(prefix.length()) : authHeader;
|
||||||
|
token = token == null ? "" : token.trim();
|
||||||
|
if (token.length() <= 8) {
|
||||||
|
return prefix + "***";
|
||||||
|
}
|
||||||
|
return prefix + token.substring(0, 4) + "***" + token.substring(token.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// private final OkHttpClient okHttpClient = OkHttpUtils.createOkHttpClient();
|
// private final OkHttpClient okHttpClient = OkHttpUtils.createOkHttpClient();
|
||||||
|
|
||||||
|
|
@ -29,28 +58,35 @@ public class ByteService implements IByteService {
|
||||||
@Value("${byteapi.url}")
|
@Value("${byteapi.url}")
|
||||||
private String API_URL;
|
private String API_URL;
|
||||||
|
|
||||||
@Value("${byteapi.apiKey}")
|
|
||||||
private String apiKey;
|
|
||||||
|
|
||||||
// 火山引擎配置
|
|
||||||
@Value("${volcengine.ark.baseUrl:https://ark.cn-beijing.volces.com}")
|
@Value("${volcengine.ark.baseUrl:https://ark.cn-beijing.volces.com}")
|
||||||
private String volcBaseUrl;
|
private String volcBaseUrl;
|
||||||
|
|
||||||
@Value("${volcengine.ark.apiKey}")
|
|
||||||
private String volcApiKey;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ByteBodyRes promptToImg(ByteBodyReq req) throws Exception {
|
public ByteBodyRes promptToImg(ByteBodyReq req) throws Exception {
|
||||||
return this.imgToImg(req);
|
throw new Exception("promptToImg error:apiKey is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ByteBodyRes promptToImg(ByteBodyReq req, String arkApiKey) throws Exception {
|
||||||
|
return this.imgToImg(req, arkApiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ByteBodyRes imgToImg(ByteBodyReq req) throws Exception {
|
public ByteBodyRes imgToImg(ByteBodyReq req) throws Exception {
|
||||||
|
throw new Exception("imgToImg error:apiKey is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ByteBodyRes imgToImg(ByteBodyReq req, String arkApiKey) throws Exception {
|
||||||
|
|
||||||
// 1. 验证请求参数(可选,根据业务需求)
|
// 1. 验证请求参数(可选,根据业务需求)
|
||||||
if (StringUtils.isBlank(req.getPrompt())) {
|
if (StringUtils.isBlank(req.getPrompt())) {
|
||||||
throw new Exception("imgToImg error:prompt is null");
|
throw new Exception("imgToImg error:prompt is null");
|
||||||
}
|
}
|
||||||
|
arkApiKey = normalizeArkApiKey(arkApiKey);
|
||||||
|
if (StringUtils.isBlank(arkApiKey)) {
|
||||||
|
throw new Exception("imgToImg error:apiKey is null");
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 构建请求体JSON(基于ByteBodyReq的字段)
|
// 2. 构建请求体JSON(基于ByteBodyReq的字段)
|
||||||
// 注意:ByteBodyReq需包含与API参数对应的字段(model、prompt等)
|
// 注意:ByteBodyReq需包含与API参数对应的字段(model、prompt等)
|
||||||
|
|
@ -59,7 +95,7 @@ public class ByteService implements IByteService {
|
||||||
Request request = new Request.Builder()
|
Request request = new Request.Builder()
|
||||||
.url(API_URL + "/images/generations")
|
.url(API_URL + "/images/generations")
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", "Bearer " + apiKey)
|
.header("Authorization", "Bearer " + arkApiKey)
|
||||||
// .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("192.168.1.1", 8080)))
|
// .proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("192.168.1.1", 8080)))
|
||||||
.post(RequestBody.create(
|
.post(RequestBody.create(
|
||||||
MediaType.parse("application/json"),
|
MediaType.parse("application/json"),
|
||||||
|
|
@ -84,7 +120,7 @@ public class ByteService implements IByteService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ByteBodyRes imgToVideo(ByteBodyReq req) throws Exception {
|
public ByteBodyRes imgToVideo(ByteBodyReq req) throws Exception {
|
||||||
return imgToVideo(req, volcApiKey);
|
throw new Exception("imgToVideo error:apiKey is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -92,40 +128,52 @@ public class ByteService implements IByteService {
|
||||||
if (req == null) {
|
if (req == null) {
|
||||||
throw new Exception("imgToVideo error:req is null");
|
throw new Exception("imgToVideo error:req is null");
|
||||||
}
|
}
|
||||||
|
arkApiKey = normalizeArkApiKey(arkApiKey);
|
||||||
if (StringUtils.isBlank(arkApiKey)) {
|
if (StringUtils.isBlank(arkApiKey)) {
|
||||||
throw new Exception("imgToVideo error:apiKey is null");
|
throw new Exception("imgToVideo error:apiKey is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
String jsonBody = objectMapper.writeValueAsString(req);
|
String jsonBody = objectMapper.writeValueAsString(req);
|
||||||
|
String requestUrl = volcBaseUrl + "/api/v1/proxy/ark/contents/generations/tasks";
|
||||||
|
String authHeader = "Bearer " + arkApiKey;
|
||||||
|
|
||||||
Request request = new Request.Builder()
|
Request request = new Request.Builder()
|
||||||
.url(volcBaseUrl + "/api/v3/contents/generations/tasks")
|
.url(requestUrl)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", "Bearer " + arkApiKey)
|
.header("Authorization", authHeader)
|
||||||
.post(RequestBody.create(
|
.post(RequestBody.create(
|
||||||
MediaType.parse("application/json; charset=utf-8"),
|
MediaType.parse("application/json; charset=utf-8"),
|
||||||
jsonBody
|
jsonBody
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
Response response = OkHttpUtils.newCall(request).execute();
|
log.error(
|
||||||
|
"imgToVideo request: url={}, method=POST, headers={{Content-Type=application/json, Authorization={}}}, body={}",
|
||||||
|
requestUrl,
|
||||||
|
maskBearer(authHeader),
|
||||||
|
jsonBody
|
||||||
|
);
|
||||||
|
|
||||||
if (!response.isSuccessful()) {
|
Response response = OkHttpUtils.newCall(request).execute();
|
||||||
String errorMsg = response.body() != null ? response.body().string() : "imgToVideo error";
|
int code = response.code();
|
||||||
throw new Exception("imgToVideo error:" + errorMsg);
|
String responseBody = response.body() != null ? response.body().string() : "";
|
||||||
|
|
||||||
|
log.error("imgToVideo response: code={}, body={}", code, responseBody);
|
||||||
|
|
||||||
|
if (code < 200 || code >= 300) {
|
||||||
|
throw new Exception("imgToVideo error:" + (StringUtils.isNotEmpty(responseBody) ? responseBody : "imgToVideo error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.body() == null) {
|
if (StringUtils.isEmpty(responseBody)) {
|
||||||
throw new Exception("imgToVideo response null");
|
throw new Exception("imgToVideo response null");
|
||||||
}
|
}
|
||||||
|
|
||||||
String responseBody = response.body().string();
|
|
||||||
return objectMapper.readValue(responseBody, ByteBodyRes.class);
|
return objectMapper.readValue(responseBody, ByteBodyRes.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ByteBodyRes uploadVideo(String id) throws Exception {
|
public ByteBodyRes uploadVideo(String id) throws Exception {
|
||||||
return uploadVideo(id, volcApiKey);
|
throw new Exception("uploadVideo error:apiKey is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -133,12 +181,13 @@ public class ByteService implements IByteService {
|
||||||
if (StringUtils.isBlank(id)) {
|
if (StringUtils.isBlank(id)) {
|
||||||
throw new Exception("uploadVideo error:id is null");
|
throw new Exception("uploadVideo error:id is null");
|
||||||
}
|
}
|
||||||
|
arkApiKey = normalizeArkApiKey(arkApiKey);
|
||||||
if (StringUtils.isBlank(arkApiKey)) {
|
if (StringUtils.isBlank(arkApiKey)) {
|
||||||
throw new Exception("uploadVideo error:apiKey is null");
|
throw new Exception("uploadVideo error:apiKey is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
Request request = new Request.Builder()
|
Request request = new Request.Builder()
|
||||||
.url(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id)
|
.url(volcBaseUrl + "/api/v1/proxy/ark/contents/generations/tasks/" + id)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", "Bearer " + arkApiKey)
|
.header("Authorization", "Bearer " + arkApiKey)
|
||||||
.get()
|
.get()
|
||||||
|
|
@ -161,7 +210,7 @@ public class ByteService implements IByteService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public AjaxResult cancelVideoTask(String id) throws Exception {
|
public AjaxResult cancelVideoTask(String id) throws Exception {
|
||||||
return cancelVideoTask(id, volcApiKey);
|
return AjaxResult.error("API Key 无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -169,13 +218,14 @@ public class ByteService implements IByteService {
|
||||||
if (StringUtils.isBlank(id)) {
|
if (StringUtils.isBlank(id)) {
|
||||||
return AjaxResult.error("任务ID不能为空");
|
return AjaxResult.error("任务ID不能为空");
|
||||||
}
|
}
|
||||||
|
arkApiKey = normalizeArkApiKey(arkApiKey);
|
||||||
if (StringUtils.isBlank(arkApiKey)) {
|
if (StringUtils.isBlank(arkApiKey)) {
|
||||||
return AjaxResult.error("API Key 无效");
|
return AjaxResult.error("API Key 无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Request request = new Request.Builder()
|
Request request = new Request.Builder()
|
||||||
.url(volcBaseUrl + "/api/v3/contents/generations/tasks/" + id)
|
.url(volcBaseUrl + "/api/v1/proxy/ark/contents/generations/tasks/" + id)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("Authorization", "Bearer " + arkApiKey)
|
.header("Authorization", "Bearer " + arkApiKey)
|
||||||
.delete()
|
.delete()
|
||||||
|
|
@ -196,13 +246,14 @@ public class ByteService implements IByteService {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception {
|
public String listVideoGenerationTasks(int pageNum, int pageSize, String arkApiKey) throws Exception {
|
||||||
|
arkApiKey = normalizeArkApiKey(arkApiKey);
|
||||||
if (StringUtils.isBlank(arkApiKey)) {
|
if (StringUtils.isBlank(arkApiKey)) {
|
||||||
throw new Exception("listVideoGenerationTasks error:apiKey is null");
|
throw new Exception("listVideoGenerationTasks error:apiKey is null");
|
||||||
}
|
}
|
||||||
int pn = pageNum > 0 ? pageNum : 1;
|
int pn = pageNum > 0 ? pageNum : 1;
|
||||||
int ps = pageSize > 0 ? Math.min(pageSize, 500) : 10;
|
int ps = pageSize > 0 ? Math.min(pageSize, 500) : 10;
|
||||||
|
|
||||||
HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v3/contents/generations/tasks");
|
HttpUrl parsed = HttpUrl.parse(volcBaseUrl + "/api/v1/proxy/ark/contents/generations/tasks");
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
throw new Exception("listVideoGenerationTasks error:invalid base url");
|
throw new Exception("listVideoGenerationTasks error:invalid base url");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue