完成视频部分的前后端
This commit is contained in:
@@ -14,11 +14,12 @@
|
||||
<AiSectionCard v-if="activeTab === 'remake'" step="01" title="参考视频" description="本地视频或抖音链接,作为复刻依据。"
|
||||
class="delivery-card">
|
||||
<AiAssetDropzone title="参考视频" description="上传后会先转为 OSS 地址。" placeholder="上传本地视频"
|
||||
helper="支持 MP4 / MOV,也可在下方粘贴抖音/视频链接" accept="video/*" :file-name="currentWorkspace.referenceAsset.fileName"
|
||||
helper="支持 MP4 / MOV,也可在下方粘贴抖音/视频链接" accept="video/*"
|
||||
:file-name="currentWorkspace.referenceAsset.fileName"
|
||||
:preview-url="currentWorkspace.referenceAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.referenceAsset.previewKind"
|
||||
:source-url="currentWorkspace.referenceAsset.sourceUrl" :loading="currentWorkspace.referenceAsset.uploading"
|
||||
compact action-label="上传参考视频" selected-label="参考视频"
|
||||
:source-url="currentWorkspace.referenceAsset.sourceUrl"
|
||||
:loading="currentWorkspace.referenceAsset.uploading" compact action-label="上传参考视频" selected-label="参考视频"
|
||||
@select="(file) => setAsset(activeTab, 'referenceAsset', file)"
|
||||
@clear="clearAsset(activeTab, 'referenceAsset')" />
|
||||
<div class="script-learning-panel__toolbar">
|
||||
@@ -43,6 +44,10 @@
|
||||
<span class="delivery-field__label">输出分辨率</span>
|
||||
<AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">模型</span>
|
||||
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">视频时长</span>
|
||||
<div class="duration-control">
|
||||
@@ -77,15 +82,14 @@
|
||||
placeholder="例如:干净明亮的现代厨房背景,温暖阳光照入。" />
|
||||
</div>
|
||||
<AiAssetDropzone v-if="currentWorkspace.sceneMode === '上传场景图替换'" title="场景图" description="上传后会先转为 OSS 地址。"
|
||||
placeholder="上传场景图" helper="支持 JPG / PNG" accept="image/*" :file-name="currentWorkspace.sceneAsset.fileName"
|
||||
:preview-url="currentWorkspace.sceneAsset.previewUrl" :preview-kind="currentWorkspace.sceneAsset.previewKind"
|
||||
:source-url="currentWorkspace.sceneAsset.sourceUrl" :loading="currentWorkspace.sceneAsset.uploading" allow-url
|
||||
compact action-label="上传场景图" selected-label="场景图" url-label="场景图链接" url-placeholder="也可以粘贴场景图链接"
|
||||
placeholder="上传场景图" helper="支持 JPG / PNG" accept="image/*"
|
||||
:file-name="currentWorkspace.sceneAsset.fileName" :preview-url="currentWorkspace.sceneAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.sceneAsset.previewKind"
|
||||
:source-url="currentWorkspace.sceneAsset.sourceUrl" :loading="currentWorkspace.sceneAsset.uploading"
|
||||
allow-url compact action-label="上传场景图" selected-label="场景图" url-label="场景图链接" url-placeholder="也可以粘贴场景图链接"
|
||||
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')"
|
||||
preview-clickable
|
||||
@preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'sceneAsset')" />
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')" preview-clickable
|
||||
@preview="openImagePreview" @clear="clearAsset(activeTab, 'sceneAsset')" />
|
||||
</AiSectionCard>
|
||||
</div>
|
||||
|
||||
@@ -101,6 +105,10 @@
|
||||
<span class="delivery-field__label">输出分辨率</span>
|
||||
<AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">模型</span>
|
||||
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">视频时长</span>
|
||||
<div class="duration-control">
|
||||
@@ -138,17 +146,18 @@
|
||||
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
|
||||
placeholder="例如:防滑耐磨、复古网面透气设计。" />
|
||||
</div>
|
||||
<AiAssetDropzone title="产品图" description="上传后会先转为 OSS 地址,提交时按接口数组格式传入。" placeholder="上传产品图" helper="支持 JPG / PNG"
|
||||
accept="image/*" :file-name="currentWorkspace.productAsset.fileName"
|
||||
:preview-url="currentWorkspace.productAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.productAsset.previewKind"
|
||||
:source-url="currentWorkspace.productAsset.sourceUrl" :loading="currentWorkspace.productAsset.uploading"
|
||||
allow-url compact preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接"
|
||||
@select="(file) => setAsset(activeTab, 'productAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'productAsset', url, 'image/*')"
|
||||
@preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'productAsset')" />
|
||||
<div class="product-assets">
|
||||
<AiAssetDropzone v-for="(asset, index) in currentWorkspace.productAssets" :key="index" :title="`产品图 ${index + 1}`"
|
||||
:description="index === 0 ? '最多上传 5 张,提交时按接口数组格式传入。' : ''" placeholder="上传产品图"
|
||||
helper="支持 JPG / PNG" accept="image/*" :file-name="asset.fileName" :preview-url="asset.previewUrl"
|
||||
:preview-kind="asset.previewKind" :source-url="asset.sourceUrl" :loading="asset.uploading" allow-url compact
|
||||
preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接" @select="(file) => setProductAsset(activeTab, index, file)"
|
||||
@url-change="(url) => setProductAssetUrl(activeTab, index, url)" @preview="openImagePreview"
|
||||
@clear="clearProductAsset(activeTab, index)" />
|
||||
<button v-if="currentWorkspace.productAssets.length < 5" type="button" class="script-action-btn product-assets__add"
|
||||
@click="addProductAsset(activeTab)">添加产品图({{ currentWorkspace.productAssets.length }}/5)</button>
|
||||
</div>
|
||||
</AiSectionCard>
|
||||
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '05' : '04'" title="人脸" description="性别、模特图或人物类型三选一。"
|
||||
@@ -163,16 +172,17 @@
|
||||
</div>
|
||||
<div v-if="currentWorkspace.faceInputType === '人物类型'" class="delivery-field">
|
||||
<span class="delivery-field__label">人物类型</span>
|
||||
<el-input v-model="currentWorkspace.modelFigure" clearable placeholder="输入人物类型,例如:甜妹 / 御姐 / 成熟女性 / 职场女性" />
|
||||
<el-input v-model="currentWorkspace.modelFigure" clearable
|
||||
placeholder="输入人物类型,例如:甜妹 / 御姐 / 成熟女性 / 职场女性" />
|
||||
</div>
|
||||
<AiAssetDropzone v-if="currentWorkspace.faceInputType === '上传模特图'" title="模特图 / 人脸图"
|
||||
description="上传后会先转为 OSS 地址。" placeholder="上传清晰正脸图" helper="建议 1080px 以上,单人正面" accept="image/*"
|
||||
:file-name="currentWorkspace.modelAsset.fileName" :preview-url="currentWorkspace.modelAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.modelAsset.previewKind" :source-url="currentWorkspace.modelAsset.sourceUrl"
|
||||
:loading="currentWorkspace.modelAsset.uploading" allow-url compact preview-clickable action-label="上传模特图" selected-label="模特图"
|
||||
url-label="模特图链接" url-placeholder="也可以粘贴模特图链接" @select="(file) => setAsset(activeTab, 'modelAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'modelAsset', url, 'image/*')"
|
||||
@preview="openImagePreview"
|
||||
:preview-kind="currentWorkspace.modelAsset.previewKind"
|
||||
:source-url="currentWorkspace.modelAsset.sourceUrl" :loading="currentWorkspace.modelAsset.uploading"
|
||||
allow-url compact preview-clickable action-label="上传模特图" selected-label="模特图" url-label="模特图链接"
|
||||
url-placeholder="也可以粘贴模特图链接" @select="(file) => setAsset(activeTab, 'modelAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'modelAsset', url, 'image/*')" @preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'modelAsset')" />
|
||||
</AiSectionCard>
|
||||
|
||||
@@ -194,21 +204,22 @@
|
||||
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
|
||||
placeholder="例如:防滑耐磨、复古网面透气设计。" />
|
||||
</div>
|
||||
<AiAssetDropzone title="产品图" description="上传后会先转为 OSS 地址,提交时按接口数组格式传入。" placeholder="上传产品图" helper="支持 JPG / PNG"
|
||||
accept="image/*" :file-name="currentWorkspace.productAsset.fileName"
|
||||
:preview-url="currentWorkspace.productAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.productAsset.previewKind"
|
||||
:source-url="currentWorkspace.productAsset.sourceUrl" :loading="currentWorkspace.productAsset.uploading"
|
||||
allow-url compact preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接"
|
||||
@select="(file) => setAsset(activeTab, 'productAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'productAsset', url, 'image/*')"
|
||||
@preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'productAsset')" />
|
||||
<div class="product-assets">
|
||||
<AiAssetDropzone v-for="(asset, index) in currentWorkspace.productAssets" :key="index" :title="`产品图 ${index + 1}`"
|
||||
:description="index === 0 ? '最多上传 5 张,提交时按接口数组格式传入。' : ''" placeholder="上传产品图"
|
||||
helper="支持 JPG / PNG" accept="image/*" :file-name="asset.fileName" :preview-url="asset.previewUrl"
|
||||
:preview-kind="asset.previewKind" :source-url="asset.sourceUrl" :loading="asset.uploading" allow-url compact
|
||||
preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接" @select="(file) => setProductAsset(activeTab, index, file)"
|
||||
@url-change="(url) => setProductAssetUrl(activeTab, index, url)" @preview="openImagePreview"
|
||||
@clear="clearProductAsset(activeTab, index)" />
|
||||
<button v-if="currentWorkspace.productAssets.length < 5" type="button" class="script-action-btn product-assets__add"
|
||||
@click="addProductAsset(activeTab)">添加产品图({{ currentWorkspace.productAssets.length }}/5)</button>
|
||||
</div>
|
||||
</AiSectionCard>
|
||||
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '06' : '05'" title="话术 / 语音"
|
||||
description="编辑口播台词,选择最终配音方式。" class="delivery-card">
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '06' : '05'" title="话术 / 语音" description="编辑口播台词,选择最终配音方式。"
|
||||
class="delivery-card">
|
||||
<div class="voice-section">
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">语言</span>
|
||||
@@ -219,6 +230,11 @@
|
||||
<button type="button" class="script-action-btn voice-manage-btn" @click="openVoiceDialog">音色管理</button>
|
||||
</div>
|
||||
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">音频类型</span>
|
||||
<AiChoicePills v-model="currentWorkspace.audioType" :options="audioTypeOptions" />
|
||||
</div>
|
||||
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">口播台词</span>
|
||||
<el-input v-model="currentWorkspace.scriptText" type="textarea" :rows="6" resize="none"
|
||||
@@ -247,9 +263,10 @@
|
||||
<AiAssetDropzone title="最终配音文件" description="对应 text_info.file_url;只放最终用于视频配音的音频。" placeholder="上传音频文件"
|
||||
helper="支持 MP3 / WAV,也可粘贴可访问的音频链接" accept="audio/*" :file-name="currentWorkspace.speechAsset.fileName"
|
||||
:preview-url="currentWorkspace.speechAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.speechAsset.previewKind" :source-url="currentWorkspace.speechAsset.sourceUrl"
|
||||
:loading="currentWorkspace.speechAsset.uploading" allow-url compact action-label="上传音频" selected-label="最终配音"
|
||||
url-label="最终配音文件链接" @select="(file) => setAsset(activeTab, 'speechAsset', file)"
|
||||
:preview-kind="currentWorkspace.speechAsset.previewKind"
|
||||
:source-url="currentWorkspace.speechAsset.sourceUrl" :loading="currentWorkspace.speechAsset.uploading"
|
||||
allow-url compact action-label="上传音频" selected-label="最终配音" url-label="最终配音文件链接"
|
||||
@select="(file) => setAsset(activeTab, 'speechAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'speechAsset', url, 'audio/*')"
|
||||
@clear="clearAsset(activeTab, 'speechAsset')" />
|
||||
</template>
|
||||
@@ -290,6 +307,14 @@
|
||||
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '07' : '06'" title="视频结果" description="提交后在这里等待、预览和下载成片。"
|
||||
class="delivery-card delivery-card--preview">
|
||||
<el-tabs v-model="currentWorkspace.generationMode" class="generation-mode-tabs">
|
||||
<el-tab-pane label="样片模式" name="draft">
|
||||
<p>快速生成预览视频,用于验证场景结构、镜头调度、主体动作和提示词意图,消耗更少 token。</p>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="正常生成" name="standard">
|
||||
<p>关闭样片模式,按正常规格生成视频。</p>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="preview-panel__meta">
|
||||
<div class="preview-row">
|
||||
<span class="preview-row__label">模式</span>
|
||||
@@ -310,8 +335,8 @@
|
||||
:disabled="assemblyBusy || assemblyDownloading" @click="handleAssemblyPrimaryAction">
|
||||
{{ assemblyPrimaryActionText }}
|
||||
</el-button>
|
||||
<el-button v-if="canResetWorkspace" class="delivery-secondary-btn" :disabled="assemblyBusy || assemblyDownloading"
|
||||
@click="resetCurrentWorkspace">
|
||||
<el-button v-if="canResetWorkspace" class="delivery-secondary-btn"
|
||||
:disabled="assemblyBusy || assemblyDownloading" @click="resetCurrentWorkspace">
|
||||
重置内容
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -319,7 +344,7 @@
|
||||
:class="{ 'assembly-waiting--done': !assemblyBusy && assemblyExecuteId }">
|
||||
<div class="assembly-waiting__spinner" aria-hidden="true"></div>
|
||||
<div class="assembly-waiting__content">
|
||||
<strong>{{ assemblyPolling ? 'Coze 正在生成视频,请保持页面打开' : '工作流已提交' }}</strong>
|
||||
<strong>{{ assemblyPolling ? '正在生成视频,请保持页面打开' : '工作流已提交' }}</strong>
|
||||
<span v-if="assemblyExecuteId">执行 ID:{{ assemblyExecuteId }}</span>
|
||||
<span v-if="assemblyPolling">已查询 {{ assemblyPollCount }} 次,系统会每 5 秒自动刷新结果。</span>
|
||||
</div>
|
||||
@@ -372,7 +397,7 @@
|
||||
<el-form-item label="音色分组 ID">
|
||||
<el-input v-model="secretForm.voiceGroupId" type="password" show-password placeholder="不填写则保留当前有效值" />
|
||||
<div v-if="secretStatus?.voiceGroupIdMasked" class="secret-form__hint">当前:{{ secretStatus.voiceGroupIdMasked
|
||||
}}</div>
|
||||
}}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期">
|
||||
<el-select v-model="secretForm.expireDays" class="delivery-select">
|
||||
@@ -398,7 +423,8 @@
|
||||
<strong>{{ filteredVoiceListItems.length }}</strong>
|
||||
<span>/ {{ voiceListItems.length }} 个音色</span>
|
||||
</div>
|
||||
<el-input v-model="voiceSearchKeyword" class="voice-search-input" clearable placeholder="搜索音色名称或 voice_id" />
|
||||
<el-input v-model="voiceSearchKeyword" class="voice-search-input" clearable
|
||||
placeholder="搜索音色名称或 voice_id" @keyup.enter="listVoices" @clear="listVoices" />
|
||||
<button type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="listVoices">
|
||||
刷新音色
|
||||
</button>
|
||||
@@ -412,18 +438,24 @@
|
||||
</div>
|
||||
<span class="voice-list__source">{{ item.sourceLabel }}</span>
|
||||
<button type="button" class="script-action-btn" @click="useVoice(item)">使用此音色</button>
|
||||
<button v-if="item.sourceKey !== 'system_voice'" type="button" class="script-action-btn script-action-btn--danger"
|
||||
:disabled="voiceLoading" @click="deleteVoice(item)">删除音色</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无可展示音色,请确认接口已返回音色数据" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="克隆音色" name="clone">
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">音色名称</span>
|
||||
<el-input v-model="voiceCloneName" clearable placeholder="输入克隆后的音色名称" />
|
||||
</div>
|
||||
<AiAssetDropzone title="克隆素材" description="仅用于音色克隆,不会写入 text_info.file_url。" placeholder="上传音频或视频"
|
||||
helper="支持 MP3 / WAV / MP4,或粘贴素材链接" accept="audio/*,video/*" :file-name="voiceCloneAsset.fileName"
|
||||
:preview-url="voiceCloneAsset.previewUrl" :preview-kind="voiceCloneAsset.previewKind"
|
||||
:source-url="voiceCloneAsset.sourceUrl" :loading="voiceCloneAsset.uploading" allow-url compact
|
||||
action-label="上传克隆素材" selected-label="克隆素材" url-label="克隆素材链接"
|
||||
@select="setVoiceCloneAsset" @url-change="setVoiceCloneUrl" @clear="clearVoiceCloneAsset" />
|
||||
action-label="上传克隆素材" selected-label="克隆素材" url-label="克隆素材链接" @select="setVoiceCloneAsset"
|
||||
@url-change="setVoiceCloneUrl" @clear="clearVoiceCloneAsset" />
|
||||
<div class="voice-dialog__toolbar">
|
||||
<button type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="cloneVoice">
|
||||
开始克隆
|
||||
@@ -431,16 +463,6 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="删除音色" name="delete">
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">待删除 voice_id</span>
|
||||
<el-input v-model="deleteVoiceId" clearable placeholder="输入 voice_id,或从音色列表选择后再删除" />
|
||||
</div>
|
||||
<div class="voice-dialog__toolbar">
|
||||
<button type="button" class="script-action-btn script-action-btn--danger" :disabled="voiceLoading"
|
||||
@click="deleteVoice">确认删除音色</button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
|
||||
@@ -476,7 +498,7 @@ import {
|
||||
type WorkspaceTab = 'remake' | 'imageToVideo'
|
||||
type PreviewKind = 'image' | 'video' | 'file'
|
||||
type MediaType = 'image' | 'video' | 'audio' | 'file'
|
||||
type AssetKey = 'referenceAsset' | 'productAsset' | 'sceneAsset' | 'modelAsset' | 'speechAsset'
|
||||
type AssetKey = 'referenceAsset' | 'sceneAsset' | 'modelAsset' | 'speechAsset'
|
||||
type VoiceMode = 'textOnly' | 'uploadAudio' | 'synthesize'
|
||||
type VoiceStatusType = 'info' | 'success' | 'error'
|
||||
|
||||
@@ -503,12 +525,14 @@ type AssemblyState = {
|
||||
|
||||
type WorkspaceState = {
|
||||
referenceAsset: AssetState
|
||||
productAsset: AssetState
|
||||
productAssets: AssetState[]
|
||||
sceneAsset: AssetState
|
||||
modelAsset: AssetState
|
||||
speechAsset: AssetState
|
||||
ratio: string
|
||||
resolution: string
|
||||
model: string
|
||||
generationMode: 'draft' | 'standard'
|
||||
duration: string
|
||||
durationSeconds: number
|
||||
videoPrompt: string
|
||||
@@ -518,6 +542,7 @@ type WorkspaceState = {
|
||||
gender: string
|
||||
modelFigure: string
|
||||
language: string
|
||||
audioType: string
|
||||
voiceMode: VoiceMode
|
||||
voiceId: string
|
||||
voiceName: string
|
||||
@@ -546,12 +571,12 @@ const secretDialogVisible = ref(false)
|
||||
const secretSaving = ref(false)
|
||||
const secretStatus = ref<ImageVideoSecretStatusVo | null>(null)
|
||||
const voiceDialogVisible = ref(false)
|
||||
const voiceDialogTab = ref<'list' | 'clone' | 'delete'>('list')
|
||||
const voiceDialogTab = ref<'list' | 'clone'>('list')
|
||||
const voiceStatusMessage = ref('')
|
||||
const voiceStatusType = ref<VoiceStatusType>('info')
|
||||
const voiceListItems = ref<VoiceListItem[]>([])
|
||||
const voiceSearchKeyword = ref('')
|
||||
const deleteVoiceId = ref('')
|
||||
const voiceCloneName = ref('')
|
||||
const imagePreviewVisible = ref(false)
|
||||
const imagePreviewUrl = ref('')
|
||||
const currentAssembly = computed(() => currentWorkspace.value.assembly)
|
||||
@@ -572,9 +597,9 @@ const assemblyBusy = computed(() => currentAssembly.value.loading || currentAsse
|
||||
const canResetWorkspace = computed(() =>
|
||||
Boolean(
|
||||
currentAssembly.value.executeId
|
||||
|| currentAssembly.value.resultText
|
||||
|| currentAssembly.value.videoUrl
|
||||
|| currentAssembly.value.debugUrl,
|
||||
|| currentAssembly.value.resultText
|
||||
|| currentAssembly.value.videoUrl
|
||||
|| currentAssembly.value.debugUrl,
|
||||
),
|
||||
)
|
||||
const assemblyPrimaryActionText = computed(() => {
|
||||
@@ -594,11 +619,20 @@ const secretForm = reactive({
|
||||
|
||||
const ratioOptions = ['9:16', '16:9', '3:4', '4:3', '1:1']
|
||||
const resolutionOptions = ['480p', '720p', '1080p']
|
||||
const modelOptions = [
|
||||
{ label: 'seedance-2.0-mini', value: 'doubao-seedance-2.0-mini' },
|
||||
{ label: 'seedance-2.0-fast', value: 'sdols-2.0-fast' },
|
||||
{ label: 'seedance-2.0', value: 'sdols-2.0' },
|
||||
]
|
||||
const durationOptions = ['15', '30', '45', '60']
|
||||
const sceneModeOptions = ['提示词微调', '上传场景图替换']
|
||||
const faceInputTypeOptions = ['性别选项', '上传模特图', '人物类型']
|
||||
const genderOptions = ['女', '男']
|
||||
const languageOptions = ['中文', '英语', '韩语', '粤语']
|
||||
const audioTypeOptions = [
|
||||
{ label: '人物口播', value: '1' },
|
||||
{ label: '背景旁白', value: '2' },
|
||||
]
|
||||
const voiceModeOptions = [
|
||||
{ label: '只提交文案', value: 'textOnly' },
|
||||
{ label: '上传/粘贴已有音频', value: 'uploadAudio' },
|
||||
@@ -634,12 +668,14 @@ function createAssemblyState(): AssemblyState {
|
||||
function createWorkspaceState(): WorkspaceState {
|
||||
return {
|
||||
referenceAsset: createAssetState(),
|
||||
productAsset: createAssetState(),
|
||||
productAssets: [createAssetState()],
|
||||
sceneAsset: createAssetState(),
|
||||
modelAsset: createAssetState(),
|
||||
speechAsset: createAssetState(),
|
||||
ratio: '9:16',
|
||||
resolution: '720p',
|
||||
model: 'sdols-2.0-fast',
|
||||
generationMode: 'draft',
|
||||
duration: '15',
|
||||
durationSeconds: 15,
|
||||
videoPrompt: '',
|
||||
@@ -649,6 +685,7 @@ function createWorkspaceState(): WorkspaceState {
|
||||
gender: '女',
|
||||
modelFigure: '御姐',
|
||||
language: '中文',
|
||||
audioType: '1',
|
||||
voiceMode: 'textOnly',
|
||||
voiceId: '',
|
||||
voiceName: '',
|
||||
@@ -682,8 +719,8 @@ const secretStatusText = computed(() => {
|
||||
|
||||
function normalizeDuration(value: number | string) {
|
||||
const nextValue = Number(value)
|
||||
if (Number.isFinite(nextValue) && nextValue > 0) return String(Math.round(nextValue))
|
||||
return '15'
|
||||
if (Number.isFinite(nextValue) && nextValue > 0) return Math.round(nextValue)
|
||||
return 15
|
||||
}
|
||||
|
||||
function setDurationPreset(value: string | string[]) {
|
||||
@@ -774,6 +811,54 @@ function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
|
||||
scheduleDeliveryGridLayout()
|
||||
}
|
||||
|
||||
function addProductAsset(tab: WorkspaceTab) {
|
||||
const productAssets = workspaces[tab].productAssets
|
||||
if (productAssets.length < 5) productAssets.push(createAssetState())
|
||||
scheduleDeliveryGridLayout()
|
||||
}
|
||||
|
||||
function setProductAssetUrl(tab: WorkspaceTab, index: number, url: string) {
|
||||
const target = workspaces[tab].productAssets[index]
|
||||
if (!target) return
|
||||
const normalizedUrl = (url || '').trim()
|
||||
target.sourceUrl = normalizedUrl
|
||||
target.previewUrl = normalizedUrl
|
||||
target.fileName = normalizedUrl ? '在线素材' : ''
|
||||
target.mediaType = resolveMediaTypeFromUrl(normalizedUrl, 'image/*')
|
||||
target.previewKind = resolvePreviewKindFromMediaType(target.mediaType)
|
||||
scheduleDeliveryGridLayout()
|
||||
}
|
||||
|
||||
async function setProductAsset(tab: WorkspaceTab, index: number, file: File) {
|
||||
const target = workspaces[tab].productAssets[index]
|
||||
if (!target) return
|
||||
target.uploading = true
|
||||
try {
|
||||
const result = await uploadImageVideoMedia(file)
|
||||
target.fileName = result.originalFilename || file.name
|
||||
target.sourceUrl = result.url
|
||||
target.previewUrl = result.url
|
||||
target.mediaType = (result.mediaType || 'file') as MediaType
|
||||
target.previewKind = resolvePreviewKindFromMediaType(target.mediaType)
|
||||
ElMessage.success('产品图上传成功')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '产品图上传失败')
|
||||
} finally {
|
||||
target.uploading = false
|
||||
scheduleDeliveryGridLayout()
|
||||
}
|
||||
}
|
||||
|
||||
function clearProductAsset(tab: WorkspaceTab, index: number) {
|
||||
const productAssets = workspaces[tab].productAssets
|
||||
if (productAssets.length === 1) {
|
||||
Object.assign(productAssets[0], createAssetState())
|
||||
} else {
|
||||
productAssets.splice(index, 1)
|
||||
}
|
||||
scheduleDeliveryGridLayout()
|
||||
}
|
||||
|
||||
function openImagePreview(url: string) {
|
||||
const normalizedUrl = (url || '').trim()
|
||||
if (!normalizedUrl) return
|
||||
@@ -867,17 +952,22 @@ async function rewriteScriptFromSource() {
|
||||
return
|
||||
}
|
||||
if (looksLikeJsonObject(url)) {
|
||||
ElMessage.warning('请粘贴抖音分享口令或链接,不要粘贴接口返回内容')
|
||||
return
|
||||
}
|
||||
if (!isLikelyDouyinSource(url)) {
|
||||
ElMessage.warning('请粘贴包含抖音链接的分享口令')
|
||||
ElMessage.warning('请粘贴视频链接或文案来源链接,不要粘贴接口返回内容')
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'remake') currentWorkspace.value.scriptSourceUrl = url
|
||||
copyLearningLoading.value = true
|
||||
try {
|
||||
const result = await runImageVideoDouyinCopy(url)
|
||||
const result = await runImageVideoDouyinCopy({
|
||||
url,
|
||||
duration: Math.max(0, Math.round(Number(currentWorkspace.value.durationSeconds) || 0)),
|
||||
proc_info: {
|
||||
type: currentWorkspace.value.categoryName.trim(),
|
||||
name: currentWorkspace.value.productName.trim(),
|
||||
proc_image: resolveProductImageUrls(currentWorkspace.value),
|
||||
properties: currentWorkspace.value.productFocus.trim(),
|
||||
},
|
||||
})
|
||||
const text = cleanCopyText(result.scriptDraft || result.recognizedContent || '')
|
||||
currentWorkspace.value.scriptText = text
|
||||
ElMessage.success('文案已提取')
|
||||
@@ -897,15 +987,6 @@ function looksLikeJsonObject(value: string) {
|
||||
return text.startsWith('{') && text.endsWith('}')
|
||||
}
|
||||
|
||||
function isLikelyDouyinSource(value: string) {
|
||||
const text = value.trim().toLowerCase()
|
||||
return text.includes('douyin.com')
|
||||
|| text.includes('v.douyin')
|
||||
|| text.includes('iesdouyin')
|
||||
|| text.includes('dou音')
|
||||
|| text.includes('抖音')
|
||||
}
|
||||
|
||||
function cleanCopyText(value: string) {
|
||||
return value
|
||||
.replace(/^\s*https?:\/\/\S+\s*$/gm, '')
|
||||
@@ -916,8 +997,7 @@ function cleanCopyText(value: string) {
|
||||
}
|
||||
|
||||
function resolveProductImageUrls(workspace: WorkspaceState) {
|
||||
const productImageUrl = resolveAssetUrl(workspace.productAsset)
|
||||
return productImageUrl ? [productImageUrl] : []
|
||||
return workspace.productAssets.map(resolveAssetUrl).filter(Boolean).slice(0, 5)
|
||||
}
|
||||
|
||||
function resolveSpeechFileUrl(workspace: WorkspaceState) {
|
||||
@@ -954,27 +1034,8 @@ function resolveFaceInfo(workspace: WorkspaceState): ImageVideoWorkflowParameter
|
||||
return { type: 1, model_figure: workspace.gender, model_image: [] }
|
||||
}
|
||||
|
||||
function validateWorkspace(workspace: WorkspaceState) {
|
||||
const productImages = resolveProductImageUrls(workspace)
|
||||
const referenceVideo = resolveAssetUrl(workspace.referenceAsset)
|
||||
if (activeTab.value === 'remake' && !referenceVideo) return '请先上传或填写参考视频'
|
||||
if (!productImages.length) return '请先上传或填写产品图'
|
||||
if (!workspace.categoryName.trim()) return '请先输入产品类目'
|
||||
if (!workspace.productName.trim()) return '请先输入产品名称'
|
||||
if (!workspace.productFocus.trim()) return '请先输入核心卖点/产品属性'
|
||||
if (workspace.sceneMode === '上传场景图替换' && !resolveAssetUrl(workspace.sceneAsset)) return '请先上传或填写场景图'
|
||||
if (workspace.faceInputType === '上传模特图' && !resolveAssetUrl(workspace.modelAsset)) return '请先上传或填写模特图'
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildImageVideoWorkflowParameters(): ImageVideoWorkflowParameters | null {
|
||||
const workspace = currentWorkspace.value
|
||||
const error = validateWorkspace(workspace)
|
||||
if (error) {
|
||||
ElMessage.warning(error)
|
||||
return null
|
||||
}
|
||||
|
||||
const referenceVideoInfo = resolveReferenceVideoInfo(workspace)
|
||||
const productImageUrls = resolveProductImageUrls(workspace)
|
||||
return {
|
||||
@@ -1000,12 +1061,17 @@ function buildImageVideoWorkflowParameters(): ImageVideoWorkflowParameters | nul
|
||||
text: workspace.scriptText.trim(),
|
||||
file_url: resolveSpeechFileUrl(workspace),
|
||||
},
|
||||
audio_info: {
|
||||
audio_url: resolveSpeechFileUrl(workspace),
|
||||
type: Number(workspace.audioType) || 1,
|
||||
},
|
||||
video_info: {
|
||||
video_url: referenceVideoInfo.videoUrl,
|
||||
share_url: referenceVideoInfo.shareUrl,
|
||||
ref_video_mode: referenceVideoInfo.refVideoMode,
|
||||
mode: activeTab.value === 'imageToVideo' ? '1' : '2',
|
||||
model: 'sdols-2.0-fast',
|
||||
draft: workspace.generationMode === 'draft',
|
||||
model: workspace.model,
|
||||
prompt: workspace.videoPrompt.trim(),
|
||||
ratio: workspace.ratio,
|
||||
resolution: workspace.resolution,
|
||||
@@ -1140,7 +1206,7 @@ async function pollAssemblyResult(tab: WorkspaceTab, executeId: string) {
|
||||
return
|
||||
}
|
||||
|
||||
if (assembly.pollCount >= 120) {
|
||||
if (assembly.pollCount >= 720) {
|
||||
assembly.polling = false
|
||||
ElMessage.warning('Coze 执行结果查询超时,请稍后通过 execute_id 查看')
|
||||
return
|
||||
@@ -1213,10 +1279,10 @@ function collectVoiceArrays(value: unknown): unknown[] {
|
||||
return
|
||||
}
|
||||
const record = node as Record<string, unknown>
|
||||
;['system_voice', 'voice_cloning', 'voice_generation', 'music_generation'].forEach((key) => {
|
||||
const item = record[key]
|
||||
if (Array.isArray(item)) arrays.push(...item)
|
||||
})
|
||||
;['system_voice', 'voice_cloning', 'voice_generation', 'music_generation'].forEach((key) => {
|
||||
const item = record[key]
|
||||
if (Array.isArray(item)) arrays.push(...item)
|
||||
})
|
||||
Object.values(record).forEach(collect)
|
||||
}
|
||||
|
||||
@@ -1456,9 +1522,9 @@ function extractVoiceItems(value: unknown): VoiceListItem[] {
|
||||
sourceConfigs.forEach((config) => {
|
||||
const direct = record[config.key]
|
||||
const nested = getRecordValue(record, ['data', config.key])
|
||||
;[direct, nested].forEach((source) => {
|
||||
if (Array.isArray(source)) source.forEach((item) => addVoice(item, config.key, config.label))
|
||||
})
|
||||
;[direct, nested].forEach((source) => {
|
||||
if (Array.isArray(source)) source.forEach((item) => addVoice(item, config.key, config.label))
|
||||
})
|
||||
})
|
||||
Object.values(record).forEach(collectFromKnownArrays)
|
||||
}
|
||||
@@ -1482,7 +1548,7 @@ function firstString(...values: unknown[]) {
|
||||
async function listVoices() {
|
||||
voiceLoading.value = true
|
||||
try {
|
||||
const result = await listImageVideoVoices()
|
||||
const result = await listImageVideoVoices(voiceSearchKeyword.value.trim())
|
||||
voiceListItems.value = extractVoiceItems(result)
|
||||
setVoiceStatus(
|
||||
voiceListItems.value.length ? `已获取 ${voiceListItems.value.length} 个音色` : '音色接口已返回,未解析到列表项',
|
||||
@@ -1497,12 +1563,12 @@ async function listVoices() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVoice() {
|
||||
const voiceId = deleteVoiceId.value.trim() || currentWorkspace.value.voiceId.trim()
|
||||
if (!voiceId) {
|
||||
ElMessage.warning('请先输入 voice_id')
|
||||
async function deleteVoice(item: VoiceListItem) {
|
||||
if (item.sourceKey === 'system_voice') {
|
||||
ElMessage.warning('系统音色不支持删除')
|
||||
return
|
||||
}
|
||||
const voiceId = item.voiceId
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除音色 ${voiceId} 吗?`, '删除音色', {
|
||||
confirmButtonText: '删除',
|
||||
@@ -1528,6 +1594,11 @@ async function deleteVoice() {
|
||||
}
|
||||
|
||||
async function cloneVoice() {
|
||||
const name = voiceCloneName.value.trim()
|
||||
if (!name) {
|
||||
ElMessage.warning('请输入音色名称')
|
||||
return
|
||||
}
|
||||
const sourceUrl = resolveAssetUrl(voiceCloneAsset)
|
||||
if (!sourceUrl) {
|
||||
ElMessage.warning('请先上传或填写音频/视频链接')
|
||||
@@ -1536,10 +1607,11 @@ async function cloneVoice() {
|
||||
voiceLoading.value = true
|
||||
try {
|
||||
const clonePayload = {
|
||||
audioUrl: voiceCloneAsset.mediaType === 'video' ? '' : sourceUrl,
|
||||
audioUrl: sourceUrl,
|
||||
videoUrl: voiceCloneAsset.mediaType === 'video' ? sourceUrl : '',
|
||||
}
|
||||
const result = await cloneImageVideoVoice({
|
||||
name,
|
||||
audioUrl: clonePayload.audioUrl,
|
||||
videoUrl: clonePayload.videoUrl,
|
||||
})
|
||||
@@ -1598,7 +1670,6 @@ async function synthesizeVoice() {
|
||||
function useVoice(item: VoiceListItem) {
|
||||
currentWorkspace.value.voiceId = item.voiceId
|
||||
currentWorkspace.value.voiceName = item.name || item.voiceId
|
||||
deleteVoiceId.value = item.voiceId
|
||||
currentWorkspace.value.voiceMode = 'synthesize'
|
||||
setVoiceStatus(`已选择音色:${currentWorkspace.value.voiceName}(${item.voiceId})`, 'success')
|
||||
voiceDialogVisible.value = false
|
||||
@@ -1856,6 +1927,15 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.product-assets {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-assets__add {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.scene-face-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -1956,6 +2036,10 @@ onBeforeUnmount(() => {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.voice-dialog__tabs :deep(.el-tabs__item:not(.is-active)) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.voice-dialog__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -1998,7 +2082,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
.voice-list__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
@@ -2049,6 +2133,29 @@ onBeforeUnmount(() => {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
.generation-mode-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.generation-mode-tabs :deep(.el-tabs__item) {
|
||||
color: #f2f2f2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.generation-mode-tabs :deep(.el-tabs__active-bar) {
|
||||
background-color: #b99455;
|
||||
}
|
||||
|
||||
.generation-mode-tabs :deep(.el-tabs__content) {
|
||||
color: #a8a8a8;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.generation-mode-tabs p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -2483,6 +2483,19 @@ export interface ImageVideoDouyinCopyVo {
|
||||
debugUrl?: string;
|
||||
}
|
||||
|
||||
export interface ImageVideoDouyinCopyPayload {
|
||||
url: string;
|
||||
api_key?: string;
|
||||
t8_key?: string;
|
||||
duration?: number;
|
||||
proc_info?: {
|
||||
type: string;
|
||||
name: string;
|
||||
proc_image: string[];
|
||||
properties: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImageVideoSecretStatusVo {
|
||||
userId?: number;
|
||||
configured?: boolean;
|
||||
@@ -2535,16 +2548,21 @@ export interface ImageVideoWorkflowParameters {
|
||||
text: string;
|
||||
file_url: string;
|
||||
};
|
||||
audio_info: {
|
||||
audio_url: string;
|
||||
type: number;
|
||||
};
|
||||
video_info: {
|
||||
video_url: string;
|
||||
share_url: string;
|
||||
ref_video_mode: string;
|
||||
mode: string;
|
||||
draft: boolean;
|
||||
model: string;
|
||||
prompt: string;
|
||||
ratio: string;
|
||||
resolution: string;
|
||||
duration: string;
|
||||
duration: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2601,11 +2619,11 @@ export function saveImageVideoSecrets(payload: ImageVideoSecretSavePayload) {
|
||||
);
|
||||
}
|
||||
|
||||
export function runImageVideoDouyinCopy(url: string) {
|
||||
export function runImageVideoDouyinCopy(payload: ImageVideoDouyinCopyPayload) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoDouyinCopyVo>, { userId: number; url: string }>(
|
||||
post<JavaApiResponse<ImageVideoDouyinCopyVo>, ImageVideoDouyinCopyPayload & { userId: number }>(
|
||||
`${JAVA_API_PREFIX}/image-video/douyin-copy`,
|
||||
{ userId: getCurrentUserId(), url },
|
||||
{ userId: getCurrentUserId(), ...payload },
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
@@ -2616,7 +2634,7 @@ export function runImageVideoWorkflow(parameters: ImageVideoWorkflowParameters)
|
||||
post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowRunRequest>(
|
||||
`${JAVA_API_PREFIX}/image-video/workflow/run`,
|
||||
{ userId: getCurrentUserId(), parameters },
|
||||
{ timeout: 120000 },
|
||||
{ timeout: 3600000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2626,7 +2644,7 @@ export function getImageVideoWorkflowResult(executeId: string) {
|
||||
post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowResultRequest>(
|
||||
`${JAVA_API_PREFIX}/image-video/workflow/result`,
|
||||
{ userId: getCurrentUserId(), executeId },
|
||||
{ timeout: 600000 },
|
||||
{ timeout: 3600000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2660,11 +2678,11 @@ function resolveImageVideoMediaType(file: File) {
|
||||
return "file";
|
||||
}
|
||||
|
||||
export function listImageVideoVoices() {
|
||||
export function listImageVideoVoices(name = "") {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number }>(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; name: string }>(
|
||||
`${JAVA_API_PREFIX}/image-video/voice/list`,
|
||||
{ userId: getCurrentUserId() },
|
||||
{ userId: getCurrentUserId(), name },
|
||||
{ timeout: 180000 },
|
||||
),
|
||||
);
|
||||
@@ -2680,9 +2698,9 @@ export function deleteImageVideoVoice(voiceId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function cloneImageVideoVoice(payload: { audioUrl?: string; videoUrl?: string }) {
|
||||
export function cloneImageVideoVoice(payload: { name?: string; audioUrl?: string; videoUrl?: string }) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; audioUrl?: string; videoUrl?: string }>(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; name?: string; audioUrl?: string; videoUrl?: string }>(
|
||||
`${JAVA_API_PREFIX}/image-video/voice/clone`,
|
||||
{ userId: getCurrentUserId(), ...payload },
|
||||
{ timeout: 180000 },
|
||||
|
||||
Reference in New Issue
Block a user