2696 lines
88 KiB
Vue
2696 lines
88 KiB
Vue
<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>
|