feat: 新增 Video 平台包(descriptor + 控件迁移)
This commit is contained in:
parent
184fd6dd8c
commit
615afbc211
96
src/platforms/video/controls/pattern.vue
Normal file
96
src/platforms/video/controls/pattern.vue
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<Select
|
||||||
|
v-model="quantity"
|
||||||
|
:options="quantityOptions"
|
||||||
|
class="quantity-select"
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<img :src="selectedIcon" alt="" style="width: 20px;">
|
||||||
|
</template>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="option-content-custom">
|
||||||
|
<img :src="option.icon" alt="" style="width: 20px;">
|
||||||
|
<span v-if="option.labelText" class="option-label-text">{{ option.labelText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Select from '@/components/Select/index.vue'
|
||||||
|
|
||||||
|
import videoPattern2 from '@/assets/dialog/videoPattern2.svg'
|
||||||
|
import videoPattern4 from '@/assets/dialog/videoPattern4.svg'
|
||||||
|
import videoPattern5 from '@/assets/dialog/videoPattern5.svg'
|
||||||
|
import videoPattern6 from '@/assets/dialog/videoPattern6.svg'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '全能参考'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const quantity = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const quantityOptions = [
|
||||||
|
{ value: '文生视频', label: '文生视频', labelText: '文生视频', icon: videoPattern2 },
|
||||||
|
{ value: '首尾帧', label: '首尾帧', labelText: '首尾帧', icon: videoPattern2 },
|
||||||
|
{ value: '数字人', label: '数字人', labelText: '数字人', icon: videoPattern2 },
|
||||||
|
{ value: '全能参考', label: '全能参考', labelText: '全能参考', icon: videoPattern4 },
|
||||||
|
{ value: '智能多帧', label: '智能多帧', labelText: '智能多帧', icon: videoPattern5 },
|
||||||
|
{ value: '主体参考', label: '主体参考', labelText: '主体参考', icon: videoPattern6 }
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedIcon = computed(() => {
|
||||||
|
const option = quantityOptions.find(opt => opt.value === quantity.value)
|
||||||
|
return option ? option.icon : videoPattern1
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.quantity-select {
|
||||||
|
:deep(.select-header) {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #E8E9EB;
|
||||||
|
background: #f5f6f7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9eaeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-text) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-menu) {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-item) {
|
||||||
|
min-width: 80px;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content-custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
242
src/platforms/video/controls/proportion.vue
Normal file
242
src/platforms/video/controls/proportion.vue
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
<template>
|
||||||
|
<Popover placement="top">
|
||||||
|
<div class="proportion-container">
|
||||||
|
<div class="section">
|
||||||
|
<h3>选择比例</h3>
|
||||||
|
<div class="proportion-options" :style="{ marginBottom: props.type === 'Video' ? '0px' : '20px' }">
|
||||||
|
<div
|
||||||
|
v-for="item in proportionOptions"
|
||||||
|
:key="item.value"
|
||||||
|
class="proportion-item"
|
||||||
|
:class="{ active: proportion === item.value }"
|
||||||
|
:style="getProportionStyle(item.value)"
|
||||||
|
@click="selectProportion(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>选择分辨率</h3>
|
||||||
|
<div class="resolution-options">
|
||||||
|
<div
|
||||||
|
v-for="item in resolutionOptions"
|
||||||
|
:key="item.value"
|
||||||
|
class="resolution-item"
|
||||||
|
:class="{ active: resolution === item.value }"
|
||||||
|
@click="selectResolution(item.value)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #reference>
|
||||||
|
<div class="choice-btn">
|
||||||
|
<img src="@/assets/dialog/proportion.svg" alt="" style="width: 16px;">
|
||||||
|
<span>{{ proportion }}</span>
|
||||||
|
<div style="border: 0.5px solid #000; width: 1px; height:13px;margin: 0 5px;" />
|
||||||
|
<span>{{ resolution }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Popover from '@/components/Popover/index.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '1:1'
|
||||||
|
},
|
||||||
|
resolution: {
|
||||||
|
type: String,
|
||||||
|
default: '2k'
|
||||||
|
},
|
||||||
|
proportionOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ value: '21:9', label: '21:9' },
|
||||||
|
{ value: '16:9', label: '16:9' },
|
||||||
|
{ value: '4:3', label: '4:3' },
|
||||||
|
{ value: '1:1', label: '1:1' },
|
||||||
|
{ value: '3:4', label: '3:4' },
|
||||||
|
{ value: '9:16', label: '9:16' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
resolutionOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ value: '360', label: '流畅 360P' },
|
||||||
|
{ value: '540', label: '标清 540P' },
|
||||||
|
{ value: '720', label: '高清 720P' },
|
||||||
|
{ value: '1k', label: '超清 1K' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:resolution'])
|
||||||
|
|
||||||
|
const proportion = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolution = computed({
|
||||||
|
get: () => props.resolution,
|
||||||
|
set: (value) => emit('update:resolution', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectProportion = (value) => {
|
||||||
|
proportion.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectResolution = (value) => {
|
||||||
|
resolution.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProportionStyle = (value) => {
|
||||||
|
if (value === '智能') {
|
||||||
|
return {
|
||||||
|
'--width': '20px',
|
||||||
|
'--height': '20px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [w, h] = value.split(':').map(Number)
|
||||||
|
const aspectRatio = w / h
|
||||||
|
const baseSize = 20
|
||||||
|
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
return {
|
||||||
|
'--width': `${baseSize}px`,
|
||||||
|
'--height': `${Math.round(baseSize / aspectRatio)}px`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
'--width': `${Math.round(baseSize * aspectRatio)}px`,
|
||||||
|
'--height': `${baseSize}px`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.choice-btn{
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 15px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #E8E9EB;
|
||||||
|
background: #f5f6f7;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.choice-btn:hover{
|
||||||
|
background: #e9eaeb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proportion-container{
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section{
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
&:last-child{
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3{
|
||||||
|
font-family: "Microsoft YaHei";
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.proportion-options{
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: #F8F9FA;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proportion-item{
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: bottom;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
&::before{
|
||||||
|
content: '';
|
||||||
|
width: var(--width, 20px);
|
||||||
|
height: var(--height, 20px);
|
||||||
|
background: #F5F6F7;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 2px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover{
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active{
|
||||||
|
color: #000F33;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
&.active::before{
|
||||||
|
border-color: #000F33;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution-options{
|
||||||
|
display: flex;
|
||||||
|
padding: 5px;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #F8F9FA;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution-item{
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&:hover{
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active{
|
||||||
|
background: #ffffff;
|
||||||
|
color: #000000;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
src/platforms/video/controls/time.vue
Normal file
84
src/platforms/video/controls/time.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<Select
|
||||||
|
v-model="quantity"
|
||||||
|
:options="quantityOptions"
|
||||||
|
class="quantity-select"
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<img src="@/assets/dialog/time.svg" alt="" style="width: 20px;">
|
||||||
|
</template>
|
||||||
|
<template #header>
|
||||||
|
<span class="header">选择视频生成时长</span>
|
||||||
|
</template>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Select from '@/components/Select/index.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [
|
||||||
|
{ value: 5, label: '5s' },
|
||||||
|
{ value: 6, label: '6s' },
|
||||||
|
{ value: 7, label: '7s' },
|
||||||
|
{ value: 8, label: '8s' },
|
||||||
|
{ value: 9, label: '9s' },
|
||||||
|
{ value: 10, label: '10s' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const quantity = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const quantityOptions = computed(() => props.options)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.quantity-select {
|
||||||
|
:deep(.select-header) {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #E8E9EB;
|
||||||
|
background: #f5f6f7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9eaeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-text) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-menu) {
|
||||||
|
min-width: 136px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-item) {
|
||||||
|
min-width: 80px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.header{
|
||||||
|
margin-left: 10px;
|
||||||
|
color: #999;
|
||||||
|
font-family: "Microsoft YaHei";
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
330
src/platforms/video/imageUploader.vue
Normal file
330
src/platforms/video/imageUploader.vue
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
<template>
|
||||||
|
<div class="video-image-uploader">
|
||||||
|
<div class="image-list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in localPreviewList"
|
||||||
|
:key="item.uid"
|
||||||
|
class="image-item"
|
||||||
|
>
|
||||||
|
<img :src="item.url" class="uploaded-image" :alt="getFrameLabel(index)" />
|
||||||
|
<div class="frame-label">{{ getFrameLabel(index) }}</div>
|
||||||
|
<div class="delete-icon" @click.stop="handleDelete(index)">
|
||||||
|
<i-ep-close />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-for="i in maxImages" :key="i">
|
||||||
|
<div
|
||||||
|
v-if="localPreviewList.length < maxImages && !localPreviewList[i - 1]"
|
||||||
|
class="upload-trigger"
|
||||||
|
@click="triggerUpload(i - 1)"
|
||||||
|
>
|
||||||
|
<i-ep-plus color="#333333" />
|
||||||
|
<div class="upload-text">{{ getUploadText(i) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<el-upload
|
||||||
|
v-for="i in maxImages"
|
||||||
|
:key="i"
|
||||||
|
:ref="el => setUploadRef(el, i - 1)"
|
||||||
|
v-show="false"
|
||||||
|
:action="uploadurl"
|
||||||
|
:limit="1"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
:on-success="(response, uploadFile) => handleSuccess(response, uploadFile, i - 1)"
|
||||||
|
:on-error="handleError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { genFileId } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelType: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
imagesCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const uploadurl = import.meta.env.VITE_API_WORKFLOW_UPLOAD
|
||||||
|
const uploadRefs = ref([])
|
||||||
|
const imageList = ref([...props.modelValue])
|
||||||
|
const localPreviewList = ref([...props.modelValue])
|
||||||
|
const isUploading = ref(false)
|
||||||
|
|
||||||
|
const showEndFrame = computed(() => props.modelType === 'image' && props.imagesCount === 2)
|
||||||
|
const maxImages = computed(() => props.modelType === 'image' ? (showEndFrame.value ? 2 : 1) : 1)
|
||||||
|
|
||||||
|
const setUploadRef = (el, index) => {
|
||||||
|
if (el) {
|
||||||
|
uploadRefs.value[index] = el
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFrameLabel = (index) => {
|
||||||
|
if (props.modelType === 'digitalHuman') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return index === 0 ? '首帧' : '尾帧'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUploadText = (i) => {
|
||||||
|
if (props.modelType === 'digitalHuman') {
|
||||||
|
return '参考内容'
|
||||||
|
}
|
||||||
|
return i === 1 ? '首帧' : '尾帧'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async (newVal) => {
|
||||||
|
if (isUploading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageList.value = [...newVal]
|
||||||
|
|
||||||
|
const newPreviewList = []
|
||||||
|
for (const img of newVal) {
|
||||||
|
let previewImg = { ...img }
|
||||||
|
if (img.url && !img.url.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(img.url)
|
||||||
|
const blob = await response.blob()
|
||||||
|
previewImg = {
|
||||||
|
...img,
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
serverUrl: img.url
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create blob URL:', error)
|
||||||
|
previewImg = {
|
||||||
|
...img,
|
||||||
|
serverUrl: img.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newPreviewList.push(previewImg)
|
||||||
|
}
|
||||||
|
localPreviewList.value = newPreviewList
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
const triggerUpload = (index) => {
|
||||||
|
if (uploadRefs.value[index]) {
|
||||||
|
uploadRefs.value[index].$el.querySelector('input').click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeUpload = (rawFile) => {
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png']
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(rawFile.type)) {
|
||||||
|
ElMessage.error('不支持的文件类型,请上传 JPG、PNG 格式的图片')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawFile.size / 1024 / 1024 > 10) {
|
||||||
|
ElMessage.error('图片大小不能超过 10MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuccess = (response, uploadFile, index) => {
|
||||||
|
ElMessage.success('上传成功')
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
|
||||||
|
const localUrl = URL.createObjectURL(uploadFile.raw)
|
||||||
|
|
||||||
|
const newImage = {
|
||||||
|
uid: uploadFile.uid,
|
||||||
|
url: response.url
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageList.value[index]) {
|
||||||
|
const previewItem = localPreviewList.value[index]
|
||||||
|
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewItem.url)
|
||||||
|
}
|
||||||
|
imageList.value[index] = newImage
|
||||||
|
localPreviewList.value[index] = {
|
||||||
|
uid: uploadFile.uid,
|
||||||
|
url: localUrl,
|
||||||
|
serverUrl: response.url
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imageList.value[index] = newImage
|
||||||
|
localPreviewList.value[index] = {
|
||||||
|
uid: uploadFile.uid,
|
||||||
|
url: localUrl,
|
||||||
|
serverUrl: response.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('update:modelValue', [...imageList.value])
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
isUploading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
ElMessage.error('上传失败,请重新选择图片')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = (index) => {
|
||||||
|
const previewItem = localPreviewList.value[index]
|
||||||
|
if (previewItem && previewItem.url && previewItem.url.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(previewItem.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
localPreviewList.value.splice(index, 1)
|
||||||
|
imageList.value.splice(index, 1)
|
||||||
|
emit('update:modelValue', [...imageList.value])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = async (url, index = 0) => {
|
||||||
|
const initialFile = await fetch(url)
|
||||||
|
const blob = await initialFile.blob()
|
||||||
|
const file = new File([blob], `selected_image_${Date.now()}.jpg`, { type: blob.type })
|
||||||
|
file.uid = genFileId()
|
||||||
|
|
||||||
|
if (uploadRefs.value[index]) {
|
||||||
|
uploadRefs.value[index].handleStart(file)
|
||||||
|
uploadRefs.value[index].submit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
handleSelect
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.video-image-uploader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploaded-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frame-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 32px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgba(0, 15, 51, 0.8);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 5;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 20;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: rgba(0, 15, 51, 0.04);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: #333;
|
||||||
|
font-family: "Microsoft YaHei";
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #F0F1F2;
|
||||||
|
border-color: #626aef;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
color: #666;
|
||||||
|
font-family: "Microsoft YaHei";
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
166
src/platforms/video/index.js
Normal file
166
src/platforms/video/index.js
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { ref, markRaw } from 'vue'
|
||||||
|
import { fetchModelConfig } from '@/utils/modelConfig'
|
||||||
|
import { fetchPlatformModels, getPlatformCode } from '@/utils/modelApi'
|
||||||
|
import VideoModelSelector from './modelSelector.vue'
|
||||||
|
import Pattern from './controls/pattern.vue'
|
||||||
|
import VideoProportion from './controls/proportion.vue'
|
||||||
|
import Time from './controls/time.vue'
|
||||||
|
import VideoImageUploader from './imageUploader.vue'
|
||||||
|
import { registerPlatform } from '../registry.js'
|
||||||
|
|
||||||
|
export function defineVideoPlatform() {
|
||||||
|
const model = ref('LTX2.0')
|
||||||
|
const modelType = ref('text')
|
||||||
|
const proportion = ref('16:9')
|
||||||
|
const resolution = ref('1k')
|
||||||
|
const duration = ref(5)
|
||||||
|
const videoPattern = ref('文生视频')
|
||||||
|
|
||||||
|
const resolutionOptions = ref([
|
||||||
|
{ value: '1k', label: '标清 1K' },
|
||||||
|
{ value: '2k', label: '高清 2K' },
|
||||||
|
{ value: '4k', label: '超清 4K' },
|
||||||
|
])
|
||||||
|
const proportionOptions = ref([
|
||||||
|
{ value: '智能', label: '智能' },
|
||||||
|
{ value: '21:9', label: '21:9' },
|
||||||
|
{ value: '16:9', label: '16:9' },
|
||||||
|
{ value: '4:3', label: '4:3' },
|
||||||
|
{ value: '1:1', label: '1:1' },
|
||||||
|
{ value: '3:4', label: '3:4' },
|
||||||
|
{ value: '9:16', label: '9:16' },
|
||||||
|
])
|
||||||
|
const durationOptions = ref([])
|
||||||
|
const modelDisplayConfig = ref(null)
|
||||||
|
const promptPlaceholder = ref('描述你想生成的画面和动作。')
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
model, modelType,
|
||||||
|
proportion, resolution,
|
||||||
|
duration, videoPattern,
|
||||||
|
resolutionOptions, proportionOptions, durationOptions,
|
||||||
|
modelDisplayConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInternalConfig(modelName, modelTypeVal) {
|
||||||
|
const config = await fetchModelConfig('Video', modelName, modelTypeVal)
|
||||||
|
modelDisplayConfig.value = config
|
||||||
|
if (config?.display) {
|
||||||
|
const d = config.display
|
||||||
|
if (d.resolution) {
|
||||||
|
resolution.value = d.resolution.default || '1k'
|
||||||
|
resolutionOptions.value = d.resolution.options || []
|
||||||
|
}
|
||||||
|
if (d.proportion) {
|
||||||
|
proportion.value = d.proportion.default || '16:9'
|
||||||
|
proportionOptions.value = d.proportion.options || []
|
||||||
|
}
|
||||||
|
if (d.duration) {
|
||||||
|
duration.value = d.duration.default || 5
|
||||||
|
durationOptions.value = d.duration.options || []
|
||||||
|
}
|
||||||
|
if (d.promptPlaceholder) {
|
||||||
|
promptPlaceholder.value = d.promptPlaceholder.default || '描述你想生成的画面和动作。'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = [
|
||||||
|
{
|
||||||
|
name: 'pattern',
|
||||||
|
component: markRaw(Pattern),
|
||||||
|
show: () => true,
|
||||||
|
props: () => ({
|
||||||
|
modelValue: videoPattern.value,
|
||||||
|
'onUpdate:modelValue': (v) => { videoPattern.value = v },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'proportion',
|
||||||
|
component: markRaw(VideoProportion),
|
||||||
|
show: () => true,
|
||||||
|
props: () => ({
|
||||||
|
modelValue: proportion.value,
|
||||||
|
'onUpdate:modelValue': (v) => { proportion.value = v },
|
||||||
|
resolution: resolution.value,
|
||||||
|
'onUpdate:resolution': (v) => { resolution.value = v },
|
||||||
|
proportionOptions: proportionOptions.value,
|
||||||
|
resolutionOptions: resolutionOptions.value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'time',
|
||||||
|
component: markRaw(Time),
|
||||||
|
show: () => true,
|
||||||
|
props: () => ({
|
||||||
|
modelValue: duration.value,
|
||||||
|
'onUpdate:modelValue': (v) => { duration.value = v },
|
||||||
|
options: durationOptions.value,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const platform = {
|
||||||
|
id: 'video',
|
||||||
|
label: 'AI视频2026',
|
||||||
|
ModelSelector: markRaw(VideoModelSelector),
|
||||||
|
modelSelectorProps: () => ({ videoPattern: videoPattern.value }),
|
||||||
|
controls,
|
||||||
|
ImageUploader: markRaw(VideoImageUploader),
|
||||||
|
state,
|
||||||
|
model,
|
||||||
|
modelType,
|
||||||
|
modelDisplayConfig,
|
||||||
|
promptPlaceholder,
|
||||||
|
|
||||||
|
async loadModels() {
|
||||||
|
const code = getPlatformCode('Video')
|
||||||
|
return fetchPlatformModels(code)
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadConfig(modelName, modelTypeVal) {
|
||||||
|
return loadInternalConfig(modelName, modelTypeVal)
|
||||||
|
},
|
||||||
|
|
||||||
|
getDefaultModel() {
|
||||||
|
return 'LTX2.0'
|
||||||
|
},
|
||||||
|
|
||||||
|
showImageUploader() {
|
||||||
|
return modelType.value !== 'text'
|
||||||
|
},
|
||||||
|
|
||||||
|
imageUploadLimit() {
|
||||||
|
return modelDisplayConfig.value?.display?.images || 1
|
||||||
|
},
|
||||||
|
|
||||||
|
isImageRequired() {
|
||||||
|
return modelType.value !== 'text'
|
||||||
|
},
|
||||||
|
|
||||||
|
buildTaskBody(shared) {
|
||||||
|
const modelParams = {
|
||||||
|
prompt: shared.prompt.value,
|
||||||
|
proportion: proportion.value,
|
||||||
|
resolution: resolution.value,
|
||||||
|
duration: duration.value,
|
||||||
|
videoPattern: videoPattern.value,
|
||||||
|
}
|
||||||
|
return modelParams
|
||||||
|
},
|
||||||
|
|
||||||
|
fillFromResult(resultData) {
|
||||||
|
if (resultData.model !== undefined) model.value = resultData.model
|
||||||
|
if (resultData.modelType !== undefined) modelType.value = resultData.modelType
|
||||||
|
if (resultData.proportion !== undefined) proportion.value = resultData.proportion
|
||||||
|
if (resultData.resolution !== undefined) resolution.value = resultData.resolution
|
||||||
|
if (resultData.duration !== undefined) duration.value = resultData.duration
|
||||||
|
if (resultData.videoPattern !== undefined) videoPattern.value = resultData.videoPattern
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return platform
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPlatform('Video', defineVideoPlatform)
|
||||||
149
src/platforms/video/modelSelector.vue
Normal file
149
src/platforms/video/modelSelector.vue
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<Select
|
||||||
|
v-model="model"
|
||||||
|
:options="modelGroups"
|
||||||
|
class="model-select"
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<img src="@/assets/dialog/model.svg" alt="" style="width: 16px;">
|
||||||
|
</template>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Select from '@/components/Select/index.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
typeValue: {
|
||||||
|
type: String,
|
||||||
|
default: 'text'
|
||||||
|
},
|
||||||
|
videoPattern: {
|
||||||
|
type: String,
|
||||||
|
default: '文生视频'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'update:typeValue'])
|
||||||
|
|
||||||
|
const videoConfig = ref({})
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const url = `${import.meta.env.VITE_API_MODEL_RESOURCE}/static/public/Platform/AIGC_modelConfig/video.json`
|
||||||
|
const response = await fetch(url)
|
||||||
|
const data = await response.json()
|
||||||
|
videoConfig.value = data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch video config:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fetchConfig()
|
||||||
|
|
||||||
|
watch(() => videoConfig.value, (newConfig) => {
|
||||||
|
const models = newConfig[props.videoPattern] || []
|
||||||
|
if (models.length > 0) {
|
||||||
|
const enabledModels = models.filter(m => !m.disabled)
|
||||||
|
if (enabledModels.length > 0) {
|
||||||
|
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||||
|
if (!currentModelExists) {
|
||||||
|
model.value = enabledModels[0].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
|
||||||
|
const model = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
const newType = getModelType(props.videoPattern)
|
||||||
|
emit('update:typeValue', newType)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const modelGroups = computed(() => {
|
||||||
|
const models = videoConfig.value[props.videoPattern] || []
|
||||||
|
return models
|
||||||
|
})
|
||||||
|
|
||||||
|
const getModelType = (value) => {
|
||||||
|
switch (value) {
|
||||||
|
case '文生视频':
|
||||||
|
return 'text'
|
||||||
|
case '首尾帧':
|
||||||
|
return 'image'
|
||||||
|
case '数字人':
|
||||||
|
return 'digitalHuman'
|
||||||
|
default:
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.videoPattern, (newPattern) => {
|
||||||
|
const models = videoConfig.value[newPattern] || []
|
||||||
|
if (models.length > 0) {
|
||||||
|
const enabledModels = models.filter(m => !m.disabled)
|
||||||
|
if (enabledModels.length > 0) {
|
||||||
|
const currentModelExists = enabledModels.find(m => m.value === props.modelValue)
|
||||||
|
if (!currentModelExists) {
|
||||||
|
model.value = enabledModels[0].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
const models = videoConfig.value[props.videoPattern] || []
|
||||||
|
const currentModel = models.find(m => m.value === newValue)
|
||||||
|
if (currentModel && currentModel.disabled) {
|
||||||
|
const enabledModels = models.filter(m => !m.disabled)
|
||||||
|
if (enabledModels.length > 0) {
|
||||||
|
model.value = enabledModels[0].value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.model-select {
|
||||||
|
:deep(.select-header) {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #E8E9EB;
|
||||||
|
background: #f5f6f7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9eaeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.select-text) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-menu) {
|
||||||
|
max-height: 510px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dropdown-item) {
|
||||||
|
min-width: 120px;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(0, 15, 51, 0.10);
|
||||||
|
color: #000F33;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user