VoxiAI / static /script.js
Shads229's picture
Update static/script.js
723c761 verified
const clientId = Math.random().toString(36).substring(7);
const uploadState = document.getElementById('upload-state');
const loadingState = document.getElementById('loading-state');
const resultState = document.getElementById('result-state');
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
let socket;
let currentStep = 1;
const ICON_SPINNER = '<i class="fas fa-circle-notch spinner text-brand text-xl"></i>';
const ICON_CHECK = '<i class="fas fa-check-circle text-brand text-xl"></i>';
function updateStepUI(stepNum, status, percent = 0) {
const stepEl = document.getElementById(`step-${stepNum}`);
if (!stepEl) return;
const iconContainer = stepEl.querySelector('.status-icon');
const percentLabel = stepEl.querySelector('.step-percent');
stepEl.classList.remove('step-pending', 'step-active', 'step-done', 'opacity-30');
if (status === 'active') {
stepEl.classList.add('step-active');
iconContainer.innerHTML = ICON_SPINNER;
percentLabel.innerText = `${Math.round(percent)}%`;
percentLabel.classList.add('text-brand');
} else if (status === 'done') {
stepEl.classList.add('step-done');
iconContainer.innerHTML = ICON_CHECK;
percentLabel.innerText = '100%';
percentLabel.classList.remove('text-brand');
} else {
stepEl.classList.add('step-pending', 'opacity-30');
percentLabel.innerText = '0%';
}
}
function connectWebSocket(callback) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(`${protocol}//${window.location.host}/ws/${clientId}`);
socket.onopen = () => {
console.log("WebSocket connecté");
if (callback) callback();
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
const progress = data.progress;
console.log("Progrès reçu:", progress, data.status);
// Logique de transition automatique des étapes
if (progress > 0) {
// Si on reçoit du progrès, l'upload (Etape 1) est forcément fini
if (currentStep < 2) {
updateStepUI(1, 'done');
currentStep = 2;
}
if (progress <= 65) {
// Phase IA (Etape 2)
const iaPercent = ((progress - 20) / 45) * 100;
updateStepUI(2, 'active', Math.max(0, iaPercent));
updateStepUI(3, 'pending');
} else if (progress < 100) {
// Phase Rendu (Etape 3)
if (currentStep < 3) {
updateStepUI(2, 'done');
currentStep = 3;
}
const renderPercent = ((progress - 65) / 35) * 100;
updateStepUI(3, 'active', Math.max(0, renderPercent));
}
}
if (data.result_url || data.stream_url) {
updateStepUI(3, 'done');
const finalUrl = data.stream_url || data.result_url;
setTimeout(() => showResult(finalUrl, data.result_url), 800);
}
};
}
// Helper pour encoder en WAV (format simple pour le serveur)
function bufferToWave(abuffer, len) {
let numOfChan = abuffer.numberOfChannels,
length = len * numOfChan * 2 + 44,
buffer = new ArrayBuffer(length),
view = new DataView(buffer),
channels = [], i, sample,
offset = 0,
pos = 0;
setUint32(0x46464952); // "RIFF"
setUint32(length - 8); // file length - 8
setUint32(0x45564157); // "WAVE"
setUint32(0x20746d66); // "fmt " chunk
setUint32(16); // length = 16
setUint16(1); // PCM (uncompressed)
setUint16(numOfChan);
setUint32(abuffer.sampleRate);
setUint32(abuffer.sampleRate * 2 * numOfChan); // avg. bytes/sec
setUint16(numOfChan * 2); // block-align
setUint16(16); // 16-bit
setUint32(0x61746164); // "data" - chunk
setUint32(length - pos - 4); // chunk length
for (i = 0; i < abuffer.numberOfChannels; i++)
channels.push(abuffer.getChannelData(i));
while (pos < length) {
for (i = 0; i < numOfChan; i++) {
sample = Math.max(-1, Math.min(1, channels[i][offset]));
sample = (0.5 + sample < 0 ? sample * 32768 : sample * 32767) | 0;
view.setInt16(pos, sample, true);
pos += 2;
}
offset++;
}
return new Blob([buffer], { type: "audio/wav" });
function setUint16(data) {
view.setUint16(pos, data, true);
pos += 2;
}
function setUint32(data) {
view.setUint32(pos, data, true);
pos += 4;
}
}
async function extractAudio(file) {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
return bufferToWave(audioBuffer, audioBuffer.length);
}
async function handleUpload(file) {
if (!file) return;
// Pré-connexion au WebSocket
connectWebSocket(async () => {
uploadState.classList.add('hidden');
loadingState.classList.remove('hidden');
updateStepUI(1, 'active', 0);
let serverFileId = null;
try {
// 1. EXTRACTION ET UPLOAD AUDIO (Priorité haute)
console.log("Extraction audio...");
const audioBlob = await extractAudio(file);
const audioData = new FormData();
audioData.append('file', audioBlob, 'audio.wav');
const audioXhr = new XMLHttpRequest();
audioXhr.open('POST', `/upload?client_id=${clientId}&type=audio`);
audioXhr.onload = () => {
const res = JSON.parse(audioXhr.responseText);
serverFileId = res.file_id;
console.log("Audio uploadé, ID:", serverFileId);
// 2. UPLOAD VIDÉO (En arrière-plan)
const videoData = new FormData();
videoData.append('file', file);
const videoXhr = new XMLHttpRequest();
videoXhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
updateStepUI(1, 'active', percent);
}
});
videoXhr.open('POST', `/upload?client_id=${clientId}&file_id=${serverFileId}&type=video`);
videoXhr.send(videoData);
};
audioXhr.send(audioData);
} catch (err) {
console.error("Erreur Turbo-Pipeline:", err);
// Fallback sur upload classique si l'extraction échoue
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
updateStepUI(1, 'active', percent);
}
});
xhr.open('POST', `/upload?client_id=${clientId}`);
xhr.send(formData);
}
});
}
// Gestion des usages pour le feedback
let videoUsageCount = parseInt(localStorage.getItem('video_usage_count') || '0');
let feedbackShown = localStorage.getItem('feedback_shown') === 'true';
let selectedRating = null;
function showResult(url, downloadUrl) {
loadingState.classList.add('hidden');
resultState.classList.remove('hidden');
const video = document.getElementById('final-video');
video.src = url;
document.getElementById('download-btn').href = downloadUrl || url;
video.load(); // Important pour forcer le rechargement avec le stream
video.play().catch(() => {});
// Incrémenter le compteur d'usage
videoUsageCount++;
localStorage.setItem('video_usage_count', videoUsageCount);
// Afficher le modal tous les 3 usages (3, 6, 9...)
if (videoUsageCount > 0 && videoUsageCount % 3 === 0) {
setTimeout(openFeedback, 2000);
}
}
// Fonctions Feedback
function openFeedback() {
document.getElementById('feedback-modal').classList.remove('hidden');
}
function closeFeedback() {
document.getElementById('feedback-modal').classList.add('hidden');
}
function setRating(e, rating) {
selectedRating = rating;
document.querySelectorAll('.rating-btn').forEach(btn => {
btn.classList.remove('border-brand', 'bg-brand/10');
});
const target = e.currentTarget || e.target;
target.classList.add('border-brand', 'bg-brand/10');
}
async function sendFeedback() {
const commentInput = document.getElementById('feedback-text');
const comment = commentInput ? commentInput.value : "";
if (!selectedRating) {
const errorMsg = document.createElement('p');
errorMsg.id = 'feedback-error';
errorMsg.className = 'text-red-500 text-xs text-center mt-2 animate-pulse';
errorMsg.innerText = "Veuillez sélectionner 'Oui' ou 'Non' pour continuer.";
const existingError = document.getElementById('feedback-error');
if (!existingError) {
document.querySelector('.rating-btn').parentNode.parentNode.appendChild(errorMsg);
}
return;
}
const existingError = document.getElementById('feedback-error');
if (existingError) existingError.remove();
const btn = document.getElementById('submit-feedback');
if (btn) {
btn.disabled = true;
btn.innerText = "Envoi...";
}
try {
const response = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: clientId,
rating: selectedRating,
comment: comment
})
});
if (response.ok) {
localStorage.setItem('feedback_shown', 'true');
feedbackShown = true;
// Remplacer le contenu du modal par un message de succès
const modalContent = document.querySelector('#feedback-modal .glass');
const originalHTML = modalContent.innerHTML;
modalContent.innerHTML = `
<div class="text-center py-10 fade-in">
<div class="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<i class="fas fa-check text-3xl text-green-500"></i>
</div>
<h2 class="text-2xl font-bold mb-4">Merci beaucoup !</h2>
<p class="text-gray-400 mb-8">Votre retour a bien été envoyé. Shadrak apprécie votre aide pour améliorer cet outil.</p>
<button onclick="closeFeedback()" class="bg-brand text-black font-bold py-3 px-8 rounded-xl transition-all hover:scale-105">
Fermer
</button>
</div>
`;
// Réinitialiser le modal après la fermeture (pour une prochaine ouverture éventuelle)
setTimeout(() => {
if (commentInput) commentInput.value = "";
selectedRating = null;
}, 500);
}
} catch (err) {
console.error("Erreur feedback:", err);
} finally {
if (btn) {
btn.disabled = false;
btn.innerText = "Envoyer le feedback";
}
}
}
if (fileInput) {
fileInput.addEventListener('change', () => handleUpload(fileInput.files[0]));
}
if (dropZone) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(n => {
dropZone.addEventListener(n, e => { e.preventDefault(); e.stopPropagation(); });
});
dropZone.addEventListener('dragenter', () => uploadState && uploadState.classList.add('border-brand', 'bg-brand/5'));
dropZone.addEventListener('dragleave', () => uploadState && uploadState.classList.remove('border-brand', 'bg-brand/5'));
dropZone.addEventListener('drop', (e) => {
uploadState && uploadState.classList.remove('border-brand', 'bg-brand/5');
handleUpload(e.dataTransfer.files[0]);
});
}