<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Agnes Studio — Création Vidéo Complète</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
:root {
--bg: #0a0a12; --panel: #14141f; --panel-2: #1c1c2b; --border: #2a2a3e;
--ink: #e8e8f0; --ink-dim: #8888a0; --ink-faint: #55556a;
--accent: #e94560; --accent-2: #4a6cf7;
--success: #2d8a4e; --error: #c0392b; --warn: #d4a017;
--radius: 12px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg); color: var(--ink); min-height: 100vh;
padding: 1rem 0.8rem 4rem; font-size: 15px; line-height: 1.5;
max-width: 640px; margin: 0 auto;
}
h1 { font-size: 1.3rem; font-weight: 700; text-align: center; margin-bottom: 0.2rem;
background: linear-gradient(135deg, #e94560, #4a6cf7);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.subtitle { font-size: 0.7rem; color: var(--ink-dim); text-align: center; margin-bottom: 1rem; }
/* ================= GRILLE 2x2 POUR ONGLETS ================= */
.tabs-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab-btn {
padding: 0.9rem 0.6rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--ink-dim);
cursor: pointer;
text-align: center;
font-size: 0.72rem;
font-weight: 700;
user-select: none;
transition: all 0.15s;
line-height: 1.3;
}
.tab-btn .tab-icon {
display: block;
font-size: 1.3rem;
margin-bottom: 0.25rem;
}
.tab-btn.active {
background: linear-gradient(135deg, #e94560, #4a6cf7);
color: #fff;
border-color: transparent;
}
.tab-btn:active { transform: scale(0.97); }
.main-panel { display: none; }
.main-panel.active { display: block; }
.panel { background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 1rem; margin-bottom: 0.8rem; }
.panel h2 { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--ink-dim); margin-bottom: 0.6rem; font-weight: 700; }
input[type="password"], input[type="text"], input[type="number"], textarea, select {
width: 100%; padding: 0.75rem 0.9rem; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg);
color: var(--ink); font-family: inherit; font-size: 0.88rem;
-webkit-appearance: none; appearance: none;
}
select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='%238888a0' d='M0 0l5 6 5-6z'/></svg>");
background-repeat: no-repeat;
background-position: right 0.9rem center;
padding-right: 2.2rem;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
textarea { min-height: 70px; line-height: 1.5; resize: vertical; }
.btn { width: 100%; padding: 0.85rem; border-radius: 8px; border: none;
font-family: inherit; font-size: 0.88rem; font-weight: 700; cursor: pointer; }
.btn-primary { background: linear-gradient(135deg, #e94560, #4a6cf7); color: #fff; }
.btn-secondary { background: var(--panel-2); color: var(--ink); border: 1px solid var(--border); }
.btn:disabled { opacity: 0.3; cursor: not-allowed; }
.btn:active:not(:disabled) { transform: scale(0.98); }
.photo-zone {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 1.2rem 1rem; text-align: center; cursor: pointer;
position: relative; overflow: hidden; min-height: 130px;
display: flex; align-items: center; justify-content: center;
}
.photo-zone input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.photo-zone img { max-width: 100%; max-height: 160px; border-radius: 8px; display: block; }
.photo-zone .placeholder { color: var(--ink-faint); font-size: 0.8rem; }
.photo-zone .placeholder .icon { font-size: 1.5rem; display: block; margin-bottom: 0.3rem; }
.mode-toggle { display: flex; gap: 0.4rem; margin-bottom: 0.7rem; }
.mode-option {
flex: 1; padding: 0.6rem; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel-2); color: var(--ink-dim); cursor: pointer;
text-align: center; font-size: 0.72rem; font-weight: 600;
}
.mode-option.selected { background: var(--accent); color: #fff; border-color: var(--accent); }
.mode-pane { display: none; }
.mode-pane.active { display: block; }
.record-btn-wrap { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.4rem 0; }
.record-indicator {
width: 60px; height: 60px; border-radius: 50%; background: var(--error);
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; cursor: pointer; user-select: none;
}
.record-indicator.recording { background: var(--success); box-shadow: 0 0 25px rgba(45,138,78,0.5); }
.record-label { font-size: 0.7rem; color: var(--ink-dim); }
.audio-preview { width: 100%; margin-top: 0.5rem; }
.audio-preview audio { width: 100%; }
.control-row { margin-bottom: 0.7rem; }
.control-label { display: block; font-size: 0.65rem; color: var(--ink-dim);
margin-bottom: 0.3rem; text-transform: uppercase; letter-spacing: 0.08em;
font-weight: 600; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.status { font-size: 0.75rem; color: var(--ink-dim); margin-top: 0.5rem; text-align: center; }
.status.ok { color: var(--success); }
.status.err { color: var(--error); }
.result-video { margin-top: 0.7rem; }
.result-video video { width: 100%; border-radius: var(--radius); background: #000; }
.result-actions { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
.result-actions .btn { flex: 1; padding: 0.55rem; font-size: 0.75rem; }
.hidden { display: none !important; }
.progress-bar { height: 4px; background: var(--panel-2); border-radius: 2px;
overflow: hidden; margin-top: 0.5rem; }
.progress-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #e94560, #4a6cf7);
transition: width 0.4s; border-radius: 2px; }
.log-panel { max-height: 160px; overflow-y: auto; font-family: ui-monospace, monospace;
font-size: 0.68rem; line-height: 1.5; padding: 0.5rem;
background: var(--bg); border-radius: 6px; }
.log-line { padding: 0.1rem 0; color: var(--ink-dim); }
.log-line.ok { color: var(--success); }
.log-line.err { color: var(--error); }
.log-line.warn { color: var(--warn); }
.role-list { display: flex; flex-direction: column; gap: 0.5rem; }
.role-card {
background: var(--bg); border: 1px solid var(--border);
border-radius: 8px; padding: 0.7rem;
}
.role-card h4 { font-size: 0.75rem; color: var(--accent); margin-bottom: 0.4rem; }
.role-photo-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; }
.role-thumb {
width: 50px; height: 50px; border-radius: 6px; background: var(--panel-2);
display: flex; align-items: center; justify-content: center;
font-size: 1.4rem; overflow: hidden; flex-shrink: 0; cursor: pointer;
border: 1px dashed var(--border);
}
.role-thumb img { width: 100%; height: 100%; object-fit: cover; }
.role-audio-btn {
flex: 1; padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--panel-2); color: var(--ink-dim); font-size: 0.7rem;
cursor: pointer; font-family: inherit;
}
.role-audio-btn.recording { background: var(--error); color: #fff; border-color: var(--error); }
.role-audio-btn.ready { background: var(--success); color: #fff; border-color: var(--success); }
.role-input {
width: 100%; padding: 0.5rem 0.7rem; border-radius: 6px;
border: 1px solid var(--border); background: var(--panel);
color: var(--ink); font-size: 0.78rem; font-family: inherit;
}
</style>
</head>
<body>
<h1>Agnes Studio</h1>
<p class="subtitle">Création vidéo IA · 100% gratuit</p>
<!-- ================= GRILLE 2x2 ================= -->
<div class="tabs-grid">
<div class="tab-btn active" data-panel="photo">
<span class="tab-icon">🗣️</span>Photo Parlante
</div>
<div class="tab-btn" data-panel="text2video">
<span class="tab-icon">🎬</span>Texte → Vidéo
</div>
<div class="tab-btn" data-panel="img2video">
<span class="tab-icon">🖼️</span>Image → Vidéo
</div>
<div class="tab-btn" data-panel="keyframes">
<span class="tab-icon">🎞️</span>Keyframes
</div>
</div>
<!-- CLÉ API -->
<div class="panel">
<h2>🔑 Clé API Agnes</h2>
<input type="password" id="apiKey" placeholder="sk-..." autocomplete="off">
<button class="btn btn-secondary" id="saveKey" style="margin-top:0.4rem;">Enregistrer</button>
<div class="status" id="keyStatus">Aucune clé enregistrée</div>
</div>
<!-- ================= PANEL 1 : PHOTO PARLANTE ================= -->
<div class="main-panel active" id="panel-photo">
<div class="panel">
<h2>🖼️ Photo</h2>
<div class="photo-zone" id="photoZoneP">
<input type="file" accept="image/*" id="photoInputP">
<div class="placeholder" id="photoPlaceholderP"><span class="icon">📷</span>Touchez pour choisir</div>
<img id="photoPreviewP" class="hidden" alt="">
</div>
</div>
<div class="panel">
<h2>🎤 Ce qu'elle doit dire</h2>
<div class="mode-toggle">
<div class="mode-option selected" data-mode="voice" data-target="P">🎙️ Ma voix</div>
<div class="mode-option" data-mode="text" data-target="P">✍️ Écrire</div>
</div>
<div class="mode-pane active" id="paneVoiceP">
<div class="record-btn-wrap">
<div class="record-indicator" id="recordBtnP">🎙️</div>
<div class="record-label" id="recordLabelP">Touchez pour enregistrer</div>
</div>
<div class="audio-preview hidden" id="audioPreviewP">
<audio controls id="audioPlayerP"></audio>
</div>
</div>
<div class="mode-pane" id="paneTextP">
<textarea id="speechTextP" placeholder="Le texte que la photo doit dire..."></textarea>
<div class="status" style="font-size:0.65rem;text-align:left;margin-top:0.3rem;">
⚠️ Le mode texte ne peut pas être synchronisé avec les lèvres sur le modèle gratuit. Utilisez la voix.
</div>
</div>
</div>
<div class="panel">
<h2>⚙️ Paramètres</h2>
<div class="grid-2">
<div class="control-row">
<label class="control-label">Durée</label>
<select id="durationP">
<option value="4">4 s</option>
<option value="5" selected>5 s</option>
<option value="8">8 s</option>
<option value="10">10 s</option>
<option value="12">12 s</option>
</select>
</div>
<div class="control-row">
<label class="control-label">Format</label>
<select id="aspectP">
<option value="16:9">16:9 Paysage</option>
<option value="9:16" selected>9:16 Portrait</option>
<option value="1:1">1:1 Carré</option>
</select>
</div>
</div>
<div class="control-row">
<label class="control-label">Mouvement</label>
<select id="motionP">
<option value="subtle">Subtil</option>
<option value="moderate" selected>Modéré</option>
<option value="strong">Fort</option>
</select>
</div>
</div>
<button class="btn btn-primary" id="genBtnP" disabled>▶ Générer la vidéo parlante</button>
<div class="status" id="genStatusP"></div>
<div class="progress-bar"><div class="progress-fill" id="progressP"></div></div>
<div class="result-video hidden" id="resultP">
<video controls playsinline id="videoPlayerP"></video>
<div class="result-actions">
<a class="btn btn-secondary" id="downloadP" download style="text-decoration:none;text-align:center;">⬇ Télécharger</a>
</div>
</div>
</div>
<!-- ================= PANEL 2 : TEXTE → VIDÉO ================= -->
<div class="main-panel" id="panel-text2video">
<div class="panel">
<h2>✍️ Description de la scène</h2>
<textarea id="promptT" placeholder="Ex : Un chat astronaute flottant dans l'espace, style cinématographique..." style="min-height:110px;"></textarea>
</div>
<div class="panel">
<h2>⚙️ Paramètres</h2>
<div class="grid-2">
<div class="control-row">
<label class="control-label">Durée</label>
<select id="durationT">
<option value="4">4 s</option>
<option value="5" selected>5 s</option>
<option value="8">8 s</option>
<option value="10">10 s</option>
<option value="12">12 s</option>
</select>
</div>
<div class="control-row">
<label class="control-label">Format</label>
<select id="aspectT">
<option value="16:9" selected>16:9 Paysage</option>
<option value="9:16">9:16 Portrait</option>
<option value="1:1">1:1 Carré</option>
</select>
</div>
</div>
<div class="control-row">
<label class="control-label">Mouvement</label>
<select id="motionT">
<option value="subtle">Subtil</option>
<option value="moderate" selected>Modéré</option>
<option value="strong">Fort</option>
</select>
</div>
<div class="control-row">
<label class="control-label">Prompt négatif (optionnel)</label>
<input type="text" id="negativeT" placeholder="Ex : flou, déformation">
</div>
</div>
<button class="btn btn-primary" id="genBtnT" disabled>▶ Générer la vidéo</button>
<div class="status" id="genStatusT"></div>
<div class="progress-bar"><div class="progress-fill" id="progressT"></div></div>
<div class="result-video hidden" id="resultT">
<video controls playsinline id="videoPlayerT"></video>
<div class="result-actions">
<a class="btn btn-secondary" id="downloadT" download style="text-decoration:none;text-align:center;">⬇ Télécharger</a>
</div>
</div>
</div>
<!-- ================= PANEL 3 : IMAGE → VIDÉO ================= -->
<div class="main-panel" id="panel-img2video">
<div class="panel">
<h2>🖼️ Image de départ</h2>
<div class="photo-zone" id="photoZoneI">
<input type="file" accept="image/*" id="photoInputI">
<div class="placeholder" id="photoPlaceholderI"><span class="icon">📷</span>Touchez pour choisir</div>
<img id="photoPreviewI" class="hidden" alt="">
</div>
</div>
<div class="panel">
<h2>✍️ Que doit-il se passer ?</h2>
<textarea id="promptI" placeholder="Ex : La personne tourne doucement la tête vers la caméra..." style="min-height:90px;"></textarea>
</div>
<div class="panel">
<h2>⚙️ Paramètres</h2>
<div class="grid-2">
<div class="control-row">
<label class="control-label">Durée</label>
<select id="durationI">
<option value="4">4 s</option>
<option value="5" selected>5 s</option>
<option value="8">8 s</option>
<option value="10">10 s</option>
<option value="12">12 s</option>
</select>
</div>
<div class="control-row">
<label class="control-label">Format</label>
<select id="aspectI">
<option value="16:9" selected>16:9 Paysage</option>
<option value="9:16">9:16 Portrait</option>
<option value="1:1">1:1 Carré</option>
</select>
</div>
</div>
<div class="control-row">
<label class="control-label">Mouvement</label>
<select id="motionI">
<option value="subtle">Subtil</option>
<option value="moderate" selected>Modéré</option>
<option value="strong">Fort</option>
</select>
</div>
</div>
<button class="btn btn-primary" id="genBtnI" disabled>▶ Générer la vidéo</button>
<div class="status" id="genStatusI"></div>
<div class="progress-bar"><div class="progress-fill" id="progressI"></div></div>
<div class="result-video hidden" id="resultI">
<video controls playsinline id="videoPlayerI"></video>
<div class="result-actions">
<a class="btn btn-secondary" id="downloadI" download style="text-decoration:none;text-align:center;">⬇ Télécharger</a>
</div>
</div>
</div>
<!-- ================= PANEL 4 : KEYFRAMES ================= -->
<div class="main-panel" id="panel-keyframes">
<div class="panel">
<h2>🖼️ Image de début</h2>
<div class="photo-zone" id="photoZoneK1">
<input type="file" accept="image/*" id="photoInputK1">
<div class="placeholder" id="photoPlaceholderK1"><span class="icon">📷</span>Touchez pour choisir</div>
<img id="photoPreviewK1" class="hidden" alt="">
</div>
</div>
<div class="panel">
<h2>🖼️ Image de fin</h2>
<div class="photo-zone" id="photoZoneK2">
<input type="file" accept="image/*" id="photoInputK2">
<div class="placeholder" id="photoPlaceholderK2"><span class="icon">📷</span>Touchez pour choisir</div>
<img id="photoPreviewK2" class="hidden" alt="">
</div>
</div>
<div class="panel">
<h2>✍️ Description du mouvement</h2>
<textarea id="promptK" placeholder="Ex : Transition fluide entre les deux images, mouvement naturel..." style="min-height:80px;"></textarea>
</div>
<div class="panel">
<h2>⚙️ Paramètres</h2>
<div class="grid-2">
<div class="control-row">
<label class="control-label">Durée</label>
<select id="durationK">
<option value="4">4 s</option>
<option value="5" selected>5 s</option>
<option value="6">6 s</option>
<option value="8">8 s</option>
<option value="10">10 s</option>
<option value="12">12 s</option>
</select>
</div>
<div class="control-row">
<label class="control-label">Format</label>
<select id="aspectK">
<option value="16:9" selected>16:9 Paysage</option>
<option value="9:16">9:16 Portrait</option>
<option value="1:1">1:1 Carré</option>
</select>
</div>
</div>
<div class="control-row">
<label class="control-label">Mouvement</label>
<select id="motionK">
<option value="subtle">Subtil</option>
<option value="moderate" selected>Modéré</option>
<option value="strong">Fort</option>
</select>
</div>
</div>
<button class="btn btn-primary" id="genBtnK" disabled>▶ Générer la transition</button>
<div class="status" id="genStatusK"></div>
<div class="progress-bar"><div class="progress-fill" id="progressK"></div></div>
<div class="result-video hidden" id="resultK">
<video controls playsinline id="videoPlayerK"></video>
<div class="result-actions">
<a class="btn btn-secondary" id="downloadK" download style="text-decoration:none;text-align:center;">⬇ Télécharger</a>
</div>
</div>
</div>
<!-- JOURNAL -->
<div class="panel">
<h2>📋 Journal</h2>
<div class="log-panel" id="log">
<div class="log-line">En attente...</div>
</div>
</div>
<script>
const API_BASE = 'https://apihub.agnes-ai.com/v1';
const POLL_BASE = 'https://apihub.agnes-ai.com/agnesapi';
const MODEL = 'agnes-video-2.5-flash';
const KEY_STORAGE = 'agnes_studio_key';
const S = {
photoP: null, voiceBase64P: null, isRecordingP: false, mediaRecorderP: null, chunksP: [],
photoI: null, photoK1: null, photoK2: null,
isGenerating: false
};
const $ = id => document.getElementById(id);
const sleep = ms => new Promise(r => setTimeout(r, ms));
function log(msg, type) {
const el = $('log');
const line = document.createElement('div');
line.className = 'log-line' + (type ? ' ' + type : '');
const t = new Date().toLocaleTimeString('fr-FR');
line.textContent = '[' + t + '] ' + msg;
el.appendChild(line);
el.scrollTop = el.scrollHeight;
console.log('[Agnes]', msg);
}
function getKey() {
try { return (localStorage.getItem(KEY_STORAGE) || '').trim(); } catch(e){ return ''; }
}
function saveKey() {
const v = $('apiKey').value.trim();
try {
if (v) localStorage.setItem(KEY_STORAGE, v);
else localStorage.removeItem(KEY_STORAGE);
} catch(e){}
updateKeyUI();
}
function updateKeyUI() {
const k = getKey();
const el = $('keyStatus');
if (k) {
el.textContent = '✓ Clé active · ' + k.slice(0,7) + '…' + k.slice(-4);
el.className = 'status ok';
$('apiKey').value = k;
} else {
el.textContent = 'Aucune clé enregistrée';
el.className = 'status err';
}
updateAllButtons();
}
document.querySelectorAll('.tab-btn').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.main-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
$('panel-' + tab.dataset.panel).classList.add('active');
});
});
function compress(dataUrl, maxSide) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
let { width, height } = img;
const scale = Math.min(1, maxSide / Math.max(width, height));
width = Math.round(width * scale);
height = Math.round(height * scale);
const c = document.createElement('canvas');
c.width = width; c.height = height;
c.getContext('2d').drawImage(img, 0, 0, width, height);
resolve(c.toDataURL('image/jpeg', 0.85));
};
img.onerror = () => resolve(dataUrl);
img.src = dataUrl;
});
}
function bindPhoto(zoneId, inputId, previewId, placeholderId, setter, maxSide) {
$(zoneId).addEventListener('click', () => $(inputId).click());
$(inputId).addEventListener('change', async (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = async ev => {
const compressed = await compress(ev.target.result, maxSide || 1024);
setter(compressed);
$(previewId).src = compressed;
$(previewId).classList.remove('hidden');
$(placeholderId).classList.add('hidden');
updateAllButtons();
};
r.readAsDataURL(f);
});
}
bindPhoto('photoZoneP', 'photoInputP', 'photoPreviewP', 'photoPlaceholderP', v => S.photoP = v, 1024);
bindPhoto('photoZoneI', 'photoInputI', 'photoPreviewI', 'photoPlaceholderI', v => S.photoI = v, 1280);
bindPhoto('photoZoneK1', 'photoInputK1', 'photoPreviewK1', 'photoPlaceholderK1', v => S.photoK1 = v, 1280);
bindPhoto('photoZoneK2', 'photoInputK2', 'photoPreviewK2', 'photoPlaceholderK2', v => S.photoK2 = v, 1280);
document.querySelectorAll('.mode-option[data-target="P"]').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.mode-option[data-target="P"]').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
const mode = el.dataset.mode;
$('paneVoiceP').classList.toggle('active', mode === 'voice');
$('paneTextP').classList.toggle('active', mode === 'text');
updateAllButtons();
});
});
$('recordBtnP').addEventListener('click', toggleRecordingP);
async function toggleRecordingP() {
if (S.isRecordingP) { stopRecordingP(); return; }
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
S.chunksP = [];
S.mediaRecorderP = new MediaRecorder(stream);
S.mediaRecorderP.ondataavailable = e => { if (e.data.size > 0) S.chunksP.push(e.data); };
S.mediaRecorderP.onstop = async () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(S.chunksP, { type: 'audio/webm' });
S.voiceBase64P = await blobToDataURL(blob);
$('audioPlayerP').src = URL.createObjectURL(blob);
$('audioPreviewP').classList.remove('hidden');
$('recordLabelP').textContent = '✓ Enregistrement prêt';
$('recordBtnP').classList.remove('recording');
S.isRecordingP = false;
updateAllButtons();
};
S.mediaRecorderP.start();
S.isRecordingP = true;
$('recordBtnP').classList.add('recording');
$('recordLabelP').textContent = '🔴 Enregistrement…';
} catch (err) {
$('genStatusP').textContent = 'Erreur micro : ' + err.message;
$('genStatusP').className = 'status err';
}
}
function stopRecordingP() {
if (S.mediaRecorderP && S.mediaRecorderP.state !== 'inactive') S.mediaRecorderP.stop();
}
function blobToDataURL(blob) {
return new Promise(res => {
const r = new FileReader();
r.onload = e => res(e.target.result);
r.readAsDataURL(blob);
});
}
function updateAllButtons() {
const hasKey = !!getKey();
let hasSourceP = false;
if ($('paneTextP').classList.contains('active')) hasSourceP = $('speechTextP').value.trim().length > 0;
else hasSourceP = !!S.voiceBase64P;
$('genBtnP').disabled = !(hasKey && S.photoP && hasSourceP) || S.isGenerating;
$('genBtnT').disabled = !(hasKey && $('promptT').value.trim().length > 0) || S.isGenerating;
$('genBtnI').disabled = !(hasKey && S.photoI && $('promptI').value.trim().length > 0) || S.isGenerating;
$('genBtnK').disabled = !(hasKey && S.photoK1 && S.photoK2 && $('promptK').value.trim().length > 0) || S.isGenerating;
}
$('speechTextP').addEventListener('input', updateAllButtons);
$('promptT').addEventListener('input', updateAllButtons);
$('promptI').addEventListener('input', updateAllButtons);
$('promptK').addEventListener('input', updateAllButtons);
async function createTask(body) {
const key = getKey();
const res = await fetch(API_BASE + '/videos', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const t = await res.text();
throw new Error('HTTP ' + res.status + ' — ' + t.slice(0, 200));
}
const data = await res.json();
const id = data.video_id || data.id || data.task_id;
if (!id) throw new Error('Pas de video_id retourné');
return id;
}
async function pollTask(videoId, onProgress) {
const key = getKey();
for (let i = 0; i < 180; i++) {
if (i > 0) await sleep(5000);
const url = POLL_BASE + '?video_id=' + encodeURIComponent(videoId) + '&model_name=' + encodeURIComponent(MODEL);
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + key } });
if (res.status === 429) {
log('Rate limit (429), attente 15s…', 'warn');
await sleep(15000);
continue;
}
if (!res.ok) throw new Error('Polling HTTP ' + res.status);
const d = await res.json();
const status = d.status || 'unknown';
const progress = d.progress || 0;
if (onProgress) onProgress(progress);
if (status === 'completed' || status === 'succeeded' || status === 'done') {
const videoUrl = (d.metadata && d.metadata.url) || d.url || (d.output && d.output.url);
if (!videoUrl) throw new Error('Terminé mais pas d\'URL');
return videoUrl;
}
if (status === 'failed' || status === 'error' || status === 'cancelled') {
throw new Error('Échec : ' + (d.error?.message || d.error || status));
}
}
throw new Error('Délai dépassé');
}
async function runGeneration(name, body, statusId, progressId, resultId, playerId, downloadId) {
S.isGenerating = true;
updateAllButtons();
$(statusId).textContent = 'Envoi…';
$(statusId).className = 'status';
$(progressId).style.width = '0%';
$(resultId).classList.add('hidden');
try {
log('Création tâche ' + name + '…');
const videoId = await createTask(body);
log('Tâche créée : ' + videoId, 'ok');
$(statusId).textContent = 'Génération…';
const url = await pollTask(videoId, p => {
$(progressId).style.width = p + '%';
$(statusId).textContent = 'Génération… ' + p + '%';
});
$(playerId).src = url;
$(downloadId).href = url;
$(resultId).classList.remove('hidden');
$(statusId).textContent = '✓ Vidéo prête';
$(statusId).className = 'status ok';
log('Vidéo prête.', 'ok');
} catch (err) {
$(statusId).textContent = 'Erreur : ' + err.message;
$(statusId).className = 'status err';
log('Erreur : ' + err.message, 'err');
} finally {
S.isGenerating = false;
updateAllButtons();
}
}
const MOTION_PROMPTS = {
subtle: 'subtle gentle movement, minimal animation',
moderate: 'moderate natural movement, clear motion',
strong: 'dynamic strong movement, energetic action'
};
$('genBtnP').addEventListener('click', () => {
if (!S.voiceBase64P) {
$('genStatusP').textContent = 'Enregistrez d\'abord votre voix.';
$('genStatusP').className = 'status err';
return;
}
const motion = MOTION_PROMPTS[$('motionP').value];
const body = {
model: MODEL,
mode: 'reference',
prompt: 'The person in <Picture 1> speaks naturally with <Audio 1>, ' + motion + '. Natural expression, cinematic portrait lighting.',
images: [S.photoP],
audios: [S.voiceBase64P],
seconds: $('durationP').value,
size: '720P',
aspect_ratio: $('aspectP').value
};
runGeneration('photo', body, 'genStatusP', 'progressP', 'resultP', 'videoPlayerP', 'downloadP');
});
$('genBtnT').addEventListener('click', () => {
const motion = MOTION_PROMPTS[$('motionT').value];
const neg = $('negativeT').value.trim();
const body = {
model: MODEL,
mode: 'text',
prompt: $('promptT').value.trim() + ', ' + motion,
seconds: $('durationT').value,
size: '720P',
aspect_ratio: $('aspectT').value
};
if (neg) body.negative_prompt = neg;
runGeneration('text', body, 'genStatusT', 'progressT', 'resultT', 'videoPlayerT', 'downloadT');
});
$('genBtnI').addEventListener('click', () => {
const motion = MOTION_PROMPTS[$('motionI').value];
const body = {
model: MODEL,
mode: 'text',
prompt: $('promptI').value.trim() + ', ' + motion,
first_frame: S.photoI,
seconds: $('durationI').value,
size: '720P',
aspect_ratio: $('aspectI').value
};
runGeneration('image', body, 'genStatusI', 'progressI', 'resultI', 'videoPlayerI', 'downloadI');
});
$('genBtnK').addEventListener('click', () => {
const motion = MOTION_PROMPTS[$('motionK').value];
const body = {
model: MODEL,
mode: 'keyframe',
prompt: $('promptK').value.trim() + ', ' + motion,
first_frame: S.photoK1,
last_frame: S.photoK2,
seconds: $('durationK').value,
size: '720P',
aspect_ratio: $('aspectK').value
};
runGeneration('keyframes', body, 'genStatusK', 'progressK', 'resultK', 'videoPlayerK', 'downloadK');
});
$('saveKey').addEventListener('click', saveKey);
$('apiKey').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); saveKey(); } });
updateKeyUI();
log('Application prête.');
</script>
</body>
</html>