|
|
@@ -1,622 +1,1518 @@
|
|
|
<template>
|
|
|
- <div class="video-generation-container">
|
|
|
- <el-card class="mb-4">
|
|
|
- <template #header>
|
|
|
- <span>文生视频生成</span>
|
|
|
- </template>
|
|
|
- <br>
|
|
|
- <el-form ref="formRef" :model="form" label-width="100px" class="mb-4"
|
|
|
- style="margin: 0px;padding: 0px !important;">
|
|
|
- <el-form-item label="提示词" prop="prompt" required>
|
|
|
- <el-input
|
|
|
- v-model="form.prompt"
|
|
|
- type="textarea"
|
|
|
- :rows="3"
|
|
|
- placeholder="请输入视频描述,例如:一个穿着宇航服的宇航员在月球上行走, 高品质, 电影级"
|
|
|
- />
|
|
|
- </el-form-item>
|
|
|
-
|
|
|
- <el-form-item label="模型" prop="model">
|
|
|
- <el-select v-model="form.model" placeholder="请选择模型">
|
|
|
- <el-option label="sora-2" value="sora-2" />
|
|
|
- </el-select>
|
|
|
- </el-form-item>
|
|
|
-
|
|
|
- <el-form-item label="时长(秒)" prop="seconds">
|
|
|
- <el-select
|
|
|
- v-model.number="form.seconds"
|
|
|
- placeholder="请选择视频时长"
|
|
|
- style="width: 100%"
|
|
|
- >
|
|
|
- <el-option label="4秒" :value="4" />
|
|
|
- <el-option label="8秒" :value="8" />
|
|
|
- <el-option label="12秒" :value="12" />
|
|
|
- </el-select>
|
|
|
- </el-form-item>
|
|
|
-
|
|
|
- <el-form-item label="分辨率" prop="size">
|
|
|
- <el-select v-model="form.size" placeholder="请选择分辨率" style="width: 100%">
|
|
|
- <el-option label="720x1280" value="720x1280" />
|
|
|
- <el-option label="1280x720" value="1280x720" />
|
|
|
- </el-select>
|
|
|
- </el-form-item>
|
|
|
-
|
|
|
- <el-form-item>
|
|
|
- <!-- <el-input
|
|
|
- v-model="searchInfo"
|
|
|
- placeholder="搜索提示词"
|
|
|
- @keyup.enter="handleSearch"
|
|
|
- clearable
|
|
|
- @clear="handleSearch"
|
|
|
- style="width: 230px; margin-right: 10px;margin: 0px;"
|
|
|
- />
|
|
|
- <el-button type="primary" icon="Search" class="search" @click="handleSearch">查询</el-button> -->
|
|
|
- <el-button
|
|
|
- type="primary"
|
|
|
- @click="generateVideo"
|
|
|
- :loading="isGenerating"
|
|
|
- >
|
|
|
- {{ isGenerating ? '生成中...' : '生成视频' }}
|
|
|
- </el-button>
|
|
|
- <el-button @click="resetForm">重置</el-button>
|
|
|
- </el-form-item>
|
|
|
- </el-form>
|
|
|
- </el-card>
|
|
|
-
|
|
|
- <!-- 视频播放对话框 -->
|
|
|
+ <div class="video-generation-page">
|
|
|
+ <div class="two-column-layout">
|
|
|
+ <div class="left-column">
|
|
|
+ <el-card class="config-card" shadow="hover">
|
|
|
+ <div class="config-body">
|
|
|
+ <el-form ref="formRef" :model="form" label-position="top" size="small" class="config-form" hide-required-asterisk>
|
|
|
+ <el-form-item class="upload-row">
|
|
|
+ <div class="upload-row-inner">
|
|
|
+ <div
|
|
|
+ v-for="frame in frameUploadList"
|
|
|
+ :key="frame.key"
|
|
|
+ class="upload-cell"
|
|
|
+ >
|
|
|
+ <span class="upload-label">{{ frame.label }}<span class="optional">(可选)</span></span>
|
|
|
+ <div class="upload-cell-inner">
|
|
|
+ <el-upload
|
|
|
+ class="image-uploader square"
|
|
|
+ :show-file-list="false"
|
|
|
+ :auto-upload="false"
|
|
|
+ :on-change="(uploadFile) => onFrameFileChange(frame.key, uploadFile)"
|
|
|
+ accept="image/jpeg,image/png,image/webp,image/jpg"
|
|
|
+ >
|
|
|
+ <div v-if="hasFrameImage(frame.key)" class="uploaded-preview">
|
|
|
+ <el-image :src="getFrameDisplayUrl(frame.key)" fit="contain" class="preview-image">
|
|
|
+ <template #error>
|
|
|
+ <div class="image-error"><el-icon><Picture /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ <div class="upload-mask">
|
|
|
+ <svg class="swap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <path d="M4 8h12l-3-3 3-3" />
|
|
|
+ <path d="M20 16H8l3 3-3 3" />
|
|
|
+ </svg>
|
|
|
+ <span>更换</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else class="upload-placeholder">
|
|
|
+ <el-icon :size="20"><Plus /></el-icon>
|
|
|
+ <span>上传{{ frame.label }}</span>
|
|
|
+ </div>
|
|
|
+ </el-upload>
|
|
|
+ <button
|
|
|
+ v-if="hasFrameImage(frame.key)"
|
|
|
+ type="button"
|
|
|
+ class="upload-remove-btn"
|
|
|
+ title="移除"
|
|
|
+ @click.stop="clearFrameImage(frame.key)"
|
|
|
+ >
|
|
|
+ <el-icon><Close /></el-icon>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="prompt-form-item">
|
|
|
+ <template #label><span>提示词</span></template>
|
|
|
+ <div class="prompt-block">
|
|
|
+ <div v-loading="isOptimizing" element-loading-text="优化中..." class="prompt-wrapper">
|
|
|
+ <el-input
|
|
|
+ v-model="form.prompt"
|
|
|
+ type="textarea"
|
|
|
+ :rows="5"
|
|
|
+ placeholder="请输入视频描述,可补充镜头与运动"
|
|
|
+ maxlength="500"
|
|
|
+ show-word-limit
|
|
|
+ resize="none"
|
|
|
+ class="prompt-textarea"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="prompt-actions-row">
|
|
|
+ <el-button type="success" link size="small" :loading="isOptimizing" @click="optimizePrompt">
|
|
|
+ <el-icon><MagicStick /></el-icon>{{ isOptimizing ? '优化中...' : '优化提示词' }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="config-row-compact">
|
|
|
+ <div class="config-item model-item">
|
|
|
+ <span class="config-label">模型</span>
|
|
|
+ <el-select
|
|
|
+ v-model="form.model"
|
|
|
+ placeholder="请选择模型"
|
|
|
+ size="small"
|
|
|
+ class="model-select"
|
|
|
+ filterable
|
|
|
+ >
|
|
|
+ <el-option
|
|
|
+ v-for="(item, idx) in modelList"
|
|
|
+ :key="item?.id ?? item?.ID ?? idx"
|
|
|
+ :label="item.model_alias || item.model_name || '-'"
|
|
|
+ :value="item.model_name || item.model_alias || ''"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="config-row-compact">
|
|
|
+ <div class="config-item size-item">
|
|
|
+ <span class="config-label">时长</span>
|
|
|
+ <div class="size-group-options">
|
|
|
+ <span
|
|
|
+ v-for="opt in durationOptions"
|
|
|
+ :key="opt.value"
|
|
|
+ class="size-chip"
|
|
|
+ :class="{ active: form.seconds === opt.value }"
|
|
|
+ @click="form.seconds = opt.value"
|
|
|
+ >{{ opt.label }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+
|
|
|
+ <el-form-item class="config-row-compact">
|
|
|
+ <div class="config-item size-item">
|
|
|
+ <span class="config-label">分辨率</span>
|
|
|
+ <div class="size-group-options">
|
|
|
+ <span
|
|
|
+ v-for="opt in resolutionOptions"
|
|
|
+ :key="opt.value"
|
|
|
+ class="size-chip"
|
|
|
+ :class="{ active: form.size === opt.value }"
|
|
|
+ @click="form.size = opt.value"
|
|
|
+ >{{ opt.label }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="right-column">
|
|
|
+ <el-card class="result-card" shadow="hover">
|
|
|
+ <template #header>
|
|
|
+ <el-button size="small" class="record-btn-header" @click="openRecordDialog">
|
|
|
+ 视频生成记录
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ <div class="result-area">
|
|
|
+ <div v-if="previewVideoUrl" class="result-preview">
|
|
|
+ <video :key="previewVideoUrl" :src="previewVideoUrl" controls class="result-video" />
|
|
|
+ <div v-if="currentVideoItem" class="result-meta">
|
|
|
+ <p class="result-meta-prompt" :title="currentVideoItem.prompt">{{ currentVideoItem.prompt || '-' }}</p>
|
|
|
+ <span class="result-meta-info">
|
|
|
+ {{ currentVideoItem.model }} · {{ currentVideoItem.seconds }}秒 · {{ currentVideoItem.size }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div class="result-actions">
|
|
|
+ <el-button type="primary" size="small" :loading="isGenerating" @click="generateVideo">
|
|
|
+ {{ isGenerating ? '生成中...' : '再次生成' }}
|
|
|
+ </el-button>
|
|
|
+ <el-button type="primary" size="small" :disabled="!previewVideoUrl" @click="downloadCurrentVideo">
|
|
|
+ 下载
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div v-else-if="isGenerating" class="result-loading">
|
|
|
+ <el-icon :size="48" class="loading-icon is-loading"><Loading /></el-icon>
|
|
|
+ <p>正在生成视频,请稍候...</p>
|
|
|
+ </div>
|
|
|
+ <div v-else class="result-empty">
|
|
|
+ <el-icon :size="60" class="empty-icon"><VideoCamera /></el-icon>
|
|
|
+ <p>视频将在此处显示</p>
|
|
|
+ <span>填写提示词或上传首帧/尾帧后点击下方按钮生成</span>
|
|
|
+ <el-button type="primary" :loading="isGenerating" size="large" class="generate-btn-center" @click="generateVideo">
|
|
|
+ {{ isGenerating ? '生成中...' : '立即生成' }}
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
<el-dialog
|
|
|
- v-model="dialogVisible"
|
|
|
- title="视频播放"
|
|
|
- width="80%"
|
|
|
- style="margin: 0px;margin-left: 10%;margin-top: 3%;"
|
|
|
- center
|
|
|
+ v-model="recordDialogVisible"
|
|
|
+ title="视频生成记录"
|
|
|
+ align-center
|
|
|
+ width="min(95vw, 1200px)"
|
|
|
+ class="video-record-dialog"
|
|
|
destroy-on-close
|
|
|
+ @open="onRecordDialogOpen"
|
|
|
>
|
|
|
- <div class="dialog-video-preview">
|
|
|
- <div v-if="!dialogVideoUrl" class="loading-text">加载中...</div>
|
|
|
- <video
|
|
|
- :src="dialogVideoUrl"
|
|
|
- controls
|
|
|
- width="100%"
|
|
|
- height="auto"
|
|
|
- v-else
|
|
|
- autoplay
|
|
|
- ></video>
|
|
|
- </div>
|
|
|
- <template #footer>
|
|
|
- <el-button @click="dialogVisible = false">关闭</el-button>
|
|
|
- </template>
|
|
|
- </el-dialog>
|
|
|
-
|
|
|
- <!-- 历史记录 -->
|
|
|
- <el-card class="mt-1">
|
|
|
- <el-table :data="historyList"
|
|
|
- :row-style="{ height: '20px' }"
|
|
|
- :cell-style="{ padding: '0px' }" :header-row-style="{ height: '20px' }"
|
|
|
- :header-cell-style="{ padding: '0px' }"
|
|
|
- style="width: 100%;height: 40vh">
|
|
|
- <el-table-column align="center" prop="id" label="ID" width="80" />
|
|
|
- <el-table-column prop="prompt" label="提示词" width="500">
|
|
|
+ <el-table :data="historyList" stripe size="small" max-height="min(60vh, 520px)" style="width: 100%">
|
|
|
+ <el-table-column align="center" prop="id" label="ID" width="70" />
|
|
|
+ <el-table-column label="首帧" width="72" align="center">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-image
|
|
|
+ v-if="historyFrameThumb(scope.row, 'first')"
|
|
|
+ :src="historyFrameThumb(scope.row, 'first')"
|
|
|
+ fit="cover"
|
|
|
+ class="history-ref-thumb history-thumb-preview"
|
|
|
+ :preview-src-list="[historyFramePreview(scope.row, 'first')]"
|
|
|
+ preview-teleported
|
|
|
+ hide-on-click-modal
|
|
|
+ :z-index="3200"
|
|
|
+ >
|
|
|
+ <template #error>
|
|
|
+ <div class="history-thumb-fallback"><el-icon><Picture /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ <span v-else class="history-ref-empty">-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="尾帧" width="72" align="center">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-image
|
|
|
+ v-if="historyFrameThumb(scope.row, 'last')"
|
|
|
+ :src="historyFrameThumb(scope.row, 'last')"
|
|
|
+ fit="cover"
|
|
|
+ class="history-ref-thumb history-thumb-preview"
|
|
|
+ :preview-src-list="[historyFramePreview(scope.row, 'last')]"
|
|
|
+ preview-teleported
|
|
|
+ hide-on-click-modal
|
|
|
+ :z-index="3200"
|
|
|
+ />
|
|
|
+ <span v-else class="history-ref-empty">-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="视频" width="80" align="center">
|
|
|
<template #default="scope">
|
|
|
- <span :title="scope.row.prompt" class="text-ellipsis" style="display: inline-block; width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;">{{ scope.row.prompt }}</span>
|
|
|
+ <div
|
|
|
+ v-if="getHistoryVideoPlayUrl(scope.row)"
|
|
|
+ class="history-video-cell"
|
|
|
+ title="点击预览视频"
|
|
|
+ @click="playHistoryVideo(scope.row)"
|
|
|
+ >
|
|
|
+ <el-image
|
|
|
+ v-if="historyFrameThumb(scope.row, 'first')"
|
|
|
+ :src="historyFrameThumb(scope.row, 'first')"
|
|
|
+ fit="cover"
|
|
|
+ class="history-ref-thumb"
|
|
|
+ >
|
|
|
+ <template #error>
|
|
|
+ <div class="history-thumb-fallback"><el-icon><VideoCamera /></el-icon></div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ <div v-else class="history-video-placeholder">
|
|
|
+ <el-icon :size="22"><VideoCamera /></el-icon>
|
|
|
+ </div>
|
|
|
+ <span class="history-video-play-badge">
|
|
|
+ <el-icon :size="16"><CaretRight /></el-icon>
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <span v-else class="history-ref-empty">-</span>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
- <el-table-column prop="model" label="模型" width="100" />
|
|
|
- <el-table-column prop="seconds" label="时长" width="80" />
|
|
|
+ <el-table-column prop="prompt" label="提示词" min-width="200" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="model" label="模型" width="100" show-overflow-tooltip />
|
|
|
+ <el-table-column prop="seconds" label="时长" width="64" align="center" />
|
|
|
<el-table-column prop="size" label="分辨率" width="100" />
|
|
|
- <el-table-column prop="sys_rq" label="创建时间" width="180" />
|
|
|
- <el-table-column label="状态" width="100">
|
|
|
+ <el-table-column prop="sys_rq" label="创建时间" width="168" />
|
|
|
+ <el-table-column label="状态" width="88" align="center">
|
|
|
<template #default="scope">
|
|
|
- <el-tag :type="scope.row.web_url ? 'success' : 'warning'">
|
|
|
+ <el-tag size="small" :type="scope.row.web_url ? 'success' : 'warning'">
|
|
|
{{ scope.row.web_url ? '已完成' : '生成中' }}
|
|
|
</el-tag>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
- <el-table-column label="操作" width="300">
|
|
|
- <template #default="scope">
|
|
|
- <el-button
|
|
|
- size="small"
|
|
|
- @click="playHistoryVideo(scope.row)"
|
|
|
- :disabled="!scope.row.web_url"
|
|
|
- >
|
|
|
- 播放
|
|
|
- </el-button>
|
|
|
- <el-button
|
|
|
- size="small"
|
|
|
- type="primary"
|
|
|
- @click="refreshVideo(scope.row)"
|
|
|
- v-if="!scope.row.web_url && scope.row.video_id"
|
|
|
- >
|
|
|
- 重新获取视频
|
|
|
- </el-button>
|
|
|
- <!-- <el-button size="small" type="danger" @click="deleteHistoryVideo(scope.row.id)">删除</el-button> -->
|
|
|
- </template>
|
|
|
+ <el-table-column label="操作" width="180" fixed="right">
|
|
|
+ <template #default="scope">
|
|
|
+ <el-button size="small" type="primary" link :disabled="!scope.row.web_url" @click="playHistoryVideo(scope.row)">
|
|
|
+ 预览
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ v-if="!scope.row.web_url && scope.row.video_id"
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ link
|
|
|
+ @click="refreshVideo(scope.row)"
|
|
|
+ >
|
|
|
+ 重新获取
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
</el-table-column>
|
|
|
</el-table>
|
|
|
- <!-- 分页组件 -->
|
|
|
- <div class="gva-pagination">
|
|
|
- <el-pagination
|
|
|
- v-model:current-page="page"
|
|
|
- v-model:page-size="pageSize"
|
|
|
- :page-sizes="[10, 30, 50, 100, 500, 1000]"
|
|
|
- layout="total, sizes, prev, pager, next, jumper"
|
|
|
- :total="total"
|
|
|
- @size-change="handleSizeChange"
|
|
|
- @current-change="handleCurrentChange">
|
|
|
- </el-pagination>
|
|
|
+ <div class="gva-pagination record-dialog-pagination">
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="page"
|
|
|
+ v-model:page-size="pageSize"
|
|
|
+ :page-sizes="[10, 30, 50, 100]"
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ :total="total"
|
|
|
+ @size-change="handleSizeChange"
|
|
|
+ @current-change="handleCurrentChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <el-dialog
|
|
|
+ v-model="recordPreviewVisible"
|
|
|
+ title="视频预览"
|
|
|
+ align-center
|
|
|
+ width="min(90vw, 900px)"
|
|
|
+ class="video-record-preview-dialog"
|
|
|
+ append-to-body
|
|
|
+ destroy-on-close
|
|
|
+ >
|
|
|
+ <div v-if="recordPreviewPlayUrl" class="record-preview-body">
|
|
|
+ <video :key="recordPreviewPlayUrl" :src="recordPreviewPlayUrl" controls class="record-preview-video" />
|
|
|
+ <div v-if="recordPreviewItem" class="record-preview-meta">
|
|
|
+ <p class="record-preview-prompt" :title="recordPreviewItem.prompt">{{ recordPreviewItem.prompt || '-' }}</p>
|
|
|
+ <span class="record-preview-info">
|
|
|
+ {{ recordPreviewItem.model }} · {{ recordPreviewItem.seconds }}秒 · {{ recordPreviewItem.size }}
|
|
|
+ </span>
|
|
|
</div>
|
|
|
- </el-card>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="recordPreviewVisible = false">关闭</el-button>
|
|
|
+ <el-button type="primary" :disabled="!recordPreviewPlayUrl" @click="downloadRecordPreview">下载</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { ref, reactive } from 'vue'
|
|
|
+import { ref, reactive, shallowReactive, onMounted, onBeforeUnmount, toRaw } from 'vue'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
-import {Getvideolist,video,videoContent
|
|
|
-} from '@/api/mes/job'
|
|
|
+import { Plus, Picture, Close, Loading, VideoCamera, MagicStick, CaretRight } from '@element-plus/icons-vue'
|
|
|
+import { Getvideolist, Create_ImgToVideo, Get_ImgToVideo, GetAIModel, CallAIModelApi } from '@/api/mes/job'
|
|
|
+import { displayImageUrl, displayImageUrlWithReference, materialThumbnailUrl } from '@/utils/displayImageUrl.js'
|
|
|
+
|
|
|
+const modelList = ref([])
|
|
|
+const FRAME_KEYS = ['first', 'last']
|
|
|
+const frameUploadList = [
|
|
|
+ { key: 'first', label: '首帧图', field: 'first_image_url' },
|
|
|
+ { key: 'last', label: '尾帧图', field: 'last_image_url' }
|
|
|
+]
|
|
|
+const framePreviewUrls = reactive({ first: '', last: '' })
|
|
|
+/** 存 File 用 shallowReactive,避免 deep reactive 导致 instanceof File 失效 */
|
|
|
+const frameFiles = shallowReactive({ first: null, last: null })
|
|
|
+const MAX_REF_IMAGE_MB = 10
|
|
|
+const VIDEO_SCENE_TYPES = ['文本生视频', '文本图片生视频', '文本视频生视频']
|
|
|
+
|
|
|
+function parseModelTypes (modelType) {
|
|
|
+ if (modelType == null || modelType === '') return []
|
|
|
+ return String(modelType).split(/[,,]/).map((t) => t.trim()).filter(Boolean)
|
|
|
+}
|
|
|
+
|
|
|
+function modelSupportsScene (item, scenes = VIDEO_SCENE_TYPES) {
|
|
|
+ const types = parseModelTypes(item?.model_type)
|
|
|
+ return scenes.some((scene) => types.includes(scene))
|
|
|
+}
|
|
|
+
|
|
|
+function dedupeModelList (list) {
|
|
|
+ const seenName = new Set()
|
|
|
+ const seenLabel = new Set()
|
|
|
+ const out = []
|
|
|
+ for (const item of list) {
|
|
|
+ const name = (item.model_name || '').trim().toLowerCase()
|
|
|
+ const alias = (item.model_alias || '').trim().toLowerCase()
|
|
|
+ const label = (alias || name).trim().toLowerCase()
|
|
|
+ if (name && seenName.has(name)) continue
|
|
|
+ if (label && seenLabel.has(label)) continue
|
|
|
+ if (name) seenName.add(name)
|
|
|
+ if (label) seenLabel.add(label)
|
|
|
+ out.push(item)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+}
|
|
|
+
|
|
|
+const defaultModelValue = () => {
|
|
|
+ const first = modelList.value[0]
|
|
|
+ return first ? (first.model_name || first.model_alias || '') : ''
|
|
|
+}
|
|
|
+
|
|
|
+const loadModelList = async () => {
|
|
|
+ try {
|
|
|
+ const res = await GetAIModel({})
|
|
|
+ const list = res?.data?.list ?? res?.data ?? []
|
|
|
+ const raw = (Array.isArray(list) ? list : []).filter((item) => modelSupportsScene(item))
|
|
|
+ modelList.value = dedupeModelList(raw)
|
|
|
+ if (modelList.value.length > 0) {
|
|
|
+ const cur = (form.model || '').trim()
|
|
|
+ const exists = modelList.value.some((m) => (m.model_name || m.model_alias || '') === cur)
|
|
|
+ if (!cur || !exists) form.model = defaultModelValue()
|
|
|
+ } else {
|
|
|
+ form.model = ''
|
|
|
+ }
|
|
|
+ } catch {
|
|
|
+ modelList.value = []
|
|
|
+ form.model = ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const durationOptions = [
|
|
|
+ { label: '5秒', value: 5 },
|
|
|
+ { label: '8秒', value: 8 },
|
|
|
+ { label: '12秒', value: 12 }
|
|
|
+]
|
|
|
+
|
|
|
+const resolutionOptions = [
|
|
|
+ { label: '9:16', value: '9:16' },
|
|
|
+ { label: '16:9', value: '16:9' }
|
|
|
+]
|
|
|
|
|
|
-// 表单数据
|
|
|
const form = reactive({
|
|
|
prompt: '',
|
|
|
- model: 'sora-2',
|
|
|
- seconds: 4,
|
|
|
- size: '1280x720'
|
|
|
+ first_image_url: '',
|
|
|
+ last_image_url: '',
|
|
|
+ model: '',
|
|
|
+ seconds: 5,
|
|
|
+ size: '16:9'
|
|
|
})
|
|
|
|
|
|
-// 表单引用
|
|
|
const formRef = ref()
|
|
|
-
|
|
|
-// 状态变量
|
|
|
const isGenerating = ref(false)
|
|
|
-const videoUrl = ref('')
|
|
|
-const dialogVisible = ref(false)
|
|
|
-const dialogVideoUrl = ref('')
|
|
|
+const isOptimizing = ref(false)
|
|
|
+const previewVideoUrl = ref('')
|
|
|
+const recordDialogVisible = ref(false)
|
|
|
+const recordPreviewVisible = ref(false)
|
|
|
+const recordPreviewPlayUrl = ref('')
|
|
|
+const recordPreviewItem = ref(null)
|
|
|
const currentVideoItem = ref(null)
|
|
|
const historyList = ref([])
|
|
|
-// 分页相关数据
|
|
|
const page = ref(1)
|
|
|
const pageSize = ref(30)
|
|
|
const total = ref(0)
|
|
|
-// 搜索关键词
|
|
|
const searchInfo = ref('')
|
|
|
|
|
|
-// 格式化视频URL
|
|
|
-const formatVideoUrl = (path) => {
|
|
|
- if (!path) return ''
|
|
|
-
|
|
|
- // 避免重复拼接http://
|
|
|
- if (path.startsWith('http://') || path.startsWith('https://')) {
|
|
|
- return path
|
|
|
+const OSS_ASSET_FALLBACK = 'https://a-7in6-com.oss-cn-hangzhou.aliyuncs.com'
|
|
|
+
|
|
|
+const getOssAssetBase = () => {
|
|
|
+ const v = import.meta.env.VITE_OSS_ASSET_BASE
|
|
|
+ return (v && String(v).trim()) ? String(v).trim().replace(/\/$/, '') : OSS_ASSET_FALLBACK.replace(/\/$/, '')
|
|
|
+}
|
|
|
+
|
|
|
+/** OSS / uploads 相对路径、完整 https 链统一为可访问地址(图片、视频共用) */
|
|
|
+const formatAssetUrl = (path, referenceUrl) => {
|
|
|
+ if (path == null || !String(path).trim()) return ''
|
|
|
+ const raw = String(path).trim()
|
|
|
+ let out = referenceUrl ? displayImageUrlWithReference(raw, referenceUrl) : ''
|
|
|
+ if (!out) out = displayImageUrl(raw)
|
|
|
+ if (!out) return ''
|
|
|
+ if (/^(https?:|\/\/|data:|blob:)/i.test(out)) return out
|
|
|
+ return `${getOssAssetBase()}/${out.replace(/^\//, '')}`
|
|
|
+}
|
|
|
+
|
|
|
+const formatRefImageUrl = (path, referenceUrl) => formatAssetUrl(path, referenceUrl)
|
|
|
+const formatVideoUrl = (path, referenceUrl) => formatAssetUrl(path, referenceUrl)
|
|
|
+
|
|
|
+const pickRowAssetPath = (row, ...keys) => {
|
|
|
+ for (const k of keys) {
|
|
|
+ const v = row?.[k]
|
|
|
+ if (v != null && String(v).trim()) return String(v).trim()
|
|
|
}
|
|
|
-
|
|
|
- const base = 'http://20.0.16.128:9093'
|
|
|
- // 避免重复拼接斜杠
|
|
|
- const normalizedPath = path.startsWith('/') ? path.substring(1) : path
|
|
|
- const normalizedBase = base.endsWith('/') ? base : `${base}/`
|
|
|
-
|
|
|
- return `${normalizedBase}${normalizedPath}`
|
|
|
+ return ''
|
|
|
}
|
|
|
|
|
|
-// 加载历史视频列表
|
|
|
-const loadVideoHistory = async () => {
|
|
|
+const getHistoryRowAssetRef = (row) =>
|
|
|
+ pickRowAssetPath(row, 'web_url', 'first_image_url', 'last_image_url', 'image_url')
|
|
|
+
|
|
|
+const formatRefThumbUrl = (path, referenceUrl) => {
|
|
|
+ const full = formatAssetUrl(path, referenceUrl)
|
|
|
+ if (!full) return ''
|
|
|
+ return materialThumbnailUrl(full, 64) || full
|
|
|
+}
|
|
|
+
|
|
|
+/** 无首帧图时,用 OSS 视频截帧做列表缩略图(旧任务常只有 web_url) */
|
|
|
+const getOssVideoSnapshotUrl = (videoUrl, maxEdge = 128) => {
|
|
|
+ if (!videoUrl) return ''
|
|
|
+ let u = videoUrl
|
|
|
+ if (u.startsWith('//')) u = `https:${u}`
|
|
|
+ if (!/^https?:\/\//i.test(u)) return ''
|
|
|
try {
|
|
|
- const params = {
|
|
|
- page: page.value,
|
|
|
- limit: pageSize.value
|
|
|
- }
|
|
|
- // 如果有搜索关键词,添加到请求参数中
|
|
|
- if (searchInfo.value.trim()) {
|
|
|
- params.search = searchInfo.value.trim()
|
|
|
- }
|
|
|
-
|
|
|
- const res = await Getvideolist(params)
|
|
|
- if (res.code === 0) {
|
|
|
- historyList.value = Array.isArray(res.data.list) ? res.data.list : res.data
|
|
|
- // 如果后端返回total,使用返回值,否则保持现有值
|
|
|
- // 设置总数
|
|
|
- total.value = res.count || 0
|
|
|
+ const parsed = new URL(u)
|
|
|
+ if (!parsed.hostname.includes('aliyuncs.com')) return ''
|
|
|
+ if (parsed.searchParams.has('x-oss-process')) return u
|
|
|
+ parsed.searchParams.set(
|
|
|
+ 'x-oss-process',
|
|
|
+ `video/snapshot,t_0,f_jpg,w_${maxEdge},h_${maxEdge},m_fast`
|
|
|
+ )
|
|
|
+ return parsed.toString()
|
|
|
+ } catch {
|
|
|
+ return ''
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryFrameCells = (row) => {
|
|
|
+ const ref = getHistoryRowAssetRef(row)
|
|
|
+ const firstRaw = pickRowAssetPath(row, 'first_image_url', 'first_image', 'FirstImageUrl', 'image_url')
|
|
|
+ const lastRaw = pickRowAssetPath(row, 'last_image_url', 'last_image', 'LastImageUrl')
|
|
|
+ const firstPreview = formatAssetUrl(firstRaw, ref)
|
|
|
+ const lastPreview = formatAssetUrl(lastRaw, ref)
|
|
|
+ let first = formatRefThumbUrl(firstRaw, ref)
|
|
|
+ let firstPreviewOut = firstPreview
|
|
|
+ if (!first && row?.web_url) {
|
|
|
+ const videoFull = formatAssetUrl(row.web_url, ref)
|
|
|
+ const snap = getOssVideoSnapshotUrl(videoFull, 64)
|
|
|
+ if (snap) {
|
|
|
+ first = snap
|
|
|
+ firstPreviewOut = getOssVideoSnapshotUrl(videoFull, 720) || snap
|
|
|
}
|
|
|
- } catch (error) {
|
|
|
- console.error('加载视频历史失败', error)
|
|
|
- ElMessage.error('加载历史记录失败')
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ first,
|
|
|
+ last: formatRefThumbUrl(lastRaw, ref),
|
|
|
+ firstPreview: firstPreviewOut,
|
|
|
+ lastPreview
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 组件挂载时加载历史
|
|
|
-loadVideoHistory()
|
|
|
+const historyFrameThumb = (row, which) => {
|
|
|
+ const cells = getHistoryFrameCells(row)
|
|
|
+ return which === 'first' ? cells.first : cells.last
|
|
|
+}
|
|
|
|
|
|
-// 生成视频
|
|
|
-const generateVideo = async () => {
|
|
|
-// console.log(form);
|
|
|
-// return false;
|
|
|
- // 表单验证
|
|
|
- if (!form.prompt) {
|
|
|
- ElMessage.warning('请输入提示词')
|
|
|
+const historyFramePreview = (row, which) => {
|
|
|
+ const cells = getHistoryFrameCells(row)
|
|
|
+ return which === 'first' ? cells.firstPreview : cells.lastPreview
|
|
|
+}
|
|
|
+
|
|
|
+const getHistoryVideoPlayUrl = (row) => {
|
|
|
+ if (!row?.web_url) return ''
|
|
|
+ return formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
+}
|
|
|
+
|
|
|
+const getFrameField = (key) => (key === 'first' ? 'first_image_url' : 'last_image_url')
|
|
|
+const getFrameLabel = (key) => (key === 'first' ? '首帧' : '尾帧')
|
|
|
+
|
|
|
+const hasFrameImage = (key) => {
|
|
|
+ return !!(frameFiles[key] || framePreviewUrls[key])
|
|
|
+}
|
|
|
+
|
|
|
+const getFrameDisplayUrl = (key) => {
|
|
|
+ if (framePreviewUrls[key]) return framePreviewUrls[key]
|
|
|
+ const field = getFrameField(key)
|
|
|
+ const ref = form.first_image_url || form.last_image_url
|
|
|
+ return formatRefImageUrl(form[field], ref)
|
|
|
+}
|
|
|
+
|
|
|
+const hasAnyFrameImage = () => FRAME_KEYS.some((key) => hasFrameImage(key))
|
|
|
+
|
|
|
+/** auto-upload=false 时不会走 before-upload,须在 on-change 里处理选图 */
|
|
|
+const getFrameRawFile = (key) => {
|
|
|
+ const f = frameFiles[key]
|
|
|
+ if (!f) return null
|
|
|
+ const raw = toRaw(f)
|
|
|
+ return raw instanceof File ? raw : (f instanceof File ? f : null)
|
|
|
+}
|
|
|
+
|
|
|
+const onFrameFileChange = (key, uploadFile) => {
|
|
|
+ const file = uploadFile?.raw
|
|
|
+ if (!file) return
|
|
|
+ const mimeOk = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg'].includes(file.type)
|
|
|
+ const extOk = /\.(jpe?g|png|webp)$/i.test(file.name || '')
|
|
|
+ if (!mimeOk && !extOk) {
|
|
|
+ ElMessage.error('仅支持 jpg、png、webp 格式')
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
- isGenerating.value = true
|
|
|
-
|
|
|
- try {
|
|
|
- // 第一步:调用video接口生成视频ID
|
|
|
- const videoIdRes = await video(form)
|
|
|
-
|
|
|
- if (videoIdRes.code === 0 && videoIdRes.data?.video_id) {
|
|
|
- const videoId = videoIdRes.data.video_id
|
|
|
- ElMessage.success('已提交视频生成请求,正在生成中...')
|
|
|
-
|
|
|
- // 第二步:使用videoContent接口根据ID生成视频
|
|
|
- // 这里添加一个延迟,确保视频ID已经在后端系统中就绪
|
|
|
- await new Promise(resolve => setTimeout(resolve, 1000))
|
|
|
-
|
|
|
- // 调用videoContent接口
|
|
|
- const generateRes = await videoContent({ video_id: videoId })
|
|
|
-
|
|
|
- // 无论成功与否,都重新加载历史记录表格
|
|
|
- await loadVideoHistory()
|
|
|
-
|
|
|
- if (generateRes.code === 0) {
|
|
|
- // 如果视频已生成完成
|
|
|
- if (generateRes.data && generateRes.data.web_url) {
|
|
|
- ElMessage.success('视频生成成功')
|
|
|
-
|
|
|
- // 查找生成的视频项
|
|
|
- const newVideoItem = historyList.value.find(item => item.video_id === videoId)
|
|
|
- if (newVideoItem) {
|
|
|
- // 自动打开视频播放对话框
|
|
|
- playHistoryVideo(newVideoItem)
|
|
|
- }
|
|
|
- } else {
|
|
|
- ElMessage.info('视频正在生成中,请稍后刷新查看')
|
|
|
-
|
|
|
- // 启动轮询检查进度
|
|
|
- startCheckingProgress(videoId)
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 提取更详细的错误信息
|
|
|
- let errorMsg = generateRes.msg || '未知错误'
|
|
|
- if (generateRes.data && generateRes.data.error_message) {
|
|
|
- try {
|
|
|
- // 尝试解析JSON格式的错误信息
|
|
|
- const errorData = JSON.parse(generateRes.data.error_message)
|
|
|
- if (errorData.error && errorData.error.message) {
|
|
|
- errorMsg += ' - ' + errorData.error.message
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- // 如果解析失败,直接使用原始错误信息
|
|
|
- errorMsg += ' - ' + generateRes.data.error_message
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- ElMessage.error('视频生成失败:' + errorMsg)
|
|
|
- }
|
|
|
- } else {
|
|
|
- // 即使创建任务失败,也刷新表格数据
|
|
|
- await loadVideoHistory()
|
|
|
-
|
|
|
- // 提取更详细的错误信息
|
|
|
- let errorMsg = videoIdRes.msg || '未知错误'
|
|
|
- if (videoIdRes.data && videoIdRes.data.error_message) {
|
|
|
- try {
|
|
|
- // 尝试解析JSON格式的错误信息
|
|
|
- const errorData = JSON.parse(videoIdRes.data.error_message)
|
|
|
- if (errorData.error && errorData.error.message) {
|
|
|
- errorMsg += ' - ' + errorData.error.message
|
|
|
- }
|
|
|
- } catch (e) {
|
|
|
- // 如果解析失败,直接使用原始错误信息
|
|
|
- errorMsg += ' - ' + videoIdRes.data.error_message
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- ElMessage.error('创建视频任务失败:' + errorMsg)
|
|
|
- }
|
|
|
- } catch (error) {
|
|
|
- console.error('视频生成过程中发生错误', error)
|
|
|
- // 发生异常也尝试刷新表格
|
|
|
- try {
|
|
|
- await loadVideoHistory()
|
|
|
- } catch (e) {
|
|
|
- console.error('刷新表格失败', e)
|
|
|
- }
|
|
|
- ElMessage.error('视频生成失败,请检查网络连接后重试')
|
|
|
- } finally {
|
|
|
- isGenerating.value = false
|
|
|
+ if (file.size / 1024 / 1024 > MAX_REF_IMAGE_MB) {
|
|
|
+ ElMessage.error(`图片大小不能超过 ${MAX_REF_IMAGE_MB}MB`)
|
|
|
+ return
|
|
|
}
|
|
|
+ if (framePreviewUrls[key]) URL.revokeObjectURL(framePreviewUrls[key])
|
|
|
+ frameFiles[key] = file
|
|
|
+ framePreviewUrls[key] = URL.createObjectURL(file)
|
|
|
+ form[getFrameField(key)] = ''
|
|
|
}
|
|
|
|
|
|
-// 轮询检查视频生成进度
|
|
|
-let checkInterval = null
|
|
|
-const startCheckingProgress = (videoId) => {
|
|
|
- if (!videoId) return
|
|
|
-
|
|
|
- // 清除之前的轮询
|
|
|
- if (checkInterval) {
|
|
|
- clearInterval(checkInterval)
|
|
|
- }
|
|
|
-
|
|
|
- // 最大检查次数,避免无限轮询
|
|
|
- let checkCount = 0
|
|
|
- const maxCheckCount = 20 // 最多检查20次(60秒)
|
|
|
-
|
|
|
- // 每3秒检查一次
|
|
|
- checkInterval = setInterval(async () => {
|
|
|
- checkCount++
|
|
|
-
|
|
|
- if (checkCount > maxCheckCount) {
|
|
|
- ElMessage.info('视频生成可能需要更长时间,请手动刷新查看')
|
|
|
- clearInterval(checkInterval)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- try {
|
|
|
- // 使用videoContent接口检查视频状态
|
|
|
- const statusRes = await videoContent({ video_id: videoId, action: 'status' })
|
|
|
-
|
|
|
- if (statusRes.code === 0 && statusRes.data?.status === 'completed' && statusRes.data?.web_url) {
|
|
|
- // 视频生成完成
|
|
|
- ElMessage.success('视频生成完成')
|
|
|
-
|
|
|
- // 重新加载历史记录
|
|
|
- await loadVideoHistory()
|
|
|
-
|
|
|
- // 查找对应的视频记录
|
|
|
- const videoItem = historyList.value.find(item => item.video_id === videoId)
|
|
|
- if (videoItem) {
|
|
|
- // 自动打开视频播放对话框
|
|
|
- playHistoryVideo(videoItem)
|
|
|
+const clearFrameImage = (key, showMsg = true) => {
|
|
|
+ if (framePreviewUrls[key]) {
|
|
|
+ URL.revokeObjectURL(framePreviewUrls[key])
|
|
|
+ framePreviewUrls[key] = ''
|
|
|
+ }
|
|
|
+ frameFiles[key] = null
|
|
|
+ form[getFrameField(key)] = ''
|
|
|
+ if (showMsg) ElMessage.info(`已移除${getFrameLabel(key)}`)
|
|
|
+}
|
|
|
+
|
|
|
+const clearAllFrameImages = (showMsg = false) => {
|
|
|
+ FRAME_KEYS.forEach((key) => clearFrameImage(key, false))
|
|
|
+ if (showMsg) ElMessage.info('已移除首尾帧')
|
|
|
+}
|
|
|
+
|
|
|
+/** 压缩后转 data:image/xxx;base64,... */
|
|
|
+const fileToBase64Compressed = (file, maxSize = 1920, quality = 0.82) =>
|
|
|
+ new Promise((resolve, reject) => {
|
|
|
+ const url = URL.createObjectURL(file)
|
|
|
+ const img = new Image()
|
|
|
+ img.onload = () => {
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+ let w = img.naturalWidth
|
|
|
+ let h = img.naturalHeight
|
|
|
+ if (w > maxSize || h > maxSize) {
|
|
|
+ if (w > h) {
|
|
|
+ h = Math.round((h * maxSize) / w)
|
|
|
+ w = maxSize
|
|
|
+ } else {
|
|
|
+ w = Math.round((w * maxSize) / h)
|
|
|
+ h = maxSize
|
|
|
}
|
|
|
-
|
|
|
- clearInterval(checkInterval)
|
|
|
- } else if (statusRes.code !== 0) {
|
|
|
- console.error('检查视频状态失败', statusRes.msg)
|
|
|
}
|
|
|
- } catch (error) {
|
|
|
- console.error('检查视频进度失败', error)
|
|
|
+ const canvas = document.createElement('canvas')
|
|
|
+ canvas.width = w
|
|
|
+ canvas.height = h
|
|
|
+ const ctx = canvas.getContext('2d')
|
|
|
+ ctx.drawImage(img, 0, 0, w, h)
|
|
|
+ resolve(canvas.toDataURL('image/jpeg', quality))
|
|
|
}
|
|
|
- }, 3000)
|
|
|
+ img.onerror = () => {
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+ reject(new Error('图片加载失败'))
|
|
|
+ }
|
|
|
+ img.src = url
|
|
|
+ })
|
|
|
+
|
|
|
+const setPreviewVideo = (row) => {
|
|
|
+ if (!row?.web_url) return
|
|
|
+ currentVideoItem.value = row
|
|
|
+ previewVideoUrl.value = formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
}
|
|
|
|
|
|
-// 重置表单
|
|
|
-const resetForm = () => {
|
|
|
- form.prompt = ''
|
|
|
- form.model = 'sora-2'
|
|
|
- form.seconds = 4
|
|
|
- form.size = '1280x720'
|
|
|
- videoUrl.value = ''
|
|
|
+const openRecordPreview = (row) => {
|
|
|
+ if (!row?.web_url) {
|
|
|
+ ElMessage.warning('视频尚未生成完成')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ recordPreviewItem.value = row
|
|
|
+ recordPreviewPlayUrl.value = formatVideoUrl(row.web_url, getHistoryRowAssetRef(row))
|
|
|
+ recordPreviewVisible.value = true
|
|
|
}
|
|
|
|
|
|
-// 下载视频
|
|
|
-const downloadVideo = () => {
|
|
|
- if (!videoUrl.value) return
|
|
|
-
|
|
|
- // 创建下载链接
|
|
|
+const downloadRecordPreview = () => {
|
|
|
+ if (!recordPreviewPlayUrl.value) return
|
|
|
const link = document.createElement('a')
|
|
|
- link.href = videoUrl.value
|
|
|
- link.download = `video_${Date.now()}.mp4`
|
|
|
+ link.href = recordPreviewPlayUrl.value
|
|
|
+ link.download = `video_${recordPreviewItem.value?.video_id || Date.now()}.mp4`
|
|
|
document.body.appendChild(link)
|
|
|
link.click()
|
|
|
document.body.removeChild(link)
|
|
|
-
|
|
|
ElMessage.success('视频下载中')
|
|
|
}
|
|
|
|
|
|
-// 下载历史视频
|
|
|
-const downloadHistoryVideo = (row) => {
|
|
|
- if (!row.web_url) {
|
|
|
- ElMessage.warning('视频尚未生成完成')
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- const videoUrl = formatVideoUrl(row.web_url)
|
|
|
+const openRecordDialog = () => {
|
|
|
+ recordDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const onRecordDialogOpen = () => {
|
|
|
+ loadVideoHistory()
|
|
|
+}
|
|
|
+
|
|
|
+const downloadCurrentVideo = () => {
|
|
|
+ if (!previewVideoUrl.value) return
|
|
|
const link = document.createElement('a')
|
|
|
- link.href = videoUrl
|
|
|
- link.download = `video_${row.video_id || Date.now()}.mp4`
|
|
|
+ link.href = previewVideoUrl.value
|
|
|
+ link.download = `video_${currentVideoItem.value?.video_id || Date.now()}.mp4`
|
|
|
document.body.appendChild(link)
|
|
|
link.click()
|
|
|
document.body.removeChild(link)
|
|
|
-
|
|
|
ElMessage.success('视频下载中')
|
|
|
}
|
|
|
|
|
|
-// 下载对话框中的视频
|
|
|
-const downloadDialogVideo = () => {
|
|
|
- if (!currentVideoItem.value || !currentVideoItem.value.web_url) return
|
|
|
-
|
|
|
- downloadHistoryVideo(currentVideoItem.value)
|
|
|
+const loadVideoHistory = async () => {
|
|
|
+ try {
|
|
|
+ const params = { page: page.value, limit: pageSize.value }
|
|
|
+ if (searchInfo.value.trim()) params.search = searchInfo.value.trim()
|
|
|
+ const res = await Getvideolist(params)
|
|
|
+ if (res.code === 0) {
|
|
|
+ historyList.value = Array.isArray(res.data?.list) ? res.data.list : res.data
|
|
|
+ total.value = res.count || 0
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载视频历史失败', error)
|
|
|
+ ElMessage.error('加载历史记录失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ loadModelList()
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ stopPollingVideoTask()
|
|
|
+ FRAME_KEYS.forEach((key) => {
|
|
|
+ if (framePreviewUrls[key]) URL.revokeObjectURL(framePreviewUrls[key])
|
|
|
+ })
|
|
|
+})
|
|
|
+
|
|
|
+/** 本页 AI 接口成功:code 为 0 或 1 */
|
|
|
+const isApiOk = (res) => res && (res.code === 0 || res.code === 1)
|
|
|
+
|
|
|
+const isVideoTaskDone = (data) => {
|
|
|
+ if (!data?.web_url) return false
|
|
|
+ const status = String(data.status || '').toLowerCase()
|
|
|
+ if (!status) return true
|
|
|
+ return status === 'succeeded' || status === 'completed' || status === 'success'
|
|
|
}
|
|
|
|
|
|
-// 重新生成
|
|
|
-const regenerateVideo = () => {
|
|
|
- generateVideo()
|
|
|
+let pollVideoTimer = null
|
|
|
+let pollVideoInterval = null
|
|
|
+let pollVideoCount = 0
|
|
|
+const POLL_FIRST_DELAY_MS = 10000
|
|
|
+const POLL_INTERVAL_MS = 6000
|
|
|
+const POLL_MAX_COUNT = 80
|
|
|
+
|
|
|
+const stopPollingVideoTask = () => {
|
|
|
+ if (pollVideoTimer) {
|
|
|
+ clearTimeout(pollVideoTimer)
|
|
|
+ pollVideoTimer = null
|
|
|
+ }
|
|
|
+ if (pollVideoInterval) {
|
|
|
+ clearInterval(pollVideoInterval)
|
|
|
+ pollVideoInterval = null
|
|
|
+ }
|
|
|
+ pollVideoCount = 0
|
|
|
}
|
|
|
|
|
|
-// 播放历史视频
|
|
|
-const playHistoryVideo = (row) => {
|
|
|
- if (!row.web_url) {
|
|
|
- ElMessage.warning('视频尚未生成完成')
|
|
|
+const startPollingVideoTask = (taskId, meta = {}) => {
|
|
|
+ if (!taskId) return
|
|
|
+ stopPollingVideoTask()
|
|
|
+ isGenerating.value = true
|
|
|
+ pollVideoCount = 0
|
|
|
+
|
|
|
+ const finishPolling = (done) => {
|
|
|
+ stopPollingVideoTask()
|
|
|
+ isGenerating.value = false
|
|
|
+ if (!done) ElMessage.info('视频生成时间较长,可在「视频生成记录」中查看')
|
|
|
+ }
|
|
|
+
|
|
|
+ const handlePollResult = async (result) => {
|
|
|
+ if (result === 'pending') return
|
|
|
+ if (result === 'success') {
|
|
|
+ ElMessage.success('视频生成成功')
|
|
|
+ await loadVideoHistory()
|
|
|
+ finishPolling(true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ finishPolling(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ const pollOnce = async () => {
|
|
|
+ pollVideoCount++
|
|
|
+ if (pollVideoCount > POLL_MAX_COUNT) return 'timeout'
|
|
|
+ try {
|
|
|
+ const res = await Get_ImgToVideo(
|
|
|
+ { task_id: taskId },
|
|
|
+ { donNotShowLoading: true, skipErrorMessage: true }
|
|
|
+ )
|
|
|
+ if (isApiOk(res) && isVideoTaskDone(res.data)) {
|
|
|
+ setPreviewVideo({
|
|
|
+ task_id: taskId,
|
|
|
+ video_id: res.data.video_id || taskId,
|
|
|
+ web_url: res.data.web_url,
|
|
|
+ prompt: meta.prompt ?? form.prompt,
|
|
|
+ model: meta.model ?? form.model,
|
|
|
+ seconds: meta.seconds ?? form.seconds,
|
|
|
+ size: meta.size ?? form.size
|
|
|
+ })
|
|
|
+ return 'success'
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('轮询视频状态失败', error)
|
|
|
+ }
|
|
|
+ return 'pending'
|
|
|
+ }
|
|
|
+
|
|
|
+ pollVideoTimer = setTimeout(async () => {
|
|
|
+ pollVideoTimer = null
|
|
|
+ const first = await pollOnce()
|
|
|
+ if (first !== 'pending') {
|
|
|
+ await handlePollResult(first)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ pollVideoInterval = setInterval(async () => {
|
|
|
+ await handlePollResult(await pollOnce())
|
|
|
+ }, POLL_INTERVAL_MS)
|
|
|
+ }, POLL_FIRST_DELAY_MS)
|
|
|
+}
|
|
|
+
|
|
|
+const generateVideo = async () => {
|
|
|
+ if (!form.prompt?.trim() && !hasAnyFrameImage()) {
|
|
|
+ ElMessage.warning('请输入提示词或上传首帧/尾帧')
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
- // 设置当前视频项和URL
|
|
|
- currentVideoItem.value = row
|
|
|
- dialogVideoUrl.value = formatVideoUrl(row.web_url)
|
|
|
-
|
|
|
- console.log('正在播放视频:', dialogVideoUrl.value)
|
|
|
-
|
|
|
- // 显示对话框
|
|
|
- dialogVisible.value = true
|
|
|
-
|
|
|
- // 填充表单
|
|
|
- // form.prompt = row.prompt
|
|
|
- // form.model = row.model
|
|
|
- // form.seconds = row.seconds
|
|
|
- // form.size = row.size
|
|
|
-}
|
|
|
-
|
|
|
-// 重新获取视频
|
|
|
-const refreshVideo = async (row) => {
|
|
|
- if (!row.video_id) {
|
|
|
- ElMessage.warning('视频ID不存在,无法重新获取')
|
|
|
+ if (!form.model) {
|
|
|
+ ElMessage.warning('请选择模型')
|
|
|
return
|
|
|
}
|
|
|
- ElMessage.info('正在重新获取视频...')
|
|
|
- // 调用videoContent接口重新获取视频
|
|
|
- const res = await videoContent({ video_id: row.video_id })
|
|
|
- // 无论获取结果如何,都刷新表格数据
|
|
|
- await loadVideoHistory()
|
|
|
- if (res.code === 0) {
|
|
|
- // 如果获取到了视频URL
|
|
|
- if (res.data && res.data.web_url) {
|
|
|
- ElMessage.success('视频获取成功')
|
|
|
-
|
|
|
- // 查找更新后的视频项
|
|
|
- const updatedVideo = historyList.value.find(item => item.video_id === row.video_id)
|
|
|
- if (updatedVideo && updatedVideo.web_url) {
|
|
|
- // 自动播放获取到的视频
|
|
|
- playHistoryVideo(updatedVideo)
|
|
|
- }
|
|
|
- } else {
|
|
|
- ElMessage.info('视频仍在生成中,请稍后再试')
|
|
|
- }
|
|
|
+ stopPollingVideoTask()
|
|
|
+ isGenerating.value = true
|
|
|
+ let pollingStarted = false
|
|
|
+ try {
|
|
|
+ const firstFile = getFrameRawFile('first')
|
|
|
+ const lastFile = getFrameRawFile('last')
|
|
|
+ const first_image = firstFile ? await fileToBase64Compressed(firstFile) : ''
|
|
|
+ const last_image = lastFile ? await fileToBase64Compressed(lastFile) : ''
|
|
|
+ const params = {
|
|
|
+ prompt: (form.prompt || '').trim(),
|
|
|
+ model: form.model,
|
|
|
+ seconds: form.seconds,
|
|
|
+ size: form.size
|
|
|
+ }
|
|
|
+ if (first_image) params.first_image = first_image
|
|
|
+ if (last_image) params.last_image = last_image
|
|
|
+
|
|
|
+ const createRes = await Create_ImgToVideo(params, { skipErrorMessage: true })
|
|
|
+ const taskId = createRes?.data?.task_id || createRes?.data?.video_id
|
|
|
+ if (isApiOk(createRes) && taskId) {
|
|
|
+ ElMessage.success(createRes.msg || '任务创建成功,正在生成视频...')
|
|
|
+ pollingStarted = true
|
|
|
+ startPollingVideoTask(taskId, {
|
|
|
+ prompt: params.prompt,
|
|
|
+ model: params.model,
|
|
|
+ seconds: params.seconds,
|
|
|
+ size: params.size
|
|
|
+ })
|
|
|
+ } else {
|
|
|
+ ElMessage.error('创建视频任务失败:' + (createRes?.msg || '未知错误'))
|
|
|
}
|
|
|
+ } catch (error) {
|
|
|
+ console.error('视频生成过程中发生错误', error)
|
|
|
+ ElMessage.error('视频生成失败,请检查网络连接后重试')
|
|
|
+ } finally {
|
|
|
+ if (!pollingStarted) isGenerating.value = false
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-// 搜索处理
|
|
|
-const handleSearch = () => {
|
|
|
- // 重置页码为1
|
|
|
- page.value = 1
|
|
|
- // 重新加载数据
|
|
|
- loadVideoHistory()
|
|
|
+const optimizePrompt = async () => {
|
|
|
+ if (!form.prompt?.trim()) {
|
|
|
+ ElMessage.warning('请先输入提示词')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ isOptimizing.value = true
|
|
|
+ try {
|
|
|
+ const res = await CallAIModelApi(
|
|
|
+ {
|
|
|
+ status_val: '文生文',
|
|
|
+ status_type: 'ImgToVideo',
|
|
|
+ prompt: form.prompt,
|
|
|
+ model: 'gpt-4'
|
|
|
+ },
|
|
|
+ { donNotShowLoading: true }
|
|
|
+ )
|
|
|
+ const content = res?.data?.content ?? res?.content
|
|
|
+ if (res?.code === 0 && content) {
|
|
|
+ form.prompt = content
|
|
|
+ ElMessage.success('提示词优化完成')
|
|
|
+ } else {
|
|
|
+ ElMessage.error(res?.msg || '优化失败')
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('[优化提示词] 错误:', e)
|
|
|
+ ElMessage.error('优化失败: ' + (e?.message || '未知错误'))
|
|
|
+ } finally {
|
|
|
+ isOptimizing.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const playHistoryVideo = (row) => {
|
|
|
+ openRecordPreview(row)
|
|
|
+}
|
|
|
+
|
|
|
+const refreshVideo = async (row) => {
|
|
|
+ const taskId = row.task_id || row.video_id
|
|
|
+ if (!taskId) {
|
|
|
+ ElMessage.warning('任务ID不存在,无法重新获取')
|
|
|
+ return
|
|
|
+ }
|
|
|
+ ElMessage.info('正在重新获取视频...')
|
|
|
+ const res = await Get_ImgToVideo({ task_id: taskId }, { skipErrorMessage: true })
|
|
|
+ await loadVideoHistory()
|
|
|
+ if (isApiOk(res) && isVideoTaskDone(res.data)) {
|
|
|
+ ElMessage.success(res.msg || '视频获取成功')
|
|
|
+ const updatedVideo = historyList.value.find(
|
|
|
+ (item) => (item.task_id || item.video_id) === taskId
|
|
|
+ )
|
|
|
+ if (updatedVideo) {
|
|
|
+ openRecordPreview(updatedVideo)
|
|
|
+ } else {
|
|
|
+ openRecordPreview({ ...row, web_url: res.data.web_url, task_id: taskId })
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ ElMessage.info(res?.msg || '视频仍在生成中,请稍后再试')
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
-// 分页大小变化处理
|
|
|
const handleSizeChange = (size) => {
|
|
|
pageSize.value = size
|
|
|
- // 重新加载数据
|
|
|
loadVideoHistory()
|
|
|
}
|
|
|
|
|
|
-// 当前页码变化处理
|
|
|
const handleCurrentChange = (current) => {
|
|
|
page.value = current
|
|
|
- // 重新加载数据
|
|
|
loadVideoHistory()
|
|
|
}
|
|
|
-
|
|
|
-// 删除历史视频
|
|
|
-const deleteHistoryVideo = async (id) => {
|
|
|
- try {
|
|
|
- // 这里应该调用实际的删除API
|
|
|
- // const res = await deleteVideoApi({ id })
|
|
|
- // if (res.code === 0) {
|
|
|
- const index = historyList.value.findIndex(item => item.id === id)
|
|
|
- if (index !== -1) {
|
|
|
- historyList.value.splice(index, 1)
|
|
|
- ElMessage.success('删除成功')
|
|
|
- }
|
|
|
- // }
|
|
|
- } catch (error) {
|
|
|
- console.error('删除视频失败', error)
|
|
|
- ElMessage.error('删除失败,请重试')
|
|
|
- }
|
|
|
-}
|
|
|
</script>
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
+.video-generation-page {
|
|
|
+ padding: 6px 10px;
|
|
|
+ height: calc(100vh - 200px);
|
|
|
+ max-height: calc(100vh - 200px);
|
|
|
+ max-height: calc(100vh - 200px);
|
|
|
+ min-height: 480px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.two-column-layout {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
|
|
|
-::v-deep(.el-card__body){
|
|
|
- padding: 0px;
|
|
|
+.left-column {
|
|
|
+ flex: 0 0 380px;
|
|
|
+ min-width: 280px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
}
|
|
|
-.video-generation-container {
|
|
|
- padding: 0px;
|
|
|
-
|
|
|
- .card-header {
|
|
|
+
|
|
|
+.right-column {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.config-card,
|
|
|
+.result-card {
|
|
|
+ flex: 1;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+ min-height: 0;
|
|
|
+
|
|
|
+ :deep(.el-card__body) {
|
|
|
+ padding: 0;
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
display: flex;
|
|
|
- justify-content: space-between;
|
|
|
- align-items: center;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
}
|
|
|
-
|
|
|
- .refresh-button {
|
|
|
- margin-left: auto;
|
|
|
+}
|
|
|
+
|
|
|
+.config-card :deep(.el-card__header) {
|
|
|
+ display: none;
|
|
|
+}
|
|
|
+
|
|
|
+.result-card :deep(.el-card__header) {
|
|
|
+ padding: 10px 14px;
|
|
|
+ flex-shrink: 0;
|
|
|
+ background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
|
|
|
+ border-bottom: 1px solid #ebeef5;
|
|
|
+}
|
|
|
+
|
|
|
+.result-card :deep(.el-card__body) {
|
|
|
+ padding: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-card :deep(.record-btn-header.el-button) {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ height: auto;
|
|
|
+ padding: 6px 14px;
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 500;
|
|
|
+ border-radius: 8px;
|
|
|
+ color: #409eff !important;
|
|
|
+ background: #ecf5ff !important;
|
|
|
+ border: 1px solid #d9ecff !important;
|
|
|
+ box-shadow: none;
|
|
|
+
|
|
|
+ &:hover,
|
|
|
+ &:focus {
|
|
|
+ background: #409eff !important;
|
|
|
+ color: #fff !important;
|
|
|
+ border-color: #409eff !important;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-.el-form {
|
|
|
- .el-form-item {
|
|
|
- margin-bottom: 10px;
|
|
|
- padding: 0px;
|
|
|
+.config-body {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.config-form {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 0;
|
|
|
+ padding: 6px 10px;
|
|
|
+ overflow-y: auto;
|
|
|
+ overflow-x: hidden;
|
|
|
+
|
|
|
+ :deep(.el-form-item) {
|
|
|
+ margin-bottom: 4px;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-.el-table {
|
|
|
- .el-button {
|
|
|
- margin-right: 5px;
|
|
|
+.upload-row-inner {
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-cell {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ max-width: 140px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-cell-inner {
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+
|
|
|
+ .optional {
|
|
|
+ color: #909399;
|
|
|
+ font-weight: normal;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 对话框视频样式
|
|
|
-.dialog-video-preview {
|
|
|
+.image-uploader.square {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(.el-upload) {
|
|
|
+ width: 100%;
|
|
|
+ display: block;
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-placeholder,
|
|
|
+ .uploaded-preview {
|
|
|
+ width: 100%;
|
|
|
+ aspect-ratio: 1;
|
|
|
+ height: auto !important;
|
|
|
+ min-height: 80px;
|
|
|
+ max-height: 130px;
|
|
|
+ max-width: 130px;
|
|
|
+ margin: 0 auto;
|
|
|
+ border: 1px dashed #d9d9d9;
|
|
|
+ border-radius: 6px;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.2s;
|
|
|
+ background: #fafafa;
|
|
|
+ overflow: hidden;
|
|
|
+ box-sizing: border-box;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #409eff;
|
|
|
+ background: #f0f9ff;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-placeholder {
|
|
|
+ color: #8c939d;
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 11px;
|
|
|
+ margin-top: 2px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .uploaded-preview {
|
|
|
+ position: relative;
|
|
|
+
|
|
|
+ .preview-image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .upload-mask {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ background: rgba(0, 0, 0, 0.4);
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #fff;
|
|
|
+ opacity: 0;
|
|
|
+ transition: opacity 0.2s;
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 11px;
|
|
|
+ margin-top: 2px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .swap-icon {
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &:hover .upload-mask {
|
|
|
+ opacity: 1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.upload-remove-btn {
|
|
|
+ position: absolute;
|
|
|
+ top: 4px;
|
|
|
+ right: 4px;
|
|
|
+ z-index: 3;
|
|
|
+ width: 18px;
|
|
|
+ height: 18px;
|
|
|
+ padding: 0;
|
|
|
+ border: none;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: rgba(0, 0, 0, 0.5);
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
display: flex;
|
|
|
+ align-items: center;
|
|
|
justify-content: center;
|
|
|
+ line-height: 1;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background: #f56c6c;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.image-error {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
align-items: center;
|
|
|
- min-height: 400px;
|
|
|
- padding: 20px 0;
|
|
|
-
|
|
|
- video {
|
|
|
- max-height: 600px;
|
|
|
- max-width: 100%;
|
|
|
- border-radius: 4px;
|
|
|
- }
|
|
|
-
|
|
|
- .loading-text {
|
|
|
- font-size: 16px;
|
|
|
+ justify-content: center;
|
|
|
+ background: #f5f7fa;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-form-item {
|
|
|
+ margin-bottom: 4px;
|
|
|
+
|
|
|
+ :deep(.el-form-item__label) {
|
|
|
+ width: 100%;
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-start;
|
|
|
+ text-align: left;
|
|
|
+ padding-left: 0;
|
|
|
+ line-height: 1.4;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-block {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 8px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-wrapper {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ width: 100%;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 6px;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #fff;
|
|
|
+
|
|
|
+ :deep(.el-textarea__inner) {
|
|
|
+ border: none;
|
|
|
+ border-radius: 0;
|
|
|
+ box-shadow: none;
|
|
|
+ padding-bottom: 28px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-input__count) {
|
|
|
+ background: #fff;
|
|
|
+ padding-left: 6px;
|
|
|
+ font-size: 12px;
|
|
|
color: #909399;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 最新生成结果样式
|
|
|
-.video-result-preview {
|
|
|
+.prompt-textarea {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(textarea) {
|
|
|
+ resize: none;
|
|
|
+ min-height: 100px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.prompt-actions-row {
|
|
|
display: flex;
|
|
|
- justify-content: space-between;
|
|
|
align-items: center;
|
|
|
- padding: 10px;
|
|
|
- background-color: #fafafa;
|
|
|
- border-radius: 4px;
|
|
|
-
|
|
|
- .video-result-info {
|
|
|
- flex: 1;
|
|
|
- p {
|
|
|
- margin: 5px 0;
|
|
|
- font-size: 14px;
|
|
|
+ justify-content: flex-end;
|
|
|
+ gap: 10px;
|
|
|
+ width: 100%;
|
|
|
+ padding: 8px 0;
|
|
|
+ background: transparent;
|
|
|
+}
|
|
|
+
|
|
|
+.config-row-compact {
|
|
|
+ margin-bottom: 0 !important;
|
|
|
+}
|
|
|
+
|
|
|
+.config-item {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.config-label {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.model-item {
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.model-select {
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ :deep(.el-select__wrapper) {
|
|
|
+ border-radius: 8px;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ background: #fafbfc;
|
|
|
+ box-shadow: none;
|
|
|
+ min-height: 36px;
|
|
|
+ padding: 6px 14px;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #c0c4cc;
|
|
|
+ background: #fff;
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- .video-result-actions {
|
|
|
- display: flex;
|
|
|
- gap: 10px;
|
|
|
+
|
|
|
+ :deep(.el-select__wrapper.is-focused) {
|
|
|
+ border-color: #409eff;
|
|
|
+ box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
|
|
|
+ background: #fff;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// 响应式调整
|
|
|
-@media screen and (max-width: 768px) {
|
|
|
- .dialog-video-preview {
|
|
|
- min-height: 300px;
|
|
|
-
|
|
|
- video {
|
|
|
- max-height: 400px;
|
|
|
- }
|
|
|
+.size-item {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.size-group-options {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.size-chip {
|
|
|
+ padding: 2px 8px;
|
|
|
+ font-size: 12px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: pointer;
|
|
|
+ user-select: none;
|
|
|
+ background: #f5f7fa;
|
|
|
+ color: #606266;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ transition: all 0.2s;
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ border-color: #409eff;
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ &.active {
|
|
|
+ background: #ecf5ff;
|
|
|
+ border-color: #409eff;
|
|
|
+ color: #409eff;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.result-area {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 320px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #f9fafb;
|
|
|
+ border-radius: 6px;
|
|
|
+ padding: 12px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+.result-preview {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-video {
|
|
|
+ width: 100%;
|
|
|
+ max-height: min(52vh, 480px);
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #000;
|
|
|
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
|
|
|
+}
|
|
|
+
|
|
|
+.result-meta {
|
|
|
+ width: 100%;
|
|
|
+ max-width: 720px;
|
|
|
+ text-align: center;
|
|
|
+ padding: 0 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-meta-prompt {
|
|
|
+ margin: 0 0 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+
|
|
|
+.result-meta-info {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.result-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+
|
|
|
+.result-empty {
|
|
|
+ text-align: center;
|
|
|
+ color: #909399;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ .empty-icon {
|
|
|
+ color: #dcdfe6;
|
|
|
+ }
|
|
|
+
|
|
|
+ p {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #c0c4cc;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.generate-btn-center {
|
|
|
+ margin-top: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.result-loading {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ color: #606266;
|
|
|
+
|
|
|
+ .loading-icon {
|
|
|
+ color: #409eff;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.history-ref-thumb {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 4px;
|
|
|
+ cursor: zoom-in;
|
|
|
+}
|
|
|
+
|
|
|
+.history-thumb-preview {
|
|
|
+ display: inline-block;
|
|
|
+ vertical-align: middle;
|
|
|
+}
|
|
|
+
|
|
|
+.history-video-cell {
|
|
|
+ position: relative;
|
|
|
+ display: inline-block;
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ .history-ref-thumb {
|
|
|
+ cursor: pointer;
|
|
|
}
|
|
|
-
|
|
|
- .video-result-preview {
|
|
|
+}
|
|
|
+
|
|
|
+.history-video-placeholder {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: #f0f2f5;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ color: #909399;
|
|
|
+}
|
|
|
+
|
|
|
+.history-video-play-badge {
|
|
|
+ position: absolute;
|
|
|
+ inset: 0;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: rgba(0, 0, 0, 0.35);
|
|
|
+ color: #fff;
|
|
|
+ pointer-events: none;
|
|
|
+}
|
|
|
+
|
|
|
+.history-ref-empty {
|
|
|
+ color: #c0c4cc;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.history-thumb-fallback {
|
|
|
+ width: 48px;
|
|
|
+ height: 48px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ background: #f0f2f5;
|
|
|
+ color: #909399;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.history-frame-thumbs {
|
|
|
+ display: inline-flex;
|
|
|
+ gap: 4px;
|
|
|
+ justify-content: center;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+
|
|
|
+.record-dialog-pagination {
|
|
|
+ margin-top: 12px;
|
|
|
+ display: flex;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-body {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-video {
|
|
|
+ width: 100%;
|
|
|
+ max-height: min(70vh, 520px);
|
|
|
+ border-radius: 6px;
|
|
|
+ background: #000;
|
|
|
+}
|
|
|
+
|
|
|
+.record-preview-meta {
|
|
|
+ .record-preview-prompt {
|
|
|
+ margin: 0 0 6px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #303133;
|
|
|
+ line-height: 1.5;
|
|
|
+ display: -webkit-box;
|
|
|
+ -webkit-line-clamp: 2;
|
|
|
+ line-clamp: 2;
|
|
|
+ -webkit-box-orient: vertical;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .record-preview-info {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #909399;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media screen and (max-width: 992px) {
|
|
|
+ .two-column-layout {
|
|
|
flex-direction: column;
|
|
|
- align-items: flex-start;
|
|
|
- gap: 15px;
|
|
|
-
|
|
|
- .video-result-actions {
|
|
|
- width: 100%;
|
|
|
- justify-content: flex-start;
|
|
|
- }
|
|
|
}
|
|
|
+
|
|
|
+ .left-column {
|
|
|
+ flex: 0 0 auto;
|
|
|
+ max-height: 50vh;
|
|
|
+ min-width: 100%;
|
|
|
+ }
|
|
|
+
|
|
|
+ .right-column {
|
|
|
+ flex: 1;
|
|
|
+ min-height: 280px;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+/* 记录弹窗内图片放大:层级高于 dialog,点击遮罩空白处关闭 */
|
|
|
+.el-image-viewer__wrapper {
|
|
|
+ z-index: 3200 !important;
|
|
|
+}
|
|
|
+
|
|
|
+.el-image-viewer__mask {
|
|
|
+ cursor: zoom-out;
|
|
|
}
|
|
|
-</style>
|
|
|
+</style>
|