Exclusive Feature

PDF to Interactive Flipbook
with Audio

The only flipbook generator with synchronized narration, sound effects and background music. Turn any PDF into an immersive reading experience.

Synced Audio Narration

Record or upload audio per page. Narration plays automatically as readers flip through.

Sound Effects & Music

Background music, page-specific SFX, ambient sounds. Full audio control per spread.

Self-Hosted HTML

One-file export. Host anywhere — your site, S3, GitHub Pages. No third-party dependency.

Flipbook to Video

Export as MP4 with all audio. Ready for YouTube, TikTok, Instagram, Amazon A+ content.

Coco Doesn't Sleep Tonight — flipbook demo

See a real flipbook in action

This children's book was turned into an interactive flipbook with narration, sound effects and lullaby music — all generated with this tool.

Watch the Demo
Get Access — Start Creating

Included in Pro and Studio plans

Verifying your account...

'; window._lastFlipbookHtml = h; openFlipbook(h, 'fb1'); } catch(err){ showFeedback('fb1', 'Error: ' + err.message, false); } } /* ===== STEP 2: AUDIO ===== */ async function generateAudio(){ var config = getConfig(); if(!numPagesFromPdf){ showFeedback('fb2', 'Upload the PDF to detect the number of pages.', false); return; } showFeedback('fb2', 'Generating...', true); try { var tmpl = templateHTML ? templateHTML : await loadTemplate(); var html = await injectInlineImages(tmpl); html = replaceVars(html, config); html = stripSections(html, 'VISUAL'); html = cleanMarkers(html, 'AUDIO'); html = cleanMarkers(html, 'PAGETURN'); html = processBgSections(html, config.BG_TYPE); if(Object.keys(hotspots).length > 0){ html = cleanMarkers(html, 'HOTSPOTS'); } else { html = stripSections(html, 'HOTSPOTS'); } html = processPwaSections(html, config); window._lastFlipbookHtml = html; openFlipbook(html, 'fb2'); } catch(err){ showFeedback('fb2', 'Error: ' + err.message, false); } } /* ===== STEP 3: VIDEO (Canvas + MediaRecorder) ===== */ var VIDEO_W = 1920, VIDEO_H = 1080; var FADE_S = 3, FADE_IN_S = 1.5; function updateVideoProgress(text, pct){ document.getElementById('videoPText').textContent = text; document.getElementById('videoPBar').style.width = pct + '%'; } function safeFilename(t){ return t.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); } function downloadBlob(filename, blob){ var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } async function generateVideo(){ var config = getConfig(); if(!pdfPages.length){ showFeedback('fb3', 'Please load a PDF first (step 1).', false); return; } var btn = document.getElementById('btnVideo'); btn.disabled = true; document.getElementById('videoProgress').style.display = 'block'; document.getElementById('fb3').className = 'feedback'; try { var timerSec = parseFloat(config.TIMER_SECONDS) || 6; var bgFile = document.getElementById('bgMusicFileInput').files[0] || null; /* Phase 1: Build spreads */ updateVideoProgress('Loading images...', 5); var spreads = []; for(var i = 0; i < pdfPages.length; i += 2){ spreads.push(pdfPages.slice(i, Math.min(i+2, pdfPages.length))); } var spreadImgs = []; for(var si = 0; si < spreads.length; si++){ var imgs = []; for(var pi = 0; pi < spreads[si].length; pi++){ var img = new Image(); var u = URL.createObjectURL(spreads[si][pi]); await new Promise(function(res, rej){ img.onload=res; img.onerror=rej; img.src=u; }); URL.revokeObjectURL(u); imgs.push(img); } spreadImgs.push(imgs); updateVideoProgress('Loading images...', 5 + Math.round(si/spreads.length*10)); } /* Phase 2: Setup canvas */ updateVideoProgress('Preparing...', 15); var canvas = document.createElement('canvas'); canvas.width = VIDEO_W; canvas.height = VIDEO_H; var ctx = canvas.getContext('2d'); function drawSpread(idx){ var imgs = spreadImgs[idx]; ctx.fillStyle = config.BG_COLOR || '#1a1a3e'; ctx.fillRect(0, 0, VIDEO_W, VIDEO_H); if(imgs.length === 1){ var im = imgs[0]; var sc = Math.min(VIDEO_W/im.naturalWidth, VIDEO_H/im.naturalHeight); var dw = im.naturalWidth*sc, dh = im.naturalHeight*sc; ctx.drawImage(im, (VIDEO_W-dw)/2, (VIDEO_H-dh)/2, dw, dh); } else { var imL=imgs[0], imR=imgs[1]; var half = VIDEO_W/2; var scL = Math.min(half/imL.naturalWidth, VIDEO_H/imL.naturalHeight); var scR = Math.min(half/imR.naturalWidth, VIDEO_H/imR.naturalHeight); ctx.drawImage(imL, half-imL.naturalWidth*scL, (VIDEO_H-imL.naturalHeight*scL)/2, imL.naturalWidth*scL, imL.naturalHeight*scL); ctx.drawImage(imR, half, (VIDEO_H-imR.naturalHeight*scR)/2, imR.naturalWidth*scR, imR.naturalHeight*scR); } } drawSpread(0); /* Phase 3: Audio (bg music optionnelle) */ var audioCtx = null, dest = null, bgGain = null; var totalDur = spreads.length * timerSec + FADE_S; if(bgFile){ audioCtx = new AudioContext({sampleRate: 44100}); if(audioCtx.state==='suspended') await audioCtx.resume(); dest = audioCtx.createMediaStreamDestination(); var bgBuf = await audioCtx.decodeAudioData(await bgFile.arrayBuffer()); bgGain = audioCtx.createGain(); var bgVol = parseFloat(config.BG_MUSIC_VOLUME)||0.15; bgGain.gain.value = bgVol; bgGain.connect(dest); var bgSrc = audioCtx.createBufferSource(); bgSrc.buffer = bgBuf; bgSrc.loop = true; bgSrc.connect(bgGain); bgSrc.start(audioCtx.currentTime + 0.2); bgGain.gain.setValueAtTime(bgVol, audioCtx.currentTime + 0.2 + spreads.length*timerSec); bgGain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.2 + totalDur); } /* Phase 4: Recorder */ var tracks = [canvas.captureStream(30).getVideoTracks()[0]]; if(dest) tracks.push(dest.stream.getAudioTracks()[0]); var mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus') ? 'video/webm;codecs=vp9,opus' : 'video/webm'; var recorder = new MediaRecorder(new MediaStream(tracks), {mimeType: mimeType, videoBitsPerSecond: 5000000}); var chunks = []; recorder.ondataavailable = function(e){ if(e.data.size>0) chunks.push(e.data); }; /* Phase 5: Record */ updateVideoProgress('Recording...', 20); recorder.start(500); var startTime = performance.now(); await new Promise(function(resolve){ recorder.onstop = resolve; var currentSpread = 0; function tick(){ var elapsed = (performance.now() - startTime) / 1000; if(elapsed >= totalDur){ recorder.stop(); return; } var spreadIdx = Math.min(Math.floor(elapsed / timerSec), spreads.length-1); if(spreadIdx !== currentSpread){ currentSpread = spreadIdx; drawSpread(currentSpread); } // Fade in if(elapsed < FADE_IN_S){ drawSpread(currentSpread); ctx.fillStyle = 'rgba(0,0,0,' + (1 - elapsed/FADE_IN_S).toFixed(3) + ')'; ctx.fillRect(0,0,VIDEO_W,VIDEO_H); } // Fade out if(elapsed > spreads.length*timerSec){ var a = Math.min(1, (elapsed - spreads.length*timerSec)/FADE_S); ctx.fillStyle = 'rgba(0,0,0,' + a.toFixed(3) + ')'; ctx.fillRect(0,0,VIDEO_W,VIDEO_H); } var pct = 20 + Math.round(elapsed/totalDur*75); var rem = Math.ceil(totalDur - elapsed); updateVideoProgress('Recording... (' + (rem>60?Math.floor(rem/60)+'min ':'') + (rem%60)+'s)', Math.min(95,pct)); requestAnimationFrame(tick); } requestAnimationFrame(tick); }); /* Phase 6: Download */ updateVideoProgress('Downloading...', 98); var blob = new Blob(chunks, {type: mimeType}); downloadBlob(safeFilename(config.BOOK_TITLE||'flipbook') + '.webm', blob); updateVideoProgress('Done!', 100); showFeedback('fb3', 'Video downloaded! (' + (blob.size/1024/1024).toFixed(1) + ' MB)', true); if(audioCtx) audioCtx.close(); } catch(err){ showFeedback('fb3', 'Error: ' + err.message, false); console.error(err); } btn.disabled = false; } /* FIN */ /* old Python script generator removed var script = [ '#!/usr/bin/env python3', '"""', 'Generate YouTube video for: ' + config.BOOK_TITLE, 'Usage: python make_video.py', '"""', '', 'import subprocess, os, sys, shutil', '', 'BASE = os.path.dirname(os.path.abspath(__file__))', "IMG_DIR = os.path.join(BASE, 'images')", "AUDIO_DIR = os.path.join(BASE, 'audio')", "OUT_DIR = os.path.join(BASE, 'youtube')", "TMP = os.path.join(OUT_DIR, 'tmp')", '', "BG = '0x" + config.BG_COLOR.replace('#','') + "'", 'VW, VH = 1920, 1080', 'FPS = 24', 'LULLABY_VOL = ' + config.BG_MUSIC_VOLUME, 'FADE_S = 4', 'FADE_IN_S = 2', '', '# (name, page_numbers, audio_file)', 'SCENES = [', scenes.join('\n'), ']', '', "LULLABY = os.path.join(AUDIO_DIR, '" + config.BG_MUSIC_FILE + "')", "IMG_PREFIX = '" + config.IMG_PREFIX + "'", "IMG_EXT = '" + config.IMG_EXT + "'", '', '', 'def run(cmd):', ' r = subprocess.run(cmd, capture_output=True, text=True)', ' if r.returncode != 0:', ' print(f"ERREUR: {r.stderr[:500]}")', ' sys.exit(1)', ' return r', '', '', 'def duration(path):', " r = run(['ffprobe', '-v', 'error', '-show_entries', 'format=duration',", " '-of', 'csv=p=0', path])", ' return float(r.stdout.strip())', '', '', 'def img(n):', " return os.path.join(IMG_DIR, f'{IMG_PREFIX}{n}{IMG_EXT}')", '', '', 'def make_spread(pages, out):', ' if len(pages) == 1:', " run(['ffmpeg', '-y', '-i', img(pages[0]),", " '-vf', f'scale=-1:{VH},pad={VW}:{VH}:(ow-iw)/2:0:color={BG}',", " '-frames:v', '1', out])", ' else:', " run(['ffmpeg', '-y', '-i', img(pages[0]), '-i', img(pages[1]),", " '-filter_complex',", " f'[0]scale=-1:{VH}[l];[1]scale=-1:{VH}[r];'", " f'color=c={BG}:s={VW}x{VH}:d=1[bg];'", " f'[bg][l]overlay=120:0[tmp];[tmp][r]overlay=960:0',", " '-frames:v', '1', out])", '', '', 'def make_segment(img_path, dur, out):', " run(['ffmpeg', '-y', '-loop', '1', '-i', img_path,", " '-t', str(dur),", " '-vf', f'fps={FPS},format=yuv420p',", " '-c:v', 'libx264', '-preset', 'medium', '-crf', '18',", " '-pix_fmt', 'yuv420p', out])", '', '', 'def main():', ' os.makedirs(TMP, exist_ok=True)', ' print("=" * 50)', ' print(" ' + config.BOOK_TITLE + ' - YouTube Video")', ' print("=" * 50, "\\n")', '', ' # 1. Audio durations', ' print("[1/6] Durees audio...")', ' durs = []', ' audio_files = []', ' for name, pages, af in SCENES:', ' p = os.path.join(AUDIO_DIR, af)', ' if not os.path.exists(p):', ' print(f" MANQUANT: {p}")', ' sys.exit(1)', ' d = duration(p)', ' durs.append(d)', ' audio_files.append(p)', ' print(f" {name:12s} {d:6.1f}s")', '', ' total_narr = sum(durs)', ' total_vid = total_narr + FADE_S', ' print(f" Total: {total_vid:.0f}s ({total_vid/60:.1f} min)\\n")', '', ' # 2. Spread images', ' print("[2/6] Images des spreads...")', ' spread_imgs = []', ' for i, (name, pages, _) in enumerate(SCENES):', " out = os.path.join(TMP, f'spread_{i:02d}.png')", ' make_spread(pages, out)', ' spread_imgs.append(out)', ' print(f" {name}")', ' print()', '', ' # 3. Video segments', ' print("[3/6] Segments video...")', ' segments = []', ' for i, ((name, pages, af), d) in enumerate(zip(SCENES, durs)):', " seg = os.path.join(TMP, f'seg_{i:02d}.mp4')", ' make_segment(spread_imgs[i], d, seg)', ' segments.append(seg)', ' print(f" seg_{i:02d}.mp4 ({d:.1f}s)")', '', " seg_fade = os.path.join(TMP, 'seg_fade.mp4')", ' make_segment(spread_imgs[-1], FADE_S, seg_fade)', ' segments.append(seg_fade)', ' print(f" seg_fade.mp4 ({FADE_S}s)\\n")', '', ' # 4. Concatenate video', ' print("[4/6] Assemblage video...")', " seg_list = os.path.join(TMP, 'segments.txt')", " with open(seg_list, 'w') as f:", ' for s in segments:', " f.write(f\"file '{s.replace(os.sep, '/')}'\\n\")", '', " raw_vid = os.path.join(TMP, 'raw_video.mp4')", " run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', seg_list,", " '-c:v', 'libx264', '-preset', 'medium', '-crf', '18',", " '-pix_fmt', 'yuv420p', raw_vid])", '', " vid = os.path.join(TMP, 'video.mp4')", " run(['ffmpeg', '-y', '-i', raw_vid,", " '-vf', f'fade=t=in:d={FADE_IN_S},fade=t=out:st={total_vid - FADE_S}:d={FADE_S}',", " '-c:v', 'libx264', '-preset', 'medium', '-crf', '18',", " '-pix_fmt', 'yuv420p', vid])", ' print(" OK\\n")', '', ' # 5. Audio mix', ' print("[5/6] Audio (narration + musique)...")', " alist = os.path.join(TMP, 'audio_list.txt')", " with open(alist, 'w') as f:", ' for p in audio_files:', " f.write(f\"file '{p.replace(os.sep, '/')}'\\n\")", '', " narr = os.path.join(TMP, 'narration.wav')", " run(['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', alist,", " '-ar', '44100', '-ac', '2', narr])", '', " mixed = os.path.join(TMP, 'mixed.aac')", ' narr_dur = duration(narr)', ' mix_dur = narr_dur + FADE_S', " run(['ffmpeg', '-y',", " '-i', narr,", " '-stream_loop', '-1', '-i', LULLABY,", " '-filter_complex',", " f'[1]volume={LULLABY_VOL}[bg];'", " f'[0]apad=pad_dur={FADE_S}[nr];'", " f'[nr][bg]amix=inputs=2:duration=first:normalize=0[mx];'", " f'[mx]afade=t=out:st={narr_dur}:d={FADE_S}',", " '-t', str(mix_dur),", " '-c:a', 'aac', '-b:a', '192k', mixed])", ' print(" OK\\n")', '', ' # 6. Final', ' print("[6/6] Export final...")', " slug = '" + config.BOOK_SLUG + "'", " final = os.path.join(OUT_DIR, f'{slug}.mp4')", " run(['ffmpeg', '-y',", " '-i', vid, '-i', mixed,", " '-c:v', 'copy', '-c:a', 'copy',", " '-shortest', final])", '', ' sz = os.path.getsize(final) / (1024 * 1024)', " print(f'\\n{\"=\" * 50}')", " print(f' TERMINE !')", " print(f' Fichier : {final}')", " print(f' Taille : {sz:.0f} MB')", " print(f' Duree : ~{total_vid / 60:.1f} min')", " print(f'{\"=\" * 50}')", '', ' shutil.rmtree(TMP)', ' print("Fichiers temporaires supprimes.")', '', '', "if __name__ == '__main__':", ' main()', */ // end removed code