feat(api): 添加Base64图片处理功能 - 在后端API中添加Base64图片处理逻辑,自动去除data URL头部信息 - 处理前端传来的包含header的Base64图片数据 refactor(video.js): 优化图片上传处理流程 - 将图片上传逻辑改为纯前端Base64处理,移除对后端上传接口的依赖 - 实现图片压缩功能,限制最大尺寸为2048像素 - 支持PNG和JPEG格式的图片压缩处理 - 移除原有的文件上传FormData方式,改用直接处理Base64数据 ```
374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
lucide.createIcons();
|
|
|
|
const submitBtn = document.getElementById('submitBtn');
|
|
const pointsDisplay = document.getElementById('pointsDisplay');
|
|
const headerPoints = document.getElementById('headerPoints');
|
|
const resultVideo = document.getElementById('resultVideo');
|
|
const finalWrapper = document.getElementById('finalWrapper');
|
|
const placeholder = document.getElementById('placeholder');
|
|
const statusInfo = document.getElementById('statusInfo');
|
|
const promptInput = document.getElementById('promptInput');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const imagePreview = document.getElementById('imagePreview');
|
|
const modelSelect = document.getElementById('modelSelect');
|
|
const ratioSelect = document.getElementById('ratioSelect');
|
|
const promptTemplates = document.getElementById('promptTemplates');
|
|
const enhancePrompt = document.getElementById('enhancePrompt');
|
|
|
|
// 历史记录相关
|
|
const historyList = document.getElementById('historyList');
|
|
const historyCount = document.getElementById('historyCount');
|
|
const historyEmpty = document.getElementById('historyEmpty');
|
|
const loadMoreBtn = document.getElementById('loadMoreBtn');
|
|
|
|
let uploadedImageUrl = null;
|
|
let historyPage = 1;
|
|
let isLoadingHistory = false;
|
|
|
|
// 初始化配置
|
|
async function initConfig() {
|
|
try {
|
|
const r = await fetch('/api/config');
|
|
if (!r.ok) throw new Error('API 响应失败');
|
|
const d = await r.json();
|
|
|
|
if (d.video_models && d.video_models.length > 0) {
|
|
modelSelect.innerHTML = d.video_models.map(m =>
|
|
`<option value="${m.value}">${m.label} (${m.cost}积分)</option>`
|
|
).join('');
|
|
}
|
|
|
|
if (d.video_prompts && d.video_prompts.length > 0) {
|
|
promptTemplates.innerHTML = d.video_prompts.map(p =>
|
|
`<button type="button" class="px-3 py-1.5 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-lg text-[9px] font-bold transition-all whitespace-nowrap" onclick="applyTemplate('${p.value.replace(/'/g, "\\'")}')">${p.label}</button>`
|
|
).join('');
|
|
}
|
|
} catch (e) {
|
|
console.error('加载系统配置失败:', e);
|
|
}
|
|
}
|
|
|
|
// 载入历史记录
|
|
async function loadHistory(page = 1, append = false) {
|
|
if (isLoadingHistory) return;
|
|
isLoadingHistory = true;
|
|
try {
|
|
const r = await fetch(`/api/history?page=${page}&per_page=10&filter_type=video`);
|
|
const d = await r.json();
|
|
|
|
// 服务端已完成过滤
|
|
const videoRecords = d.history;
|
|
|
|
if (videoRecords.length > 0) {
|
|
const html = videoRecords.map(item => {
|
|
const videoObj = item.urls.find(u => u.type === 'video') || { url: item.urls[0] };
|
|
const videoUrl = typeof videoObj === 'string' ? videoObj : videoObj.url;
|
|
|
|
return `
|
|
<div class="group bg-white rounded-2xl p-3 border border-slate-100 hover:border-indigo-200 hover:shadow-md transition-all cursor-pointer animate-in fade-in slide-in-from-right-2" onclick="playHistoryVideo('${videoUrl}')">
|
|
<div class="relative aspect-video bg-black rounded-xl overflow-hidden mb-2">
|
|
<div class="absolute inset-0 flex items-center justify-center bg-slate-900/40 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<i data-lucide="play-circle" class="w-10 h-10 text-white"></i>
|
|
</div>
|
|
<video src="${videoUrl}" class="w-full h-full object-cover"></video>
|
|
</div>
|
|
<div class="space-y-1">
|
|
<p class="text-[10px] text-slate-600 font-medium line-clamp-2 leading-relaxed">${item.prompt || '无描述'}</p>
|
|
<div class="flex items-center justify-between pt-1">
|
|
<span class="text-[8px] text-slate-300 font-black uppercase tracking-tighter">${item.created_at}</span>
|
|
<div class="flex gap-1">
|
|
<button onclick="event.stopPropagation(); downloadUrl('${videoUrl}')" class="p-1 hover:text-indigo-600 transition-colors">
|
|
<i data-lucide="download" class="w-3 h-3"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
if (append) {
|
|
historyList.insertAdjacentHTML('beforeend', html);
|
|
} else {
|
|
historyList.innerHTML = html;
|
|
}
|
|
|
|
historyEmpty.classList.add('hidden');
|
|
historyCount.innerText = videoRecords.length + (append ? parseInt(historyCount.innerText) : 0);
|
|
|
|
if (d.history.length >= 10) {
|
|
loadMoreBtn.classList.remove('hidden');
|
|
} else {
|
|
loadMoreBtn.classList.add('hidden');
|
|
}
|
|
} else if (!append) {
|
|
historyEmpty.classList.remove('hidden');
|
|
historyList.innerHTML = '';
|
|
}
|
|
|
|
lucide.createIcons();
|
|
} catch (e) {
|
|
console.error('加载历史失败:', e);
|
|
} finally {
|
|
isLoadingHistory = false;
|
|
}
|
|
}
|
|
|
|
window.playHistoryVideo = (url) => {
|
|
showVideo(url);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
};
|
|
|
|
window.downloadUrl = (url) => {
|
|
// 使用后端代理强制下载,绕过跨域限制
|
|
showToast('开始下载...', 'info');
|
|
const filename = `vision-video-${Date.now()}.mp4`;
|
|
const proxyUrl = `/api/download_proxy?url=${encodeURIComponent(url)}&filename=${filename}`;
|
|
|
|
// 创建隐藏的 iframe 触发下载,相比 a 标签兼容性更好
|
|
const iframe = document.createElement('iframe');
|
|
iframe.style.display = 'none';
|
|
iframe.src = proxyUrl;
|
|
document.body.appendChild(iframe);
|
|
|
|
// 1分钟后清理 iframe
|
|
setTimeout(() => document.body.removeChild(iframe), 60000);
|
|
};
|
|
|
|
loadMoreBtn.onclick = () => {
|
|
historyPage++;
|
|
loadHistory(historyPage, true);
|
|
};
|
|
|
|
initConfig();
|
|
loadHistory();
|
|
|
|
window.applyTemplate = (text) => {
|
|
promptInput.value = text;
|
|
showToast('已应用提示词模板', 'success');
|
|
};
|
|
|
|
// 图片处理辅助函数
|
|
const processImageFile = (file) => new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const maxDim = 2048;
|
|
let w = img.width;
|
|
let h = img.height;
|
|
|
|
if (w <= maxDim && h <= maxDim) {
|
|
resolve(e.target.result);
|
|
return;
|
|
}
|
|
|
|
if (w > h) {
|
|
if (w > maxDim) {
|
|
h = Math.round(h * (maxDim / w));
|
|
w = maxDim;
|
|
}
|
|
} else {
|
|
if (h > maxDim) {
|
|
w = Math.round(w * (maxDim / h));
|
|
h = maxDim;
|
|
}
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = w;
|
|
canvas.height = h;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
// 保持原格式,如果不是 png 则默认 jpeg (0.9 质量)
|
|
const outType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
|
resolve(canvas.toDataURL(outType, 0.9));
|
|
};
|
|
img.onerror = reject;
|
|
img.src = e.target.result;
|
|
};
|
|
reader.onerror = reject;
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
// 上传图片逻辑 (改为纯前端 Base64 处理)
|
|
fileInput.onchange = async (e) => {
|
|
const files = e.target.files;
|
|
if (files.length === 0) return;
|
|
|
|
try {
|
|
submitBtn.disabled = true;
|
|
// 压缩并转换为 Base64
|
|
uploadedImageUrl = await processImageFile(files[0]);
|
|
|
|
imagePreview.innerHTML = `
|
|
<div class="relative w-20 h-20 rounded-xl overflow-hidden border border-indigo-200 shadow-sm group">
|
|
<img src="${uploadedImageUrl}" class="w-full h-full object-cover">
|
|
<div class="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center cursor-pointer" onclick="removeImage()">
|
|
<i data-lucide="x" class="w-5 h-5 text-white"></i>
|
|
</div>
|
|
</div>
|
|
`;
|
|
lucide.createIcons();
|
|
showToast('图片已就绪', 'success');
|
|
} catch (err) {
|
|
console.error(err);
|
|
showToast('图片处理失败', 'error');
|
|
} finally {
|
|
submitBtn.disabled = false;
|
|
}
|
|
};
|
|
|
|
window.removeImage = () => {
|
|
uploadedImageUrl = null;
|
|
imagePreview.innerHTML = '';
|
|
fileInput.value = '';
|
|
};
|
|
|
|
// 提交生成任务
|
|
submitBtn.onclick = async () => {
|
|
const prompt = promptInput.value.trim();
|
|
if (!prompt) return showToast('请输入视频描述', 'warning');
|
|
|
|
const payload = {
|
|
prompt,
|
|
model: modelSelect.value,
|
|
enhance_prompt: enhancePrompt ? enhancePrompt.checked : false,
|
|
aspect_ratio: ratioSelect ? ratioSelect.value : '9:16',
|
|
images: uploadedImageUrl ? [uploadedImageUrl] : []
|
|
};
|
|
|
|
try {
|
|
setLoading(true);
|
|
const r = await fetch('/api/video/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
const d = await r.json();
|
|
|
|
if (d.error) {
|
|
showToast(d.error, 'error');
|
|
setLoading(false);
|
|
} else if (d.task_id) {
|
|
showToast(d.message || '任务已提交', 'info');
|
|
pollTaskStatus(d.task_id);
|
|
} else {
|
|
showToast('未知异常,请重试', 'error');
|
|
setLoading(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('提交生成异常:', err);
|
|
showToast('任务提交失败', 'error');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
async function pollTaskStatus(taskId) {
|
|
let attempts = 0;
|
|
const maxAttempts = 180; // 提升到 15 分钟
|
|
|
|
const check = async () => {
|
|
try {
|
|
const r = await fetch(`/api/task_status/${taskId}?t=${Date.now()}`);
|
|
if (!r.ok) {
|
|
setTimeout(check, 5000);
|
|
return;
|
|
}
|
|
const d = await r.json();
|
|
|
|
if (d.status === 'complete') {
|
|
setLoading(false);
|
|
showVideo(d.video_url);
|
|
refreshUserPoints();
|
|
// 刷新历史列表
|
|
loadHistory(1, false);
|
|
} else if (d.status === 'error') {
|
|
showToast(d.message || '生成失败', 'error');
|
|
setLoading(false);
|
|
refreshUserPoints();
|
|
} else {
|
|
attempts++;
|
|
if (attempts >= maxAttempts) {
|
|
showToast('生成超时,请稍后在历史记录中查看', 'warning');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setTimeout(check, 5000);
|
|
}
|
|
} catch (e) {
|
|
setTimeout(check, 5000);
|
|
}
|
|
};
|
|
check();
|
|
}
|
|
|
|
function showVideo(url) {
|
|
if (!url) return;
|
|
try {
|
|
placeholder.classList.add('hidden');
|
|
finalWrapper.classList.remove('hidden');
|
|
resultVideo.src = url;
|
|
resultVideo.load();
|
|
const playPromise = resultVideo.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.catch(e => console.warn('自动播放被拦截'));
|
|
}
|
|
} catch (err) {
|
|
console.error('展示视频失败:', err);
|
|
}
|
|
}
|
|
|
|
const closePreviewBtn = document.getElementById('closePreviewBtn');
|
|
if (closePreviewBtn) {
|
|
closePreviewBtn.onclick = () => {
|
|
finalWrapper.classList.add('hidden');
|
|
placeholder.classList.remove('hidden');
|
|
resultVideo.pause();
|
|
resultVideo.src = "";
|
|
};
|
|
}
|
|
|
|
function setLoading(isLoading) {
|
|
submitBtn.disabled = isLoading;
|
|
if (isLoading) {
|
|
statusInfo.classList.remove('hidden');
|
|
submitBtn.innerHTML = '<i data-lucide="loader-2" class="w-5 h-5 animate-spin"></i><span>导演创作中...</span>';
|
|
// 如果正在生成,确保回到预览背景状态(如果当前正在播放旧视频)
|
|
if (!finalWrapper.classList.contains('hidden')) {
|
|
finalWrapper.classList.add('hidden');
|
|
placeholder.classList.remove('hidden');
|
|
}
|
|
} else {
|
|
statusInfo.classList.add('hidden');
|
|
submitBtn.innerHTML = '<i data-lucide="clapperboard" class="w-5 h-5"></i><span class="text-base font-bold tracking-widest">开始生成视频</span>';
|
|
}
|
|
if (window.lucide) lucide.createIcons();
|
|
}
|
|
|
|
async function refreshUserPoints() {
|
|
try {
|
|
const r = await fetch('/api/auth/me');
|
|
const d = await r.json();
|
|
if (d.points !== undefined) {
|
|
if (pointsDisplay) pointsDisplay.innerText = d.points;
|
|
if (headerPoints) headerPoints.innerText = d.points;
|
|
}
|
|
} catch (e) { }
|
|
}
|
|
|
|
document.getElementById('downloadBtn').onclick = () => {
|
|
const url = resultVideo.src;
|
|
if (!url) return;
|
|
downloadUrl(url);
|
|
};
|
|
|
|
if (regenBtn) {
|
|
regenBtn.onclick = () => {
|
|
submitBtn.click();
|
|
};
|
|
}
|
|
});
|
|
|