<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VoiceReel — Your Face, Your Voice, 9:16</title>
<link href="https://fonts.googleapis.com/css2?family=Gloock:ital@0;1&family=Bricolage+Grotesque:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--ink:#0A0A0A; --ink3:#1A1A1A; --ink4:#242424;
--line:rgba(255,255,255,0.07); --line2:rgba(255,255,255,0.13);
--text:#F2EFE9; --sub:#888480; --ghost:#3D3A36;
--teal:#39D9B4; --teal2:rgba(57,217,180,0.1);
--coral:#FF6B5B;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--ink);color:var(--text);font-family:'Bricolage Grotesque',sans-serif;min-height:100vh;overflow-x:hidden}
body::before{content:'';position:fixed;inset:0;background-image:radial-gradient(circle,rgba(255,255,255,.035) 1px,transparent 1px);background-size:28px 28px;pointer-events:none;z-index:0}
nav{display:flex;align-items:center;justify-content:space-between;padding:1rem 2rem;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:100;background:rgba(10,10,10,.94);backdrop-filter:blur(24px)}
.logo{font-family:'Gloock',serif;font-size:1.35rem;letter-spacing:-.02em}
.logo em{color:var(--teal);font-style:italic}
.nav-right{display:flex;align-items:center;gap:.75rem}
.nav-tag{font-family:'DM Mono',monospace;font-size:.62rem;letter-spacing:.1em;color:var(--sub);border:1px solid var(--line2);padding:.28rem .7rem;border-radius:3px}
.live-ind{display:flex;align-items:center;gap:.4rem;font-family:'DM Mono',monospace;font-size:.62rem;color:var(--coral);opacity:0;transition:opacity .3s}
.live-ind.show{opacity:1}
.live-dot{width:5px;height:5px;border-radius:50%;background:var(--coral);animation:lp .8s ease-in-out infinite}
@keyframes lp{0%,100%{opacity:1}50%{opacity:.3}}
main{display:grid;grid-template-columns:1fr 400px;min-height:calc(100vh - 57px);position:relative;z-index:1}
@media(max-width:820px){main{grid-template-columns:1fr}}
.canvas-side{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2.5rem 2rem;border-right:1px solid var(--line);min-height:580px}
.canvas-label{font-family:'DM Mono',monospace;font-size:.6rem;letter-spacing:.15em;color:var(--sub);text-transform:uppercase;margin-bottom:1rem;display:flex;align-items:center;gap:.6rem}
.canvas-label::before,.canvas-label::after{content:'';flex:1;height:1px;background:var(--line2);max-width:60px}
.phone-frame{position:relative;width:270px;height:480px;border-radius:28px;overflow:hidden;box-shadow:0 0 0 1.5px rgba(255,255,255,.1),0 32px 64px rgba(0,0,0,.6);flex-shrink:0}
#videoCanvas{width:100%;height:100%;display:block;border-radius:28px}
.phone-ring{position:absolute;inset:-6px;border-radius:34px;border:2px solid transparent;transition:all .3s;pointer-events:none}
.phone-ring.rec{border-color:var(--coral);box-shadow:0 0 0 4px rgba(255,107,91,.15);animation:rf 1.5s ease-in-out infinite}
@keyframes rf{0%,100%{box-shadow:0 0 0 4px rgba(255,107,91,.15)}50%{box-shadow:0 0 0 10px rgba(255,107,91,.04)}}
.canvas-meta{margin-top:1rem;display:flex;gap:.5rem;align-items:center;font-family:'DM Mono',monospace;font-size:.62rem;color:var(--sub)}
.canvas-meta span{background:var(--ink3);padding:.2rem .6rem;border-radius:3px;border:1px solid var(--line)}
.sidebar{padding:1.75rem 1.5rem;border-left:1px solid var(--line);display:flex;flex-direction:column;gap:1.4rem;overflow-y:auto;max-height:calc(100vh - 57px)}
.sec-title{font-family:'DM Mono',monospace;font-size:.6rem;letter-spacing:.14em;text-transform:uppercase;color:var(--sub);margin-bottom:.85rem;padding-bottom:.6rem;border-bottom:1px solid var(--line)}
/* UPLOAD */
.upload-drop{border:1.5px dashed var(--ghost);border-radius:16px;padding:1.5rem 1rem;text-align:center;cursor:pointer;transition:all .25s;background:var(--ink3);display:flex;flex-direction:column;align-items:center;gap:.6rem}
.upload-drop:hover,.upload-drop.drag{border-color:var(--teal);background:var(--teal2)}
.upload-drop.has-photo{border-style:solid;border-color:var(--teal);padding:.75rem}
.upload-icon{font-size:2rem}
.upload-title{font-size:.82rem;font-weight:500;color:var(--text)}
.upload-sub{font-size:.7rem;color:var(--sub)}
.upload-fmts{display:flex;gap:.3rem;justify-content:center;font-family:'DM Mono',monospace;font-size:.6rem;color:var(--ghost)}
.photo-row{display:none;align-items:center;gap:1rem;width:100%}
.photo-row.show{display:flex}
.photo-thumb{width:56px;height:56px;border-radius:50%;object-fit:cover;border:2px solid var(--teal);flex-shrink:0}
.photo-info{flex:1;text-align:left}
.photo-name{font-size:.78rem;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.photo-meta{font-size:.65rem;color:var(--sub);margin-top:.15rem}
.photo-rm{width:28px;height:28px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--sub);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:.75rem;flex-shrink:0;transition:all .2s}
.photo-rm:hover{border-color:var(--coral);color:var(--coral)}
.style-row{display:none;gap:.5rem;flex-wrap:wrap;margin-top:.75rem}
.sty-btn{padding:.35rem .85rem;border-radius:100px;border:1.5px solid var(--line2);background:var(--ink3);color:var(--sub);font-size:.72rem;cursor:pointer;transition:all .2s;font-family:'Bricolage Grotesque',sans-serif}
.sty-btn:hover{color:var(--text)}
.sty-btn.on{border-color:var(--teal);color:var(--teal);background:var(--teal2)}
/* BG */
.bg-row{display:flex;gap:.4rem;flex-wrap:wrap}
.bg-sw{width:36px;height:36px;border-radius:8px;cursor:pointer;border:2px solid transparent;transition:all .2s}
.bg-sw.on{border-color:white;box-shadow:0 0 0 3px rgba(255,255,255,.1)}
/* TEXT */
.text-input{width:100%;padding:.65rem .85rem;background:var(--ink3);border:1px solid var(--line2);border-radius:8px;color:var(--text);font-family:'Bricolage Grotesque',sans-serif;font-size:.82rem;outline:none;transition:border-color .2s;resize:none}
.text-input:focus{border-color:rgba(57,217,180,.4)}
.text-input::placeholder{color:var(--ghost)}
/* SLIDERS */
.audio-grid{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}
.mini-sl{display:flex;flex-direction:column;gap:.35rem}
.mini-lbl{display:flex;justify-content:space-between;font-size:.7rem;color:var(--sub)}
.mini-lbl em{font-style:normal;color:var(--teal);font-family:'DM Mono',monospace;font-size:.68rem}
input[type=range]{-webkit-appearance:none;width:100%;height:3px;border-radius:2px;background:var(--ghost);outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--ink);border:2px solid var(--teal);transition:transform .15s}
input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.3)}
/* WAVE */
.wave-strip{height:48px;background:var(--ink3);border:1px solid var(--line);border-radius:8px;display:flex;align-items:center;padding:0 8px;gap:2px;overflow:hidden}
.wv-b{width:3px;border-radius:1.5px;background:var(--teal);min-height:3px;transition:height .06s;opacity:.7;flex-shrink:0}
/* TIMER */
.timer-disp{text-align:center;font-family:'DM Mono',monospace;font-size:2rem;font-weight:500;letter-spacing:.08em;padding:.5rem 0}
.timer-disp.rec{color:var(--coral)}
.timer-sub{text-align:center;font-size:.72rem;color:var(--sub);margin-top:-.25rem;margin-bottom:.5rem}
/* REC BTN */
.rec-wrap{display:flex;justify-content:center}
.rec-btn{width:72px;height:72px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1.6rem;background:var(--ink3);border:2px solid var(--line2);transition:all .25s;position:relative}
.rec-btn::before{content:'';position:absolute;inset:-8px;border-radius:50%;border:1.5px solid rgba(255,255,255,.06);animation:breathe 3s ease-in-out infinite}
@keyframes breathe{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
.rec-btn.ready:hover{background:var(--teal2);border-color:var(--teal)}
.rec-btn.recording{background:rgba(255,107,91,.12);border-color:var(--coral);animation:rpulse .9s ease-in-out infinite}
@keyframes rpulse{0%,100%{box-shadow:0 0 0 0 rgba(255,107,91,.3)}50%{box-shadow:0 0 0 14px rgba(255,107,91,0)}}
.rec-btn.done{background:var(--teal2);border-color:var(--teal)}
/* ACTIONS */
.actions{display:flex;flex-direction:column;gap:.6rem}
.action-btn{width:100%;padding:.75rem;border-radius:10px;border:1.5px solid var(--line2);background:var(--ink3);color:var(--text);font-family:'Bricolage Grotesque',sans-serif;font-size:.85rem;font-weight:500;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;gap:.5rem}
.action-btn:hover{background:var(--ink4)}
.action-btn:disabled{opacity:.35;cursor:not-allowed}
.action-btn.primary{background:var(--teal);border-color:var(--teal);color:#0A0A0A;font-weight:600}
.action-btn.primary:hover{background:#2EC8A3}
.action-btn.primary:disabled{background:var(--ink3);border-color:var(--line2);color:var(--sub)}
.status-bar{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem;border-radius:7px;background:var(--ink4);border:1px solid var(--line2);font-size:.75rem;color:var(--sub);min-height:34px}
.status-bar.ok{border-color:rgba(57,217,180,.25);color:var(--teal)}
.status-bar.err{border-color:rgba(255,107,91,.25);color:var(--coral)}
/* OVERLAY */
.proc-overlay{display:none;position:fixed;inset:0;z-index:200;background:rgba(10,10,10,.9);backdrop-filter:blur(12px);align-items:center;justify-content:center;flex-direction:column;gap:1.25rem}
.proc-overlay.show{display:flex}
.spinner{width:48px;height:48px;border-radius:50%;border:2px solid var(--ink4);border-top-color:var(--teal);animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.proc-title{font-family:'Gloock',serif;font-size:1.4rem}
.proc-sub{font-size:.82rem;color:var(--sub)}
.proc-bar{width:240px;height:3px;background:var(--ink4);border-radius:2px;overflow:hidden}
.proc-fill{height:100%;background:var(--teal);width:0%;transition:width .4s ease}
</style>
</head>
<body>
<nav>
<div class="logo">Voice<em>Reel</em></div>
<div class="nav-right">
<div class="live-ind" id="liveInd"><div class="live-dot"></div>REC</div>
<div class="nav-tag">9:16 VERTICAL VIDEO</div>
</div>
</nav>
<main>
<!-- CANVAS PREVIEW -->
<div class="canvas-side">
<div class="canvas-label">Live Preview</div>
<div style="position:relative">
<div class="phone-frame">
<canvas id="videoCanvas" width="405" height="720"></canvas>
</div>
<div class="phone-ring" id="phoneRing"></div>
</div>
<div class="canvas-meta">
<span>9:16</span><span>405 × 720</span><span>30 fps</span><span>WebM</span>
</div>
</div>
<!-- SIDEBAR -->
<div class="sidebar">
<!-- PHOTO UPLOAD -->
<div>
<div class="sec-title">Your Avatar Photo</div>
<div class="upload-drop" id="uploadDrop"
onclick="document.getElementById('photoInput').click()"
ondragover="onDragOver(event)" ondragleave="onDragLeave(event)" ondrop="onDrop(event)">
<div id="uploadDefault">
<div class="upload-icon">📸</div>
<div class="upload-title">Upload your photo</div>
<div class="upload-sub">Drag & drop or click to browse</div>
<div class="upload-fmts">JPG · PNG · WEBP · HEIC</div>
</div>
<div class="photo-row" id="photoRow">
<img class="photo-thumb" id="photoThumb" src="" alt="">
<div class="photo-info">
<div class="photo-name" id="photoName">—</div>
<div class="photo-meta" id="photoMeta">—</div>
</div>
<button class="photo-rm" onclick="removePhoto(event)">✕</button>
</div>
</div>
<input type="file" id="photoInput" accept="image/*" style="display:none" onchange="handlePhoto(this.files[0])">
<!-- Frame shape -->
<div class="style-row" id="styleRow">
<button class="sty-btn on" onclick="setStyle('circle',this)">◯ Circle</button>
<button class="sty-btn" onclick="setStyle('square',this)">▢ Square</button>
<button class="sty-btn" onclick="setStyle('hexagon',this)">⬡ Hex</button>
<button class="sty-btn" onclick="setStyle('full',this)">⬛ Full</button>
</div>
</div>
<!-- BACKGROUND -->
<div>
<div class="sec-title">Background</div>
<div class="bg-row">
<div class="bg-sw on" style="background:linear-gradient(160deg,#0A0F1E,#0F1F1A)" onclick="setBg(0,this)"></div>
<div class="bg-sw" style="background:linear-gradient(160deg,#1A0A0A,#2A0F0F)" onclick="setBg(1,this)"></div>
<div class="bg-sw" style="background:linear-gradient(160deg,#0A0A1A,#120A2A)" onclick="setBg(2,this)"></div>
<div class="bg-sw" style="background:linear-gradient(160deg,#0F1A0A,#0A1A10)" onclick="setBg(3,this)"></div>
<div class="bg-sw" style="background:linear-gradient(160deg,#1A1208,#120E04)" onclick="setBg(4,this)"></div>
<div class="bg-sw" style="background:linear-gradient(160deg,#0A0A0A,#1A1A1A)" onclick="setBg(5,this)"></div>
<div class="bg-sw" style="background:linear-gradient(160deg,#F5F0E8,#EDE5D4)" onclick="setBg(6,this)"></div>
<div class="bg-sw" style="background:linear-gradient(160deg,#0A1828,#051220)" onclick="setBg(7,this)"></div>
</div>
</div>
<!-- TEXT -->
<div>
<div class="sec-title">Text Overlay</div>
<textarea class="text-input" id="titleText" rows="2" placeholder="Your headline on the video..." oninput="redraw()">Speak your mind.</textarea>
<div style="margin-top:.5rem">
<input class="text-input" id="subText" placeholder="Your name or subtitle..." oninput="redraw()" style="padding:.55rem .85rem">
</div>
</div>
<!-- AUDIO -->
<div>
<div class="sec-title">Voice Enhancement</div>
<div class="audio-grid">
<div class="mini-sl">
<div class="mini-lbl">Warmth <em id="lW">+5</em></div>
<input type="range" id="rW" min="-6" max="12" value="5" step=".5" oninput="document.getElementById('lW').textContent=(this.value>0?'+':'')+this.value">
</div>
<div class="mini-sl">
<div class="mini-lbl">Presence <em id="lP">+4</em></div>
<input type="range" id="rP" min="-6" max="10" value="4" step=".5" oninput="document.getElementById('lP').textContent=(this.value>0?'+':'')+this.value">
</div>
<div class="mini-sl">
<div class="mini-lbl">Compress <em id="lC">4:1</em></div>
<input type="range" id="rC" min="1" max="16" value="4" step=".5" oninput="document.getElementById('lC').textContent=parseFloat(this.value).toFixed(1)+':1'">
</div>
<div class="mini-sl">
<div class="mini-lbl">Noise Gate <em id="lG">−28</em></div>
<input type="range" id="rG" min="-60" max="-5" value="-28" step="1" oninput="document.getElementById('lG').textContent=this.value">
</div>
</div>
</div>
<!-- RECORD -->
<div>
<div class="sec-title">Recording</div>
<div class="wave-strip" id="waveStrip"></div>
<div style="margin-top:1rem">
<div class="timer-disp" id="timerDisp">0:00</div>
<div class="timer-sub" id="timerSub">Ready to record</div>
</div>
<div class="rec-wrap" style="margin:.75rem 0">
<button class="rec-btn ready" id="recBtn" onclick="toggleRecord()">🎙️</button>
</div>
</div>
<div class="status-bar" id="statusBar">Allow microphone to begin</div>
<div class="actions">
<button class="action-btn primary" id="exportBtn" onclick="exportVideo()" disabled>↓ Export 9:16 Video (.webm)</button>
<button class="action-btn" id="playBtn" onclick="playback()" disabled>▶ Preview Recording</button>
<button class="action-btn" onclick="clearAll()">✕ Clear & Start Over</button>
</div>
</div>
</main>
<div class="proc-overlay" id="procOverlay">
<div class="spinner"></div>
<div class="proc-title">Rendering your video</div>
<div class="proc-sub" id="procSub">Combining canvas + audio...</div>
<div class="proc-bar"><div class="proc-fill" id="procFill"></div></div>
</div>
<script>
const canvas = document.getElementById('videoCanvas');
const ctx = canvas.getContext('2d');
const CW=405, CH=720;
let S = {
bgIdx:0, style:'circle',
isRec:false, hasRec:false,
tick:0, energy:0, energySmooth:0,
particles:[], photoImg:null,
};
const BG=[
{top:'#0A0F1E',bot:'#0F1F1A',accent:'#39D9B4',dark:true},
{top:'#1A0A0A',bot:'#2A0F0F',accent:'#FF6B5B',dark:true},
{top:'#0A0A1A',bot:'#120A2A',accent:'#9B7EF0',dark:true},
{top:'#0F1A0A',bot:'#0A1A10',accent:'#4BAD7A',dark:true},
{top:'#1A1208',bot:'#120E04',accent:'#F5A623',dark:true},
{top:'#0A0A0A',bot:'#1A1A1A',accent:'#AAAAAA',dark:true},
{top:'#F5F0E8',bot:'#EDE5D4',accent:'#5C4A32',dark:false},
{top:'#0A1828',bot:'#051220',accent:'#4A9ECC',dark:true},
];
function initParticles(){
S.particles=Array.from({length:20},()=>({
x:Math.random()*CW, y:Math.random()*CH,
r:Math.random()*2+.5,
vx:(Math.random()-.5)*.3, vy:-Math.random()*.4-.1,
a:Math.random()*.4+.1
}));
}
initParticles();
// ── DRAW LOOP ──
function draw(){
requestAnimationFrame(draw);
S.tick++;
const bg=BG[S.bgIdx];
S.energySmooth+=(S.energy-S.energySmooth)*.18;
// BG
const gr=ctx.createLinearGradient(0,0,0,CH);
gr.addColorStop(0,bg.top); gr.addColorStop(1,bg.bot);
ctx.fillStyle=gr; ctx.fillRect(0,0,CW,CH);
// Glow
ctx.save(); ctx.globalAlpha=0.07+S.energySmooth*.07;
const rg=ctx.createRadialGradient(CW/2,CH*.48,0,CW/2,CH*.48,250);
rg.addColorStop(0,bg.accent); rg.addColorStop(1,'transparent');
ctx.fillStyle=rg; ctx.fillRect(0,0,CW,CH);
ctx.restore();
// Particles
S.particles.forEach(p=>{
p.x+=p.vx; p.y+=p.vy;
if(p.y<-10){p.y=CH+10;p.x=Math.random()*CW;}
ctx.save(); ctx.globalAlpha=p.a*(0.35+S.energySmooth*.65);
ctx.fillStyle=bg.accent;
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2); ctx.fill();
ctx.restore();
});
const floatY=Math.sin(S.tick*.022)*6;
const avCX=CW/2, avCY=CH*.41+floatY, avR=100;
// Voice ripples
if(S.isRec && S.energySmooth>.02){
for(let i=0;i<4;i++){
const ripR=avR+18+i*24+S.energySmooth*16;
const alpha=Math.max(0,(0.38-i*.09)*S.energySmooth);
ctx.save(); ctx.globalAlpha=alpha;
ctx.strokeStyle=bg.accent; ctx.lineWidth=2.5-i*.4;
ctx.beginPath(); ctx.arc(avCX,avCY,ripR,0,Math.PI*2); ctx.stroke();
ctx.restore();
}
}
// Wobble ring when speaking
if(S.energySmooth>.02){
ctx.save(); ctx.globalAlpha=0.18+S.energySmooth*.22;
ctx.strokeStyle=bg.accent; ctx.lineWidth=1.5;
const wR=avR+14;
ctx.beginPath();
for(let a=0;a<Math.PI*2;a+=.055){
const w=1+S.energySmooth*.12*Math.sin(a*12+S.tick*.07);
const px=avCX+Math.cos(a)*wR*w, py=avCY+Math.sin(a)*wR*w;
a<.06?ctx.moveTo(px,py):ctx.lineTo(px,py);
}
ctx.closePath(); ctx.stroke();
ctx.restore();
}
// Avatar
if(S.photoImg) drawPhoto(avCX,avCY,avR,bg);
else drawPlaceholder(avCX,avCY,avR,bg);
// Title text
const title=document.getElementById('titleText').value||'';
const sub=document.getElementById('subText').value||'';
const tc=bg.dark?'#F2EFE9':'#1A1208';
const sc=bg.dark?'rgba(242,239,233,.55)':'rgba(26,18,8,.5)';
if(title){
ctx.save();
ctx.font='bold 36px Gloock,Georgia,serif';
ctx.fillStyle=tc; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.shadowColor=bg.dark?'rgba(0,0,0,.6)':'rgba(255,255,255,.4)';
ctx.shadowBlur=14;
wrapText(ctx,title,CW/2,CH*.775,CW-60,44);
ctx.restore();
}
if(sub){
ctx.save();
ctx.font='300 22px Bricolage Grotesque,sans-serif';
ctx.fillStyle=sc; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(sub,CW/2,CH*.775+(title?54:0));
ctx.restore();
}
// REC indicator
if(S.isRec){
ctx.save();
ctx.fillStyle='#FF6B5B';
ctx.beginPath(); ctx.arc(30,30,7,0,Math.PI*2); ctx.fill();
ctx.globalAlpha=.3+Math.sin(S.tick*.12)*.3;
ctx.strokeStyle='#FF6B5B'; ctx.lineWidth=2;
ctx.beginPath(); ctx.arc(30,30,13,0,Math.PI*2); ctx.stroke();
ctx.restore();
ctx.font='500 17px DM Mono,monospace';
ctx.fillStyle='#FF6B5B'; ctx.textAlign='left'; ctx.textBaseline='middle';
ctx.fillText('REC',46,30);
}
if(S.isRec||S.hasRec){
ctx.font='400 19px DM Mono,monospace';
ctx.fillStyle=bg.dark?'rgba(255,255,255,.38)':'rgba(0,0,0,.35)';
ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText(document.getElementById('timerDisp').textContent,CW-18,30);
}
// Bottom accent
ctx.save(); ctx.fillStyle=bg.accent; ctx.globalAlpha=.65;
ctx.fillRect(0,CH-3,CW,3);
ctx.restore();
}
// ── DRAW PHOTO ──
function drawPhoto(cx,cy,r,bg){
const scale=1+S.energySmooth*.05;
ctx.save();
ctx.translate(cx,cy); ctx.scale(scale,scale); ctx.translate(-cx,-cy);
// Clip
ctx.save();
ctx.beginPath();
applyShapePath(cx,cy,r);
ctx.clip();
// Cover-crop image
if(S.style==='full'){
const asp=S.photoImg.width/S.photoImg.height;
const h=CH*.62, w=h*asp;
ctx.drawImage(S.photoImg,cx-w/2,cy-h/2,w,h);
} else {
const d=r*2;
const iw=S.photoImg.width, ih=S.photoImg.height;
const sc2=Math.max(d/iw,d/ih);
const sw=d/sc2, sh=d/sc2;
const sx=(iw-sw)/2, sy=(ih-sh)/2;
ctx.drawImage(S.photoImg,sx,sy,sw,sh,cx-r,cy-r,d,d);
}
ctx.restore();
// Glowing border ring
ctx.save();
ctx.strokeStyle=bg.accent;
ctx.lineWidth=3+S.energySmooth*5;
ctx.globalAlpha=.7+S.energySmooth*.3;
ctx.shadowColor=bg.accent;
ctx.shadowBlur=14+S.energySmooth*22;
ctx.beginPath();
applyShapePath(cx,cy,r+2);
ctx.stroke();
ctx.restore();
ctx.restore(); // undo scale
}
function applyShapePath(cx,cy,r){
if(S.style==='circle'){
ctx.arc(cx,cy,r,0,Math.PI*2);
} else if(S.style==='square'){
const h=r*.95;
rrect(ctx,cx-h,cy-h,h*2,h*2,16);
} else if(S.style==='hexagon'){
ctx.beginPath();
for(let i=0;i<6;i++){
const a=Math.PI/180*(60*i-30);
const x=cx+r*Math.cos(a), y=cy+r*Math.sin(a);
i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);
}
ctx.closePath();
} else {
// full rectangle
if(S.photoImg){
const asp=S.photoImg.width/S.photoImg.height;
const h=CH*.62, w=h*asp;
ctx.rect(cx-w/2,cy-h/2,w,h);
}
}
}
function rrect(ctx,x,y,w,h,r){
ctx.beginPath();
ctx.moveTo(x+r,y);
ctx.lineTo(x+w-r,y); ctx.quadraticCurveTo(x+w,y,x+w,y+r);
ctx.lineTo(x+w,y+h-r); ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);
ctx.lineTo(x+r,y+h); ctx.quadraticCurveTo(x,y+h,x,y+h-r);
ctx.lineTo(x,y+r); ctx.quadraticCurveTo(x,y,x+r,y);
ctx.closePath();
}
function drawPlaceholder(cx,cy,r,bg){
ctx.save();
ctx.fillStyle='rgba(255,255,255,.04)';
ctx.strokeStyle=bg.accent; ctx.lineWidth=2; ctx.setLineDash([6,4]); ctx.globalAlpha=.6;
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2);
ctx.fill(); ctx.stroke(); ctx.setLineDash([]);
ctx.restore();
ctx.font='48px serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('📸',cx,cy-10);
ctx.font='500 15px Bricolage Grotesque,sans-serif';
ctx.fillStyle='rgba(255,255,255,.3)'; ctx.fillText('Upload your photo',cx,cy+48);
}
function wrapText(ctx,text,x,y,maxW,lineH){
const words=text.split(' ');
let line='',lines=[];
words.forEach(w=>{
const t=line+w+' ';
if(ctx.measureText(t).width>maxW&&line){lines.push(line);line=w+' ';}
else line=t;
});
lines.push(line);
const total=lines.length*lineH;
lines.forEach((l,i)=>ctx.fillText(l.trim(),x,y-total/2+i*lineH+lineH/2));
}
// ── PHOTO UPLOAD ──
function onDragOver(e){e.preventDefault();document.getElementById('uploadDrop').classList.add('drag');}
function onDragLeave(){document.getElementById('uploadDrop').classList.remove('drag');}
function onDrop(e){
e.preventDefault(); document.getElementById('uploadDrop').classList.remove('drag');
const f=e.dataTransfer.files[0];
if(f&&f.type.startsWith('image/')) handlePhoto(f);
}
function handlePhoto(file){
if(!file) return;
const reader=new FileReader();
reader.onload=e=>{
const img=new Image();
img.onload=()=>{
S.photoImg=img;
document.getElementById('photoThumb').src=e.target.result;
document.getElementById('photoName').textContent=file.name;
document.getElementById('photoMeta').textContent=
img.width+'×'+img.height+' · '+(file.size>1e6?(file.size/1e6).toFixed(1)+' MB':(file.size/1e3).toFixed(0)+' KB');
document.getElementById('uploadDefault').style.display='none';
document.getElementById('photoRow').classList.add('show');
document.getElementById('uploadDrop').classList.add('has-photo');
document.getElementById('styleRow').style.display='flex';
setStatus('Photo ready — hit record!','ok');
};
img.src=e.target.result;
};
reader.readAsDataURL(file);
}
function removePhoto(e){
e.stopPropagation();
S.photoImg=null;
document.getElementById('photoThumb').src='';
document.getElementById('uploadDefault').style.display='';
document.getElementById('photoRow').classList.remove('show');
document.getElementById('uploadDrop').classList.remove('has-photo');
document.getElementById('styleRow').style.display='none';
document.getElementById('photoInput').value='';
}
function setStyle(style,el){
S.style=style;
document.querySelectorAll('.sty-btn').forEach(b=>b.classList.remove('on'));
el.classList.add('on');
}
function setBg(idx,el){
S.bgIdx=idx;
document.querySelectorAll('.bg-sw').forEach(s=>s.classList.remove('on'));
el.classList.add('on');
}
function redraw(){}
// ── AUDIO ENGINE ──
let audioCtx=null,micStream=null,analyser=null,mediaRecorder=null;
let recordedChunks=[],isRecording=false,startTime=null,timerInt=null,procDest=null;
async function initMic(){
try{
if(!audioCtx) audioCtx=new(window.AudioContext||window.webkitAudioContext)({sampleRate:44100});
if(audioCtx.state==='suspended') await audioCtx.resume();
micStream=await navigator.mediaDevices.getUserMedia({
audio:{echoCancellation:false,noiseSuppression:false,autoGainControl:false,sampleRate:44100}
});
const src=audioCtx.createMediaStreamSource(micStream);
const hp=audioCtx.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=80;
const ls=audioCtx.createBiquadFilter(); ls.type='lowshelf'; ls.frequency.value=250;
ls.gain.value=parseFloat(document.getElementById('rW').value);
const hm=audioCtx.createBiquadFilter(); hm.type='peaking'; hm.frequency.value=3000; hm.Q.value=.9;
hm.gain.value=parseFloat(document.getElementById('rP').value);
const hs=audioCtx.createBiquadFilter(); hs.type='highshelf'; hs.frequency.value=9000; hs.gain.value=2;
const comp=audioCtx.createDynamicsCompressor();
comp.threshold.value=parseFloat(document.getElementById('rG').value);
comp.ratio.value=parseFloat(document.getElementById('rC').value);
comp.attack.value=.003; comp.release.value=.15; comp.knee.value=5;
const gain=audioCtx.createGain(); gain.gain.value=1.3;
analyser=audioCtx.createAnalyser(); analyser.fftSize=1024; analyser.smoothingTimeConstant=.8;
procDest=audioCtx.createMediaStreamDestination();
src.connect(hp);hp.connect(ls);ls.connect(hm);hm.connect(hs);
hs.connect(comp);comp.connect(gain);gain.connect(analyser);gain.connect(procDest);
setStatus('Mic ready — tap the button to record!','ok');
startWaveAnim();
return true;
}catch(e){
setStatus('Mic access denied — please allow microphone.','err');
return false;
}
}
// Wave bars
function buildWave(){
const strip=document.getElementById('waveStrip'); strip.innerHTML='';
for(let i=0;i<52;i++){const b=document.createElement('div');b.className='wv-b';b.style.height='3px';strip.appendChild(b);}
}
buildWave();
function startWaveAnim(){
const bars=document.querySelectorAll('.wv-b');
function frame(){
requestAnimationFrame(frame);
if(!analyser) return;
const data=new Uint8Array(analyser.frequencyBinCount);
analyser.getByteTimeDomainData(data);
bars.forEach((b,i)=>{
const idx=Math.floor(i*data.length/bars.length);
const h=Math.abs(data[idx]-128)/128;
b.style.height=(3+h*38)+'px';
});
let sum=0; data.forEach(v=>sum+=Math.pow((v-128)/128,2));
S.energy=Math.min(1,Math.sqrt(sum/data.length)*6);
}
frame();
}
async function toggleRecord(){
if(isRecording){stopRecording();return;}
if(!micStream){const ok=await initMic();if(!ok)return;}
startRecording();
}
function startRecording(){
const canvasStream=canvas.captureStream(30);
const audioTrack=procDest.stream.getAudioTracks()[0];
const combined=new MediaStream([...canvasStream.getVideoTracks(),audioTrack]);
const mime=MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')?'video/webm;codecs=vp9,opus':'video/webm';
mediaRecorder=new MediaRecorder(combined,{mimeType:mime,videoBitsPerSecond:2500000});
recordedChunks=[];
mediaRecorder.ondataavailable=e=>{if(e.data.size>0)recordedChunks.push(e.data);};
mediaRecorder.onstop=()=>{
S.hasRec=true;
window._blob=new Blob(recordedChunks,{type:'video/webm'});
document.getElementById('exportBtn').disabled=false;
document.getElementById('playBtn').disabled=false;
setStatus('Video ready — export or preview!','ok');
};
mediaRecorder.start(100);
isRecording=true; S.isRec=true; startTime=Date.now();
document.getElementById('recBtn').className='rec-btn recording';
document.getElementById('recBtn').textContent='⏹';
document.getElementById('phoneRing').classList.add('rec');
document.getElementById('liveInd').classList.add('show');
document.getElementById('timerDisp').classList.add('rec');
document.getElementById('timerSub').textContent='Recording — speak naturally';
setStatus('Recording 9:16 video live...','ok');
timerInt=setInterval(()=>{
const s=Math.floor((Date.now()-startTime)/1000);
document.getElementById('timerDisp').textContent=Math.floor(s/60)+':'+String(s%60).padStart(2,'0');
},1000);
}
function stopRecording(){
isRecording=false; S.isRec=false; S.energy=0;
clearInterval(timerInt);
document.getElementById('recBtn').className='rec-btn done';
document.getElementById('recBtn').textContent='🎙️';
document.getElementById('phoneRing').classList.remove('rec');
document.getElementById('liveInd').classList.remove('show');
document.getElementById('timerDisp').classList.remove('rec');
document.getElementById('timerSub').textContent='Recording complete';
if(mediaRecorder&&mediaRecorder.state!=='inactive') mediaRecorder.stop();
}
function playback(){
if(!window._blob) return;
const url=URL.createObjectURL(window._blob);
const v=document.createElement('video');
v.src=url; v.controls=true; v.autoplay=true;
v.style.cssText='position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:300;max-height:90vh;border-radius:16px;background:#000;box-shadow:0 0 0 1px rgba(255,255,255,.1),0 32px 64px rgba(0,0,0,.8)';
const ov=document.createElement('div');
ov.style.cssText='position:fixed;inset:0;z-index:299;background:rgba(0,0,0,.8)';
ov.onclick=()=>{v.remove();ov.remove();};
document.body.append(ov,v);
}
async function exportVideo(){
if(!window._blob) return;
document.getElementById('procOverlay').classList.add('show');
document.getElementById('procFill').style.width='0%';
await prog(25,'Packaging video stream...');
await prog(60,'Encoding audio + video...');
await prog(90,'Finalising file...');
await prog(100,'Done!');
setTimeout(()=>{
document.getElementById('procOverlay').classList.remove('show');
const url=URL.createObjectURL(window._blob);
const a=document.createElement('a');
a.href=url; a.download='voicereel-9x16-'+Date.now()+'.webm'; a.click();
setStatus('Downloaded! Open in iMovie to export as MP4.','ok');
},300);
}
async function prog(pct,msg){
document.getElementById('procFill').style.width=pct+'%';
document.getElementById('procSub').textContent=msg;
await new Promise(r=>setTimeout(r,380));
}
function setStatus(msg,type){
const el=document.getElementById('statusBar');
el.textContent=msg;
el.className='status-bar'+(type?' '+type:'');
}
function clearAll(){
if(isRecording) stopRecording();
window._blob=null; recordedChunks=[];
S.hasRec=false; S.isRec=false; S.energy=0; S.energySmooth=0;
document.getElementById('exportBtn').disabled=true;
document.getElementById('playBtn').disabled=true;
document.getElementById('timerDisp').textContent='0:00';
document.getElementById('timerDisp').classList.remove('rec');
document.getElementById('timerSub').textContent='Ready to record';
document.getElementById('recBtn').className='rec-btn ready';
document.getElementById('recBtn').textContent='🎙️';
setStatus('Cleared. Ready for next take.','');
}
window.addEventListener('load',()=>{ initMic(); draw(); });
</script>
</body>
</html>
For further actions, you may consider blocking this person and/or reporting abuse
Top comments (0)