ai-clone/frontend-ai/pages/video-gen/video-gen.vue
2026-03-05 14:29:21 +08:00

576 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="video-gen-page">
<view class="header">
<text class="title">🎬 AI图生视频</text>
<text class="subtitle">火山引擎 · doubao-seedance-1.0-pro</text>
</view>
<!-- 消息提示 -->
<view v-if="message.text" :class="['message', message.type]">
{{ message.text }}
</view>
<scroll-view scroll-y class="content">
<!-- 上传照片 -->
<view class="section">
<view class="section-title">📸 上传照片</view>
<view class="upload-area" @click="chooseImage">
<image v-if="imageUrl" :src="imageUrl" class="preview-image" mode="aspectFit"></image>
<view v-else class="upload-placeholder">
<text class="upload-icon">📷</text>
<text class="upload-text">点击上传照片</text>
<text class="upload-hint">支持 JPG、PNG 格式</text>
</view>
</view>
</view>
<!-- 提示词 -->
<view class="section">
<view class="section-title">✍️ 提示词(可选)</view>
<textarea v-model="prompt" class="prompt-input" :placeholder="promptPlaceholder" :maxlength="500"></textarea>
<view class="char-count">{{ prompt.length }}/500</view>
</view>
<!-- 视频时长 -->
<view class="section">
<view class="section-title"> 视频时长</view>
<view class="duration-selector">
<view
v-for="d in durations"
:key="d"
:class="['duration-item', duration === d ? 'active' : '']"
@click="duration = d"
>
{{ d }}秒
</view>
</view>
</view>
<!-- 视频名称 -->
<view class="section">
<view class="section-title">📝 视频名称(可选)</view>
<input
v-model="videoName"
class="name-input"
placeholder="给视频起个名字"
:maxlength="50"
/>
</view>
<!-- 生成按钮 -->
<view class="action-section">
<button
class="generate-btn"
:disabled="!imageUrl || generating"
@click="generateVideo"
>
<text v-if="generating">⏳ 生成中...</text>
<text v-else>🎬 生成视频</text>
</button>
</view>
<!-- 进度提示 -->
<view v-if="generating" class="progress-section">
<view class="progress-text">{{ progressText }}</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: progress + '%' }"></view>
</view>
</view>
<!-- 生成结果 -->
<view v-if="generatedVideo" class="result-section">
<view class="section-title">✅ 生成成功</view>
<video
:src="generatedVideo"
class="result-video"
controls
autoplay
></video>
<view class="result-actions">
<button class="action-btn" @click="saveToAlbum">💾 保存到相册</button>
<button class="action-btn secondary" @click="reset">🔄 重新生成</button>
</view>
</view>
<!-- 使用说明 -->
<view class="tips-section">
<view class="tips-title">💡 使用提示</view>
<view class="tip-item">• 支持 JPG、PNG 格式的照片</view>
<view class="tip-item">• 提示词支持中文,可描述动作、表情、场景等</view>
<view class="tip-item">• 视频时长支持 2-12 秒</view>
<view class="tip-item">• 生成时间约 1-3 分钟,请耐心等待</view>
<view class="tip-item"> 使用火山引擎 doubao-seedance-1.0-pro 模型</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { request, uploadFile, API_BASE } from '@/config/api.js';
export default {
data() {
return {
imageUrl: '',
imageFile: null,
prompt: '',
promptPlaceholder: '描述你想要的视频效果,支持中文,例如:一个人在微笑着说话,背景是温馨的客厅',
duration: 10,
durations: [2, 3, 4, 5, 6, 8, 10, 12],
videoName: '',
generating: false,
progress: 0,
progressText: '准备中...',
generatedVideo: '',
message: {
text: '',
type: 'info'
}
};
},
methods: {
// 选择图片
chooseImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const selectedPath = res.tempFilePaths[0];
const selectedFile = res.tempFiles && res.tempFiles[0] ? res.tempFiles[0] : null;
const setSelected = (filePath) => {
this.imageUrl = filePath;
this.imageFile = selectedFile;
this.showMessage('照片已选择', 'success');
};
uni.getFileInfo({
filePath: selectedPath,
success: (info) => {
const maxBytes = 2 * 1024 * 1024;
if (info.size && info.size > maxBytes) {
this.showMessage('图片超过2MB正在自动压缩...', 'info');
const qualities = [60, 40, 20];
const tryCompress = (index, currentPath) => {
if (index >= qualities.length) {
this.showMessage('图片压缩后仍超过2MB请换一张更小的图片建议拍照/截图后再上传)', 'error');
return;
}
uni.compressImage({
src: currentPath,
quality: qualities[index],
success: (cRes) => {
uni.getFileInfo({
filePath: cRes.tempFilePath,
success: (cInfo) => {
if (cInfo.size && cInfo.size <= maxBytes) {
setSelected(cRes.tempFilePath);
} else {
tryCompress(index + 1, cRes.tempFilePath);
}
},
fail: (cInfoErr) => {
console.error('获取压缩后文件信息失败:', cInfoErr);
tryCompress(index + 1, cRes.tempFilePath);
}
});
},
fail: (cErr) => {
console.error('压缩图片失败:', cErr);
this.showMessage('图片过大且压缩失败,请换一张更小的图片', 'error');
}
});
};
tryCompress(0, selectedPath);
} else {
setSelected(selectedPath);
}
},
fail: (infoErr) => {
console.error('获取文件信息失败:', infoErr);
setSelected(selectedPath);
}
});
},
fail: (err) => {
console.error('选择图片失败:', err);
this.showMessage('选择图片失败', 'error');
}
});
},
// 生成视频
async generateVideo() {
if (!this.imageUrl) {
this.showMessage('请先上传照片', 'error');
return;
}
this.generating = true;
this.progress = 0;
this.progressText = '正在上传照片...';
try {
// 上传照片并生成视频
this.progress = 10;
this.progressText = '照片上传中...';
const result = await uploadFile({
url: '/api/photo-revival/volcengine-video',
filePath: this.imageUrl,
name: 'photo',
formData: {
text: '',
prompt: this.prompt || '',
duration: this.duration,
name: this.videoName || '火山引擎视频'
}
});
this.progress = 30;
this.progressText = '视频生成中,请稍候...';
// 模拟进度更新
const progressInterval = setInterval(() => {
if (this.progress < 90) {
this.progress += 2;
}
}, 2000);
// 等待一段时间后检查结果
setTimeout(() => {
clearInterval(progressInterval);
this.progress = 100;
this.progressText = '视频生成完成!';
if (result.status === 'success') {
this.generatedVideo = API_BASE + result.videoUrl;
this.showMessage('视频生成成功!', 'success');
} else {
throw new Error(result.message || '视频生成失败');
}
this.generating = false;
}, 3000);
} catch (error) {
console.error('生成视频失败:', error);
this.showMessage('生成失败: ' + (error.message || '未知错误'), 'error');
this.generating = false;
this.progress = 0;
}
},
// 保存到相册
saveToAlbum() {
if (!this.generatedVideo) {
this.showMessage('没有可保存的视频', 'error');
return;
}
uni.downloadFile({
url: this.generatedVideo,
success: (res) => {
if (res.statusCode === 200) {
uni.saveVideoToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
this.showMessage('已保存到相册', 'success');
},
fail: (err) => {
console.error('保存失败:', err);
this.showMessage('保存失败,请检查相册权限', 'error');
}
});
}
},
fail: (err) => {
console.error('下载失败:', err);
this.showMessage('下载失败', 'error');
}
});
},
// 重置
reset() {
this.imageUrl = '';
this.imageFile = null;
this.prompt = '';
this.duration = 10;
this.videoName = '';
this.generatedVideo = '';
this.progress = 0;
this.progressText = '准备中...';
this.message.text = '';
},
// 显示消息
showMessage(text, type = 'info') {
this.message = { text, type };
setTimeout(() => {
this.message.text = '';
}, 3000);
}
}
};
</script>
<style scoped>
.video-gen-page {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20rpx;
}
.header {
text-align: center;
padding: 40rpx 20rpx;
color: white;
}
.title {
font-size: 48rpx;
font-weight: bold;
display: block;
margin-bottom: 10rpx;
}
.subtitle {
font-size: 28rpx;
opacity: 0.9;
}
.message {
margin: 20rpx;
padding: 20rpx;
border-radius: 10rpx;
text-align: center;
font-size: 28rpx;
}
.message.success {
background: #d4edda;
color: #155724;
}
.message.error {
background: #f8d7da;
color: #721c24;
}
.message.info {
background: #d1ecf1;
color: #0c5460;
}
.content {
height: calc(100vh - 200rpx);
padding: 20rpx;
}
.section {
background: white;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
color: #333;
}
.upload-area {
border: 2rpx dashed #ddd;
border-radius: 15rpx;
min-height: 400rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
}
.upload-placeholder {
text-align: center;
}
.upload-icon {
font-size: 80rpx;
display: block;
margin-bottom: 20rpx;
}
.upload-text {
font-size: 32rpx;
color: #666;
display: block;
margin-bottom: 10rpx;
}
.upload-hint {
font-size: 24rpx;
color: #999;
}
.preview-image {
width: 100%;
max-height: 400rpx;
border-radius: 15rpx;
}
.prompt-input {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
border: 1rpx solid #e0e0e0;
border-radius: 10rpx;
font-size: 28rpx;
background: #f8f9fa;
}
.char-count {
text-align: right;
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
.duration-selector {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.duration-item {
flex: 0 0 calc(25% - 12rpx);
padding: 20rpx;
text-align: center;
border: 2rpx solid #e0e0e0;
border-radius: 10rpx;
font-size: 28rpx;
background: #f8f9fa;
}
.duration-item.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.name-input {
width: 100%;
padding: 20rpx;
border: 1rpx solid #e0e0e0;
border-radius: 10rpx;
font-size: 28rpx;
background: #f8f9fa;
}
.action-section {
padding: 0 20rpx;
margin-bottom: 20rpx;
}
.generate-btn {
width: 100%;
padding: 30rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 15rpx;
font-size: 32rpx;
font-weight: bold;
}
.generate-btn[disabled] {
opacity: 0.6;
}
.progress-section {
background: white;
border-radius: 20rpx;
padding: 30rpx;
margin: 0 20rpx 20rpx;
}
.progress-text {
text-align: center;
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
.progress-bar {
height: 20rpx;
background: #e0e0e0;
border-radius: 10rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: width 0.3s;
}
.result-section {
background: white;
border-radius: 20rpx;
padding: 30rpx;
margin: 0 20rpx 20rpx;
}
.result-video {
width: 100%;
height: 500rpx;
border-radius: 15rpx;
margin: 20rpx 0;
}
.result-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
padding: 25rpx;
background: #667eea;
color: white;
border: none;
border-radius: 10rpx;
font-size: 28rpx;
}
.action-btn.secondary {
background: #6c757d;
}
.tips-section {
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
padding: 30rpx;
margin: 0 20rpx 40rpx;
}
.tips-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
color: #333;
}
.tip-item {
font-size: 26rpx;
color: #666;
line-height: 1.8;
margin-bottom: 10rpx;
}
</style>