Files
crawler-plugin/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue
2026-07-14 22:11:19 +08:00

2696 lines
88 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section class="delivery-workspace">
<div class="delivery-workspace__tabs">
<el-tabs v-model="activeTab" class="delivery-workspace__tab-panel">
<el-tab-pane label="视频复刻" name="remake" />
<el-tab-pane label="图生视频" name="imageToVideo" />
</el-tabs>
<button type="button" class="secret-settings-btn" @click="openSecretDialog">密钥设置</button>
</div>
<div class="delivery-grid">
<div class="delivery-form-area">
<div class="delivery-column">
<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"
:preview-url="currentWorkspace.referenceAsset.previewUrl"
:preview-kind="currentWorkspace.referenceAsset.previewKind"
: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">
<el-input v-model="currentWorkspace.referenceAsset.sourceUrl" class="script-learning-panel__source-input"
placeholder="粘贴抖音链接或视频链接,作为视频复刻的参考视频" />
<button type="button" class="script-action-btn" @click="pasteReferenceLink">粘贴</button>
<button type="button" class="script-primary-action-btn" :disabled="copyLearningLoading"
@click="rewriteScriptFromSource">
提取/仿写文案
</button>
</div>
</AiSectionCard>
<AiSectionCard v-if="activeTab === 'imageToVideo'" step="01" title="视频规格" description="控制成片尺寸、清晰度、时长和整体画面风格。"
class="delivery-card">
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">画面比例</span>
<AiChoicePills v-model="currentWorkspace.ratio" :options="ratioOptions" />
</div>
<div class="delivery-field">
<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">
<AiChoicePills v-model="currentWorkspace.duration" :options="durationOptions"
class="duration-control__presets" @update:model-value="setDurationPreset" />
<div class="duration-control__custom">
<span class="duration-control__custom-label">自定义</span>
<el-input-number v-model="currentWorkspace.durationSeconds" class="duration-control__input" :min="1"
:max="300" :step="1" controls-position="right" @focus="clearDurationPreset"
@change="handleCustomDurationChange" />
<span class="duration-control__unit"></span>
</div>
</div>
</div>
<div class="delivery-field">
<span class="delivery-field__label">视频风格/动作引导</span>
<el-input v-model="currentWorkspace.videoPrompt" type="textarea" :rows="4" resize="none"
placeholder="例如:视频画面保持高清,运镜平稳,整体风格干净明亮。" />
</div>
</div>
</AiSectionCard>
<AiSectionCard :step="activeTab === 'remake' ? '04' : '03'" title="场景" description="提示词微调或上传背景图替换。"
class="delivery-card">
<div class="delivery-field">
<span class="delivery-field__label">处理方式</span>
<AiChoicePills v-model="currentWorkspace.sceneMode" :options="sceneModeOptions" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">场景提示词</span>
<el-input v-model="currentWorkspace.scenePrompt" type="textarea" :rows="4" resize="none"
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="也可以粘贴场景图链接"
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')" preview-clickable
@preview="openImagePreview" @clear="clearAsset(activeTab, 'sceneAsset')" />
</AiSectionCard>
</div>
<div class="delivery-column">
<AiSectionCard v-if="activeTab === 'remake'" step="02" title="视频规格" description="控制成片尺寸清晰度时长和整体画面风格"
class="delivery-card">
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">画面比例</span>
<AiChoicePills v-model="currentWorkspace.ratio" :options="ratioOptions" />
</div>
<div class="delivery-field">
<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">
<AiChoicePills v-model="currentWorkspace.duration" :options="durationOptions"
class="duration-control__presets" @update:model-value="setDurationPreset" />
<div class="duration-control__custom">
<span class="duration-control__custom-label">自定义</span>
<el-input-number v-model="currentWorkspace.durationSeconds" class="duration-control__input" :min="1"
:max="300" :step="1" controls-position="right" @focus="clearDurationPreset"
@change="handleCustomDurationChange" />
<span class="duration-control__unit">秒</span>
</div>
</div>
</div>
<div class="delivery-field">
<span class="delivery-field__label">视频风格/动作引导</span>
<el-input v-model="currentWorkspace.videoPrompt" type="textarea" :rows="4" resize="none"
placeholder="例如视频画面保持高清运镜平稳整体风格干净明亮" />
</div>
</div>
</AiSectionCard>
<AiSectionCard v-if="activeTab === 'imageToVideo'" step="02" title="产品信息" description="填写商品基础信息上传一张核心产品图"
class="delivery-card">
<div class="delivery-field">
<span class="delivery-field__label">产品类目</span>
<el-input v-model="currentWorkspace.categoryName" clearable placeholder="输入产品类目例如服饰鞋包 / 男鞋 / 运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">产品名称 *</span>
<el-input v-model="currentWorkspace.productName" clearable aria-required="true" placeholder="例如新款复古运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">核心卖点 / 产品属性</span>
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
placeholder="例如防滑耐磨复古网面透气设计" />
</div>
<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="性别模特图或人物类型三选一"
class="delivery-card">
<div class="delivery-field">
<span class="delivery-field__label">输入方式</span>
<AiChoicePills v-model="currentWorkspace.faceInputType" :options="faceInputTypeOptions" />
</div>
<div v-if="currentWorkspace.faceInputType === '性别选项'" class="delivery-field">
<span class="delivery-field__label">性别</span>
<AiChoicePills v-model="currentWorkspace.gender" :options="genderOptions" />
</div>
<div v-if="currentWorkspace.faceInputType === '人物类型'" class="delivery-field">
<span class="delivery-field__label">人物类型</span>
<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"
@clear="clearAsset(activeTab, 'modelAsset')" />
</AiSectionCard>
</div>
<div class="delivery-column">
<AiSectionCard v-if="activeTab === 'remake'" step="03" title="产品信息" description="填写商品基础信息上传一张核心产品图"
class="delivery-card">
<div class="delivery-field">
<span class="delivery-field__label">产品类目</span>
<el-input v-model="currentWorkspace.categoryName" clearable placeholder="输入产品类目例如服饰鞋包 / 男鞋 / 运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">产品名称 *</span>
<el-input v-model="currentWorkspace.productName" clearable aria-required="true" placeholder="例如新款复古运动鞋" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">核心卖点 / 产品属性</span>
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
placeholder="例如防滑耐磨复古网面透气设计" />
</div>
<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">
<div class="voice-section">
<div class="delivery-field">
<span class="delivery-field__label">语言</span>
<el-select v-model="currentWorkspace.language" class="delivery-select">
<el-option v-for="item in languageOptions" :key="item" :label="item" :value="item" />
</el-select>
</div>
<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"
placeholder="输入指定的口播台词或文案内容" />
</div>
<div v-if="activeTab === 'imageToVideo'" class="delivery-field">
<span class="delivery-field__label">文案来源链接</span>
<div class="script-learning-panel__toolbar">
<el-input v-model="currentWorkspace.scriptSourceUrl" class="script-learning-panel__source-input"
placeholder="粘贴抖音链接或视频链接用于提取/仿写口播文案" />
<button type="button" class="script-action-btn" @click="pasteScriptSourceLink">粘贴</button>
<button type="button" class="script-primary-action-btn" :disabled="copyLearningLoading"
@click="rewriteScriptFromSource">
提取/仿写文案
</button>
</div>
</div>
<div class="delivery-field">
<span class="delivery-field__label">配音方式</span>
<AiChoicePills v-model="currentWorkspace.voiceMode" :options="voiceModeOptions" />
</div>
<template v-if="currentWorkspace.voiceMode === 'uploadAudio'">
<AiAssetDropzone title="背景音乐文件" description="对应 audio_info.bgm_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)"
@url-change="(url) => setAssetUrl(activeTab, 'speechAsset', url, 'audio/*')"
@clear="clearAsset(activeTab, 'speechAsset')" />
</template>
<template v-else-if="currentWorkspace.voiceMode === 'synthesize'">
<div class="delivery-form-grid delivery-form-grid--two">
<div class="delivery-field">
<span class="delivery-field__label">合成音色</span>
<el-input v-model="currentWorkspace.voiceId" clearable placeholder="选择音色或手动输入 voice_id" />
<div v-if="currentWorkspace.voiceName" class="selected-voice-name">
{{ currentWorkspace.voiceName }}
<span>{{ currentWorkspace.voiceId }}</span>
</div>
</div>
<div class="delivery-field">
<span class="delivery-field__label">音色名称</span>
<el-input v-model="currentWorkspace.voiceName" clearable placeholder="对应 audio_info.voice_name" />
</div>
<div class="delivery-field">
<span class="delivery-field__label">音色操作</span>
<div class="voice-inline-actions">
<button type="button" class="script-action-btn" @click="openVoiceDialog">选择音色</button>
<button type="button" class="script-primary-action-btn" :disabled="voiceLoading"
@click="synthesizeVoice">生成配音</button>
</div>
</div>
<div class="delivery-field delivery-field--wide">
<span class="delivery-field__label">最终配音文件链接</span>
<el-input :model-value="currentWorkspace.speechAsset.sourceUrl" readonly
placeholder="语音合成成功后会自动写入这里" />
</div>
</div>
</template>
<div v-if="voiceStatusMessage" class="voice-status" :class="`voice-status--${voiceStatusType}`">
{{ voiceStatusMessage }}
</div>
</AiSectionCard>
</div>
</div>
<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>
<span class="preview-row__value">{{ activeTab === 'remake' ? '视频复刻' : '图生视频' }}</span>
</div>
<div class="preview-row">
<span class="preview-row__label">规格</span>
<span class="preview-row__value">{{ currentWorkspace.ratio }} / {{ currentWorkspace.resolution }} / {{
currentWorkspace.durationSeconds }}秒</span>
</div>
<div class="preview-row">
<span class="preview-row__label">产品</span>
<span class="preview-row__value">{{ currentWorkspace.productName || '未设置' }}</span>
</div>
</div>
<div class="video-result-actions">
<el-button class="delivery-primary-btn" type="primary" :loading="assemblyBusy || assemblyDownloading"
:disabled="assemblyBusy || assemblyDownloading" @click="handleAssemblyPrimaryAction">
{{ assemblyPrimaryActionText }}
</el-button>
<el-button v-if="canResetWorkspace" class="delivery-secondary-btn"
:disabled="assemblyBusy || assemblyDownloading" @click="resetCurrentWorkspace">
重置内容
</el-button>
</div>
<div v-if="assemblyBusy || assemblyExecuteId" class="assembly-waiting"
:class="{ 'assembly-waiting--done': !assemblyBusy && assemblyExecuteId }">
<div class="assembly-waiting__spinner" aria-hidden="true"></div>
<div class="assembly-waiting__content">
<strong>{{ assemblyPolling ? '正在生成视频,请保持页面打开' : '工作流已提交' }}</strong>
<span v-if="assemblyExecuteId">执行 ID{{ assemblyExecuteId }}</span>
<span v-if="assemblyPolling">已查询 {{ assemblyPollCount }} 次,系统会每 5 秒自动刷新结果。</span>
</div>
</div>
<div class="video-result-panel">
<div v-if="assemblyVideoUrl" class="video-result-panel__player">
<video :src="assemblyVideoUrl" controls playsinline preload="metadata"></video>
</div>
<div v-else class="video-result-panel__empty">
<span>{{ assemblyPolling ? '视频生成中,完成后会在这里显示结果。' : '提交生成后,这里会显示生成的视频结果。' }}</span>
</div>
<div v-if="assemblyVideoUrl" class="video-result-panel__link">
<span>视频地址</span>
<a :href="assemblyVideoUrl" target="_blank" rel="noreferrer">{{ assemblyVideoUrl }}</a>
</div>
<div v-if="assemblyDebugUrl" class="video-result-panel__link">
<span>调试链接</span>
<a :href="assemblyDebugUrl" target="_blank" rel="noreferrer">{{ assemblyDebugUrl }}</a>
</div>
<details v-if="assemblyResultText" class="video-result-panel__debug">
<summary>查看原始返回</summary>
<el-input v-model="assemblyResultText" type="textarea" :rows="8" resize="none" readonly />
</details>
</div>
</AiSectionCard>
</div>
<el-dialog v-model="secretDialogVisible" title="视频密钥设置" width="560px" class="image-video-secret-dialog"
append-to-body destroy-on-close>
<div class="secret-status" :class="{ 'secret-status--valid': secretStatus?.valid }">
<span>{{ secretStatusText }}</span>
<span v-if="secretExpireText">{{ secretExpireText }}</span>
</div>
<el-form label-position="top" class="secret-form">
<el-form-item label="爬虫密钥">
<el-input v-model="secretForm.copyApiKey" type="password" show-password placeholder="不填写则保留当前有效值" />
<div v-if="secretStatus?.copyApiKeyMasked" class="secret-form__hint">当前:{{ secretStatus.copyApiKeyMasked }}
</div>
</el-form-item>
<el-form-item label="T8密钥海外">
<el-input v-model="secretForm.t8Key" type="password" show-password placeholder="不填写则保留当前有效值" />
<div v-if="secretStatus?.t8KeyMasked" class="secret-form__hint">当前:{{ secretStatus.t8KeyMasked }}</div>
</el-form-item>
<el-form-item label="T8密钥国内">
<el-input v-model="secretForm.t8VideoKey" type="password" show-password placeholder="不填写则保留当前有效值" />
<div v-if="secretStatus?.t8VideoKeyMasked" class="secret-form__hint">当前:{{ secretStatus.t8VideoKeyMasked }}
</div>
</el-form-item>
<el-form-item label="音频密钥">
<el-input v-model="secretForm.voiceApiKey" type="password" show-password placeholder="不填写则保留当前有效值" />
<div v-if="secretStatus?.voiceApiKeyMasked" class="secret-form__hint">当前:{{ secretStatus.voiceApiKeyMasked }}
</div>
</el-form-item>
<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>
</el-form-item>
<el-form-item label="有效期">
<el-select v-model="secretForm.expireDays" class="delivery-select">
<el-option label="1 " :value="1" />
<el-option label="7 " :value="7" />
<el-option label="30 " :value="30" />
<el-option label="永久" :value="0" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="secretDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="secretSaving" @click="saveSecretSettings">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="voiceDialogVisible" title="音色管理" width="920px" class="image-video-secret-dialog voice-dialog"
append-to-body>
<el-tabs v-model="voiceDialogTab" class="voice-dialog__tabs">
<el-tab-pane label="音色列表" name="list">
<div class="voice-dialog__toolbar voice-dialog__toolbar--list">
<div class="voice-list-summary">
<strong>{{ filteredVoiceListItems.length }}</strong>
<span>/ {{ voiceListItems.length }} 个音色</span>
</div>
<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>
</div>
<div v-if="filteredVoiceListItems.length" class="voice-list">
<div v-for="item in filteredVoiceListItems" :key="item.voiceId" class="voice-list__item">
<div class="voice-list__meta">
<strong>{{ item.name || item.voiceId }}</strong>
<span>{{ item.voiceId }}</span>
<em v-if="item.description">{{ item.description }}</em>
</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" />
<div class="voice-dialog__toolbar">
<button type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="cloneVoice">
开始克隆
</button>
</div>
</el-tab-pane>
</el-tabs>
</el-dialog>
<el-dialog v-model="imagePreviewVisible" title="图片预览" width="760px" class="image-preview-dialog" append-to-body>
<img v-if="imagePreviewUrl" :src="imagePreviewUrl" class="image-preview-dialog__image" alt="" />
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AiAssetDropzone from '@/shared/components/ai-workflow/AiAssetDropzone.vue'
import AiChoicePills from '@/shared/components/ai-workflow/AiChoicePills.vue'
import AiSectionCard from '@/shared/components/ai-workflow/AiSectionCard.vue'
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
import {
cloneImageVideoVoice,
deleteImageVideoVoice,
getImageVideoAsyncTask,
getImageVideoSecretStatus,
listImageVideoVoices,
runImageVideoDouyinCopy,
runImageVideoWorkflow,
saveImageVideoSecrets,
synthesizeImageVideoVoice,
uploadImageVideoMedia,
type ImageVideoSecretStatusVo,
type ImageVideoAsyncTaskVo,
type ImageVideoWorkflowParameters,
} from '@/shared/api/java-modules'
type WorkspaceTab = 'remake' | 'imageToVideo'
type PreviewKind = 'image' | 'video' | 'file'
type MediaType = 'image' | 'video' | 'audio' | 'file'
type AssetKey = 'referenceAsset' | 'sceneAsset' | 'modelAsset' | 'speechAsset'
type VoiceMode = 'textOnly' | 'uploadAudio' | 'synthesize'
type VoiceStatusType = 'info' | 'success' | 'error'
const IMAGE_VIDEO_TASK_POLL_DELAY_MS = 5000
const IMAGE_VIDEO_TASK_MAX_POLLS = 720
type AssetState = {
fileName: string
previewUrl: string
previewKind: PreviewKind
sourceUrl: string
mediaType: MediaType
uploading: boolean
}
type AssemblyState = {
loading: boolean
resultText: string
executeId: string
polling: boolean
pollCount: number
videoUrl: string
debugUrl: string
downloading: boolean
pollTimer: number
}
type WorkspaceState = {
referenceAsset: AssetState
productAssets: AssetState[]
sceneAsset: AssetState
modelAsset: AssetState
speechAsset: AssetState
ratio: string
resolution: string
model: string
generationMode: 'draft' | 'standard'
duration: string
durationSeconds: number
videoPrompt: string
sceneMode: string
scenePrompt: string
faceInputType: string
gender: string
modelFigure: string
language: string
audioType: string
voiceMode: VoiceMode
voiceId: string
voiceName: string
scriptText: string
scriptSourceUrl: string
categoryName: string
productName: string
productFocus: string
assembly: AssemblyState
}
type VoiceListItem = {
voiceId: string
name: string
groupId: string
description: string
sourceKey: string
sourceLabel: string
raw: unknown
}
const activeTab = ref<WorkspaceTab>('remake')
const copyLearningLoading = ref(false)
const voiceLoading = ref(false)
const secretDialogVisible = ref(false)
const secretSaving = ref(false)
const secretStatus = ref<ImageVideoSecretStatusVo | null>(null)
const voiceDialogVisible = ref(false)
const voiceDialogTab = ref<'list' | 'clone'>('list')
const voiceStatusMessage = ref('')
const voiceStatusType = ref<VoiceStatusType>('info')
const voiceListItems = ref<VoiceListItem[]>([])
const voiceSearchKeyword = ref('')
const voiceCloneName = ref('')
const imagePreviewVisible = ref(false)
const imagePreviewUrl = ref('')
const currentAssembly = computed(() => currentWorkspace.value.assembly)
const assemblyResultText = computed({
get: () => currentAssembly.value.resultText,
set: (value: string) => {
currentAssembly.value.resultText = value
},
})
const assemblyExecuteId = computed(() => currentAssembly.value.executeId)
const assemblyPolling = computed(() => currentAssembly.value.polling)
const assemblyPollCount = computed(() => currentAssembly.value.pollCount)
const assemblyVideoUrl = computed(() => currentAssembly.value.videoUrl)
const assemblyDebugUrl = computed(() => currentAssembly.value.debugUrl)
const assemblyDownloading = computed(() => currentAssembly.value.downloading)
const assemblyLoading = computed(() => currentAssembly.value.loading)
const assemblyBusy = computed(() => currentAssembly.value.loading || currentAssembly.value.polling)
const canResetWorkspace = computed(() =>
Boolean(
currentAssembly.value.executeId
|| currentAssembly.value.resultText
|| currentAssembly.value.videoUrl
|| currentAssembly.value.debugUrl,
),
)
const assemblyPrimaryActionText = computed(() => {
if (assemblyVideoUrl.value) return assemblyDownloading.value ? '下载中' : '下载视频'
if (assemblyPolling.value) return '生成中,请等待'
if (assemblyLoading.value) return '提交中'
return '开始生成'
})
const secretForm = reactive({
copyApiKey: '',
t8Key: '',
t8VideoKey: '',
voiceApiKey: '',
voiceGroupId: '',
expireDays: 7,
})
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' },
{ label: '参考音色', value: 'synthesize' },
]
const voiceCloneAsset = reactive<AssetState>(createAssetState())
function createAssetState(): AssetState {
return {
fileName: '',
previewUrl: '',
previewKind: 'file',
sourceUrl: '',
mediaType: 'file',
uploading: false,
}
}
function createAssemblyState(): AssemblyState {
return {
loading: false,
resultText: '',
executeId: '',
polling: false,
pollCount: 0,
videoUrl: '',
debugUrl: '',
downloading: false,
pollTimer: 0,
}
}
function createWorkspaceState(): WorkspaceState {
return {
referenceAsset: 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: '',
sceneMode: '提示词微调',
scenePrompt: '',
faceInputType: '性别选项',
gender: '女',
modelFigure: '御姐',
language: '中文',
audioType: '1',
voiceMode: 'textOnly',
voiceId: '',
voiceName: '',
scriptText: '',
scriptSourceUrl: '',
categoryName: '',
productName: '',
productFocus: '',
assembly: createAssemblyState(),
}
}
const workspaces = reactive<Record<WorkspaceTab, WorkspaceState>>({
remake: createWorkspaceState(),
imageToVideo: createWorkspaceState(),
})
const currentWorkspace = computed(() => workspaces[activeTab.value])
const filteredVoiceListItems = computed(() => {
const keyword = voiceSearchKeyword.value.trim().toLowerCase()
if (!keyword) return voiceListItems.value
return voiceListItems.value.filter((item) =>
[item.voiceId, item.name, item.description, item.sourceLabel].some((text) => text.toLowerCase().includes(keyword)),
)
})
const secretStatusText = computed(() => {
if (!secretStatus.value?.configured) return '未配置完整密钥'
if (secretStatus.value.valid) return '密钥已配置且有效'
return '密钥已过期或不可用'
})
const secretExpireText = computed(() => {
if (!secretStatus.value?.expiresAt) return ''
if (secretStatus.value.expireDays === 0) return '有效期:永久'
return `过期时间:${formatSecretTime(secretStatus.value.expiresAt)}`
})
function normalizeDuration(value: number | string) {
const nextValue = Number(value)
if (Number.isFinite(nextValue) && nextValue > 0) return Math.round(nextValue)
return 15
}
function setDurationPreset(value: string | string[]) {
const rawValue = Array.isArray(value) ? value[0] : value
const nextValue = Number(rawValue)
if (Number.isFinite(nextValue) && nextValue > 0) {
currentWorkspace.value.duration = rawValue
currentWorkspace.value.durationSeconds = nextValue
}
}
function clearDurationPreset() {
currentWorkspace.value.duration = ''
}
function handleCustomDurationChange(value: number | undefined) {
clearDurationPreset()
const nextValue = Number(value)
if (Number.isFinite(nextValue) && nextValue > 0) {
currentWorkspace.value.durationSeconds = Math.round(nextValue)
}
}
function scheduleDeliveryGridLayout() {
// CSS grid now owns layout. Keep this as a harmless compatibility hook for
// existing upload/state handlers that used to request masonry reflow.
}
function resolvePreviewKindFromMediaType(mediaType: string): PreviewKind {
if (mediaType === 'image') return 'image'
if (mediaType === 'video') return 'video'
return 'file'
}
function resolveMediaTypeFromUrl(url: string, accept = ''): MediaType {
const cleanUrl = url.split('?')[0]?.toLowerCase() || ''
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(cleanUrl)) return 'image'
if (/\.(mp4|mov|webm|m4v|ogg|avi|mkv)$/.test(cleanUrl)) return 'video'
if (/\.(mp3|wav|m4a|aac|flac)$/.test(cleanUrl)) return 'audio'
return 'file'
}
function setAssetUrl(tab: WorkspaceTab, assetKey: AssetKey, url: string, accept = '') {
const target = workspaces[tab][assetKey]
const normalizedUrl = (url || '').trim()
const mediaType = resolveMediaTypeFromUrl(normalizedUrl, accept)
target.sourceUrl = normalizedUrl
target.previewUrl = normalizedUrl
target.fileName = normalizedUrl ? '在线素材' : ''
target.mediaType = mediaType
target.previewKind = resolvePreviewKindFromMediaType(mediaType)
scheduleDeliveryGridLayout()
}
function setReferenceLink(tab: WorkspaceTab, url: string) {
const target = workspaces[tab].referenceAsset
const normalizedUrl = (url || '').trim()
const mediaType = resolveMediaTypeFromUrl(normalizedUrl)
target.sourceUrl = normalizedUrl
target.mediaType = mediaType === 'video' ? 'video' : 'file'
target.previewKind = mediaType === 'video' ? 'video' : 'file'
target.previewUrl = mediaType === 'video' ? normalizedUrl : ''
target.fileName = normalizedUrl ? (mediaType === 'video' ? '在线视频' : '在线链接') : ''
scheduleDeliveryGridLayout()
}
async function setAsset(tab: WorkspaceTab, assetKey: AssetKey, file: File) {
const target = workspaces[tab][assetKey]
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 clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
Object.assign(workspaces[tab][assetKey], createAssetState())
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
imagePreviewUrl.value = normalizedUrl
imagePreviewVisible.value = true
}
function resetCurrentWorkspace() {
const tab = activeTab.value
stopAssemblyPolling(tab)
workspaces[tab] = createWorkspaceState()
voiceStatusMessage.value = ''
voiceStatusType.value = 'info'
scheduleDeliveryGridLayout()
ElMessage.success('已重置当前模块内容')
}
async function pasteReferenceLink() {
try {
const text = await navigator.clipboard.readText()
setReferenceLink(activeTab.value, text)
if (activeTab.value === 'remake') currentWorkspace.value.scriptSourceUrl = text.trim()
} catch {
ElMessage.warning('无法读取剪贴板,请手动粘贴')
}
}
async function pasteScriptSourceLink() {
try {
currentWorkspace.value.scriptSourceUrl = (await navigator.clipboard.readText()).trim()
scheduleDeliveryGridLayout()
} catch {
ElMessage.warning('无法读取剪贴板,请手动粘贴')
}
}
async function refreshSecretStatus(showMessage = false) {
try {
secretStatus.value = await getImageVideoSecretStatus()
if (showMessage) ElMessage.success('密钥状态已刷新')
} catch (error) {
if (showMessage) ElMessage.error(error instanceof Error ? error.message : '密钥状态查询失败')
}
}
function openSecretDialog() {
resetSecretForm()
secretDialogVisible.value = true
void refreshSecretStatus(false)
}
function resetSecretForm() {
secretForm.copyApiKey = ''
secretForm.t8Key = ''
secretForm.t8VideoKey = ''
secretForm.voiceApiKey = ''
secretForm.voiceGroupId = ''
secretForm.expireDays = secretStatus.value?.expireDays ?? 7
}
async function saveSecretSettings() {
secretSaving.value = true
try {
secretStatus.value = await saveImageVideoSecrets({
copyApiKey: secretForm.copyApiKey.trim(),
t8Key: secretForm.t8Key.trim(),
t8VideoKey: secretForm.t8VideoKey.trim(),
voiceApiKey: secretForm.voiceApiKey.trim(),
voiceGroupId: secretForm.voiceGroupId.trim(),
expireDays: secretForm.expireDays,
})
ElMessage.success('密钥已保存')
secretDialogVisible.value = false
resetSecretForm()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '密钥保存失败')
} finally {
secretSaving.value = false
}
}
function formatSecretTime(value: string) {
if (!value) return ''
return value.replace('T', ' ').slice(0, 19)
}
function sleep(milliseconds: number) {
return new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds))
}
function normalizeTaskStatus(value: string | undefined) {
return (value || '').trim().toUpperCase()
}
function isTerminalCozeStatus(value: string | undefined) {
return ['SUCCESS', 'SUCCEEDED', 'COMPLETED', 'DONE', 'FINISHED', 'FAILED', 'FAIL', 'ERROR', 'CANCELED', 'CANCELLED']
.includes(normalizeTaskStatus(value))
}
function isRecoverableFailedImageVideoTask(task: ImageVideoAsyncTaskVo) {
return task.status === 'FAILED'
&& Boolean(task.cozeExecuteId)
&& !isTerminalCozeStatus(task.cozeStatus)
}
function isTerminalImageVideoTask(task: ImageVideoAsyncTaskVo) {
return task.status === 'SUCCESS' || (task.status === 'FAILED' && !isRecoverableFailedImageVideoTask(task))
}
async function waitForImageVideoTask(ticket: ImageVideoAsyncTaskVo): Promise<unknown> {
let task = ticket
for (let pollCount = 0; pollCount < IMAGE_VIDEO_TASK_MAX_POLLS; pollCount += 1) {
if (task.status === 'SUCCESS') return task.result
if (isTerminalImageVideoTask(task)) {
throw new Error(task.errorMessage || 'Coze task failed')
}
await sleep(IMAGE_VIDEO_TASK_POLL_DELAY_MS)
task = await getImageVideoAsyncTask(task.taskId)
}
throw new Error('Coze task polling timed out')
}
async function rewriteScriptFromSource() {
const sourceUrl = currentWorkspace.value.scriptSourceUrl.trim()
const referenceUrl = currentWorkspace.value.referenceAsset.sourceUrl.trim()
const url = activeTab.value === 'remake' ? referenceUrl || sourceUrl : sourceUrl
if (!url) {
ElMessage.warning('请先粘贴文案来源链接')
return
}
if (looksLikeJsonObject(url)) {
ElMessage.warning('请粘贴视频链接或文案来源链接,不要粘贴接口返回内容')
return
}
const productName = currentWorkspace.value.productName.trim()
if (!productName) {
ElMessage.warning('请输入产品名称')
return
}
if (activeTab.value === 'remake') currentWorkspace.value.scriptSourceUrl = url
copyLearningLoading.value = true
try {
const ticket = await runImageVideoDouyinCopy({
url,
duration: Math.max(0, Math.round(Number(currentWorkspace.value.durationSeconds) || 0)),
proc_info: {
type: currentWorkspace.value.categoryName.trim(),
name: productName,
proc_image: resolveProductImageUrls(currentWorkspace.value),
properties: currentWorkspace.value.productFocus.trim(),
},
})
const result = await waitForImageVideoTask(ticket)
const text = cleanCopyText(findTextByKeys(result, ['scriptDraft', 'script_draft', 'recognizedContent', 'recognized_content']))
currentWorkspace.value.scriptText = text
ElMessage.success('文案已提取')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '文案提取失败')
} finally {
copyLearningLoading.value = false
}
}
function resolveAssetUrl(asset: AssetState) {
return asset.sourceUrl.trim()
}
function looksLikeJsonObject(value: string) {
const text = value.trim()
return text.startsWith('{') && text.endsWith('}')
}
function cleanCopyText(value: string) {
return value
.replace(/^\s*https?:\/\/\S+\s*$/gm, '')
.replace(/https?:\/\/\S+/g, '')
.replace(/^[ \t]+|[ \t]+$/gm, '')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function resolveProductImageUrls(workspace: WorkspaceState) {
return workspace.productAssets.map(resolveAssetUrl).filter(Boolean).slice(0, 5)
}
function resolveVoiceAudioUrl(workspace: WorkspaceState) {
if (workspace.voiceMode !== 'synthesize') return ''
const url = resolveAssetUrl(workspace.speechAsset)
return isCozeWorkflowDebugUrl(url) ? '' : url
}
function resolveBgmUrl(workspace: WorkspaceState) {
if (workspace.voiceMode !== 'uploadAudio') return ''
const url = resolveAssetUrl(workspace.speechAsset)
return isCozeWorkflowDebugUrl(url) ? '' : url
}
function resolveAudioMode(workspace: WorkspaceState) {
if (workspace.voiceMode === 'uploadAudio') return 2
return 1
}
function resolveAudioInfo(workspace: WorkspaceState): ImageVideoWorkflowParameters['audio_info'] {
const mode = resolveAudioMode(workspace)
const audioUrl = mode === 1 ? resolveVoiceAudioUrl(workspace) : ''
const bgmUrl = mode === 2 ? resolveBgmUrl(workspace) : ''
return {
audio_url: audioUrl,
type: Number(workspace.audioType) || 1,
bgm_url: bgmUrl,
mode,
voice_name: mode === 1 ? workspace.voiceName.trim() : '',
}
}
function resolveReferenceVideoInfo(workspace: WorkspaceState) {
if (activeTab.value !== 'remake') {
return { videoUrl: '', shareUrl: '', refVideoMode: '1' }
}
const referenceUrl = resolveAssetUrl(workspace.referenceAsset)
if (!referenceUrl) {
return { videoUrl: '', shareUrl: '', refVideoMode: '1' }
}
const mediaType = workspace.referenceAsset.mediaType
const refVideoMode = mediaType === 'video' ? '1' : '2'
return {
videoUrl: refVideoMode === '1' ? referenceUrl : '',
shareUrl: refVideoMode === '2' ? referenceUrl : '',
refVideoMode,
}
}
function resolveFaceInfo(workspace: WorkspaceState): ImageVideoWorkflowParameters['face_info'] {
if (workspace.faceInputType === '上传模特图') {
const modelImageUrl = resolveAssetUrl(workspace.modelAsset)
return { type: 2, model_figure: '', model_image: modelImageUrl ? [modelImageUrl] : [] }
}
if (workspace.faceInputType === '人物类型') {
return { type: 3, model_figure: workspace.modelFigure, model_image: [] }
}
return { type: 1, model_figure: workspace.gender, model_image: [] }
}
function buildImageVideoWorkflowParameters(): ImageVideoWorkflowParameters | null {
const workspace = currentWorkspace.value
const referenceVideoInfo = resolveReferenceVideoInfo(workspace)
const productImageUrls = resolveProductImageUrls(workspace)
return {
api_key_info: {
t8star_key: '',
t8_video_key: '',
ai_conductor_key: '',
},
bg_info: {
type: workspace.sceneMode === '上传场景图替换' ? 2 : 1,
prompt: workspace.scenePrompt.trim(),
bg_image: resolveAssetUrl(workspace.sceneAsset),
},
face_info: resolveFaceInfo(workspace),
proc_info: {
type: workspace.categoryName.trim(),
name: workspace.productName.trim(),
proc_image: productImageUrls,
properties: workspace.productFocus.trim(),
},
text_info: {
type: 1,
language: workspace.language,
text: workspace.scriptText.trim(),
file_url: resolveVoiceAudioUrl(workspace),
},
audio_info: resolveAudioInfo(workspace),
video_info: {
video_url: referenceVideoInfo.videoUrl,
share_url: referenceVideoInfo.shareUrl,
ref_video_mode: referenceVideoInfo.refVideoMode,
mode: activeTab.value === 'imageToVideo' ? '1' : '2',
draft: workspace.generationMode === 'draft',
model: workspace.model,
prompt: workspace.videoPrompt.trim(),
ratio: workspace.ratio,
resolution: workspace.resolution,
duration: normalizeDuration(workspace.durationSeconds),
},
}
}
async function startAssembly() {
if (assemblyLoading.value) return
const tab = activeTab.value
const assembly = workspaces[tab].assembly
const parameters = buildImageVideoWorkflowParameters()
if (!parameters) return
stopAssemblyPolling(tab)
assembly.loading = true
assembly.resultText = ''
assembly.executeId = ''
assembly.videoUrl = ''
assembly.debugUrl = ''
try {
const ticket = await runImageVideoWorkflow(parameters)
assembly.resultText = JSON.stringify(ticket, null, 2)
startAssemblyPolling(tab, ticket.taskId)
ElMessage.success('工作流已提交')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '工作流提交失败')
} finally {
assembly.loading = false
}
}
function handleAssemblyPrimaryAction() {
if (assemblyVideoUrl.value) {
void downloadAssemblyVideo()
return
}
void startAssembly()
}
function buildAssemblyVideoFilename() {
const modeName = activeTab.value === 'remake' ? '视频复刻' : '图生视频'
const productName = currentWorkspace.value.productName.trim() || '带货视频'
const executeId = assemblyExecuteId.value.trim()
const extensionMatch = assemblyVideoUrl.value.split('?')[0]?.match(/\.(mp4|mov|webm|m4v)$/i)
const extension = extensionMatch?.[1]?.toLowerCase() || 'mp4'
const baseName = [modeName, productName, executeId].filter(Boolean).join('-')
return `${baseName.replace(/[\\/:*?"<>|\s]+/g, '-')}.${extension}`
}
async function downloadAssemblyVideo() {
const url = assemblyVideoUrl.value.trim()
if (!url) {
ElMessage.warning('当前没有可下载的视频')
return
}
const assembly = currentAssembly.value
assembly.downloading = true
try {
const filename = buildAssemblyVideoFilename()
const downloadId = `image-video:${assemblyExecuteId.value || Date.now()}`
const result = await saveUrlWithProgress(url, filename, downloadId)
if (result.success) {
ElMessage.success(`已保存: ${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
} finally {
assembly.downloading = false
}
}
function startAssemblyPolling(tab: WorkspaceTab, taskId: number) {
stopAssemblyPolling(tab)
const assembly = workspaces[tab].assembly
assembly.polling = true
assembly.pollCount = 0
void pollAssemblyResult(tab, taskId)
}
function stopAssemblyPolling(tab: WorkspaceTab) {
const assembly = workspaces[tab].assembly
if (assembly.pollTimer) {
window.clearTimeout(assembly.pollTimer)
assembly.pollTimer = 0
}
assembly.polling = false
assembly.pollCount = 0
}
async function pollAssemblyResult(tab: WorkspaceTab, taskId: number) {
if (!taskId) return
const assembly = workspaces[tab].assembly
assembly.pollCount += 1
try {
const task = await getImageVideoAsyncTask(taskId)
const result = task.result
const status = task.cozeStatus || task.status
const videoUrl = findVideoResultUrl(result)
const debugUrl = findTextByKeys(result, ['debug_url', 'debugUrl'])
if (task.cozeExecuteId) assembly.executeId = task.cozeExecuteId
if (videoUrl) assembly.videoUrl = videoUrl
if (debugUrl) assembly.debugUrl = debugUrl
assembly.resultText = JSON.stringify({
task_id: taskId,
execute_id: task.cozeExecuteId || assembly.executeId,
polling: !isTerminalImageVideoTask(task),
poll_count: assembly.pollCount,
status,
video_url: videoUrl || assembly.videoUrl,
result,
}, null, 2)
if (isTerminalImageVideoTask(task)) {
assembly.polling = false
if (task.status === 'FAILED') {
ElMessage.error('Coze 工作流执行失败')
} else {
ElMessage.success(videoUrl || assembly.videoUrl ? '视频生成完成' : 'Coze 工作流执行完成,未解析到视频地址')
}
return
}
} catch (error) {
assembly.resultText = JSON.stringify({
task_id: taskId,
polling: false,
error: error instanceof Error ? error.message : '查询 Coze 执行结果失败',
}, null, 2)
assembly.polling = false
ElMessage.error(error instanceof Error ? error.message : '查询 Coze 执行结果失败')
return
}
if (assembly.pollCount >= 720) {
assembly.polling = false
ElMessage.warning('Coze 执行结果查询超时,请稍后通过 execute_id 查看')
return
}
assembly.pollTimer = window.setTimeout(() => {
void pollAssemblyResult(tab, taskId)
}, IMAGE_VIDEO_TASK_POLL_DELAY_MS)
}
function setVoiceStatus(message: string, type: VoiceStatusType = 'info') {
voiceStatusMessage.value = message
voiceStatusType.value = type
scheduleDeliveryGridLayout()
}
function openVoiceDialog() {
voiceDialogVisible.value = true
if (!voiceListItems.value.length) {
void listVoices()
}
}
function parseJsonString(value: string): unknown {
const text = value.trim()
if (!text) return null
const candidates = [text]
if (text.startsWith('"') && text.endsWith('"')) {
candidates.push(text.slice(1, -1))
}
for (const candidate of candidates) {
const nextText = candidate.trim()
if (!['{', '['].includes(nextText[0])) continue
try {
return JSON.parse(nextText)
} catch {
try {
return JSON.parse(nextText.replace(/\\"/g, '"').replace(/\\\\/g, '\\'))
} catch {
// Continue trying other candidates.
}
}
}
return null
}
function expandJsonStrings(value: unknown, depth = 0): unknown {
if (depth > 5 || value == null) return value
if (typeof value === 'string') {
const parsed = parseJsonString(value)
return parsed == null ? value : expandJsonStrings(parsed, depth + 1)
}
if (Array.isArray(value)) {
return value.map((item) => expandJsonStrings(item, depth + 1))
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>
return Object.fromEntries(Object.entries(record).map(([key, item]) => [key, expandJsonStrings(item, depth + 1)]))
}
return value
}
function collectVoiceArrays(value: unknown): unknown[] {
const arrays: unknown[] = []
const expanded = expandJsonStrings(value)
function collect(node: unknown) {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) {
node.forEach(collect)
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)
})
Object.values(record).forEach(collect)
}
collect(expanded)
return arrays.length ? arrays : Array.isArray(expanded) ? expanded : [expanded]
}
function getRecordValue(record: Record<string, unknown>, path: string[]) {
let current: unknown = record
for (const key of path) {
if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined
current = (current as Record<string, unknown>)[key]
}
return current
}
function findUrl(value: unknown): string {
if (!value) return ''
if (typeof value === 'string') {
const match = value.match(/https?:\/\/[^\s"'<>]+/)
if (match?.[0]) return match[0]
const parsed = parseJsonString(value)
return parsed ? findUrl(parsed) : ''
}
if (Array.isArray(value)) {
for (const item of value) {
const found = findUrl(item)
if (found) return found
}
}
if (typeof value === 'object') {
for (const item of Object.values(value as Record<string, unknown>)) {
const found = findUrl(item)
if (found) return found
}
}
return ''
}
function findUrlByKeys(value: unknown, keys: string[]): string {
if (!value) return ''
const normalizedKeys = keys.map((key) => key.toLowerCase())
if (typeof value === 'string') {
const parsed = parseJsonString(value)
return parsed ? findUrlByKeys(parsed, keys) : ''
}
if (Array.isArray(value)) {
for (const item of value) {
const found = findUrlByKeys(item, keys)
if (found) return found
}
return ''
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>
for (const [key, rawValue] of Object.entries(record)) {
if (!normalizedKeys.includes(key.toLowerCase())) continue
const url = typeof rawValue === 'string'
? findUrl(rawValue)
: findUrlMatching(rawValue, (item) => !isCozeWorkflowDebugUrl(item))
if (url && !isCozeWorkflowDebugUrl(url)) return url
}
for (const rawValue of Object.values(record)) {
const found = findUrlByKeys(rawValue, keys)
if (found) return found
}
}
return ''
}
function findUrlMatching(value: unknown, predicate: (url: string) => boolean): string {
if (!value) return ''
if (typeof value === 'string') {
const matches = value.match(/https?:\/\/[^\s"'<>]+/g) || []
const found = matches.find(predicate)
if (found) return found
const parsed = parseJsonString(value)
return parsed ? findUrlMatching(parsed, predicate) : ''
}
if (Array.isArray(value)) {
for (const item of value) {
const found = findUrlMatching(item, predicate)
if (found) return found
}
}
if (typeof value === 'object') {
for (const item of Object.values(value as Record<string, unknown>)) {
const found = findUrlMatching(item, predicate)
if (found) return found
}
}
return ''
}
function isCozeWorkflowDebugUrl(url: string) {
const text = (url || '').trim().toLowerCase()
return text.includes('coze.cn/work_flow') || (text.includes('execute_id=') && text.includes('workflow_id='))
}
function isMediaFileUrl(url: string) {
const text = (url || '').trim()
if (!/^https?:\/\//i.test(text) || isCozeWorkflowDebugUrl(text)) return false
return /\.(mp3|wav|m4a|aac|flac|ogg|mp4|mov|webm|m4v)(\?|#|$)/i.test(text)
}
function isVideoFileUrl(url: string) {
const text = (url || '').trim()
if (!/^https?:\/\//i.test(text) || isCozeWorkflowDebugUrl(text)) return false
return /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(text)
}
function findVideoResultUrl(value: unknown) {
const expanded = expandJsonStrings(value)
return findUrlByKeys(expanded, [
'video_url',
'videoUrl',
'video',
'output_video_url',
'outputVideoUrl',
'result_video_url',
'resultVideoUrl',
]) || findUrlMatching(expanded, isVideoFileUrl)
}
function findMediaFileUrl(value: unknown) {
return findUrlByKeys(value, [
'audio_url',
'audioUrl',
'file_url',
'fileUrl',
'media_url',
'mediaUrl',
'output_url',
'outputUrl',
'url',
]) || findUrlMatching(value, isMediaFileUrl)
}
function resolveWorkflowStatus(value: unknown) {
return (findTextByKeys(value, [
'execute_status',
'executeStatus',
'workflow_status',
'workflowStatus',
'status',
]) || '').toUpperCase()
}
function isTerminalWorkflowStatus(status: string) {
const normalized = (status || '').toUpperCase()
return ['SUCCESS', 'SUCCEEDED', 'COMPLETED', 'DONE', 'FINISHED', 'FAILED', 'FAIL', 'ERROR', 'CANCELED', 'CANCELLED'].includes(normalized)
}
function isFailedWorkflowStatus(status: string) {
const normalized = (status || '').toUpperCase()
return ['FAILED', 'FAIL', 'ERROR', 'CANCELED', 'CANCELLED'].includes(normalized)
}
function findTextByKeys(value: unknown, keys: string[]): string {
if (!value) return ''
const normalizedKeys = keys.map((key) => key.toLowerCase())
if (typeof value === 'string') {
const parsed = parseJsonString(value)
return parsed ? findTextByKeys(parsed, keys) : ''
}
if (typeof value === 'object') {
if (Array.isArray(value)) {
for (const item of value) {
const found = findTextByKeys(item, keys)
if (found) return found
}
return ''
}
const record = value as Record<string, unknown>
for (const [key, rawValue] of Object.entries(record)) {
if (normalizedKeys.includes(key.toLowerCase()) && rawValue != null) {
const text = String(rawValue).trim()
if (text) return text
}
}
for (const rawValue of Object.values(record)) {
const found = findTextByKeys(rawValue, keys)
if (found) return found
}
}
return ''
}
function extractVoiceItems(value: unknown): VoiceListItem[] {
const items: VoiceListItem[] = []
const seen = new Set<string>()
const expanded = expandJsonStrings(value)
const sourceConfigs = [
{ key: 'system_voice', label: '系统音色' },
{ key: 'voice_cloning', label: '克隆音色' },
{ key: 'voice_generation', label: '生成音色' },
{ key: 'music_generation', label: '音乐生成' },
]
function addVoice(node: unknown, sourceKey = 'unknown', sourceLabel = '音色') {
if (!node) return
if (typeof node === 'string') {
const parsed = parseJsonString(node)
if (parsed) addVoice(parsed, sourceKey, sourceLabel)
return
}
if (typeof node !== 'object') return
if (Array.isArray(node)) {
node.forEach((item) => addVoice(item, sourceKey, sourceLabel))
return
}
const record = node as Record<string, unknown>
const voiceId = firstString(record.voice_id, record.voiceId, record.voiceID)
if (voiceId && !seen.has(voiceId)) {
seen.add(voiceId)
items.push({
voiceId,
name: firstString(record.name, record.voice_name, record.voiceName, record.title, record.nickname),
groupId: firstString(record.group_id, record.groupId),
description: Array.isArray(record.description)
? record.description.map((item) => String(item).trim()).filter(Boolean).join('')
: firstString(record.description),
sourceKey,
sourceLabel,
raw: node,
})
}
}
function collectFromKnownArrays(node: unknown) {
if (!node || typeof node !== 'object') return
if (Array.isArray(node)) {
node.forEach(collectFromKnownArrays)
return
}
const record = node as Record<string, unknown>
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))
})
})
Object.values(record).forEach(collectFromKnownArrays)
}
collectFromKnownArrays(expanded)
if (!items.length) {
collectVoiceArrays(expanded).forEach((item) => addVoice(item))
}
return items
}
function firstString(...values: unknown[]) {
for (const value of values) {
if (value == null) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
async function listVoices() {
voiceLoading.value = true
try {
const ticket = await listImageVideoVoices(voiceSearchKeyword.value.trim())
const result = await waitForImageVideoTask(ticket)
voiceListItems.value = extractVoiceItems(result)
setVoiceStatus(
voiceListItems.value.length ? `已获取 ${voiceListItems.value.length} 个音色` : '音色接口已返回,未解析到列表项',
voiceListItems.value.length ? 'success' : 'info',
)
} catch (error) {
const message = error instanceof Error ? error.message : '查看音色失败'
setVoiceStatus(message, 'error')
ElMessage.error(message)
} finally {
voiceLoading.value = false
}
}
async function deleteVoice(item: VoiceListItem) {
if (item.sourceKey === 'system_voice') {
ElMessage.warning('系统音色不支持删除')
return
}
const voiceId = item.voiceId
try {
await ElMessageBox.confirm(`确定删除音色 ${voiceId} 吗?`, '删除音色', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
voiceLoading.value = true
try {
const ticket = await deleteImageVideoVoice(voiceId)
await waitForImageVideoTask(ticket)
voiceListItems.value = voiceListItems.value.filter((item) => item.voiceId !== voiceId)
setVoiceStatus(`删除音色请求已提交:${voiceId}`, 'success')
ElMessage.success('删除音色请求已提交')
} catch (error) {
const message = error instanceof Error ? error.message : '删除音色失败'
setVoiceStatus(message, 'error')
ElMessage.error(message)
} finally {
voiceLoading.value = false
}
}
async function cloneVoice() {
const name = voiceCloneName.value.trim()
if (!name) {
ElMessage.warning('请输入音色名称')
return
}
const sourceUrl = resolveAssetUrl(voiceCloneAsset)
if (!sourceUrl) {
ElMessage.warning('请先上传或填写音频/视频链接')
return
}
voiceLoading.value = true
try {
const clonePayload = {
audioUrl: sourceUrl,
videoUrl: voiceCloneAsset.mediaType === 'video' ? sourceUrl : '',
}
const ticket = await cloneImageVideoVoice({
name,
audioUrl: clonePayload.audioUrl,
videoUrl: clonePayload.videoUrl,
})
const result = await waitForImageVideoTask(ticket)
const voiceId = findTextByKeys(result, ['voice_id', 'voiceId', 'voiceID'])
if (voiceId) {
currentWorkspace.value.voiceId = voiceId
currentWorkspace.value.voiceName = name
setVoiceStatus(`音色克隆完成,已回填 voice_id${voiceId}`, 'success')
ElMessage.success('音色克隆完成,已回填 voice_id')
} else {
setVoiceStatus('音色克隆请求已提交,返回中未解析到 voice_id', 'success')
ElMessage.success('音色克隆请求已提交')
}
} catch (error) {
const message = error instanceof Error ? error.message : '音色克隆失败'
setVoiceStatus(message, 'error')
ElMessage.error(message)
} finally {
voiceLoading.value = false
}
}
async function synthesizeVoice() {
const workspace = currentWorkspace.value
const text = workspace.scriptText.trim()
const voiceId = workspace.voiceId.trim()
if (!text) {
ElMessage.warning('请先输入口播台词')
return
}
if (!voiceId) {
ElMessage.warning('请先输入 voice_id')
return
}
voiceLoading.value = true
try {
const ticket = await synthesizeImageVideoVoice({ text, voiceId })
const result = await waitForImageVideoTask(ticket)
const audioUrl = findMediaFileUrl(result)
if (audioUrl) {
setAssetUrl(activeTab.value, 'speechAsset', audioUrl, 'audio/*')
setVoiceStatus('语音合成完成,已写入最终配音文件', 'success')
ElMessage.success('语音合成完成,已写入口播音频链接')
} else {
setVoiceStatus('语音合成请求已提交,未在返回中解析到音频 URL', 'info')
ElMessage.success('语音合成请求已提交')
}
} catch (error) {
const message = error instanceof Error ? error.message : '语音合成失败'
setVoiceStatus(message, 'error')
ElMessage.error(message)
} finally {
voiceLoading.value = false
}
}
function useVoice(item: VoiceListItem) {
currentWorkspace.value.voiceId = item.voiceId
currentWorkspace.value.voiceName = item.name || item.voiceId
currentWorkspace.value.voiceMode = 'synthesize'
setVoiceStatus(`已选择音色:${currentWorkspace.value.voiceName}${item.voiceId}`, 'success')
voiceDialogVisible.value = false
}
async function setVoiceCloneAsset(file: File) {
voiceCloneAsset.uploading = true
try {
const result = await uploadImageVideoMedia(file)
voiceCloneAsset.fileName = result.originalFilename || file.name
voiceCloneAsset.sourceUrl = result.url
voiceCloneAsset.previewUrl = result.url
voiceCloneAsset.mediaType = (result.mediaType || 'file') as MediaType
voiceCloneAsset.previewKind = resolvePreviewKindFromMediaType(voiceCloneAsset.mediaType)
ElMessage.success('克隆素材上传成功')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '克隆素材上传失败')
} finally {
voiceCloneAsset.uploading = false
}
}
function setVoiceCloneUrl(url: string) {
const normalizedUrl = (url || '').trim()
const mediaType = resolveMediaTypeFromUrl(normalizedUrl, 'audio/*,video/*')
voiceCloneAsset.sourceUrl = normalizedUrl
voiceCloneAsset.previewUrl = normalizedUrl
voiceCloneAsset.fileName = normalizedUrl ? '在线克隆素材' : ''
voiceCloneAsset.mediaType = mediaType
voiceCloneAsset.previewKind = resolvePreviewKindFromMediaType(mediaType)
}
function clearVoiceCloneAsset() {
Object.assign(voiceCloneAsset, createAssetState())
}
onMounted(() => {
void refreshSecretStatus(false)
})
onBeforeUnmount(() => {
stopAssemblyPolling('remake')
stopAssemblyPolling('imageToVideo')
})
</script>
<style scoped>
.delivery-workspace {
--delivery-bg: #101010;
--delivery-surface: #181818;
--delivery-surface-2: #202020;
--delivery-border: #303030;
--delivery-text: #f3f3f3;
--delivery-muted: #9b9b9b;
--delivery-gold: #b58c51;
--delivery-result-width: clamp(340px, 24vw, 420px);
min-height: 100%;
padding: 18px 20px 32px;
background: var(--delivery-bg);
color: var(--delivery-text);
}
.delivery-workspace__tabs {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 18px;
border-bottom: 1px solid #2f2f2f;
}
.delivery-workspace__tab-panel {
flex: 1;
min-width: 0;
}
.delivery-workspace__tab-panel :deep(.el-tabs__nav-wrap::after) {
background: #d8d8d8;
}
.delivery-workspace__tab-panel :deep(.el-tabs__item) {
color: #ffffff;
font-size: 14px;
font-weight: 700;
opacity: 1;
}
.delivery-workspace__tab-panel :deep(.el-tabs__item.is-active) {
color: #409eff;
}
.delivery-workspace__tab-panel :deep(.el-tabs__active-bar) {
background-color: #409eff;
}
.secret-settings-btn {
height: 36px;
padding: 0 18px;
border: 1px solid #3e3e3e;
border-radius: 8px;
background: #161616;
color: #f4d392;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.secret-settings-btn:hover {
border-color: #a9824e;
color: #fff1cf;
}
.delivery-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--delivery-result-width);
gap: 14px;
align-items: start;
}
.delivery-form-area {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
align-items: start;
min-width: 0;
}
.delivery-column {
display: flex;
min-width: 0;
flex-direction: column;
gap: 14px;
}
.delivery-card {
width: 100%;
min-width: 0;
align-self: stretch;
}
.delivery-card--wide {
grid-column: auto;
}
.delivery-card--preview {
position: sticky;
top: 72px;
grid-column: 2;
grid-row: 1;
align-self: start;
max-height: calc(100vh - 88px);
overflow-y: auto;
overscroll-behavior: contain;
}
.delivery-form-grid {
display: grid;
gap: 14px;
}
.delivery-form-grid--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.delivery-field {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.delivery-field__label {
color: #f6f6f6;
font-size: 13px;
font-weight: 700;
}
.delivery-field--wide {
grid-column: 1 / -1;
}
.duration-control {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.duration-control__presets {
width: 100%;
}
.duration-control__custom {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 40px;
}
.duration-control__custom-label,
.duration-control__unit {
color: #b8b8b8;
font-size: 12px;
white-space: nowrap;
}
.duration-control__input {
width: 112px;
}
.script-learning-panel__toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
}
.script-learning-panel__source-input {
min-width: 0;
}
.script-action-btn,
.script-primary-action-btn {
min-height: 36px;
border: 1px solid #484848;
border-radius: 8px;
background: #171717;
color: #f7f7ff;
font: inherit;
font-size: 13px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.script-primary-action-btn,
.delivery-primary-btn {
background: linear-gradient(135deg, #a57d45 0%, #c9a260 100%);
border-color: transparent;
color: #fff;
}
.script-action-btn--danger {
color: #ff9f9f;
}
.script-action-btn:disabled,
.script-primary-action-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.voice-tools {
display: flex;
flex-wrap: wrap;
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));
gap: 14px;
}
.scene-face-panel {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid #303030;
border-radius: 10px;
background: #141414;
}
.scene-face-panel__head {
display: grid;
gap: 4px;
padding-bottom: 8px;
border-bottom: 1px solid #2b2b2b;
}
.scene-face-panel__head strong {
color: #f3f3f3;
font-size: 14px;
}
.scene-face-panel__head span {
color: #8e8e8e;
font-size: 12px;
}
.voice-section {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
}
.voice-manage-btn {
min-width: 104px;
}
.voice-inline-actions {
display: flex;
gap: 8px;
align-items: center;
min-height: 36px;
}
.voice-status {
padding: 9px 11px;
border: 1px solid #393939;
border-radius: 8px;
background: #1a1a1a;
color: #d6d6d6;
font-size: 12px;
line-height: 1.5;
}
.voice-status--success {
border-color: rgba(74, 170, 118, 0.45);
background: rgba(74, 170, 118, 0.12);
color: #b8f0cf;
}
.voice-status--error {
border-color: rgba(218, 84, 84, 0.55);
background: rgba(218, 84, 84, 0.12);
color: #ffb6b6;
}
.selected-voice-name {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 10px;
border: 1px solid rgba(74, 170, 118, 0.45);
border-radius: 8px;
background: rgba(74, 170, 118, 0.12);
color: #d8ffe6;
font-size: 13px;
font-weight: 700;
}
.selected-voice-name span {
color: #a9d9bd;
font-size: 12px;
font-weight: 500;
word-break: break-all;
}
.voice-dialog__tabs {
max-height: 62vh;
overflow-y: auto;
padding-right: 4px;
}
.voice-dialog__tabs :deep(.el-tabs__item:not(.is-active)) {
color: #fff;
}
.voice-dialog__toolbar {
display: flex;
justify-content: flex-end;
gap: 8px;
margin: 10px 0 0;
}
.voice-dialog__toolbar--list {
align-items: center;
justify-content: space-between;
margin: 12px 0;
}
.voice-list-summary {
display: inline-flex;
align-items: baseline;
gap: 4px;
min-width: 110px;
color: #6f6f6f;
font-size: 12px;
}
.voice-list-summary strong {
color: #b58c51;
font-size: 18px;
}
.voice-search-input {
flex: 1;
min-width: 180px;
}
.voice-list {
display: grid;
gap: 8px;
max-height: 420px;
overflow-y: auto;
padding-right: 4px;
}
.voice-list__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 8px;
background: #171717;
}
.voice-list__meta {
display: grid;
gap: 4px;
min-width: 0;
}
.voice-list__meta strong {
color: #f4f4f4;
font-size: 13px;
}
.voice-list__meta span {
color: #9d9d9d;
font-size: 12px;
word-break: break-all;
}
.voice-list__meta em {
color: #777;
font-size: 12px;
font-style: normal;
line-height: 1.4;
}
.voice-list__source {
padding: 3px 8px;
border: 1px solid rgba(181, 140, 81, 0.34);
border-radius: 999px;
color: #b58c51;
font-size: 12px;
white-space: nowrap;
}
.preview-panel__meta {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid #363636;
border-radius: 8px;
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;
gap: 12px;
color: #c8c8c8;
font-size: 13px;
}
.preview-row__value {
color: #f3f3f3;
font-weight: 700;
text-align: right;
}
.video-result-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.video-result-actions .delivery-primary-btn {
width: 100%;
}
.delivery-secondary-btn {
min-height: 36px;
border-color: #474747;
background: #1a1a1a;
color: #f2f2f2;
}
.assembly-waiting {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
margin: -2px 0 12px;
padding: 10px 12px;
border: 1px solid rgba(181, 140, 81, 0.42);
border-radius: 8px;
background: rgba(181, 140, 81, 0.12);
color: #f4d392;
}
.assembly-waiting--done {
border-color: rgba(74, 170, 118, 0.45);
background: rgba(74, 170, 118, 0.12);
color: #b8f0cf;
}
.assembly-waiting__spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(244, 211, 146, 0.28);
border-top-color: #f4d392;
border-radius: 50%;
animation: assembly-spin 0.8s linear infinite;
}
.assembly-waiting--done .assembly-waiting__spinner {
border-color: rgba(184, 240, 207, 0.45);
animation: none;
}
.assembly-waiting__content {
display: grid;
gap: 3px;
min-width: 0;
}
.assembly-waiting__content strong {
font-size: 13px;
line-height: 1.4;
}
.assembly-waiting__content span {
color: #cfcfcf;
font-size: 12px;
line-height: 1.45;
word-break: break-all;
}
.video-result-panel {
display: grid;
gap: 10px;
}
.video-result-panel__player,
.video-result-panel__empty {
width: 100%;
min-height: 220px;
border: 1px solid #303030;
border-radius: 8px;
background: #0f0f0f;
overflow: hidden;
}
.video-result-panel__player video {
display: block;
width: 100%;
max-height: min(48vh, 520px);
background: #000;
}
.video-result-panel__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: #858585;
font-size: 13px;
text-align: center;
}
.video-result-panel__link {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 9px 10px;
border: 1px solid #333;
border-radius: 8px;
background: #151515;
font-size: 12px;
}
.video-result-panel__link span {
color: #9f9f9f;
white-space: nowrap;
}
.video-result-panel__link a {
color: #7eb7ff;
text-decoration: none;
word-break: break-all;
}
.video-result-panel__debug {
border: 1px solid #333;
border-radius: 8px;
background: #121212;
overflow: hidden;
}
.video-result-panel__debug summary {
padding: 10px 12px;
color: #b8b8b8;
font-size: 12px;
cursor: pointer;
}
.video-result-panel__debug :deep(.el-textarea__inner) {
border-width: 1px 0 0;
border-radius: 0;
}
@keyframes assembly-spin {
to {
transform: rotate(360deg);
}
}
.secret-status {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding: 10px 12px;
border: 1px solid #4a3620;
border-radius: 8px;
background: rgba(181, 140, 81, 0.12);
color: #f4d392;
font-size: 13px;
}
.secret-status--valid {
border-color: rgba(74, 170, 118, 0.45);
background: rgba(74, 170, 118, 0.12);
color: #b8f0cf;
}
.secret-form {
max-height: 56vh;
overflow-y: auto;
padding-right: 4px;
}
.secret-form__hint {
margin-top: 6px;
color: #a8a8a8;
font-size: 12px;
}
.image-preview-dialog__image {
display: block;
width: 100%;
max-height: 72vh;
object-fit: contain;
border-radius: 8px;
background: #101010;
}
:deep(.ai-section-card) {
border-color: var(--delivery-border);
border-radius: 10px;
background: var(--delivery-surface);
overflow: hidden;
}
:deep(.ai-section-card__header) {
padding: 10px 12px;
border-bottom-color: #333;
}
:deep(.ai-section-card__body) {
gap: 11px;
padding: 12px 14px 14px;
}
:deep(.ai-section-card__badge) {
width: 22px;
height: 22px;
background: linear-gradient(135deg, #7d6744 0%, #b18a54 100%);
font-size: 11px;
}
:deep(.ai-section-card__title) {
font-size: 14px;
}
:deep(.ai-section-card__description) {
margin-top: 4px;
font-size: 11px;
line-height: 1.35;
}
:deep(.ai-choice-pills) {
gap: 6px;
}
:deep(.ai-choice-pills__item) {
min-height: 34px;
padding: 0 12px;
border-color: #3f3f3f;
background: #171717;
color: #f2f2f2;
}
:deep(.ai-choice-pills__item--active) {
border-color: #b99455;
background: rgba(181, 140, 81, 0.2);
color: #fff;
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner),
:deep(.el-select__wrapper) {
background: #111111;
border: 1px solid #383838;
box-shadow: none;
}
:deep(.el-input__inner),
:deep(.el-textarea__inner),
:deep(.el-select__placeholder),
:deep(.el-select__selected-item) {
color: #f4f4f4;
}
:deep(.el-textarea__inner::placeholder),
:deep(.el-input__inner::placeholder) {
color: #838383;
}
:deep(.el-input__wrapper.is-focus),
:deep(.el-textarea__inner:focus),
:deep(.el-select__wrapper.is-focused) {
border-color: #a9824e;
}
:deep(.el-input-number .el-input__wrapper) {
padding-left: 8px;
padding-right: 32px;
}
:deep(.el-button.delivery-primary-btn) {
width: 100%;
min-height: 40px;
margin-bottom: 12px;
font-weight: 800;
}
:global(.el-dialog.image-video-secret-dialog) {
border: 1px solid #393939;
border-radius: 12px;
background: #151515;
color: #f2f2f2;
}
:global(.el-dialog.image-video-secret-dialog .el-dialog__header) {
margin: 0;
padding: 18px 20px 12px;
border-bottom: 1px solid #303030;
background: #151515;
}
:global(.el-dialog.image-video-secret-dialog .el-dialog__body) {
padding: 16px 20px;
background: #151515;
color: #f2f2f2;
}
:global(.el-dialog.image-video-secret-dialog .el-dialog__footer) {
padding: 12px 20px 18px;
border-top: 1px solid #303030;
background: #151515;
}
:global(.el-dialog.image-video-secret-dialog .el-dialog__close) {
color: #e8e8e8;
}
:deep(.el-dialog__title),
:deep(.el-form-item__label) {
color: #f5f5f5;
}
:global(.el-dialog.image-video-secret-dialog .el-dialog__title) {
color: #ffffff;
font-weight: 700;
}
:global(.el-dialog.image-video-secret-dialog .el-form-item__label) {
color: #ffffff;
font-weight: 700;
}
:global(.el-dialog.image-video-secret-dialog .el-input__wrapper),
:global(.el-dialog.image-video-secret-dialog .el-select__wrapper),
:global(.el-dialog.image-video-secret-dialog .el-textarea__inner) {
background: #101010;
border-color: #414141;
color: #f4f4f4;
}
:global(.el-dialog.image-video-secret-dialog .el-input__inner),
:global(.el-dialog.image-video-secret-dialog .el-select__selected-item),
:global(.el-dialog.image-video-secret-dialog .el-select__placeholder) {
color: #f4f4f4;
}
:global(.el-dialog.image-video-secret-dialog .el-input__inner::placeholder),
:global(.el-dialog.image-video-secret-dialog .el-textarea__inner::placeholder) {
color: #9d9d9d;
}
:global(.el-dialog.image-video-secret-dialog .secret-form__hint) {
color: #b8b8b8;
}
:global(.el-dialog.image-video-secret-dialog .secret-status) {
color: #f4d392;
}
:global(.el-dialog.image-video-secret-dialog .secret-status--valid) {
color: #b8f0cf;
}
@media (max-width: 1320px) {
.delivery-grid {
--delivery-result-width: 360px;
grid-template-columns: minmax(0, 1fr) var(--delivery-result-width);
}
.delivery-form-area {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.delivery-card--preview {
grid-column: 2;
grid-row: 1;
}
}
@media (max-width: 980px) {
.delivery-workspace {
padding: 16px;
}
.delivery-grid {
grid-template-columns: 1fr;
}
.delivery-form-area {
grid-template-columns: 1fr;
}
.delivery-form-grid--two {
grid-template-columns: 1fr;
}
.scene-face-grid {
grid-template-columns: 1fr;
}
.delivery-card,
.delivery-card--wide,
.delivery-card--preview {
grid-column: 1;
}
.delivery-card--preview {
position: static;
max-height: none;
overflow: visible;
}
.script-learning-panel__toolbar {
grid-template-columns: 1fr;
}
.script-action-btn,
.script-primary-action-btn,
.duration-control__custom,
.duration-control__input {
width: 100%;
}
.voice-section,
.voice-list__item {
grid-template-columns: 1fr;
}
.voice-dialog__toolbar--list {
align-items: stretch;
flex-direction: column;
}
.voice-inline-actions {
flex-direction: column;
align-items: stretch;
}
}
</style>