1// assets/js/free-story-maker.js 2// 3// Free Story Maker. Two layers live in this file: 4// 1. UI layer (this top part): views, steps, pickers, preview, making/ready/failed 5// screens. Rewritten for the light redesign â see 6// docs/plans/2026-09-24-story-maker-ui-revamp-design.md. 7// 2. Generation layer (further down): FFmpeg SDK/core loading, preload, and the 8// encode pipeline. Kept from the previous version on purpose (LEARNINGS #31, 9// #35); only its UI hand-offs (progress, stages, ready/failed, cancel) changed. 10 11// ---------------------------------------------------------------------------- 12// Globals & State 13// ---------------------------------------------------------------------------- 14const state = { 15 view: 'step1', // 'step1'..'step4' | 'making' | 'ready' | 'failed' 16 step: 1, // 1..4 â the stepper position (4 for making/ready/failed) 17 subStep: 'message', // step 3 on mobile: 'message' | 'sticker' | 'style' 18 items: [], // media in play order â see makePhotoItem(); spec §3.1 19 photoSeconds: 3, // how long each photo shows (the length slider); 3 = default 20 photoSecondsAuto: true, 21 videoDuration: 0, // derived: written only by updateDuration() from StoryTimeline.compute() 22 maxPhotos: 10, 23 selectedTrack: null, // {source: 'library'|'own', id, name, artist, duration, audio, file?, crop: {start, end}} 24 skipAudio: true, // "Continue without music" is the default: music is optional 25 deviceTier: 'mid', // 'low' | 'mid' | 'high' â StoryTimeline.tierFor(navigator.deviceMemory) 26 textConfig: { 27 occasion: '', 28 message: '', 29 name: '', 30 color: 'white', 31 animation: 'static', 32 xPct: 50, 33 yPct: 50 34 }, 35 ffmpegLoaded: false, 36 ffmpegProgressActive: false, 37 stickerConfig: { 38 id: null, // 'birthday' | 'newyear' | 'anniversary' | 'diwali' | 'celebration' | 'love' | null 39 xPct: 25, // centre-x as % of preview stage 40 yPct: 25, // centre-y as % of preview stage 41 sizePct: 25, // sticker size as % of stage width (see STICKER_SIZE_STEPS) 42 movement: 'fixed' // 'fixed' | 'dance' 43 } 44}; 45 46const OCCASION_TEMPLATES = { 47 birthday: { message: 'हà¥à¤ªà¥à¤ªà¥ बरà¥à¤¥à¤¡à¥!', name: '' }, 48 'birthday-hindi': { message: 'हà¥à¤ªà¥à¤ªà¥ बरà¥à¤¥à¤¡à¥!', name: '' }, 49 'birthday-english': { message: 'Happy Birthday!', name: '' }, 50 anniversary: { message: 'सालà¤à¤¿à¤°à¤¹ मà¥à¤¬à¤¾à¤°à¤!', name: '' }, 51 'anniversary-hindi': { message: 'शादॠà¤à¥ सालà¤à¤¿à¤°à¤¹ मà¥à¤¬à¤¾à¤°à¤!', name: '' }, 52 'anniversary-english': { message: 'Happy Anniversary!', name: '' }, 53 diwali: { message: 'दà¥à¤ªà¤¾à¤µà¤²à¥ à¤à¥ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 54 holi: { message: 'हà¥à¤²à¥ à¤à¥ हारà¥à¤¦à¤¿à¤ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 55 eid: { message: 'à¤à¤¦ मà¥à¤¬à¤¾à¤°à¤!', name: '' }, 56 'raksha-bandhan': { message: 'रà¤à¥à¤·à¤¾à¤¬à¤à¤§à¤¨ à¤à¥ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 57 'ganesh-chaturthi': { message: 'à¤à¤£à¤ªà¤¤à¤¿ बापà¥à¤ªà¤¾ मà¥à¤°à¤¯à¤¾!', name: '' }, 58 navratri: { message: 'नवरातà¥à¤°à¤¿ à¤à¥ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 59 dussehra: { message: 'दशहरा à¤à¥ हारà¥à¤¦à¤¿à¤ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 60 'karwa-chauth': { message: 'à¤à¤°à¤µà¤¾ à¤à¥à¤¥ à¤à¥ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 61 dhanteras: { message: 'धनतà¥à¤°à¤¸ à¤à¥ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 62 'bhai-dooj': { message: 'à¤à¤¾à¤ दà¥à¤ à¤à¥ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 63 'chhath-puja': { message: 'à¤à¤ पà¥à¤à¤¾ à¤à¥ हारà¥à¤¦à¤¿à¤ शà¥à¤à¤à¤¾à¤®à¤¨à¤¾à¤à¤!', name: '' }, 64 'good-morning': { message: 'सà¥à¤ªà¥à¤°à¤à¤¾à¤¤!', name: '' }, 65 christmas: { message: 'Merry Christmas!', name: '' }, 66 valentine: { message: 'Happy Valentine\'s Day', name: '' }, 67 'independence-day-india': { message: 'à¤à¤¯ हिà¤à¤¦! Happy Independence Day', name: '' }, 68 'republic-day-india': { message: 'à¤à¤¯ हिà¤à¤¦! Happy Republic Day', name: '' }, 69 newyear: { message: 'नया साल मà¥à¤¬à¤¾à¤°à¤!', name: '' }, 70 'new-year': { message: 'नया साल मà¥à¤¬à¤¾à¤°à¤!', name: '' }, 71 custom: { message: '', name: '' }, 72}; 73 74// Parse ?preset=⦠query param and pre-select the occasion when present. 75// Powers deep-linkable festival/occasion presets used by blog CTAs. 76// Maps unknown values to null (callers fall back to default behaviour).
77function getPresetFromUrl() { 78 try { 79 const url = new URL(window.location.href); 80 const p = (url.searchParams.get('preset') || '').toLowerCase().trim(); 81 if (!p) return null; 82 if (OCCASION_TEMPLATES[p]) return p; 83 const aliases = { 84 'birthday_hindi': 'birthday-hindi', 85 'birthday_english': 'birthday-english', 86 'anniversary_hindi': 'anniversary-hindi', 87 'rakhi': 'raksha-bandhan', 88 'rakshabandhan': 'raksha-bandhan', 89 'happy-new-year': 'newyear', 90 'happy-birthday': 'birthday', 91 'happy-anniversary': 'anniversary', 92 'india-independence': 'independence-day-india', 93 'india-republic': 'republic-day-india', 94 }; 95 return aliases[p] || null; 96 } catch (_) { 97 return null; 98 } 99} 100 101const STICKER_LIBRARY = { 102 birthday: { label: 'Birthday', path: '/assets/stickers/birthday.json', defaultSize: 40 }, 103 newyear: { label: 'New Year', path: '/assets/stickers/newyear.json', defaultSize: 25 }, 104 anniversary: { label: 'Anniversary', path: '/assets/stickers/anniversary.json', defaultSize: 30 }, 105 diwali: { label: 'Diwali', path: '/assets/stickers/diwali.json', defaultSize: 25 }, 106 celebration: { label: 'Celebration', path: '/assets/stickers/celebration.json', defaultSize: 40 }, 107 love: { label: 'Love', path: '/assets/stickers/love.json', defaultSize: 25 }, 108}; 109 110// Single source of truth for text colours: the swatches, the CSS preview AND the 111// FFmpeg drawtext filter all read this map, so the preview can't drift from the video. 112const TEXT_COLORS = { 113 white: { hex: '#FFFFFF', label: 'White' }, 114 red: { hex: '#DD524C', label: 'Red' }, 115 yellow: { hex: '#F2C24B', label: 'Yellow' }, 116 green: { hex: '#6AD09D', label: 'Green' }, 117 blue: { hex: '#71A4F4', label: 'Blue' }, 118 purple: { hex: '#A38CF3', label: 'Purple' }, 119}; 120 121const ANIMATION_WORDS = { static: 'no movement', fadein: 'fades in', slideup: 'slides up', typewriter: 'types out' }; 122 123// The UI offers 5 friendly size steps; the encoder still takes a % of frame width. 124const STICKER_SIZE_STEPS = [15, 20, 25, 35, 45]; 125const STICKER_SIZE_WORDS = ['Very small', 'Small', 'Medium', 'Large', 'Very large']; 126 127const STEP_NAMES = [ 128 { full: 'Add photos', short: 'Photos' }, 129 { full: 'Choose music', short: 'Music' }, 130 { full: 'Add message', short: 'Message' }, 131 { full: 'Make video', short: 'Make video' }, 132]; 133const SUB_STEPS = ['message', 'sticker', 'style']; 134const VIEW_IDS = { 135 step1: 'step-1', step2: 'step-2', step3: 'step-3', step4: 'step-4', 136 making: 'view-making', ready: 'view-ready', failed: 'view-failed' 137}; 138 139const stickerCache = new Map(); // id -> parsed JSON 140let stickerTileAnims = []; // lottie instances in the picker tiles (destroy on reinit) 141let stickerPreviewAnim = null; // lottie instance in preview stage 142let stickerGlowDismissed = false; 143 144const FPS = 25; 145const CROSSFADE_DURATION = StoryTimeline.CROSSFADE; // seconds; lengths come from StoryTimeline.compute() 146const RESOLUTION = '720:1280'; 147 148// Music preloading 149const PRELOAD_CATEGORIES = ['happy', 'emotional', 'action', 'adventure', 'calm']; 150const preloadedResults = {}; 151let autoResultRendered = false; // true once any results are shown to user 152 153// FFmpeg preloading & caching 154const FFMPEG_CACHE_NAME = 'ffmpeg-core-v0.11.0'; 155let ffmpegLoadPromise = null; // resolves when ffmpeg.load() completes 156 157const mobileMQ = window.matchMedia('(max-width: 767px)'); 158const isMobile = () => mobileMQ.matches; 159const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 160const $ = (id) => document.getElementById(id); 161 162// Wait for DOM 163document.addEventListener('DOMContentLoaded', initApp); 164 165// ---------------------------------------------------------------------------- 166// Initialization 167// ---------------------------------------------------------------------------- 168function initApp() { 169 state.deviceTier = StoryTimeline.tierFor(navigator.deviceMemory); 170 initNavigation(); 171 initStep1(); 172 initStep2(); 173 initStep3(); 174 initStep4(); 175 initResultViews(); 176 initItemSheet(); 177 initAccountSheet(); 178 if (mobileMQ.addEventListener) mobileMQ.addEventListener('change', onLayoutChange); 179 else if (mobileMQ.addListener) mobileMQ.addListener(onLayoutChange); 180 window.addEventListener('resize', debounce(() => { if (state.view === 'step2') layoutCrop(); }, 120)); 181 trackTopHeight(); 182 showView('step1', { focus: false }); 183 preloadMusicCategories(); // fire all 5 categories in parallel, no await 184 preloadStickers(); // fire sticker preload in parallel, no await 185 // FFmpeg preload is deferred: ffmpeg.min.js (~600KB) + ffmpeg-core.wasm (~25MB) are 186 // only fetched once the user commits to making a video (first photo added, or 187 // reaching Step 2) â see kickFFmpegPreloadIfNeeded(). 188} 189 190// ---------------------------------------------------------------------------- 191// Small helpers 192// ---------------------------------------------------------------------------- 193function escapeHtml(str) { 194 if (!str) return ''; 195 return String(str).replace(/[&<>"']/g, match => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[match]); 196} 197 198function formatTime(secs) { 199 const m = Math.floor(secs / 60); 200 const s = Math.floor(secs % 60); 201 return `${m}:${s.toString().padStart(2, '0')}`; 202} 203 204function debounce(fn, ms) { 205 let t; 206 return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; 207} 208 209function hashString(str) { 210 let h = 2166136261; 211 for (let i = 0; i < str.length; i++) { 212 h ^= str.charCodeAt(i); 213 h = Math.imul(h, 16777619); 214 } 215 return h >>> 0; 216} 217 218// Deterministic PRNG (mulberry32) â the decorative waveform for a song must look 219// the same every time it is drawn, without downloading and decoding the audio. 220function seededRandom(seed) { 221 let a = seed >>> 0; 222 return function () { 223 a = (a + 0x6D2B79F5) >>> 0; 224 let t = a; 225 t = Math.imul(t ^ (t >>> 15), t | 1); 226 t ^= t + Math.imul(t ^ (t >>> 7), t | 61); 227 return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 228 }; 229} 230 231function plural(n, word) { return `${n} ${word}${n === 1 ? '' : 's'}`; } 232 233// --- Media items ------------------------------------------------------------- 234let itemSeq = 0; 235function makePhotoItem(file, source, freeUrl) { 236 const item = { 237 id: `m${++itemSeq}`, kind: 'photo', file, url: URL.createObjectURL(file), 238 source: source || 'own', freeUrl: freeUrl || null, 239 width: 0, height: 0, fit: 'fill', fitAuto: true, status: 'ready' 240 }; 241 measurePhoto(item); 242 return item; 243} 244 245// Natural size decides the automatic Fit / Fill choice (landscape and square â Fit). 246function measurePhoto(item) { 247 const img = new Image(); 248 img.onload = () => {
249 item.width = img.naturalWidth; 250 item.height = img.naturalHeight; 251 if (item.fitAuto) { 252 const fit = StoryTimeline.autoFit(item.width, item.height); 253 if (fit !== item.fit && state.items.includes(item)) { item.fit = fit; onItemsChanged(); } 254 } 255 }; 256 img.src = item.url; 257} 258 259function makeClipItem(file) { 260 return { 261 id: `m${++itemSeq}`, kind: 'clip', file, url: URL.createObjectURL(file), source: 'own', freeUrl: null, 262 width: 0, height: 0, fit: 'fill', fitAuto: true, 263 duration: 0, trim: { start: 0, end: 0 }, muted: false, hasAudio: true, 264 poster: null, frames: null, status: 'reading' 265 }; 266} 267 268function clipsAllowed() { return StoryTimeline.TIERS[state.deviceTier].clips; } 269function tierLimits() { 270 // Test hook: lets the e2e harness shrink the caps instead of needing 100 MB fixtures. 271 return Object.assign({}, StoryTimeline.TIERS[state.deviceTier], (window.__fsmTest && window.__fsmTest.tier) || {}); 272} 273 274function waitForEvent(target, name, ms) { 275 return new Promise((resolve, reject) => { 276 const timer = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for ${name}`)); }, ms); 277 const ok = () => { cleanup(); resolve(); }; 278 const bad = () => { cleanup(); reject(new Error('Media could not be read')); }; 279 const cleanup = () => { clearTimeout(timer); target.removeEventListener(name, ok); target.removeEventListener('error', bad); }; 280 target.addEventListener(name, ok); 281 target.addEventListener('error', bad); 282 }); 283} 284 285// Draws the current frame of `video` to a JPEG object URL, no larger than `maxSide` pixels. 286function grabVideoFrame(video, maxSide) { 287 const scale = Math.min(1, maxSide / Math.max(video.videoWidth, video.videoHeight)); 288 const canvas = document.createElement('canvas'); 289 canvas.width = Math.max(2, Math.round(video.videoWidth * scale)); 290 canvas.height = Math.max(2, Math.round(video.videoHeight * scale)); 291 canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height); 292 return new Promise((resolve, reject) => { 293 canvas.toBlob((blob) => (blob ? resolve(URL.createObjectURL(blob)) : reject(new Error('No frame'))), 'image/jpeg', 0.8); 294 }); 295} 296 297// Reads a just-added video: length, size and a poster frame. The browser has to be able to 298// play it â if it cannot, the clip is dropped with a plain message. Every step re-checks that 299// the item is still in the list (the user may remove or reorder things while this runs). 300async function readClip(item) { 301 const video = document.createElement('video'); 302 video.muted = true; 303 video.playsInline = true; 304 video.preload = 'auto'; 305 try { 306 const delay = window.__fsmTest && window.__fsmTest.readDelayMs; 307 if (delay) await new Promise(r => setTimeout(r, delay)); 308 video.src = item.url; 309 await waitForEvent(video, 'loadedmetadata', 15000); 310 if (!isFinite(video.duration) || video.duration <= 0 || !video.videoWidth || !video.videoHeight) { 311 throw new Error('Unreadable video'); 312 } 313 const duration = video.duration; 314 video.currentTime = Math.min(0.1, duration / 2); 315 await waitForEvent(video, 'seeked', 15000); 316 const poster = await grabVideoFrame(video, 720); 317 if (!state.items.includes(item)) { URL.revokeObjectURL(poster); return; } 318 319 item.duration = duration; 320 item.trim = { start: 0, end: duration }; 321 item.width = video.videoWidth; // the browser has already applied any rotation flag 322 item.height = video.videoHeight; 323 item.fit = StoryTimeline.autoFit(item.width, item.height); 324 item.poster = poster; 325 // Whether there is an audio track is only knowable where the browser says so; when it 326 // does not, assume yes â preparing the clip falls back to silence if there is none. 327 if (video.mozHasAudio === false) item.hasAudio = false; 328 else if (video.audioTracks && typeof video.audioTracks.length === 'number') item.hasAudio = video.audioTracks.length > 0; 329 item.status = 'ready'; 330 trackAction('story_clip_added', { seconds: Math.round(duration), mb: Math.round(item.file.size / StoryTimeline.MB), tier: state.deviceTier }); 331 onItemsChanged(); 332 announce(`Video added. ${itemCountText().long} in your video.`); 333 } catch (err) { 334 const idx = state.items.indexOf(item); 335 if (idx >= 0) state.items.splice(idx, 1); 336 releaseItem(item); 337 trackAction('story_clip_rejected', { reason: 'unreadable' }); 338 onItemsChanged(); 339 showPhotoError(`This video canât be used on this ${deviceWord()}. Try a different one.`, true); 340 } finally { 341 video.removeAttribute('src'); 342 try { video.load(); } catch (_) { } 343 } 344} 345
346function toggleClipMute(id) { 347 const item = itemById(id); 348 if (!item || item.kind !== 'clip') return; 349 item.muted = !item.muted; 350 trackAction('story_clip_muted', { muted: item.muted }); 351 onItemsChanged(); 352 if (sheetItemId === id) renderItemSheet(); 353 announce(item.muted ? 'Video sound off.' : 'Video sound on.'); 354} 355 356function photoItems() { return state.items.filter(it => it.kind === 'photo'); } 357function readyItems() { return state.items.filter(StoryTimeline.isReady); } 358function itemById(id) { return state.items.find(it => it.id === id) || null; } 359function currentTimeline() { return StoryTimeline.compute(state.items, { photoSeconds: state.photoSeconds }); } 360function releaseItem(item) { 361 try { 362 URL.revokeObjectURL(item.url); 363 if (item.poster) URL.revokeObjectURL(item.poster); 364 (item.frames || []).forEach(u => URL.revokeObjectURL(u)); 365 } catch (_) { } 366} 367 368function announce(msg) { 369 const live = $('fsm-live'); 370 if (!live) return; 371 live.textContent = ''; 372 requestAnimationFrame(() => { live.textContent = msg; }); 373} 374 375// Product analytics for the new screens. New Relic Browser is already on the page 376// (see reportVideoGenError); page actions are a no-op when it hasn't loaded. 377function trackAction(name, attrs) { 378 try { 379 if (window.newrelic && typeof window.newrelic.addPageAction === 'function') { 380 window.newrelic.addPageAction(name, attrs || {}); 381 } 382 } catch (_) { /* analytics must never break the flow */ } 383} 384 385// Rough on-device estimate of how long making the video takes: engine warm-up plus encode 386// seconds per second of video. The per-tier table is a starting guess; after a successful run 387// the measured rate for this device (localStorage) replaces it. 388const ENCODE_RATE = { low: 3, mid: 2, high: 1 }; 389const CLIP_PREP_RATE = { low: 0, mid: 3, high: 1.5 }; // extra seconds per second of video clip (it is encoded twice) 390// Measured separately for videos with and without clips: clips cost far more per second. 391function rateKey() { return itemCountText().clips ? 'fsm-rate-clips' : 'fsm-rate'; } 392function measuredRate() { 393 try { 394 const v = Number(localStorage.getItem(rateKey())); 395 return v > 0.05 && v < 60 ? v : null; 396 } catch (_) { return null; } 397} 398function rememberRate(encodeSeconds, videoSeconds) { 399 try { if (videoSeconds > 0) localStorage.setItem(rateKey(), String(encodeSeconds / videoSeconds)); } catch (_) { } 400} 401function estimateSeconds() { 402 const total = currentTimeline().total; 403 const clipSeconds = StoryTimeline.bounds(state.items).clipSeconds; 404 const measured = measuredRate(); 405 const secs = measured != null 406 ? total * measured 407 : 10 + total * ENCODE_RATE[state.deviceTier] + clipSeconds * CLIP_PREP_RATE[state.deviceTier]; 408 return Math.max(15, Math.round(secs / 5) * 5); 409} 410// "20 seconds" / "1 minute" / "3 minutes" 411function estimateWords(secs) { 412 return secs < 55 ? plural(secs, 'second') : plural(Math.ceil(secs / 60), 'minute'); 413} 414function deviceWord() { return isMobile() ? 'phone' : 'computer'; } 415const LONG_WAIT_SECONDS = 300; 416let longWaitShown = false; 417 418// The pinned header + stepper change height with breakpoints and fonts loading; 419// CSS reads --fsm-top-h for the sticky preview and in-page anchor offsets. 420function trackTopHeight() { 421 const top = $('fsm-top'); 422 if (!top) return; 423 const set = () => document.documentElement.style.setProperty('--fsm-top-h', `${top.offsetHeight}px`); 424 set(); 425 if ('ResizeObserver' in window) new ResizeObserver(set).observe(top); 426 else window.addEventListener('resize', set); 427} 428 429// ---------------------------------------------------------------------------- 430// Views, stepper & navigation 431// ---------------------------------------------------------------------------- 432function showView(view, opts = {}) { 433 state.view = view; 434 Object.entries(VIEW_IDS).forEach(([v, id]) => { $(id).hidden = v !== view; }); 435 state.step = view.startsWith('step') ? Number(view.slice(4)) : 4; 436 $('fsm-hero').hidden = view !== 'step1'; 437 const barGroup = view.startsWith('step') ? 'steps' : view; 438 document.querySelectorAll('#wizard-nav .fsm-nav__group').forEach(g => { g.hidden = g.dataset.for !== barGroup; }); 439 renderStepper(); 440 updateNav(); 441 442 if (opts.focus !== false) { 443 // Header + stepper are pinned, so every view simply starts at the top of the page. 444 window.scrollTo({ top: 0, behavior: 'auto' }); 445 focusViewHeading(view); 446 } 447} 448 449function focusViewHeading(view) { 450 let heading; 451 if (view === 'step3') heading = isMobile() ? $('step3-title-m') : $('step3-title'); 452 else heading = document.querySelector(`#${VIEW_IDS[view]} h2`); 453 if (heading) heading.focus({ preventScroll: true }); 454} 455
456function renderStepper() { 457 const nav = $('fsm-stepper'); 458 const current = state.step; 459 const locked = state.view === 'making'; 460 let html = '<ol class="fsm-stepper__list">'; 461 STEP_NAMES.forEach((s, i) => { 462 const n = i + 1; 463 const status = n < current ? 'done' : (n === current ? 'current' : 'todo'); 464 if (i > 0) html += `<li class="fsm-stepper__line${n <= current ? ' is-done' : ''}" aria-hidden="true"></li>`; 465 const dot = status === 'done' ? '<i class="ph-bold ph-check" aria-hidden="true"></i>' : n; 466 const stateText = status === 'done' ? 'Done' : (status === 'current' ? 'You are here' : `Step ${n}`); 467 const inner = `<span class="fsm-stepper__dot">${dot}</span>` + 468 `<span class="fsm-stepper__text"><span class="fsm-stepper__state">${stateText}</span>` + 469 `<span class="fsm-stepper__name"><span class="fsm-desk-only">${s.full}</span><span class="fsm-mob-only">${s.short}</span></span></span>`; 470 let el; 471 if (status === 'done' && !locked) { 472 el = `<button type="button" class="fsm-stepper__btn" data-step="${n}" aria-label="${s.full}: done. Go back to this step">${inner}</button>`; 473 } else { 474 const label = `${s.full}: ${status === 'done' ? 'done' : (status === 'current' ? 'current step' : 'not started')}`; 475 el = `<div class="fsm-stepper__btn" role="img" aria-label="${label}"${status === 'current' ? ' aria-current="step"' : ''}>${inner}</div>`; 476 } 477 html += `<li class="fsm-stepper__item is-${status}">${el}</li>`; 478 }); 479 nav.innerHTML = html + '</ol>'; 480} 481 482function updateNav() { 483 if (!state.view.startsWith('step')) return; 484 const step = state.step; 485 const mob = isMobile(); 486 const nav = $('wizard-nav'); 487 nav.classList.toggle('is-step4', step === 4); 488 $('prev-btn').hidden = step === 1; 489 490 const status = $('nav-status'); 491 status.classList.remove('is-alert'); 492 if (step === 1) { 493 status.hidden = false; 494 renderNavStatus(); 495 } else { 496 status.hidden = true; 497 status.innerHTML = ''; 498 } 499 500 let label; 501 let make = false; 502 if (step === 1) label = mob ? 'Next: Music' : 'Next: Choose music'; 503 else if (step === 2) label = mob ? 'Next: Message' : 'Next: Add message'; 504 else if (step === 3) { 505 label = mob 506 ? { message: 'Next: Sticker', sticker: 'Next: Text style', style: 'Next: Make video' }[state.subStep] 507 : 'Next: Check and make video'; 508 } else { 509 label = 'Make my video'; 510 make = true; 511 } 512 $('next-label').textContent = label; 513 $('next-icon-make').hidden = !make; 514 $('next-icon-arrow').hidden = make; 515 $('next-btn').setAttribute('aria-disabled', isStepValid(step) ? 'false' : 'true'); 516} 517 518// "4 photos and 2 videos" (long) / "4 photos · 2 videos" (short), counting ready items only. 519function itemCountText() { 520 const ready = readyItems(); 521 const photos = ready.filter(it => it.kind === 'photo').length; 522 const clips = ready.length - photos; 523 const parts = []; 524 if (photos || !clips) parts.push(plural(photos, 'photo')); 525 if (clips) parts.push(plural(clips, 'video')); 526 return { long: parts.join(' and '), short: parts.join(' · '), comma: parts.join(', '), photos, clips }; 527} 528 529function renderNavStatus() { 530 const status = $('nav-status'); 531 const n = readyItems().length; 532 const ready = n >= 1; 533 status.classList.toggle('is-ready', ready); 534 const what = itemCountText(); 535 const desk = ready 536 ? `<i class="ph-bold ph-check-circle" aria-hidden="true"></i><span class="fsm-nav__status-d">${what.long} added</span>` 537 : `<span class="fsm-nav__status-d">${clipsAllowed() ? 'Add at least 1 photo or video to continue' : 'Add at least 1 photo to continue'}</span>`; 538 const loading = state.items.filter(it => it.status === 'reading').length; 539 status.innerHTML = desk + (ready 540 ? `<span class="fsm-nav__status-m"><strong>${what.short}</strong><span>${loading ? `+ ${loading} loading` : 'Ready to go'}</span></span>` 541 : `<span class="fsm-nav__status-m"><strong>${loading ? 'Reading videoâ¦' : 'Nothing yet'}</strong><span>${loading ? 'One moment' : 'Add 1 to start'}</span></span>`); 542} 543 544let navReasonTimer = null; 545function showNavReason(text) { 546 const status = $('nav-status'); 547 status.hidden = false;
548 status.classList.remove('is-ready'); 549 status.classList.add('is-alert'); 550 status.innerHTML = `<i class="ph-bold ph-warning-circle" aria-hidden="true"></i><span class="fsm-nav__status-d">${text}</span>` + 551 `<span class="fsm-nav__status-m"><strong>${text}</strong></span>`; 552 clearTimeout(navReasonTimer); 553 navReasonTimer = setTimeout(updateNav, 4000); 554} 555 556function initNavigation() { 557 $('next-btn').addEventListener('click', () => { 558 const step = state.step; 559 if (!isStepValid(step)) { 560 showNavReason(isVideoOver() 561 ? 'Your video is too big. Trim a video first' 562 : (clipsAllowed() ? 'Add at least 1 photo or video first' : 'Add at least 1 photo first')); 563 return; 564 } 565 if (step === 3 && isMobile() && state.subStep !== 'style') { 566 setSubStep(SUB_STEPS[SUB_STEPS.indexOf(state.subStep) + 1], { focusTab: true }); 567 return; 568 } 569 if (step < 4) goToStep(step + 1); 570 else startVideoGeneration(); 571 }); 572 573 $('prev-btn').addEventListener('click', () => { 574 const step = state.step; 575 if (step === 3 && isMobile() && state.subStep !== 'message') { 576 setSubStep(SUB_STEPS[SUB_STEPS.indexOf(state.subStep) - 1], { focusTab: true }); 577 return; 578 } 579 if (step === 4 && isMobile()) { goToStep(3, 'style'); return; } 580 if (step > 1) goToStep(step - 1); 581 }); 582 583 $('fsm-stepper').addEventListener('click', (e) => { 584 const btn = e.target.closest('button[data-step]'); 585 if (!btn || state.view === 'making') return; 586 if (state.view === 'ready' || state.view === 'failed') discardResult(); 587 goToStep(Number(btn.dataset.step)); 588 }); 589} 590 591function goToStep(n, sub) { 592 const prev = state.view; 593 if (prev === 'step2' && n !== 2) stopAudio(); 594 if (prev === 'step3' && n !== 3) { 595 // Leaving Step 3 â tear down looping picker animations to stop CPU/memory drain 596 stickerTileAnims.forEach((anim) => anim.destroy()); 597 stickerTileAnims = []; 598 } 599 closePreviewDialog(); 600 stopPreviewCycle(); 601 602 showView(`step${n}`); 603 604 if (n === 2) { 605 // Lazy-kick FFmpeg SDK download now â the user has committed past Step 1. 606 kickFFmpegPreloadIfNeeded(); 607 requestAnimationFrame(layoutCrop); 608 } 609 if (n === 3) { 610 mountStage($('s3-phone'), { interactive: true }); 611 initStickerPicker(); 612 setSubStep(sub || 'message', { scroll: !!sub }); 613 startPreviewCycle(false); 614 if (sub && isMobile()) { 615 const tab = document.querySelector(`.fsm-seg [data-sub="${sub}"]`); 616 if (tab) tab.focus(); 617 } 618 } 619 if (n === 4) { 620 updateSummary(); 621 mountStage($('s4-phone'), { interactive: false }); 622 startPreviewCycle(false); 623 $('s4-play').hidden = false; 624 updateTimeEstimate(); 625 } 626 announce(`Step ${n} of 4: ${STEP_NAMES[n - 1].full}`); 627} 628 629function isStepValid(stepNum) { 630 if (stepNum === 1) return readyItems().length >= 1 && !isVideoOver(); 631 return true; // Step 3 is optional; step 4 has no Next 632} 633 634function onLayoutChange() { 635 updateNav(); 636 renderStepper(); 637 if (state.view === 'step3') setSubStep(state.subStep); 638 if (!isMobile()) closePreviewDialog(); 639 if (state.view === 'step2') layoutCrop(); 640 if (state.view === 'ready') layoutReadyActions(lastCanShare); 641 updateStickerPreview(); 642} 643 644// ---------------------------------------------------------------------------- 645// Step 1: Photos 646// ---------------------------------------------------------------------------- 647const addedFree = new Map(); // free-image download URL -> item (for Add/Added state) 648const pendingFree = new Map(); // free-image download URL -> AbortController 649let topicData = []; 650let topicShown = 0; 651let topicQuery = ''; 652let topicSearchSeq = 0; 653const TOPIC_FIRST_PAGE = 7; 654const TOPIC_PAGE = 8; 655let photoErrorTimer = null; 656 657function initStep1() { 658 const fileInput = $('file-input'); 659 document.querySelectorAll('[data-pick-files]').forEach((btn) => { 660 btn.addEventListener('click', () => fileInput.click()); 661 }); 662 fileInput.addEventListener('change', (e) => { 663 handleFiles(Array.from(e.target.files), 'own'); 664 // Reset so selecting the same file again re-triggers the change event 665 e.target.value = ''; 666 }); 667 668 const dropZone = $('drop-zone'); 669 const isFileDrag = (e) => e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files'); 670 dropZone.addEventListener('dragover', (e) => { 671 if (!isFileDrag(e)) return; 672 e.preventDefault(); 673 dropZone.classList.add('is-dragover'); 674 }); 675 dropZone.addEventListener('dragleave', (e) => { 676 if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove('is-dragover'); 677 }); 678 dropZone.addEventListener('drop', (e) => { 679 if (!isFileDrag(e)) return; 680 e.preventDefault(); 681 dropZone.classList.remove('is-dragover'); 682 handleFiles(Array.from(e.dataTransfer.files), 'own'); 683 }); 684 685 $('topic-search-form').addEventListener('submit', (e) => { 686 e.preventDefault(); 687 const q = $('topic-search-input').value.trim(); 688 if (q) runTopicSearch(q); 689 }); 690 document.querySelectorAll('#topic-chips .fsm-chip').forEach((chip) => { 691 chip.addEventListener('click', () => { 692 $('topic-search-input').value = chip.textContent.trim(); 693 runTopicSearch(chip.dataset.q); 694 }); 695 }); 696 697 $('photo-error').addEventListener('click', (e) => { 698 if (e.target.closest('[data-act="pick"]')) fileInput.click(); 699 }); 700 applyTierToStep1(); 701 initLengthControl(); 702 onItemsChanged(); 703} 704 705// The page is written for phones that can take video clips. On low-memory phones (2 GB or 706// less) clips are not offered: the picker takes photos only and the copy says so calmly. 707function applyTierToStep1() { 708 if (clipsAllowed()) return; 709 $('file-input').accept = 'image/*'; 710 $('clips-note').hidden = false;
711 const set = (id, text) => { const el = $(id); if (el) el.textContent = text; }; 712 set('step1-title', 'Add your photos'); 713 set('step1-lead-d', 'Add at least one photo. Everything else is optional â you can use our free images too.'); 714 set('step1-lead-m', 'Add at least one photo. Everything else is optional.'); 715 set('own-title', 'Add photos'); 716 set('own-pick-label', 'Choose photos'); 717 set('own-hint', 'or drag and drop photos into this box'); 718 set('own-m-title', 'Add photos'); 719} 720 721function showPhotoError(msg, offerPick) { 722 const el = $('photo-error'); 723 el.innerHTML = `<span>${escapeHtml(msg)}</span>` + 724 (offerPick ? ' <button type="button" class="fsm-link-btn" data-act="pick">Choose another</button>' : ''); 725 el.hidden = false; 726 clearTimeout(photoErrorTimer); 727 photoErrorTimer = setTimeout(() => { el.hidden = true; }, offerPick ? 15000 : 7000); 728} 729 730function hidePhotoError() { 731 clearTimeout(photoErrorTimer); 732 $('photo-error').hidden = true; 733} 734 735function handleFiles(files, source) { 736 hidePhotoError(); 737 738 const imageExts = /\.(jpe?g|png|gif|webp|bmp|heic|heif|avif|tiff?)$/i; 739 const videoExts = /\.(mp4|mov|m4v|webm|3gp|mkv)$/i; 740 const kindOf = (f) => { 741 if (f.type.startsWith('image/') || (!f.type && imageExts.test(f.name))) return 'photo'; 742 if (f.type.startsWith('video/') || (!f.type && videoExts.test(f.name))) return 'clip'; 743 return null; 744 }; 745 const picked = Array.from(files).map(file => ({ file, kind: kindOf(file) })).filter(p => p.kind); 746 if (files.length && !picked.length) { 747 showPhotoError(clipsAllowed() 748 ? 'Those files are not photos or videos.' 749 : 'Those files are not photos. Please choose JPG, PNG or similar pictures.'); 750 return; 751 } 752 753 // Items are added in the order they were picked, photos and videos together. 754 let addedPhotos = 0; 755 let addedClips = 0; 756 let photoLimitHit = false; 757 for (const { file, kind } of picked) { 758 // Ignore duplicates â file input may contain the same file multiple times 759 if (state.items.some(it => it.file.name === file.name && it.file.size === file.size)) continue; 760 761 if (kind === 'clip') { 762 if (!clipsAllowed()) { 763 showPhotoError(`Video clips are not available on this ${deviceWord()}. You can still make a video from photos.`); 764 continue; 765 } 766 // Checked against this device's size limit before anything is read. The limit is on 767 // the file itself â the video engine has to hold the whole file in memory even when 768 // only a part of it is used, so an over-size video is turned away, not trimmed to fit. 769 if (file.size > tierLimits().clipBytes) { 770 trackAction('story_clip_rejected', { reason: 'too-big', mb: Math.round(file.size / StoryTimeline.MB), tier: state.deviceTier }); 771 showPhotoError(`This video is too big for this ${deviceWord()}. Pick a shorter one.`, true); 772 continue; 773 } 774 if (isVideoFull()) { showVideoFull(); continue; } 775 const item = makeClipItem(file); 776 state.items.push(item); 777 readClip(item); 778 addedClips++; 779 continue; 780 } 781 782 if (isVideoFull() || wouldOverfill({ seconds: state.photoSeconds })) { showVideoFull(); continue; } 783 784 if (photoItems().length >= state.maxPhotos) { 785 if (!photoLimitHit) showPhotoError(`You can add up to ${state.maxPhotos} photos. The rest were left out.`); 786 photoLimitHit = true; 787 continue; 788 } 789 let finalFile = file; 790 // Fix for item #30: ensure missing MIME types are set based on file extension (without changing name) 791 if (!file.type && file.name) { 792 const ext = file.name.split('.').pop().toLowerCase(); 793 const mimeByExt = { 794 jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', 795 webp: 'image/webp', bmp: 'image/bmp', heic: 'image/heic', heif: 'image/heif', 796 avif: 'image/avif', tif: 'image/tiff', tiff: 'image/tiff' 797 }; 798 const type = mimeByExt[ext]; 799 // Preserve original name; only add a reliable MIME type 800 if (type) finalFile = new File([file], file.name, { type }); 801 } 802 state.items.push(makePhotoItem(finalFile, source)); 803 addedPhotos++; 804 } 805 806 onItemsChanged(); 807 if (addedPhotos) announce(`${plural(addedPhotos, 'photo')} added. ${itemCountText().long} in your video.`); 808 809 // Kick the FFmpeg SDK + WASM core download in the background as soon as the user 810 // has committed to building a video (first successful add), rather than waiting 811 // for them to reach Step 2. Idempotent â safe to call repeatedly. 812 if (addedPhotos > 0 || addedClips > 0) kickFFmpegPreloadIfNeeded(); 813} 814 815function showVideoFull() { 816 showPhotoError('Your video is full. Trim a video or remove something to add more.'); 817} 818 819function onItemsChanged(focusIndex) { 820 updateDuration(); 821 renderThumbnails(focusIndex); 822 syncAllResultTiles(); 823 updateNav(); 824} 825 826function updateDuration() { 827 // The one place the video length is decided: the same timeline drives the filtergraph. 828 state.videoDuration = currentTimeline().total; 829 $('photo-count-pill').textContent = itemCountText().short; 830 syncLength(); 831 syncSize(); 832 $('no-music-sub').textContent = clipsHaveSound() ? 'Your videosâ own sound will still play' : 'Your video will have no sound'; 833 834 if (state.selectedTrack) updateCropWindow(); // Recalculate crop if duration changes 835} 836 837// --- Video length: a quiet line, with a slider behind "Change" ----------------- 838// The slider sets how long each photo shows (2â5 s). Videos always play at their own length. 839function initLengthControl() { 840 const toggle = $('length-toggle'); 841 const panel = $('length-panel'); 842 toggle.addEventListener('click', () => setLengthPanel(panel.hidden)); 843 $('length-range').addEventListener('input', (e) => { 844 state.photoSeconds = Math.min(StoryTimeline.clampPhotoSeconds(Number(e.target.value)), maxPhotoSecondsForBudget()); 845 state.photoSecondsAuto = false;
846 updateDuration(); 847 }); 848 $('length-range').addEventListener('change', () => { 849 trackAction('story_length_changed', { photoSeconds: state.photoSeconds }); 850 }); 851 $('length-trim-hint').addEventListener('click', openLongestClip); 852 $('size-trim-link').addEventListener('click', openLongestClip); 853 $('length-reset').addEventListener('click', () => { 854 state.photoSeconds = StoryTimeline.PHOTO_DEFAULT; 855 state.photoSecondsAuto = true; 856 updateDuration(); 857 $('length-range').focus(); 858 }); 859} 860 861// --- Size meter: how much of this device's finished-video budget is used ------- 862// Shown only once a video clip is in the mix (photo-only videos never come near the limit). 863// The size is predicted from the length, which the encoder's bitrate ceiling makes reliable. 864function sizeBudget() { 865 const cap = tierLimits().videoBytes; 866 const used = StoryTimeline.estimateBytes(state.videoDuration, state.deviceTier); 867 return { cap, used, ratio: isFinite(cap) && cap > 0 ? used / cap : 0 }; 868} 869function isVideoFull() { 870 return itemCountText().clips > 0 && sizeBudget().ratio >= 1; 871} 872// Past the budget (a long clip was added): the video cannot be made until something is trimmed. 873function isVideoOver() { 874 return itemCountText().clips > 0 && sizeBudget().ratio > 1.02; 875} 876 877function syncSize() { 878 const box = $('size-box'); 879 const hasClips = itemCountText().clips > 0; 880 const { cap, used, ratio } = sizeBudget(); 881 const show = hasClips && isFinite(cap); 882 box.hidden = !show; 883 const full = show && ratio >= 1; 884 const near = show && !full && ratio >= 0.85; 885 886 // When full, nothing more can be added until something is trimmed or removed. 887 document.querySelectorAll('[data-pick-files]').forEach((btn) => { btn.disabled = full; }); 888 document.querySelectorAll('#thumbnail-container .fsm-add-tile').forEach((btn) => { btn.disabled = full; }); 889 $('topic-search-btn').disabled = full; 890 $('size-full-note').hidden = !full; 891 if (!show) return; 892 $('size-full-text').textContent = isVideoOver() 893 ? `Your video is too big for this ${deviceWord()}. Trim a video or remove something to continue.` 894 : 'Your video is full. Trim a video or remove something to add more.'; 895 896 const mb = (bytes) => Math.round(bytes / StoryTimeline.MB); 897 const capMb = mb(cap); 898 // Never show "60 of 60" unless it really is full. 899 const usedMb = Math.min(full ? capMb : Math.max(1, capMb - 1), Math.max(1, mb(used))); 900 const text = `${usedMb} of ${capMb} MB used${full ? ' â full' : near ? ' â nearly full' : ''}`; 901 $('size-text').textContent = text; 902 box.classList.toggle('is-near', near); 903 box.classList.toggle('is-full', full); 904 $('size-bar').style.width = `${Math.min(100, Math.round(ratio * 100))}%`; 905 const track = $('size-track'); 906 track.setAttribute('aria-valuemax', String(capMb)); 907 track.setAttribute('aria-valuenow', String(usedMb)); 908 track.setAttribute('aria-valuetext', text); 909 if (full && !sizeFullShown) trackAction('story_size_full', { tier: state.deviceTier, seconds: Math.round(state.videoDuration) }); 910 sizeFullShown = full; 911} 912let sizeFullShown = false; 913 914// Would adding `extraSeconds` of video push the finished size past this device's budget? 915function wouldOverfill(extraSeconds) { 916 const cap = tierLimits().videoBytes; 917 if (!isFinite(cap)) return false; 918 const clips = itemCountText().clips; 919 if (!clips && !extraSeconds.clip) return false; 920 return StoryTimeline.estimateBytes(state.videoDuration + extraSeconds.seconds, state.deviceTier) > cap; 921} 922 923// Opens the item sheet of the clip that currently plays longest: the one worth trimming. 924function openLongestClip() { 925 const clips = readyItems().filter(it => it.kind === 'clip'); 926 if (!clips.length) return; 927 const longest = clips.reduce((a, b) => ((b.trim.end - b.trim.start) > (a.trim.end - a.trim.start) ? b : a)); 928 openItemSheet(longest.id); 929} 930 931// Longest per-photo time that keeps the finished video inside this device's size budget. 932function maxPhotoSecondsForBudget() { 933 const b = StoryTimeline.bounds(state.items); 934 const cap = tierLimits().videoBytes; 935 if (!b.photos || !b.clipSeconds || !isFinite(cap)) return StoryTimeline.PHOTO_MAX; 936 const maxTotal = cap / StoryTimeline.bytesPerSecond(state.deviceTier); 937 const per = Math.floor(((maxTotal - b.clipSeconds) / b.photos) / StoryTimeline.PHOTO_STEP) * StoryTimeline.PHOTO_STEP; 938 return Math.min(StoryTimeline.PHOTO_MAX, Math.max(StoryTimeline.PHOTO_MIN, per)); 939} 940 941function setLengthPanel(open) { 942 $('length-panel').hidden = !open; 943 $('length-toggle').setAttribute('aria-expanded', String(open)); 944 $('length-toggle').textContent = open ? 'Done' : 'Change'; 945} 946 947function syncLength() { 948 const box = $('length-box'); 949 const b = StoryTimeline.bounds(state.items); 950 const any = readyItems().length > 0; 951 box.hidden = !any;
952 if (!any) { setLengthPanel(false); return; } 953 954 const fmt = StoryTimeline.formatLength; 955 const total = state.videoDuration; 956 $('length-line').textContent = fmt(total); 957 // Nothing to stretch without photos: the length is simply the videos' length. 958 $('length-toggle').hidden = b.photos === 0; 959 if (b.photos === 0) setLengthPanel(false); 960 961 const range = $('length-range'); 962 range.value = String(state.photoSeconds); 963 const each = plural(state.photoSeconds, 'second'); 964 range.setAttribute('aria-valuetext', `${fmt(total)}, each photo shows for ${each}`); 965 const pct = ((state.photoSeconds - StoryTimeline.PHOTO_MIN) / (StoryTimeline.PHOTO_MAX - StoryTimeline.PHOTO_MIN)) * 100; 966 range.style.setProperty('--fill', `${pct}%`); 967 $('length-total').textContent = fmt(total); 968 $('length-each').textContent = each; 969 $('length-clips-note').hidden = b.clipSeconds === 0; 970 $('length-min').textContent = fmt(b.min); 971 $('length-max').textContent = fmt(Math.min(b.max, b.clipSeconds + b.photos * maxPhotoSecondsForBudget())); 972 $('length-trim-hint').hidden = !(b.clipSeconds > 0 && state.photoSeconds <= StoryTimeline.PHOTO_MIN); 973 const reset = $('length-reset'); 974 reset.hidden = state.photoSeconds === StoryTimeline.PHOTO_DEFAULT; 975 reset.textContent = `Reset to ${fmt(b.def)}`; 976} 977 978function renderThumbnails(focusIndex) { 979 const list = $('thumbnail-container'); 980 list.innerHTML = ''; 981 982 const n = state.items.length; 983 $('reorder-hint').hidden = n < 1; 984 // Native HTML5 drag-and-drop is mouse-only; on touch screens it either doesn't 985 // fire or hijacks long-press, so touch uses bindTouchReorder() instead. 986 const finePointer = !window.matchMedia('(pointer: coarse)').matches; 987 988 state.items.forEach((item, index) => { 989 const isClip = item.kind === 'clip'; 990 const word = isClip ? 'video' : 'photo'; 991 const Word = isClip ? 'Video' : 'Photo'; 992 993 // A video still being read: a quiet placeholder that cannot be opened or moved yet. 994 if (item.status === 'reading') { 995 const li = document.createElement('li'); 996 li.className = 'fsm-thumb is-reading'; 997 li.dataset.index = index; 998 li.dataset.id = item.id; 999 li.setAttribute('role', 'status'); 1000 li.innerHTML = `<span class="fsm-thumb__spin" aria-hidden="true"></span><span class="fsm-thumb__reading">Reading videoâ¦</span>` + 1001 `<button type="button" class="fsm-thumb__remove" aria-label="Remove video ${index + 1}"><span><i class="ph-bold ph-x" aria-hidden="true"></i></span></button>`;
1002 li.querySelector('.fsm-thumb__remove').addEventListener('click', () => removeImage(state.items.indexOf(item))); 1003 list.appendChild(li); 1004 return; 1005 } 1006 1007 const url = item.poster || item.url; 1008 const meta = item; 1009 const srcLabel = meta.source === 'free' ? 'Free image' : (isClip ? 'Your video' : 'Your photo'); 1010 1011 const fitted = item.fit === 'fit'; 1012 const li = document.createElement('li'); 1013 li.className = `fsm-thumb${fitted ? ' is-fit' : ''}${isClip ? ' is-clip' : ''}`; 1014 li.dataset.index = index; 1015 li.dataset.id = item.id; 1016 li.tabIndex = 0; 1017 li.draggable = finePointer; 1018 li.setAttribute('aria-label', `${Word} ${index + 1} of ${n}, ${srcLabel.toLowerCase()}. Press Enter to change how it looks. Use the left and right arrow keys to change the order.`); 1019 let extras; 1020 if (isClip) { 1021 const len = item.trim.end - item.trim.start; 1022 const trimmed = len < item.duration - 0.05; 1023 const soundOn = !item.muted && item.hasAudio !== false; 1024 extras = 1025 `<span class="fsm-thumb__badge" aria-hidden="true"><i class="ph-fill ${trimmed ? 'ph-scissors' : 'ph-play'}"></i>${formatTime(Math.round(len))}</span>` + 1026 (item.hasAudio === false ? '' : 1027 `<button type="button" class="fsm-thumb__sound" aria-pressed="${soundOn}" aria-label="${soundOn ? `Mute video ${index + 1}` : `Turn sound on for video ${index + 1}`}">` + 1028 `<span><i class="ph-bold ${soundOn ? 'ph-speaker-high' : 'ph-speaker-slash'}" aria-hidden="true"></i></span></button>`); 1029 } else { 1030 extras = `<span class="fsm-thumb__src" aria-hidden="true"><i class="ph-bold ${meta.source === 'free' ? 'ph-globe-simple' : 'ph-device-mobile'}"></i>${srcLabel}</span>`; 1031 } 1032 li.innerHTML = 1033 (fitted 1034 ? `<img class="fsm-thumb__bg" src="${url}" alt="" aria-hidden="true"><img class="fsm-thumb__fg" src="${url}" alt="">` 1035 : `<img src="${url}" alt="">`) + 1036 `<span class="fsm-thumb__num" aria-hidden="true">${index + 1}</span>` + 1037 `<button type="button" class="fsm-thumb__remove" aria-label="Remove ${word} ${index + 1}"><span><i class="ph-bold ph-x" aria-hidden="true"></i></span></button>` + 1038 extras; 1039
1040 const soundBtn = li.querySelector('.fsm-thumb__sound'); 1041 if (soundBtn) soundBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleClipMute(item.id); }); 1042 1043 li.querySelector('.fsm-thumb__remove').addEventListener('click', (e) => { 1044 e.stopPropagation(); 1045 removeImage(index); 1046 }); 1047 1048 li.addEventListener('keydown', (e) => { 1049 if (e.target !== li) return; 1050 if (e.key === 'ArrowLeft' && index > 0) { e.preventDefault(); moveImage(index, index - 1); } 1051 if (e.key === 'ArrowRight' && index < n - 1) { e.preventDefault(); moveImage(index, index + 1); } 1052 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openItemSheet(item.id); } 1053 }); 1054 1055 // Tap or click opens the item sheet. A touch that lifted the tile to reorder it is not a tap. 1056 li.addEventListener('click', (e) => { 1057 if (e.target.closest('button')) return; 1058 if (li.dataset.lifted) return; 1059 openItemSheet(item.id); 1060 }); 1061 1062 // Mouse: HTML5 drag and drop reordering 1063 li.addEventListener('dragstart', (e) => { 1064 e.dataTransfer.effectAllowed = 'move'; 1065 e.dataTransfer.setData('text/plain', String(index)); 1066 setTimeout(() => li.classList.add('is-dragging'), 0); 1067 }); 1068 li.addEventListener('dragend', () => li.classList.remove('is-dragging')); 1069 li.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }); 1070 li.addEventListener('dragenter', (e) => { e.preventDefault(); li.classList.add('is-drop-target'); }); 1071 li.addEventListener('dragleave', () => li.classList.remove('is-drop-target')); 1072 li.addEventListener('drop', (e) => { 1073 e.preventDefault(); 1074 li.classList.remove('is-drop-target'); 1075 const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10); 1076 if (!Number.isNaN(fromIndex) && fromIndex !== index) moveImage(fromIndex, index); 1077 }); 1078 1079 bindTouchReorder(li, index); 1080 list.appendChild(li); 1081 }); 1082 1083 if (photoItems().length < state.maxPhotos || clipsAllowed()) { 1084 const li = document.createElement('li'); 1085 li.innerHTML = `<button type="button" class="fsm-add-tile"><i class="ph-bold ph-plus" aria-hidden="true"></i><span>${n ? 'Add more' : (clipsAllowed() ? 'Add photos or videos' : 'Add photos')}</span></button>`; 1086 li.querySelector('button').disabled = isVideoFull(); 1087 li.querySelector('button').addEventListener('click', () => $('file-input').click()); 1088 list.appendChild(li); 1089 } 1090 1091 if (focusIndex != null && list.children[focusIndex]) list.children[focusIndex].focus(); 1092} 1093 1094// Touch reordering: press and hold (~350ms) lifts a photo, then drag it over another. 1095// A short press or a quick swipe still scrolls the strip normally. 1096function bindTouchReorder(li, index) { 1097 let timer = null; 1098 let active = false; 1099 let startX = 0; 1100 let startY = 0; 1101 let targetIdx = null; 1102 1103 const clearTargets = () => document.querySelectorAll('.fsm-thumb.is-drop-target') 1104 .forEach(el => el.classList.remove('is-drop-target')); 1105 1106 li.addEventListener('touchstart', (e) => { 1107 if (e.touches.length !== 1 || e.target.closest('.fsm-thumb__remove')) return; 1108 startX = e.touches[0].clientX; 1109 startY = e.touches[0].clientY; 1110 timer = setTimeout(() => { 1111 active = true; 1112 li.dataset.lifted = '1'; 1113 li.classList.add('is-lifted'); 1114 li.style.pointerEvents = 'none'; // so elementFromPoint sees the photo underneath 1115 if (navigator.vibrate) navigator.vibrate(15); 1116 }, 350); 1117 }, { passive: true }); 1118 1119 li.addEventListener('touchmove', (e) => { 1120 const t = e.touches[0]; 1121 if (!active) { 1122 if (Math.hypot(t.clientX - startX, t.clientY - startY) > 8) clearTimeout(timer); 1123 return; 1124 } 1125 e.preventDefault(); // stop the page/strip from scrolling while a photo is lifted 1126 li.style.transform = `translate(${t.clientX - startX}px, ${t.clientY - startY}px) scale(1.06)`; 1127 const under = document.elementFromPoint(t.clientX, t.clientY); 1128 const over = under && under.closest('.fsm-thumb'); 1129 clearTargets(); 1130 targetIdx = over ? Number(over.dataset.index) : null; 1131 if (over) over.classList.add('is-drop-target'); 1132 }, { passive: false }); 1133 1134 const end = () => { 1135 clearTimeout(timer); 1136 if (active) { 1137 active = false;
1138 li.classList.remove('is-lifted'); 1139 li.style.transform = ''; 1140 li.style.pointerEvents = ''; 1141 clearTargets(); 1142 if (targetIdx != null && targetIdx !== index) moveImage(index, targetIdx); 1143 setTimeout(() => { delete li.dataset.lifted; }, 400); 1144 } 1145 targetIdx = null; 1146 }; 1147 li.addEventListener('touchend', end); 1148 li.addEventListener('touchcancel', end); 1149 li.addEventListener('contextmenu', (e) => e.preventDefault()); 1150} 1151 1152function moveImage(from, to) { 1153 const [item] = state.items.splice(from, 1); 1154 state.items.splice(to, 0, item); 1155 onItemsChanged(to); 1156 announce(`Moved to position ${to + 1} of ${state.items.length}`); 1157} 1158 1159function removeImage(index) { 1160 const [item] = state.items.splice(index, 1); 1161 if (!item) return; 1162 if (item.source === 'free' && item.freeUrl) addedFree.delete(item.freeUrl); 1163 releaseItem(item); 1164 const focusAt = Math.min(index, state.items.length); // next item, or the add tile 1165 onItemsChanged(focusAt); 1166 announce(`Removed. ${itemCountText().long} left.`); 1167} 1168 1169// --- Item sheet: what can be changed for one photo or video ------------------ 1170let sheetItemId = null; 1171 1172function initItemSheet() { 1173 const sheet = $('fsm-item-sheet'); 1174 if (!sheet || typeof sheet.showModal !== 'function') return; 1175 $('isheet-close').addEventListener('click', closeItemSheet); 1176 $('isheet-done').addEventListener('click', closeItemSheet); 1177 sheet.addEventListener('click', (e) => { if (e.target === sheet) closeItemSheet(); }); 1178 sheet.addEventListener('close', () => { 1179 const id = sheetItemId; 1180 sheetItemId = null; 1181 const playing = $('isheet-video'); 1182 if (playing) playing.pause(); 1183 const tile = id && document.querySelector(`#thumbnail-container [data-id="${id}"]`); 1184 if (tile) tile.focus(); 1185 }); 1186 sheet.querySelectorAll('[data-fit]').forEach((btn) => { 1187 btn.addEventListener('click', () => { 1188 const item = itemById(sheetItemId);
1189 if (!item || item.fit === btn.dataset.fit) return; 1190 item.fit = btn.dataset.fit; 1191 item.fitAuto = false; 1192 trackAction('story_fit_changed', { kind: item.kind, mode: item.fit }); 1193 onItemsChanged(); 1194 renderItemSheet(); 1195 }); 1196 }); 1197 $('isheet-remove').addEventListener('click', () => { 1198 const index = state.items.findIndex(it => it.id === sheetItemId); 1199 sheetItemId = null; // the tile is going away: let focus land on its neighbour instead 1200 closeItemSheet(); 1201 if (index >= 0) removeImage(index); 1202 }); 1203} 1204 1205function openItemSheet(id) { 1206 const sheet = $('fsm-item-sheet'); 1207 const item = itemById(id); 1208 if (!sheet || !item || !StoryTimeline.isReady(item) || typeof sheet.showModal !== 'function') return; 1209 sheetItemId = id; 1210 renderItemSheet(); 1211 if (!sheet.open) sheet.showModal(); 1212} 1213 1214function closeItemSheet() { 1215 const sheet = $('fsm-item-sheet'); 1216 if (sheet && sheet.open) sheet.close(); 1217} 1218 1219function itemShapeWord(item) { 1220 if (item.width > item.height * 1.1) return 'Landscape'; 1221 if (item.height > item.width * 1.1) return 'Vertical'; 1222 return 'Square'; 1223} 1224 1225function clipSubline(item) { 1226 const len = item.trim.end - item.trim.start; 1227 const trimmed = len < item.duration - 0.05; 1228 const sound = item.hasAudio === false ? 'no sound' : (item.muted ? 'muted' : 'sound on'); 1229 return `${trimmed ? `Trimmed to ${formatTime(Math.round(len))}` : formatTime(Math.round(item.duration))} · ${sound}`; 1230} 1231 1232function renderItemSheet() { 1233 const item = itemById(sheetItemId); 1234 if (!item) { closeItemSheet(); return; } 1235 const isClip = item.kind === 'clip'; 1236 const Word = isClip ? 'Video' : 'Photo'; 1237 const number = state.items.indexOf(item) + 1; 1238 const fitted = item.fit === 'fit'; 1239 $('isheet-title').textContent = `${Word} ${number}`; 1240 if (isClip) { 1241 $('isheet-sub').textContent = clipSubline(item); 1242 } else { 1243 $('isheet-sub').textContent = item.fitAuto 1244 ? `${itemShapeWord(item)} photo · ${fitted ? 'fitted automatically' : 'fills the screen'}` 1245 : (fitted ? 'Whole picture, soft background' : 'Fills the screen, edges cut'); 1246 } 1247 1248 const src = item.poster || item.url; 1249 const preview = $('isheet-preview'); 1250 preview.className = `fsm-isheet__phone${fitted ? ' is-fit' : ''}`; 1251 if (isClip) { 1252 // Rebuild the player only when the item or its framing changes, so dragging a trim 1253 // handle (which re-renders the text) does not restart it. 1254 const key = `${item.id}:${item.fit}`; 1255 if (preview.dataset.key !== key) { 1256 preview.dataset.key = key; 1257 preview.innerHTML = 1258 (fitted ? `<img class="fsm-thumb__bg" src="${src}" alt="" aria-hidden="true">` : '') + 1259 `<video class="${fitted ? 'fsm-thumb__fg' : ''}" id="isheet-video" playsinline preload="metadata" poster="${src}" src="${item.url}"></video>` + 1260 `<button type="button" class="fsm-isheet__play" id="isheet-play" aria-label="Play video ${number}"><i class="ph-fill ph-play" aria-hidden="true"></i></button>`; 1261 bindSheetPlayer(item); 1262 } 1263 const video = $('isheet-video'); 1264 if (video) video.muted = item.muted; 1265 } else { 1266 delete preview.dataset.key; 1267 preview.innerHTML = fitted 1268 ? `<img class="fsm-thumb__bg" src="${src}" alt="" aria-hidden="true"><img class="fsm-thumb__fg" src="${src}" alt="Photo ${number} preview">` 1269 : `<img src="${src}" alt="Photo ${number} preview">`; 1270 } 1271 1272 $('fsm-item-sheet').querySelectorAll('[data-fit]').forEach((btn) => {
1273 btn.setAttribute('aria-pressed', String(btn.dataset.fit === item.fit)); 1274 }); 1275 $('isheet-remove-label').textContent = `Remove ${Word.toLowerCase()}`; 1276 1277 const clipBox = $('isheet-clip'); 1278 clipBox.hidden = !isClip; 1279 if (isClip) renderClipControls(item); 1280} 1281 1282// Plays only the part of the video that will be in the finished video. 1283function bindSheetPlayer(item) { 1284 const video = $('isheet-video'); 1285 const play = $('isheet-play'); 1286 if (!video || !play) return; 1287 const sync = () => { 1288 play.innerHTML = `<i class="ph-fill ${video.paused ? 'ph-play' : 'ph-pause'}" aria-hidden="true"></i>`; 1289 play.classList.toggle('is-playing', !video.paused); 1290 }; 1291 play.addEventListener('click', () => { 1292 if (video.paused) { 1293 if (video.currentTime < item.trim.start || video.currentTime >= item.trim.end - 0.1) video.currentTime = item.trim.start; 1294 video.play().catch(() => { }); 1295 } else video.pause(); 1296 }); 1297 video.addEventListener('timeupdate', () => { 1298 if (!video.paused && video.currentTime >= item.trim.end) { video.pause(); video.currentTime = item.trim.start; } 1299 }); 1300 ['play', 'pause', 'ended'].forEach(ev => video.addEventListener(ev, sync)); 1301} 1302 1303// --- Clip controls in the item sheet: trim and sound -------------------------- 1304const TRIM_MIN_SECONDS = 1; 1305const TRIM_FRAMES = 8; 1306 1307function renderClipControls(item) { 1308 const box = $('isheet-clip'); 1309 if (box.dataset.id !== item.id) { 1310 box.dataset.id = item.id; 1311 box.innerHTML = 1312 `<div class="fsm-isheet__group">` + 1313 `<div class="fsm-trim__head"><span class="fsm-isheet__label" id="trim-label">Trim</span><span class="fsm-trim__range" id="trim-range"></span></div>` + 1314 `<div class="fsm-trim" id="trim-strip">` + 1315 `<div class="fsm-trim__frames" id="trim-frames" aria-hidden="true"></div>` + 1316 `<div class="fsm-trim__shade fsm-trim__shade--l" id="trim-shade-l"></div><div class="fsm-trim__shade fsm-trim__shade--r" id="trim-shade-r"></div>` + 1317 `<div class="fsm-trim__window" id="trim-window">` + 1318 `<button type="button" class="fsm-trim__handle fsm-trim__handle--l" id="trim-start" role="slider" aria-label="Start of the part that plays"></button>` + 1319 `<button type="button" class="fsm-trim__handle fsm-trim__handle--r" id="trim-end" role="slider" aria-label="End of the part that plays"></button>` + 1320 `</div></div>` + 1321 `<div class="fsm-trim__ends"><span>0:00</span><span>Drag the green ends</span><span id="trim-total"></span></div>` + 1322 `</div>` + 1323 `<div class="fsm-isheet__row" id="sound-row">` + 1324 `<div><span class="fsm-isheet__label" id="sound-label">Video sound</span><span class="fsm-isheet__hint" id="sound-hint"></span></div>` + 1325 `<button type="button" class="fsm-switch" id="sound-switch" role="switch" aria-labelledby="sound-label"><span></span></button>` + 1326 `</div>`; 1327 bindTrim(item); 1328 $('sound-switch').addEventListener('click', () => toggleClipMute(item.id)); 1329 loadTrimFrames(item); 1330 } 1331 syncClipControls(item); 1332} 1333 1334function syncClipControls(item) { 1335 const start = item.trim.start; 1336 const end = item.trim.end; 1337 const D = item.duration; 1338 const len = end - start; 1339 const secs = Math.round(len * 10) / 10; 1340 $('trim-range').textContent = `Plays ${formatTime(Math.round(start))} â ${formatTime(Math.round(end))} · ${plural(Number.isInteger(secs) ? secs : secs.toFixed(1), 'second')}`; 1341 $('trim-total').textContent = formatTime(Math.round(D)); 1342 const l = (start / D) * 100; 1343 const r = (end / D) * 100; 1344 $('trim-window').style.left = `${l}%`; 1345 $('trim-window').style.width = `${r - l}%`; 1346 $('trim-shade-l').style.width = `${l}%`; 1347 $('trim-shade-r').style.width = `${100 - r}%`; 1348 [['trim-start', start, 0, end - TRIM_MIN_SECONDS], ['trim-end', end, start + TRIM_MIN_SECONDS, D]].forEach(([id, now, min, max]) => { 1349 const h = $(id); 1350 h.setAttribute('aria-valuemin', String(Math.round(min))); 1351 h.setAttribute('aria-valuemax', String(Math.round(max))); 1352 h.setAttribute('aria-valuenow', String(Math.round(now))); 1353 h.setAttribute('aria-valuetext', formatTime(Math.round(now))); 1354 }); 1355
1356 const row = $('sound-row'); 1357 const sw = $('sound-switch'); 1358 if (item.hasAudio === false) { 1359 sw.hidden = true; 1360 $('sound-hint').textContent = 'This video has no sound'; 1361 } else { 1362 sw.hidden = false; 1363 sw.setAttribute('aria-checked', String(!item.muted)); 1364 $('sound-hint').textContent = item.muted ? 'Muted â only music plays' : 'On â music goes quieter here'; 1365 } 1366 row.classList.toggle('is-off', item.muted || item.hasAudio === false); 1367 $('isheet-sub').textContent = clipSubline(item); 1368} 1369 1370// Moves one end of the trim. Keeps at least TRIM_MIN_SECONDS selected and the ends in order. 1371function setTrim(item, which, seconds) { 1372 const D = item.duration; 1373 let { start, end } = item.trim; 1374 if (which === 'start') start = Math.min(Math.max(0, seconds), end - TRIM_MIN_SECONDS); 1375 else end = Math.max(Math.min(D, seconds), start + TRIM_MIN_SECONDS); 1376 if (start === item.trim.start && end === item.trim.end) return; 1377 item.trim = { start, end }; 1378 syncClipControls(item); 1379 const video = $('isheet-video'); 1380 if (video) { video.pause(); try { video.currentTime = which === 'start' ? start : Math.max(start, end - 0.05); } catch (_) { } } 1381} 1382 1383function commitTrim(item) { 1384 trackAction('story_clip_trimmed', { seconds: Math.round(item.trim.end - item.trim.start), of: Math.round(item.duration) }); 1385 onItemsChanged(); 1386} 1387 1388function bindTrim(item) { 1389 const strip = $('trim-strip'); 1390 [['trim-start', 'start'], ['trim-end', 'end']].forEach(([id, which]) => { 1391 const handle = $(id); 1392 let dragging = false; 1393 const at = (clientX) => { 1394 const rect = strip.getBoundingClientRect(); 1395 return rect.width ? ((clientX - rect.left) / rect.width) * item.duration : 0; 1396 }; 1397 handle.addEventListener('pointerdown', (e) => { 1398 dragging = true; 1399 handle.setPointerCapture(e.pointerId); 1400 e.preventDefault(); 1401 handle.focus({ preventScroll: true }); 1402 }); 1403 handle.addEventListener('pointermove', (e) => { if (dragging) setTrim(item, which, at(e.clientX)); }); 1404 const end = (e) => { 1405 if (!dragging) return; 1406 dragging = false; 1407 try { handle.releasePointerCapture(e.pointerId); } catch (_) { } 1408 commitTrim(item); 1409 }; 1410 handle.addEventListener('pointerup', end); 1411 handle.addEventListener('pointercancel', end); 1412 handle.addEventListener('keydown', (e) => { 1413 const step = e.shiftKey ? 5 : 0.5; 1414 const now = item.trim[which]; 1415 let next; 1416 if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = now - step; 1417 else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = now + step; 1418 else if (e.key === 'Home') next = which === 'start' ? 0 : item.trim.start + TRIM_MIN_SECONDS; 1419 else if (e.key === 'End') next = which === 'start' ? item.trim.end - TRIM_MIN_SECONDS : item.duration; 1420 else return; 1421 e.preventDefault(); 1422 setTrim(item, which, next); 1423 commitTrim(item); 1424 }); 1425 }); 1426} 1427 1428// Eight evenly spaced frames behind the trim window, captured once per clip. 1429async function loadTrimFrames(item) { 1430 const host = $('trim-frames'); 1431 const paint = () => { 1432 if (!host || $('isheet-clip').dataset.id !== item.id) return; 1433 host.innerHTML = (item.frames || []).map(u => `<img src="${u}" alt="">`).join(''); 1434 }; 1435 if (item.frames) { paint(); return; } 1436 if (item.poster) host.innerHTML = Array.from({ length: TRIM_FRAMES }, () => `<img src="${item.poster}" alt="">`).join(''); 1437 const video = document.createElement('video'); 1438 video.muted = true; 1439 video.playsInline = true; 1440 video.preload = 'auto'; 1441 const frames = []; 1442 try { 1443 video.src = item.url; 1444 await waitForEvent(video, 'loadedmetadata', 15000); 1445 for (let i = 0; i < TRIM_FRAMES; i++) { 1446 video.currentTime = Math.min(item.duration - 0.05, ((i + 0.5) / TRIM_FRAMES) * item.duration); 1447 await waitForEvent(video, 'seeked', 8000); 1448 frames.push(await grabVideoFrame(video, 160)); 1449 if (!state.items.includes(item)) break; 1450 } 1451 if (state.items.includes(item) && frames.length === TRIM_FRAMES) { item.frames = frames; paint(); }
1452 else frames.forEach(u => URL.revokeObjectURL(u)); 1453 } catch (_) { 1454 frames.forEach(u => URL.revokeObjectURL(u)); // the poster placeholders stay 1455 } finally { 1456 video.removeAttribute('src'); 1457 try { video.load(); } catch (_) { } 1458 } 1459} 1460 1461// --- Human check for picture and music search (/api/pass.php, ADR-026) --------- 1462// The search endpoints answer only to a browser that holds a 30-minute pass. The server sets 1463// the pass after Cloudflare Turnstile says a real browser is asking. Most people see nothing; 1464// a small box appears at the bottom of the screen only when Turnstile wants a tap. 1465// Where Turnstile is not set up (local Docker) the server says `required: false`. 1466let passPromise = null; 1467let passNotRequired = false; 1468const PASS_FAILED_MESSAGE = 'We could not check your browser. Please reload the page and try again.'; 1469 1470// The pass itself is not readable from here; the server sets this second cookie with its end time. 1471function passLooksValid() { 1472 const m = document.cookie.match(/(?:^|; )id8_pass_until=(\d+)/); 1473 return !!m && Number(m[1]) * 1000 > Date.now() + 15000; 1474} 1475 1476function loadTurnstile() { 1477 if (window.turnstile) return Promise.resolve(); 1478 return new Promise((resolve, reject) => { 1479 const script = document.createElement('script'); 1480 script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'; 1481 script.async = true; 1482 script.onload = () => resolve(); 1483 script.onerror = () => reject(new Error(PASS_FAILED_MESSAGE)); 1484 document.head.appendChild(script); 1485 }); 1486} 1487 1488function runHumanCheck(siteKey) { 1489 return loadTurnstile().then(() => new Promise((resolve, reject) => { 1490 let box = $('fsm-human-check'); 1491 if (!box) { 1492 box = document.createElement('div'); 1493 box.id = 'fsm-human-check'; 1494 box.style.cssText = 'position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:2147483000;'; 1495 document.body.appendChild(box); 1496 } 1497 let widget = null; 1498 const finish = (settle, value) => { 1499 clearTimeout(timer); 1500 try { if (widget !== null) window.turnstile.remove(widget); } catch (_) { } 1501 settle(value); 1502 }; 1503 const timer = setTimeout(() => finish(reject, new Error(PASS_FAILED_MESSAGE)), 45000); 1504 widget = window.turnstile.render(box, { 1505 sitekey: siteKey, 1506 theme: 'light', 1507 appearance: 'interaction-only', 1508 callback: (token) => finish(resolve, token), 1509 'error-callback': () => { finish(reject, new Error(PASS_FAILED_MESSAGE)); return true; }, 1510 }); 1511 })); 1512} 1513 1514// Resolves once this browser holds a pass (or none is needed). Calls made together share one check. 1515function ensurePass(force) { 1516 if (!force && (passNotRequired || passLooksValid())) return Promise.resolve(); 1517 if (passPromise) return passPromise; 1518 passPromise = (async () => { 1519 const info = await (await fetch('/api/pass.php')).json(); 1520 if (!info.required) { passNotRequired = true; return; } 1521 if (!info.site_key) throw new Error(PASS_FAILED_MESSAGE); 1522 const token = await runHumanCheck(info.site_key); 1523 const res = await fetch('/api/pass.php', { 1524 method: 'POST', 1525 headers: { 'Content-Type': 'application/json' }, 1526 body: JSON.stringify({ token }), 1527 }); 1528 if (!res.ok) throw new Error(PASS_FAILED_MESSAGE); 1529 })().finally(() => { passPromise = null; }); 1530 return passPromise; 1531} 1532 1533// fetch() for the two search endpoints: gets a pass first, and once more if the server asks for one. 1534async function searchFetch(url) { 1535 try { await ensurePass(false); } catch (_) { /* the request below reports the problem */ } 1536 let res = await fetch(url); 1537 if (res.status === 401) { 1538 await ensurePass(true); 1539 res = await fetch(url); 1540 if (res.status === 401) throw new Error(PASS_FAILED_MESSAGE); 1541 } 1542 return res; 1543} 1544 1545// Song links stop working after an hour or two. Each track remembers the search it came from, 1546// so a link that has grown old can be swapped for a new one just before the video is made. 1547function stampTracks(tracks, query) {
1548 (tracks || []).forEach((t) => { t.searchQuery = query; t.linkedAt = Date.now(); }); 1549 return tracks; 1550} 1551 1552async function refreshTrackLink(track) { 1553 if (!track || track.source === 'own' || !track.searchQuery) return; 1554 if (Date.now() - (track.linkedAt || 0) < 50 * 60 * 1000) return; 1555 try { 1556 const res = await searchFetch(`/api/wishes-music.php?q=${encodeURIComponent(track.searchQuery)}&limit=5`); 1557 if (!res.ok) return; 1558 const data = await res.json(); 1559 const same = (data.tracks || []).find((t) => t.id === track.id); 1560 if (same) { track.audio = same.audio; track.linkedAt = Date.now(); } 1561 } catch (_) { /* keep the old link; the usual failure screen covers it */ } 1562} 1563 1564// --- Free images (/api/stock-images.php) -------------------------------------- 1565function setTopicChip(query) { 1566 document.querySelectorAll('#topic-chips .fsm-chip').forEach((chip) => { 1567 chip.setAttribute('aria-pressed', String(chip.dataset.q === query.toLowerCase())); 1568 }); 1569} 1570 1571async function runTopicSearch(query) { 1572 const seq = ++topicSearchSeq; 1573 topicQuery = query; 1574 setTopicChip(query); 1575 const panel = $('topic-results-panel'); 1576 const grid = $('topic-results'); 1577 const status = $('topic-loading'); 1578 panel.hidden = false; 1579 grid.innerHTML = ''; 1580 status.hidden = false; 1581 status.textContent = 'Finding picturesâ¦'; 1582 $('topic-results-title').textContent = `Free images for â${query}â`; 1583 hidePhotoError(); 1584 $('topic-search-btn').disabled = true; 1585 1586 try { 1587 const res = await searchFetch(`/api/stock-images.php?q=${encodeURIComponent(query)}`); 1588 let data; 1589 try { 1590 data = await res.json(); 1591 } catch (parseError) { 1592 if (!res.ok) throw new Error(`Could not load pictures (error ${res.status}). Please try again.`); 1593 throw new Error('Received an unexpected response from the server.'); 1594 } 1595 if (!res.ok) throw new Error((data && data.error) || `Could not load pictures (error ${res.status}). Please try again.`); 1596 if (!Array.isArray(data) || data.length === 0) throw new Error('No pictures found for this word. Try another one.'); 1597 if (seq !== topicSearchSeq) return; 1598 1599 // Support both old API format (flat string) and new ({thumbnail, download}). 1600 topicData = data.map(item => ({ 1601 download: typeof item === 'string' ? item : item.download, 1602 thumbnail: typeof item === 'string' ? item : item.thumbnail, 1603 })); 1604 topicShown = 0; 1605 status.hidden = true; 1606 showMoreTopicResults(TOPIC_FIRST_PAGE); 1607 } catch (err) { 1608 if (seq !== topicSearchSeq) return; 1609 status.hidden = false; 1610 status.textContent = err.message; 1611 } finally { 1612 if (seq === topicSearchSeq) $('topic-search-btn').disabled = false; 1613 } 1614} 1615 1616function showMoreTopicResults(count) { 1617 const grid = $('topic-results');
1618 const oldMore = grid.querySelector('.fsm-more-tile'); 1619 if (oldMore) oldMore.remove(); 1620 1621 const firstNew = topicShown; 1622 topicData.slice(topicShown, topicShown + count).forEach((item, i) => { 1623 grid.appendChild(buildResultTile(item, firstNew + i)); 1624 }); 1625 topicShown = Math.min(topicData.length, topicShown + count); 1626 1627 if (topicShown < topicData.length) { 1628 const more = document.createElement('button'); 1629 more.type = 'button'; 1630 more.className = 'fsm-more-tile'; 1631 more.innerHTML = '<i class="ph-bold ph-plus-circle" aria-hidden="true"></i><span>Show more images</span>'; 1632 more.addEventListener('click', () => { 1633 const before = grid.querySelectorAll('.fsm-result').length; 1634 showMoreTopicResults(TOPIC_PAGE); 1635 const next = grid.querySelectorAll('.fsm-result')[before]; 1636 if (next) next.querySelector('button').focus(); 1637 }); 1638 grid.appendChild(more); 1639 } 1640} 1641 1642function buildResultTile(item, idx) { 1643 const tile = document.createElement('div'); 1644 tile.className = 'fsm-result'; 1645 tile.dataset.url = item.download; 1646 1647 const img = document.createElement('img'); 1648 img.alt = `Free picture ${idx + 1} for ${topicQuery}`; 1649 img.loading = 'lazy'; 1650 img.decoding = 'async'; 1651 // On error the tile is removed so the user never sees a broken placeholder. 1652 img.onerror = () => tile.remove(); 1653 img.src = item.thumbnail; 1654 1655 const check = document.createElement('span'); 1656 check.className = 'fsm-result__check'; 1657 check.setAttribute('aria-hidden', 'true'); 1658 check.innerHTML = '<i class="ph-bold ph-check"></i>'; 1659 1660 const btn = document.createElement('button'); 1661 btn.type = 'button'; 1662 btn.className = 'fsm-result__btn'; 1663 btn.addEventListener('click', () => toggleFreeImage(item.download)); 1664 1665 tile.append(img, check, btn); 1666 syncResultTile(tile); 1667 return tile; 1668} 1669 1670function syncResultTile(tile) { 1671 const url = tile.dataset.url; 1672 const added = addedFree.has(url); 1673 const busy = pendingFree.has(url);
1674 const btn = tile.querySelector('.fsm-result__btn'); 1675 tile.classList.toggle('is-added', added); 1676 btn.setAttribute('aria-pressed', String(added)); 1677 btn.setAttribute('aria-busy', String(busy)); 1678 btn.disabled = busy; 1679 btn.innerHTML = busy 1680 ? '<i class="ph-bold ph-circle-notch" aria-hidden="true"></i><span>Addingâ¦</span>' 1681 : added 1682 ? '<i class="ph-bold ph-check" aria-hidden="true"></i><span>Added</span><span class="fsm-sr-only"> (press to remove)</span>' 1683 : '<i class="ph-bold ph-plus" aria-hidden="true"></i><span>Add</span><span class="fsm-sr-only"> to your video</span>'; 1684} 1685 1686function syncAllResultTiles() { 1687 document.querySelectorAll('#topic-results .fsm-result').forEach(syncResultTile); 1688} 1689 1690// Each free picture is downloaded the moment the user presses Add (replaces the 1691// old select-many-then-"Import Selected" flow), so what they see in "Your video" 1692// is always exactly what goes into the render. 1693async function toggleFreeImage(url) { 1694 if (pendingFree.has(url)) return; 1695 if (addedFree.has(url)) { 1696 const idx = state.items.indexOf(addedFree.get(url)); 1697 if (idx >= 0) removeImage(idx); 1698 else { addedFree.delete(url); syncAllResultTiles(); } 1699 return; 1700 } 1701 if (photoItems().length >= state.maxPhotos) { 1702 showPhotoError(`You can add up to ${state.maxPhotos} photos. Remove one to add another.`); 1703 return; 1704 } 1705 if (isVideoFull() || wouldOverfill({ seconds: state.photoSeconds })) { showVideoFull(); return; } 1706 1707 const ctrl = new AbortController(); 1708 pendingFree.set(url, ctrl); 1709 syncAllResultTiles(); 1710 try { 1711 const res = await withTimeout(fetch(url, { signal: ctrl.signal }), 30000, 'The picture took too long to download.'); 1712 if (!res.ok) throw new Error(`Download failed (error ${res.status}).`); 1713 const blob = await res.blob(); 1714 if (blob.type && !blob.type.startsWith('image/')) throw new Error('That file is not a picture.'); 1715 if (photoItems().length >= state.maxPhotos) throw new Error(`You already have ${state.maxPhotos} photos.`); 1716 const ext = ((blob.type || 'image/jpeg').split('/')[1] || 'jpg').replace('jpeg', 'jpg'); 1717 const file = new File([blob], `free_image_${hashString(url)}.${ext}`, { type: blob.type || 'image/jpeg' }); 1718 const item = makePhotoItem(file, 'free', url); 1719 addedFree.set(url, item); 1720 state.items.push(item); 1721 pendingFree.delete(url); 1722 onItemsChanged(); 1723 announce(`Picture added. ${itemCountText().long} in your video.`); 1724 kickFFmpegPreloadIfNeeded(); 1725 } catch (err) { 1726 if (!err || err.name !== 'AbortError') showPhotoError(`Could not add that picture. ${err && err.message ? err.message : ''}`); 1727 } finally { 1728 pendingFree.delete(url); 1729 syncAllResultTiles(); 1730 } 1731} 1732 1733// ---------------------------------------------------------------------------- 1734// Step 2: Music 1735// ---------------------------------------------------------------------------- 1736// One audio element for everything on this step: song previews and the looping 1737// crop segment of the selected song. Guarantees only one thing plays at a time. 1738const player = new Audio(); 1739player.preload = 'none'; 1740let playerSrc = ''; 1741let playingTrackId = null; 1742let lastSearchResults = []; 1743let cropKeyTimer = null; 1744 1745player.addEventListener('timeupdate', () => { 1746 const t = state.selectedTrack; 1747 if (t && playingTrackId === t.id && player.currentTime > 0 && player.currentTime >= t.crop.end - 0.1) { 1748 player.currentTime = t.crop.start; 1749 } 1750}); 1751['play', 'pause', 'ended'].forEach(ev => player.addEventListener(ev, syncPlayButtons)); 1752 1753function playTrack(track, startAt) { 1754 if (playerSrc !== track.audio) { 1755 player.src = track.audio; 1756 playerSrc = track.audio; 1757 } 1758 playingTrackId = track.id; 1759 try { player.currentTime = startAt || 0; } catch (_) { /* before metadata: ignored */ } 1760 player.play().catch(e => console.log('Playback prevented', e)); 1761 syncPlayButtons(); 1762} 1763 1764function stopAudio() { player.pause(); } 1765 1766function toggleTrackPlayback(track) { 1767 if (playingTrackId === track.id && !player.paused) { 1768 player.pause(); 1769 return; 1770 } 1771 const isSelected = state.selectedTrack && state.selectedTrack.id === track.id; 1772 playTrack(track, isSelected ? state.selectedTrack.crop.start : 0); 1773} 1774 1775function playAudioSegment(start) { 1776 if (!state.selectedTrack) return; 1777 playTrack(state.selectedTrack, start); 1778} 1779 1780function syncPlayButtons() { 1781 document.querySelectorAll('.fsm-track__play').forEach((btn) => { 1782 const on = btn.dataset.id === String(playingTrackId) && !player.paused; 1783 btn.innerHTML = on 1784 ? '<i class="ph-fill ph-pause" aria-hidden="true"></i><span class="fsm-track__play-label">
1784Pause</span>' 1785 : '<i class="ph-fill ph-play" aria-hidden="true"></i><span class="fsm-track__play-label">Play</span>'; 1786 btn.setAttribute('aria-label', `${on ? 'Pause' : 'Play'} ${btn.dataset.name}`); 1787 }); 1788} 1789 1790function setActiveChip(cat) { 1791 document.querySelectorAll('.category-chip').forEach(c => { 1792 c.setAttribute('aria-pressed', String(c.dataset.cat === cat)); 1793 }); 1794} 1795 1796function initStep2() { 1797 // Show loading state until preload delivers 'happy' results 1798 $('music-results').innerHTML = '<p class="fsm-empty">Loading songsâ¦</p>'; 1799 1800 // Manual search â clears chip active state 1801 $('music-search-form').addEventListener('submit', (e) => { 1802 e.preventDefault(); 1803 const q = $('music-search').value.trim(); 1804 if (!q) return; 1805 setActiveChip(null); 1806 searchMusic(q); 1807 }); 1808 1809 // Category chips â instant if preloaded, fetches on demand otherwise 1810 document.querySelectorAll('.category-chip').forEach(chip => { 1811 chip.addEventListener('click', () => { 1812 const cat = chip.dataset.cat;
1813 setActiveChip(cat); 1814 $('music-search').value = ''; 1815 searchMusic(cat); 1816 }); 1817 }); 1818 1819 $('no-music-btn').addEventListener('click', () => { 1820 if (state.skipAudio && !state.selectedTrack) return; // already the choice 1821 clearSelectedTrack(); 1822 announce('No music.'); 1823 }); 1824 1825 // Own audio: the file is only ever read on this device. 1826 const ownInput = $('own-audio-input'); 1827 $('own-audio-btn').addEventListener('click', () => ownInput.click()); 1828 ownInput.addEventListener('change', (e) => { 1829 handleOwnAudio(e.target.files && e.target.files[0]); 1830 e.target.value = ''; 1831 }); 1832 $('own-audio-error').addEventListener('click', (e) => { 1833 const act = e.target.closest('[data-act]'); 1834 if (!act) return; 1835 if (act.dataset.act === 'pick') ownInput.click(); 1836 else $('music-results').scrollIntoView({ behavior: 'smooth', block: 'start' }); 1837 }); 1838 syncNoMusic(); 1839} 1840 1841function syncNoMusic() { 1842 const btn = $('no-music-btn'); 1843 const none = state.skipAudio || !state.selectedTrack; 1844 btn.setAttribute('aria-pressed', String(none)); 1845 $('no-music-sub').textContent = clipsHaveSound() ? 'Your videosâ own sound will still play' : 'Your video will have no sound'; 1846 // "No music" leads the step while it is the choice; once a song is picked it moves below the list. 1847 const step = $('step-2'); 1848 if (none) step.insertBefore(btn, $('own-audio-btn')); 1849 else step.appendChild(btn); 1850 $('own-audio-btn').hidden = !!(state.selectedTrack && state.selectedTrack.source === 'own'); 1851 renderOwnAudioCard(); 1852} 1853 1854function revokeOwnAudio(track) { 1855 if (track && track.source === 'own' && track.audio) { 1856 if (playerSrc === track.audio) { player.pause(); player.removeAttribute('src'); playerSrc = ''; } 1857 try { URL.revokeObjectURL(track.audio); } catch (_) { } 1858 } 1859} 1860 1861function clearSelectedTrack() { 1862 revokeOwnAudio(state.selectedTrack); 1863 state.selectedTrack = null; 1864 state.skipAudio = true; 1865 stopAudio(); 1866 syncNoMusic(); 1867 renderMusicResults(lastSearchResults); 1868 updateNav(); 1869} 1870 1871function showOwnAudioError(msg, offerLibrary) { 1872 const el = $('own-audio-error'); 1873 el.innerHTML = `<span>${escapeHtml(msg)}</span> <button type="button" class="fsm-link-btn" data-act="pick">Choose another</button>` + 1874 (offerLibrary ? ` <button type="button" class="fsm-link-btn" data-act="library">Pick from the free music below</button>` : ''); 1875 el.hidden = false; 1876} 1877 1878function handleOwnAudio(file) { 1879 if (!file) return; 1880 $('own-audio-error').hidden = true; 1881 if (file.size > StoryTimeline.OWN_AUDIO_MAX_BYTES) { 1882 trackAction('story_own_audio_rejected', { reason: 'too-big' }); 1883 showOwnAudioError('That audio file is too big. Try a shorter one.', false); 1884 return; 1885 } 1886 const url = URL.createObjectURL(file); 1887 const probe = new Audio(); 1888 let settled = false; 1889 const done = (ok) => { 1890 if (settled) return; 1891 settled = true; 1892 probe.onloadedmetadata = probe.onerror = null; 1893 if (!ok || !isFinite(probe.duration) || probe.duration <= 0) { 1894 URL.revokeObjectURL(url); 1895 trackAction('story_own_audio_rejected', { reason: 'unreadable' }); 1896 showOwnAudioError('That audio file canât be used. Try an MP3 or M4A file.', true); 1897 return; 1898 } 1899 trackAction('story_own_audio_added', { seconds: Math.round(probe.duration), loops: probe.duration < state.videoDuration }); 1900 selectTrack({ 1901 source: 'own', id: `own-${file.name}-${file.size}`, name: file.name, artist: 'Your audio', 1902 duration: probe.duration, audio: url, file 1903 }); 1904 }; 1905 probe.onloadedmetadata = () => done(true); 1906 probe.onerror = () => done(false); 1907 setTimeout(() => done(false), 10000); 1908 probe.preload = 'metadata'; 1909 probe.src = url; 1910} 1911 1912function renderOwnAudioCard() { 1913 const host = $('own-audio-card'); 1914 const t = state.selectedTrack; 1915 host.innerHTML = ''; 1916 if (!t || t.source !== 'own') return; 1917 const card = buildTrackCard(t); 1918 const actions = document.createElement('div'); 1919 actions.className = 'fsm-track__own-actions'; 1920 actions.innerHTML = '<button type="button" class="fsm-link-btn" data-own="replace">Replace</button>' + 1921 '<button type="button" class="fsm-link-btn" data-own="remove">Remove</button>'; 1922 actions.querySelector('[data-own="replace"]').addEventListener('click', () => $('own-audio-input').click()); 1923 actions.querySelector('[data-own="remove"]').addEventListener('click', () => { 1924 clearSelectedTrack(); 1925 $('own-audio-btn').focus(); 1926 });
1927 card.insertBefore(actions, card.querySelector('.fsm-crop')); 1928 host.appendChild(card); 1929} 1930 1931async function searchMusic(query) { 1932 autoResultRendered = true; // any call locks out the auto-render 1933 const q = query.trim().toLowerCase(); 1934 const resultsContainer = $('music-results'); 1935 1936 // Serve from cache instantly â no spinner, no network round-trip 1937 if (preloadedResults[q]) { 1938 lastSearchResults = preloadedResults[q]; 1939 renderMusicResults(preloadedResults[q]); 1940 return; 1941 } 1942 1943 resultsContainer.innerHTML = '<p class="fsm-empty">Searchingâ¦</p>'; 1944 const showError = (msg) => { resultsContainer.innerHTML = `<p class="fsm-empty is-error">${msg}</p>`; }; 1945 1946 let responseText; 1947 try { 1948 const response = await searchFetch(`/api/wishes-music.php?q=${encodeURIComponent(q)}&limit=5`); 1949 1950 // Handle HTTP error codes with user-friendly messages before touching the body. 1951 if (response.status === 429) { showError('Too many searches. Please wait a moment and try again.'); return; } 1952 if (!response.ok) { showError('Music search is not working right now. Please try again.'); return; } 1953 1954 // Read as text first so that an HTML error page (PHP crash, Apache error, 1955 // or a synthetic 502 from the COI service worker) doesn't cause 1956 // "Unexpected token '<'" â we catch the parse failure explicitly. 1957 responseText = await response.text(); 1958 } catch (err) { 1959 // Network failures (TypeError, offline, DNS, etc.), or the human check did not finish. 1960 showError(err && err.message === PASS_FAILED_MESSAGE 1961 ? PASS_FAILED_MESSAGE 1962 : 'Could not reach the music server. Please check your internet and try again.'); 1963 return; 1964 } 1965 1966 let data; 1967 try { 1968 data = JSON.parse(responseText); 1969 } catch (_) { 1970 showError('Music search gave an unexpected answer. Please try again.'); 1971 return; 1972 } 1973 1974 if (data.error) { 1975 showError(escapeHtml(data.error)); 1976 return; 1977 } 1978 1979 stampTracks(data.tracks, q); 1980 preloadedResults[q] = data.tracks; // cache for future use 1981 lastSearchResults = data.tracks; 1982 renderMusicResults(data.tracks); 1983} 1984 1985function renderMusicResults(tracks) { 1986 const container = $('music-results'); 1987 if (!tracks || tracks.length === 0) { 1988 // Nothing fetched yet (or nothing found): keep whatever message is showing, unless a 1989 // search really came back empty. 1990 if (tracks && autoResultRendered) container.innerHTML = '<p class="fsm-empty">No songs found. Try another word or a mood above.</p>'; 1991 syncCropAfterRender(); 1992 return; 1993 } 1994 1995 // Keep the chosen song visible even when the current search doesn't include it. 1996 let list = tracks; 1997 if (state.selectedTrack && state.selectedTrack.source !== 'own' && !tracks.some(t => t.id === state.selectedTrack.id)) { 1998 list = [state.selectedTrack, ...tracks]; 1999 } 2000 2001 container.innerHTML = ''; 2002 list.forEach(track => container.appendChild(buildTrackCard(track))); 2003 syncCropAfterRender(); 2004} 2005 2006// The selected card (in the list, or the own-audio card) carries the one crop control. 2007function syncCropAfterRender() { 2008 syncPlayButtons(); 2009 if (state.selectedTrack) { 2010 bindCrop(); 2011 layoutCrop(); 2012 } 2013} 2014 2015function buildTrackCard(track) { 2016 const selected = !!(state.selectedTrack && state.selectedTrack.id === track.id); 2017 const name = escapeHtml(track.name); 2018 const artist = escapeHtml(track.artist); 2019 const useBtn = '<button type="button" class="fsm-btn fsm-btn--secondary fsm-track__use">Use this song</button>'; 2020 2021 const card = document.createElement('div'); 2022 card.className = `fsm-track${selected ? ' is-selected' : ''}`; 2023 card.innerHTML = 2024 `<div class="fsm-track__row">` + 2025 `<button type="button" class="fsm-track__play" data-id="${escapeHtml(String(track.id))}" data-name="${name}"></button>` + 2026 `<div class="fsm-track__meta"><span class="fsm-track__name">${name}</span><span class="fsm-track__by">${artist} · ${formatTime(track.duration)}</span></div>` + 2027 (selected ? '<span class="fsm-selected-badge"><i class="ph-bold ph-check" aria-hidden="true"></i>Selected</span>' : useBtn) + 2028 `</div>` + 2029 (selected 2030 ? '<div class="fsm-selected-line"><i class="ph-bold ph-check" aria-hidden="true"></i><span>Selected for your video</span></div>' + cropMarkup() 2031 : useBtn); 2032
2033 card.querySelector('.fsm-track__play').addEventListener('click', () => toggleTrackPlayback(track)); 2034 card.querySelectorAll('.fsm-track__use').forEach(b => b.addEventListener('click', () => selectTrack(track))); 2035 return card; 2036} 2037 2038function selectTrack(track) { 2039 if (state.selectedTrack && state.selectedTrack.audio !== track.audio) revokeOwnAudio(state.selectedTrack); 2040 state.selectedTrack = { 2041 source: 'library', 2042 ...track, 2043 crop: { 2044 start: 0, 2045 end: Math.min(track.duration, state.videoDuration) 2046 } 2047 }; 2048 state.skipAudio = false; 2049 $('own-audio-error').hidden = true; 2050 syncNoMusic(); 2051 2052 // Re-render results list to show selected state 2053 renderMusicResults(lastSearchResults); 2054 updateNav(); 2055 2056 // Play from the crop start immediately â user clicked "Use this song" so gesture is active 2057 playAudioSegment(state.selectedTrack.crop.start); 2058 announce(trackRepeats(state.selectedTrack) 2059 ? `${track.name} selected for your video. It is shorter than your video, so it will repeat.` 2060 : `${track.name} selected for your video. Drag the green box to choose which part plays.`);
2061 const playBtn = document.querySelector('.fsm-track.is-selected .fsm-track__play'); 2062 if (playBtn) playBtn.focus(); 2063} 2064 2065// --- Crop: full-song decorative waveform + draggable window ------------------ 2066function cropMarkup() { 2067 return `<div class="fsm-crop">` + 2068 `<p class="fsm-crop__repeat" id="crop-repeat" role="status" hidden><i class="ph-bold ph-repeat" aria-hidden="true"></i><span>This audio is shorter than your video, so it will repeat.</span></p>` + 2069 `<div class="fsm-crop__pick" id="crop-pick">` + 2070 `<div class="fsm-crop__head"><span class="fsm-crop__title" id="crop-title">Choose which part plays <span class="fsm-crop__opt">Optional</span></span>` + 2071 `<span class="fsm-crop__hint" id="crop-hint"></span></div>` + 2072 `<div class="fsm-crop__wave" id="crop-wave"><div class="fsm-crop__window" id="crop-window" role="slider" tabindex="0" aria-labelledby="crop-title"></div></div>` + 2073 `<div class="fsm-crop__labels"><span>0:00</span><span class="is-on" id="crop-range"></span><span id="crop-total"></span></div>` + 2074 `</div></div>`; 2075} 2076 2077function clipsHaveSound() { return currentTimeline().clipWindows.length > 0; } 2078 2079// Audio shorter than the video repeats until the video ends; there is no part to choose. 2080function trackRepeats(t) { 2081 return !!t && t.duration < state.videoDuration - 0.05; 2082} 2083 2084function cropGeometry() { 2085 const wave = $('crop-wave'); 2086 const t = state.selectedTrack; 2087 const W = wave ? wave.clientWidth : 0; 2088 const D = t.duration; 2089 const V = Math.min(state.videoDuration, D); 2090 // Proportional width, but never narrower than a comfortable 56px touch target â 2091 // an 8s window on an 8-minute song would otherwise be a few pixels wide. 2092 const winW = D <= V ? W : Math.min(W, Math.max(56, (W * V) / D)); 2093 return { W, D, V, winW, travel: Math.max(0, W - winW), maxStart: Math.max(0, D - V) }; 2094} 2095 2096function layoutCrop() { 2097 const wave = $('crop-wave'); 2098 const t = state.selectedTrack; 2099 if (!wave || !t || !wave.clientWidth) return; 2100 const barW = isMobile() ? 4 : 5; 2101 const count = Math.max(12, Math.floor(wave.clientWidth / (barW + 2.6))); 2102 if (wave.dataset.count !== String(count) || wave.dataset.track !== String(t.id)) { 2103 wave.querySelectorAll('.fsm-crop__bar').forEach(b => b.remove()); 2104 const rnd = seededRandom(hashString(String(t.id))); 2105 const win = $('crop-window'); 2106 for (let i = 0; i < count; i++) { 2107 const bar = document.createElement('span'); 2108 bar.className = 'fsm-crop__bar'; 2109 bar.style.height = `${Math.round(22 + rnd() * 76)}%`; 2110 wave.insertBefore(bar, win); 2111 } 2112 wave.dataset.count = String(count); 2113 wave.dataset.track = String(t.id); 2114 } 2115 updateCropWindow(); 2116} 2117 2118function updateCropWindow() { 2119 const t = state.selectedTrack; 2120 const wave = $('crop-wave'); 2121 if (!t || !wave) return; 2122 const g = cropGeometry(); 2123 2124 const start = Math.min(Math.max(0, t.crop.start), g.maxStart); 2125 t.crop.start = start; 2126 t.crop.end = start + g.V; 2127 2128 // Longer than the video: choose which part plays. Shorter: it repeats. Same length: neither. 2129 const repeats = trackRepeats(t); 2130 $('crop-repeat').hidden = !repeats; 2131 $('crop-pick').hidden = g.maxStart <= 0.05; 2132 2133 const hint = $('crop-hint'); 2134 if (hint) hint.textContent = `Drag the green box. Your video is ${StoryTimeline.formatLength(state.videoDuration)}.`; 2135 const rangeText = `${formatTime(start)} â ${formatTime(t.crop.end)}, fades out`; 2136 $('crop-range').textContent = rangeText; 2137 $('crop-total').textContent = formatTime(g.D); 2138 2139 const win = $('crop-window'); 2140 win.setAttribute('aria-valuemin', '0'); 2141 win.setAttribute('aria-valuemax', String(Math.round(g.maxStart))); 2142 win.setAttribute('aria-valuenow', String(Math.round(start))); 2143 win.setAttribute('aria-valuetext', `Plays ${formatTime(start)} to ${formatTime(t.crop.end)} of ${formatTime(g.D)}`); 2144 if (!g.W) return; 2145 2146 const left = g.maxStart ? (start / g.maxStart) * g.travel : 0; 2147 win.style.width = `${g.winW}px`; 2148 win.style.left = `${left}px`; 2149 wave.querySelectorAll('.fsm-crop__bar').forEach((bar) => { 2150 const c = bar.offsetLeft + bar.offsetWidth / 2; 2151 bar.classList.toggle('is-on', c >= left && c <= left + g.winW); 2152 }); 2153} 2154 2155function bindCrop() { 2156 const wave = $('crop-wave'); 2157 const win = $('crop-window'); 2158 if (!wave || !win) return; 2159 let dragging = false;
2160 let grabOffset = 0; 2161 2162 const setFromLeft = (leftPx) => { 2163 const g = cropGeometry(); 2164 const l = Math.min(Math.max(0, leftPx), g.travel); 2165 state.selectedTrack.crop.start = g.travel ? (l / g.travel) * g.maxStart : 0; 2166 updateCropWindow(); 2167 }; 2168 2169 wave.addEventListener('pointerdown', (e) => { 2170 const rect = wave.getBoundingClientRect(); 2171 const winRect = win.getBoundingClientRect(); 2172 dragging = true; 2173 grabOffset = e.target === win ? e.clientX - winRect.left : winRect.width / 2; 2174 wave.setPointerCapture(e.pointerId); 2175 win.focus({ preventScroll: true }); 2176 setFromLeft(e.clientX - rect.left - grabOffset); 2177 e.preventDefault(); 2178 }); 2179 wave.addEventListener('pointermove', (e) => { 2180 if (!dragging) return; 2181 setFromLeft(e.clientX - wave.getBoundingClientRect().left - grabOffset); 2182 }); 2183 const end = (e) => { 2184 if (!dragging) return; 2185 dragging = false; 2186 try { wave.releasePointerCapture(e.pointerId); } catch (_) { } 2187 playAudioSegment(state.selectedTrack.crop.start); 2188 }; 2189 wave.addEventListener('pointerup', end); 2190 wave.addEventListener('pointercancel', end); 2191 2192 win.addEventListener('keydown', (e) => { 2193 const t = state.selectedTrack; 2194 const stepSecs = e.shiftKey ? 5 : 1; 2195 let s = t.crop.start; 2196 if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') s -= stepSecs; 2197 else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') s += stepSecs; 2198 else if (e.key === 'Home') s = 0; 2199 else if (e.key === 'End') s = Infinity; 2200 else return; 2201 e.preventDefault(); 2202 t.crop.start = s; 2203 updateCropWindow(); 2204 clearTimeout(cropKeyTimer); 2205 cropKeyTimer = setTimeout(() => playAudioSegment(t.crop.start), 300); 2206 }); 2207} 2208 2209// ---------------------------------------------------------------------------- 2210// Step 3: Message, sticker, text style + live preview 2211// ---------------------------------------------------------------------------- 2212let previewTimer = null; 2213let previewImages = []; 2214let previewStill = false; // true when the preview shows a still first frame (step 4 / making) 2215let textGlowDismissed = false; 2216let typewriterWarnTimer = null; 2217let emojiWarnTimer = null; 2218 2219function initStep3() { 2220 const messageInput = $('wishes-message'); 2221 const nameInput = $('wishes-name'); 2222 2223 // Colour swatches (built from TEXT_COLORS so the map stays the single source) 2224 $('color-picker').innerHTML = Object.entries(TEXT_COLORS).map(([key, c]) => 2225 `<button type="button" class="fsm-swatch" data-color="${key}" aria-pressed="${key === state.textConfig.color}">` + 2226 `<span class="fsm-swatch__dot" style="background:${c.hex}"></span><span class="fsm-swatch__name">${c.label}</span></button>` 2227 ).join(''); 2228 $('color-picker').addEventListener('click', (e) => { 2229 const sw = e.target.closest('.fsm-swatch'); 2230 if (!sw) return; 2231 state.textConfig.color = sw.dataset.color; 2232 syncTextStyleControls(); 2233 updateTextConfig(); 2234 }); 2235 2236 // Emoji blocking (drawtext fonts can't render them) 2237 function blockEmoji(e) { 2238 const emojiRegex = /\p{Extended_Pictographic}/gu; 2239 if (emojiRegex.test(e.target.value)) { 2240 e.target.value = e.target.value.replace(emojiRegex, ''); 2241 const warn = $('emoji-warning'); 2242 warn.hidden = false; 2243 clearTimeout(emojiWarnTimer); 2244 emojiWarnTimer = setTimeout(() => { warn.hidden = true; }, 4000); 2245 } 2246 updateTextConfig(); 2247 } 2248 messageInput.addEventListener('input', blockEmoji); 2249 nameInput.addEventListener('input', blockEmoji); 2250 2251 // Occasions 2252 document.querySelectorAll('#occasion-buttons .fsm-chip').forEach(btn => { 2253 btn.addEventListener('click', () => setOccasion(btn.dataset.occ, { focusInput: btn.dataset.occ === 'custom' })); 2254 }); 2255 2256 // ?preset=⦠deep-link support. Pre-fill the message but DON'T auto-advance â the 2257 // user still picks photos + music. Just primes Step 3 so when they arrive, it's set. 2258 const presetSlug = getPresetFromUrl(); 2259 if (presetSlug && OCCASION_TEMPLATES[presetSlug]) { 2260 const buttonKeyMap = { 2261 'birthday': 'birthday', 'birthday-hindi': 'birthday', 'birthday-english': 'birthday', 2262 'anniversary': 'anniversary', 'anniversary-hindi': 'anniversary', 'anniversary-english': 'anniversary', 2263 'diwali': 'diwali', 2264 'newyear': 'newyear', 'new-year': 'newyear', 2265 }; 2266 const btnKey = buttonKeyMap[presetSlug] || null; 2267 if (btnKey) { 2268 setOccasion(btnKey); 2269 // Language-specific presets keep their own wording 2270 messageInput.value = OCCASION_TEMPLATES[presetSlug].message; 2271 } else { 2272 // Occasions without a dedicated chip (Holi, Eid, Raksha Bandhan, â¦) 2273 messageInput.value = OCCASION_TEMPLATES[presetSlug].message; 2274 state.textConfig.occasion = 'custom'; 2275 } 2276 } 2277 2278 // Animations 2279 document.querySelectorAll('#animation-picker .fsm-option').forEach(btn => { 2280 btn.addEventListener('click', () => { 2281 state.textConfig.animation = btn.dataset.anim; 2282 syncTextStyleControls(); 2283 updateTextConfig(); 2284 }); 2285 }); 2286 2287 // Sticker options 2288 $('sticker-size-slider').addEventListener('input', (e) => setStickerSizeStep(Number(e.target.value) - 1)); 2289 $('sticker-smaller').addEventListener('click', () => setStickerSizeStep(currentSizeStep() - 1)); 2290 $('sticker-bigger').addEventListener('click', () => setStickerSizeStep(currentSizeStep() + 1)); 2291 document.querySelectorAll('.movement-btn').forEach(btn => { 2292 btn.addEventListener('click', () => { 2293 state.stickerConfig.movement = btn.dataset.movement; 2294 syncStickerOptions(); 2295 }); 2296 }); 2297 2298 // Mobile sub-tabs (Message / Sticker / Text style) 2299 const tabs = Array.from(document.querySelectorAll('.fsm-seg [role="tab"]'));
2300 tabs.forEach((tab, i) => { 2301 tab.addEventListener('click', () => setSubStep(tab.dataset.sub)); 2302 tab.addEventListener('keydown', (e) => { 2303 let j = null; 2304 if (e.key === 'ArrowRight') j = (i + 1) % tabs.length; 2305 if (e.key === 'ArrowLeft') j = (i - 1 + tabs.length) % tabs.length; 2306 if (j === null) return; 2307 e.preventDefault(); 2308 setSubStep(tabs[j].dataset.sub, { focusTab: true }); 2309 }); 2310 }); 2311 2312 // Desktop jump links + scroll-spy 2313 document.querySelectorAll('.fsm-jump a').forEach(a => { 2314 a.addEventListener('click', (e) => { 2315 e.preventDefault(); 2316 setSubStep(a.dataset.sub, { scroll: true }); 2317 }); 2318 }); 2319 if ('IntersectionObserver' in window) { 2320 const io = new IntersectionObserver((entries) => { 2321 if (isMobile() || state.view !== 'step3') return; 2322 entries.forEach(entry => { 2323 if (entry.isIntersecting) markJumpLink(entry.target.dataset.sub); 2324 }); 2325 }, { rootMargin: '-25% 0px -60% 0px' }); 2326 document.querySelectorAll('.fsm-s3__panel').forEach(p => io.observe(p)); 2327 } 2328 2329 $('play-preview-btn').addEventListener('click', togglePlayPreview); 2330 $('full-preview-btn').addEventListener('click', openPreviewDialog); 2331 initPreviewDialog(); 2332 2333 initPreviewDrag(); 2334 initStickerDrag(); 2335 syncTextStyleControls(); 2336 syncStickerOptions(); 2337 updateTextConfig(); 2338} 2339 2340function setOccasion(occ, opts = {}) { 2341 document.querySelectorAll('#occasion-buttons .fsm-chip').forEach(b => { 2342 b.setAttribute('aria-pressed', String(b.dataset.occ === occ)); 2343 }); 2344 state.textConfig.occasion = occ; 2345 const input = $('wishes-message'); 2346 input.value = OCCASION_TEMPLATES[occ].message; 2347 updateTextConfig(); 2348 if (opts.focusInput) input.focus(); 2349} 2350 2351function syncTextStyleControls() { 2352 document.querySelectorAll('#color-picker .fsm-swatch').forEach(sw => { 2353 sw.setAttribute('aria-pressed', String(sw.dataset.color === state.textConfig.color)); 2354 }); 2355 document.querySelectorAll('#animation-picker .fsm-option').forEach(btn => { 2356 btn.setAttribute('aria-pressed', String(btn.dataset.anim === state.textConfig.animation)); 2357 }); 2358} 2359 2360function setSubStep(sub, opts = {}) { 2361 if (!SUB_STEPS.includes(sub)) sub = 'message'; 2362 state.subStep = sub; 2363 const mob = isMobile(); 2364 document.querySelectorAll('.fsm-seg [role="tab"]').forEach(t => { 2365 const on = t.dataset.sub === sub; 2366 t.setAttribute('aria-selected', String(on)); 2367 t.tabIndex = on ? 0 : -1; 2368 if (on && opts.focusTab) t.focus(); 2369 }); 2370 document.querySelectorAll('.fsm-s3__panel').forEach(p => { 2371 p.classList.toggle('is-m-hidden', p.dataset.sub !== sub); 2372 }); 2373 markJumpLink(sub); 2374 if (!mob && opts.scroll) { 2375 const panel = $(`s3-${sub}`); 2376 if (panel) panel.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'start' }); 2377 } 2378 if (mob && sub === 'sticker') updateStickerPreview(); 2379 updateNav(); 2380} 2381 2382function markJumpLink(sub) { 2383 document.querySelectorAll('.fsm-jump a').forEach(a => a.classList.toggle('is-active', a.dataset.sub === sub)); 2384} 2385 2386// --- Stickers ----------------------------------------------------------------- 2387function currentSizeStep() { 2388 return nearestSizeStep(state.stickerConfig.sizePct); 2389} 2390 2391function nearestSizeStep(pct) { 2392 let best = 0;
2393 STICKER_SIZE_STEPS.forEach((v, i) => { 2394 if (Math.abs(v - pct) < Math.abs(STICKER_SIZE_STEPS[best] - pct)) best = i; 2395 }); 2396 return best; 2397} 2398 2399function setStickerSizeStep(idx) { 2400 const i = Math.max(0, Math.min(STICKER_SIZE_STEPS.length - 1, idx)); 2401 state.stickerConfig.sizePct = STICKER_SIZE_STEPS[i]; 2402 syncStickerOptions(); 2403 updateStickerPreview(); 2404} 2405 2406function syncStickerOptions() { 2407 const hasSticker = !!state.stickerConfig.id; 2408 $('sticker-options').hidden = !hasSticker; 2409 const i = currentSizeStep(); 2410 const slider = $('sticker-size-slider'); 2411 slider.value = String(i + 1); 2412 slider.setAttribute('aria-valuetext', STICKER_SIZE_WORDS[i]); 2413 slider.style.setProperty('--fill', `${(i / (STICKER_SIZE_STEPS.length - 1)) * 100}%`); 2414 $('sticker-size-label').textContent = STICKER_SIZE_WORDS[i]; 2415 $('sticker-smaller').disabled = i === 0; 2416 $('sticker-bigger').disabled = i === STICKER_SIZE_STEPS.length - 1; 2417 document.querySelectorAll('.movement-btn').forEach(btn => { 2418 btn.setAttribute('aria-pressed', String(btn.dataset.movement === state.stickerConfig.movement)); 2419 }); 2420} 2421 2422function initStickerPicker() { 2423 const grid = $('sticker-picker-grid'); 2424 if (!grid) return; 2425 grid.innerHTML = ''; 2426 2427 // Destroy any existing tile animations 2428 stickerTileAnims.forEach((anim) => anim.destroy()); 2429 stickerTileAnims = []; 2430 2431 const makeTile = (id, label, artHtml) => { 2432 const tile = document.createElement('button'); 2433 tile.type = 'button'; 2434 tile.className = 'fsm-sticker-tile'; 2435 tile.dataset.id = id || ''; 2436 tile.setAttribute('aria-pressed', String((state.stickerConfig.id || '') === (id || ''))); 2437 tile.innerHTML = 2438 '<span class="fsm-sticker-tile__check" aria-hidden="true"><i class="ph-bold ph-check"></i></span>' + 2439 `<span class="fsm-sticker-tile__art" aria-hidden="true">${artHtml || ''}</span>` + 2440 `<span class="fsm-sticker-tile__name">${label}</span>`; 2441 grid.appendChild(tile); 2442 return tile; 2443 }; 2444 2445 makeTile(null, 'No sticker', '<i class="ph ph-prohibit"></i>'); 2446 Object.entries(STICKER_LIBRARY).forEach(([id, meta]) => { 2447 const tile = makeTile(id, meta.label); 2448 const animData = stickerCache.get(id); 2449 if (animData && typeof lottie !== 'undefined') { 2450 stickerTileAnims.push(lottie.loadAnimation({
2451 container: tile.querySelector('.fsm-sticker-tile__art'), 2452 renderer: 'svg', 2453 loop: true, 2454 autoplay: !prefersReducedMotion, 2455 animationData: JSON.parse(JSON.stringify(animData)), 2456 })); 2457 } 2458 }); 2459 2460 if (!grid.dataset.clickBound) { 2461 grid.addEventListener('click', (e) => { 2462 const tile = e.target.closest('.fsm-sticker-tile'); 2463 if (tile) selectSticker(tile.dataset.id || null); 2464 }); 2465 grid.dataset.clickBound = '1'; 2466 } 2467 2468 syncStickerOptions(); 2469 updateStickerPreview(); 2470} 2471 2472function selectSticker(id) { 2473 state.stickerConfig.id = id; 2474 // Restore per-sticker default size (snapped to the nearest UI step) when switching 2475 if (id && STICKER_LIBRARY[id]) { 2476 state.stickerConfig.sizePct = STICKER_SIZE_STEPS[nearestSizeStep(STICKER_LIBRARY[id].defaultSize || 25)]; 2477 } 2478 document.querySelectorAll('#sticker-picker-grid .fsm-sticker-tile').forEach((t) => { 2479 t.setAttribute('aria-pressed', String((t.dataset.id || null) === id)); 2480 }); 2481 syncStickerOptions(); 2482 updateStickerPreview(); 2483 announce(id ? `${STICKER_LIBRARY[id].label} sticker added. Drag it on the preview to move it.` : 'No sticker'); 2484} 2485 2486function updateStickerPreview() { 2487 const el = $('preview-sticker'); 2488 if (!el) return; 2489 2490 if (stickerPreviewAnim) { 2491 stickerPreviewAnim.destroy(); 2492 stickerPreviewAnim = null; 2493 } 2494 2495 if (!state.stickerConfig.id) { 2496 el.classList.add('hidden'); 2497 return; 2498 } 2499 2500 // Apply dynamic size based on actual preview stage width (fallback 270px) 2501 const stageEl = $('preview-stage'); 2502 const stageWidth = stageEl && stageEl.clientWidth ? stageEl.clientWidth : 270; 2503 const pxSize = Math.round((state.stickerConfig.sizePct / 100) * stageWidth); 2504 el.style.width = pxSize + 'px'; 2505 el.style.height = pxSize + 'px'; 2506 2507 el.classList.remove('hidden'); 2508 el.style.left = `${state.stickerConfig.xPct}%`; 2509 el.style.top = `${state.stickerConfig.yPct}%`; 2510 el.classList.toggle('draggable-glow', !stickerGlowDismissed); 2511 2512 const animData = stickerCache.get(state.stickerConfig.id); 2513 if (!animData || typeof lottie === 'undefined') return; 2514 2515 // SVG renderer: transparent background, no canvas state bleed between stickers 2516 stickerPreviewAnim = lottie.loadAnimation({ 2517 container: el, 2518 renderer: 'svg', 2519 loop: true, 2520 autoplay: !previewStill && !prefersReducedMotion, 2521 animationData: JSON.parse(JSON.stringify(animData)), 2522 }); 2523} 2524 2525function initStickerDrag() { 2526 const el = $('preview-sticker'); 2527 if (!el) return; 2528 2529 let isDragging = false; 2530 let startX = 0; 2531 let startY = 0; 2532 2533 el.addEventListener('pointerdown', (e) => { 2534 isDragging = true; 2535 stickerGlowDismissed = true; 2536 el.classList.remove('draggable-glow'); // remove glow on first touch 2537 const rect = el.getBoundingClientRect(); 2538 startX = e.clientX - rect.left; 2539 startY = e.clientY - rect.top; 2540 el.setPointerCapture(e.pointerId); 2541 e.stopPropagation(); 2542 }); 2543 2544 el.addEventListener('pointermove', (e) => { 2545 if (!isDragging) return; 2546 // The stage can live in different frames (step 3, full preview) â always 2547 // measure the one the sticker is currently in. 2548 const stageRect = $('preview-stage').getBoundingClientRect(); 2549 const elRect = el.getBoundingClientRect(); 2550 2551 let newX = e.clientX - stageRect.left - startX; 2552 let newY = e.clientY - stageRect.top - startY; 2553 2554 newX = Math.max(0, Math.min(newX, stageRect.width - elRect.width)); 2555 newY = Math.max(0, Math.min(newY, stageRect.height - elRect.height)); 2556 2557 const centerX = newX + elRect.width / 2; 2558 const centerY = newY + elRect.height / 2; 2559 2560 state.stickerConfig.xPct = Math.max(0, Math.min(100, (centerX / stageRect.width) * 100)); 2561 state.stickerConfig.yPct = Math.max(0, Math.min(100, (centerY / stageRect.height) * 100)); 2562 2563 el.style.left = `${state.stickerConfig.xPct}%`; 2564 el.style.top = `${state.stickerConfig.yPct}%`; 2565 }); 2566 2567 const end = (e) => { 2568 isDragging = false;
2569 try { el.releasePointerCapture(e.pointerId); } catch (_) { } 2570 }; 2571 el.addEventListener('pointerup', end); 2572 el.addEventListener('pointercancel', end); 2573} 2574 2575// --- Text ------------------------------------------------------------------- 2576function checkTypewriterLimit() { 2577 const warning = $('typewriter-warning'); 2578 const totalLength = state.textConfig.message.length + state.textConfig.name.length; 2579 2580 if (state.textConfig.animation === 'typewriter' && totalLength > 40) { 2581 // Switch to fade in 2582 state.textConfig.animation = 'fadein'; 2583 syncTextStyleControls(); 2584 warning.hidden = false; 2585 clearTimeout(typewriterWarnTimer); 2586 typewriterWarnTimer = setTimeout(() => { warning.hidden = true; }, 6000); 2587 } 2588} 2589 2590function updateTextConfig() { 2591 const raw = $('wishes-message').value; 2592 state.textConfig.message = raw.trim(); 2593 state.textConfig.name = $('wishes-name').value.trim(); 2594 const count = [...raw].length; 2595 $('msg-count').textContent = isMobile() ? `${count}/80` : `${count} of 80 letters`; 2596 checkTypewriterLimit(); 2597 renderPreviewText(); 2598} 2599 2600function initPreviewDrag() { 2601 const textOverlay = $('preview-text'); 2602 2603 let isDragging = false; 2604 let startX = 0; let startY = 0; 2605 2606 textOverlay.addEventListener('pointerdown', (e) => { 2607 isDragging = true; 2608 textGlowDismissed = true; 2609 textOverlay.classList.remove('draggable-glow'); // remove on first touch 2610 2611 // Disable transitioning while drag 2612 textOverlay.style.transition = 'none'; 2613 2614 // Calculate offset between pointer and top-left of element 2615 const rect = textOverlay.getBoundingClientRect(); 2616 startX = e.clientX - rect.left; 2617 startY = e.clientY - rect.top; 2618 2619 textOverlay.setPointerCapture(e.pointerId); 2620 }); 2621 2622 textOverlay.addEventListener('pointermove', (e) => { 2623 if (!isDragging) return; 2624 2625 const stageRect = $('preview-stage').getBoundingClientRect(); 2626 const textRect = textOverlay.getBoundingClientRect(); 2627 2628 // New top-left coordinates relative to stage 2629 let newX = e.clientX - stageRect.left - startX; 2630 let newY = e.clientY - stageRect.top - startY; 2631 2632 // Clamp 2633 newX = Math.max(0, Math.min(newX, stageRect.width - textRect.width)); 2634 newY = Math.max(0, Math.min(newY, stageRect.height - textRect.height)); 2635 2636 // Centre of the text element, as % of the stage 2637 const centerX = newX + (textRect.width / 2); 2638 const centerY = newY + (textRect.height / 2); 2639 2640 state.textConfig.xPct = Math.max(0, Math.min(100, (centerX / stageRect.width) * 100)); 2641 state.textConfig.yPct = Math.max(0, Math.min(100, (centerY / stageRect.height) * 100)); 2642 2643 textOverlay.style.left = `${state.textConfig.xPct}%`; 2644 textOverlay.style.top = `${state.textConfig.yPct}%`; 2645 }); 2646 2647 const end = (e) => { 2648 isDragging = false; 2649 textOverlay.style.transition = ''; // restore 2650 try { textOverlay.releasePointerCapture(e.pointerId); } catch (_) { } 2651 }; 2652 textOverlay.addEventListener('pointerup', end); 2653 textOverlay.addEventListener('pointercancel', end); 2654} 2655 2656function renderPreviewText() { 2657 const textOverlay = $('preview-text'); 2658 2659 // Message on top, name underneath â same layout as the drawtext filter. 2660 let displayHtml = ''; 2661 if (state.textConfig.message) displayHtml += `<div>${escapeHtml(state.textConfig.message)}</div>`; 2662 if (state.textConfig.name) displayHtml += `<div>${escapeHtml(state.textConfig.name)}</div>`; 2663 2664 textOverlay.innerHTML = displayHtml; 2665 textOverlay.style.color = (TEXT_COLORS[state.textConfig.color] || TEXT_COLORS.white).hex; 2666 textOverlay.style.left = `${state.textConfig.xPct}%`; 2667 textOverlay.style.top = `${state.textConfig.yPct}%`; 2668 textOverlay.setAttribute('aria-label', displayHtml ? 'Your message â drag to move' : ''); 2669 2670 // Apply animation class (trigger reflow) 2671 textOverlay.className = `preview-text-overlay${textGlowDismissed ? '' : ' draggable-glow'}`; 2672 void textOverlay.offsetWidth; 2673 if (displayHtml && !previewStill && !prefersReducedMotion) { 2674 textOverlay.classList.add(`anim-${state.textConfig.animation}`); 2675 } 2676} 2677 2678// --- Preview stage: one DOM instance, moved between frames ------------------ 2679// Drag, sticker and slideshow code all key off #preview-stage, so instead of 2680// cloning it for each screen we move the same element into the frame in use. 2681function mountStage(home, { interactive }) { 2682 const stage = $('preview-stage'); 2683 if (!home || !stage) return; 2684 if (stage.parentElement !== home) home.insertBefore(stage, home.firstChild); 2685 stage.classList.toggle('is-static', !interactive); 2686 updateStickerPreview(); // re-size the sticker for the new frame width 2687} 2688 2689function startPreviewCycle(autoplay) { 2690 stopPreviewCycle(); 2691 const stage = $('preview-stage'); 2692 previewStill = !autoplay; 2693 stage.classList.toggle('is-still', previewStill); 2694 const items = readyItems(); 2695 if (items.length === 0) { renderPreviewText(); return; } 2696 2697 // One slide per ready item, in timeline order (previewImages[i] â timeline.segments[i]). 2698 previewImages = items.map(item => { 2699 const slide = document.createElement('div'); 2700 const src = item.poster || item.url; 2701 if (item.kind === 'clip') { 2702 // A video plays its trimmed part in place; Fit shows it whole over its blurred poster. 2703 const fitted = item.fit === 'fit'; 2704 slide.className = `preview-slide is-clip${fitted ? ' is-fit' : ''}`; 2705 slide.innerHTML = (fitted ? `<span class="preview-slide__bg" style="background-image:url(${src})"></span>` : '') + 2706 `<video class="preview-slide__video" muted playsinline preload="metadata" poster="${src}" src="${item.url}#t=${item.trim.start.toFixed(2)}"></video>`; 2707 stage.insertBefore(slide, $('preview-sticker')); 2708 return { el: slide, item, video: slide.querySelector('video') }; 2709 } 2710 if (item.fit === 'fit') { 2711 // Whole picture over a blurred copy of itself, like the finished video. 2712 slide.className = 'preview-slide is-fit'; 2713 slide.innerHTML = `<span class="preview-slide__bg" style="background-image:url(${src})"></span>` + 2714 `<span class="preview-slide__fg" style="background-image:url(${src})"></span>`;
2715 } else { 2716 slide.className = 'preview-slide'; 2717 slide.style.backgroundImage = `url(${src})`; 2718 } 2719 stage.insertBefore(slide, $('preview-sticker')); 2720 return { el: slide, item }; 2721 }); 2722 previewImages[0].el.classList.add('active'); 2723 showPreviewBadge(0); 2724 if (autoplay) playPreview(); 2725 else renderPreviewText(); 2726 syncPlayPreviewBtn(); 2727} 2728 2729// "Video 2 · with sound" over the preview while a video clip is the current slide. 2730function showPreviewBadge(k) { 2731 const stage = $('preview-stage'); 2732 let badge = $('preview-clip-badge'); 2733 if (!badge) { 2734 badge = document.createElement('div'); 2735 badge.id = 'preview-clip-badge'; 2736 badge.className = 'preview-clip-badge'; 2737 stage.appendChild(badge); 2738 } 2739 const cur = previewImages[k]; 2740 const isClip = !!cur && cur.item.kind === 'clip'; 2741 badge.hidden = !isClip; 2742 if (isClip) { 2743 const number = state.items.indexOf(cur.item) + 1; 2744 const sound = !cur.item.muted && cur.item.hasAudio !== false; 2745 badge.innerHTML = `<i class="ph-fill ph-play" aria-hidden="true"></i><span>Video ${number}${sound ? ' · with sound' : ''}</span>`; 2746 } 2747} 2748 2749function pausePreviewVideos() { 2750 previewImages.forEach((p) => { if (p.video) { try { p.video.pause(); } catch (_) { } } }); 2751} 2752 2753// Starts the clip on slide k from the start of its trimmed part. Sound plays when the clip's 2754// sound is on; if the browser refuses sound here, it plays muted rather than not at all. 2755function playPreviewVideo(k) { 2756 const cur = previewImages[k]; 2757 if (!cur || !cur.video) return; 2758 const v = cur.video; 2759 try { v.currentTime = cur.item.trim.start; } catch (_) { } 2760 v.muted = cur.item.muted || cur.item.hasAudio === false; 2761 const p = v.play(); 2762 if (p && p.catch) p.catch(() => { v.muted = true; v.play().catch(() => { }); }); 2763} 2764 2765// Plays the slideshow from photo 1 and replays the text animation â i.e. what the 2766// finished video will look like. With { once: true } it plays each photo once 2767// (the length of the real video) and then settles back on the still first frame; 2768// without it (full-preview dialog) it loops until closed. 2769let previewPlaying = false; 2770let previewEndTimer = null; 2771 2772function playPreview(opts = {}) { 2773 clearTimeout(previewTimer); 2774 clearTimeout(previewEndTimer); 2775 previewStill = false; 2776 previewPlaying = true; 2777 $('preview-stage').classList.remove('is-still'); 2778 if (stickerPreviewAnim && !prefersReducedMotion) stickerPreviewAnim.play(); 2779 renderPreviewText(); 2780 syncPlayPreviewBtn(); 2781 if (!previewImages.length) return; 2782 // Each slide stays for its segment's real length, so the preview runs as long as the video. 2783 const tl = currentTimeline(); 2784 let currentSlide = 0; 2785 const show = (k) => { 2786 previewImages.forEach((p, j) => p.el.classList.toggle('active', j === k)); 2787 pausePreviewVideos(); 2788 showPreviewBadge(k); 2789 playPreviewVideo(k); 2790 }; 2791 show(0); 2792 const advance = () => { 2793 const seg = tl.segments[currentSlide]; 2794 if (!seg) return; 2795 previewTimer = setTimeout(() => { 2796 if (currentSlide + 1 < previewImages.length) currentSlide++; 2797 else if (!opts.once) currentSlide = 0; 2798 else return; 2799 show(currentSlide); 2800 advance(); 2801 }, seg.show * 1000); 2802 }; 2803 advance(); 2804 if (opts.once) { 2805 previewEndTimer = setTimeout(() => { 2806 stopPlayback(); 2807 if (opts.onEnd) opts.onEnd(); 2808 }, Math.max(1, tl.total) * 1000); 2809 } 2810} 2811 2812// Back to the still first frame (text shown without animation). 2813function stopPlayback() { 2814 clearTimeout(previewTimer); 2815 clearTimeout(previewEndTimer); 2816 previewPlaying = false; 2817 previewStill = true; 2818 $('preview-stage').classList.add('is-still'); 2819 pausePreviewVideos(); 2820 previewImages.forEach((p, i) => { 2821 p.el.classList.toggle('active', i === 0); 2822 if (p.video) { try { p.video.currentTime = p.item.trim.start; } catch (_) { } } 2823 }); 2824 showPreviewBadge(0); 2825 if (stickerPreviewAnim) stickerPreviewAnim.goToAndStop(0, true); 2826 renderPreviewText(); 2827 syncPlayPreviewBtn(); 2828} 2829 2830function syncPlayPreviewBtn() { 2831 const btn = $('play-preview-btn'); 2832 if (!btn) return; 2833 btn.innerHTML = previewPlaying 2834 ? '<i class="ph-fill ph-stop" aria-hidden="true"></i><span>Stop preview</span>' 2835 : '<i class="ph-fill ph-play" aria-hidden="true"></i><span>Play preview</span>'; 2836} 2837 2838function togglePlayPreview() { 2839 if (previewPlaying) stopPlayback(); 2840 else playPreview({ once: true }); 2841} 2842 2843function stopPreviewCycle() { 2844 clearTimeout(previewTimer); 2845 clearTimeout(previewEndTimer); 2846 previewPlaying = false;
2847 // Slides reuse each item's own object URL, so there is nothing to revoke here. 2848 previewImages.forEach(img => { 2849 if (img.video) { try { img.video.pause(); img.video.removeAttribute('src'); img.video.load(); } catch (_) { } } 2850 if (img.el && img.el.parentNode) img.el.parentNode.removeChild(img.el); 2851 }); 2852 const badge = $('preview-clip-badge'); 2853 if (badge) badge.hidden = true; 2854 previewImages = []; 2855} 2856 2857// --- Mobile full preview dialog ---------------------------------------------- 2858function initPreviewDialog() { 2859 const dialog = $('fsm-preview-dialog'); 2860 if (!dialog) return; 2861 const close = () => closePreviewDialog(); 2862 $('pdialog-close').addEventListener('click', close); 2863 $('pdialog-done').addEventListener('click', close); 2864 dialog.addEventListener('click', (e) => { if (e.target === dialog) close(); }); 2865 dialog.addEventListener('close', () => { 2866 if (state.view === 'step3') { 2867 mountStage($('s3-phone'), { interactive: true }); 2868 stopPlayback(); 2869 $('full-preview-btn').focus(); 2870 } 2871 }); 2872} 2873 2874function openPreviewDialog() { 2875 const dialog = $('fsm-preview-dialog'); 2876 if (!dialog || typeof dialog.showModal !== 'function') return; 2877 dialog.showModal(); 2878 mountStage($('dialog-phone'), { interactive: true }); 2879 playPreview(); 2880} 2881 2882function closePreviewDialog() { 2883 const dialog = $('fsm-preview-dialog'); 2884 if (dialog && dialog.open) dialog.close(); 2885} 2886 2887// ---------------------------------------------------------------------------- 2888// Step 4: Check & make 2889// ---------------------------------------------------------------------------- 2890 2891function initStep4() { 2892 $('s4-play').addEventListener('click', () => { 2893 $('s4-play').hidden = true; 2894 playPreview({ once: true, onEnd: () => { if (state.view === 'step4') $('s4-play').hidden = false; } }); 2895 }); 2896 $('long-wait-trim').addEventListener('click', () => { goToStep(1); openLongestClip(); }); 2897 document.querySelectorAll('.fsm-change').forEach(btn => { 2898 btn.addEventListener('click', () => goToStep(Number(btn.dataset.goto), btn.dataset.sub)); 2899 }); 2900} 2901 2902function updateTimeEstimate() { 2903 const est = estimateSeconds(); 2904 const words = estimateWords(est); 2905 $('time-estimate').textContent = `Takes about ${words} on this ${deviceWord()}.`; 2906 // A long wait is announced before it starts, with the way to shorten it. It never blocks. 2907 const longWait = est > LONG_WAIT_SECONDS && itemCountText().clips > 0; 2908 $('long-wait').hidden = !longWait; 2909 if (longWait) { 2910 $('long-wait-text').textContent = `This will take about ${words} on this ${deviceWord()}. Keep this screen open the whole time. Shorter videos are quicker.`; 2911 if (!longWaitShown) trackAction('story_long_wait', { seconds: est, tier: state.deviceTier }); 2912 } 2913 longWaitShown = longWait; 2914 $('making-sub').textContent = itemCountText().clips 2915 ? `Videos take a little longer. About ${words} in total.` 2916 : `This takes about ${words} on this ${deviceWord()}. Everything happens on your device.`; 2917} 2918 2919function updateSummary() { 2920 $('summary-photos').textContent = `${itemCountText().comma} · about ${StoryTimeline.formatLength(state.videoDuration)}`; 2921 $('summary-media-label').textContent = itemCountText().clips ? 'Photos and videos' : 'Photos'; 2922 $('summary-thumbs').innerHTML = readyItems().map(it => `<img src="${it.poster || it.url}" alt="">`).join(''); 2923 2924 const tr = state.selectedTrack; 2925 if (state.skipAudio || !tr) { 2926 $('summary-music').textContent = 'No music'; 2927 $('summary-music-meta').textContent = clipsHaveSound() ? 'Your videosâ own sound will play' : 'Your video will have no sound'; 2928 } else { 2929 $('summary-music').textContent = tr.name; 2930 $('summary-music-meta').textContent = trackRepeats(tr) 2931 ? 'Repeats until your video ends' 2932 : `Plays ${formatTime(tr.crop.start)} â ${formatTime(tr.crop.end)}`; 2933 } 2934 2935 const { message, name, color, animation } = state.textConfig; 2936 $('summary-text').textContent = message ? `â${message}â` : (name ? 'Name only' : 'No message'); 2937 const styleText = `${(TEXT_COLORS[color] || TEXT_COLORS.white).label} text, ${ANIMATION_WORDS[animation] || ANIMATION_WORDS.static}`;
2938 $('summary-text-meta').textContent = (message || name) 2939 ? [name ? `Name: ${name}` : '', styleText].filter(Boolean).join(' · ') 2940 : ''; 2941 2942 if (state.stickerConfig.id) { 2943 const meta = STICKER_LIBRARY[state.stickerConfig.id]; 2944 $('summary-sticker').textContent = meta.label; 2945 $('summary-sticker-meta').textContent = 2946 `${STICKER_SIZE_WORDS[currentSizeStep()]} size · ${state.stickerConfig.movement === 'dance' ? 'Dances around' : 'Stays in place'}`; 2947 } else { 2948 $('summary-sticker').textContent = 'No sticker'; 2949 $('summary-sticker-meta').textContent = ''; 2950 } 2951} 2952 2953// ---------------------------------------------------------------------------- 2954// Making / Ready / Failed 2955// ---------------------------------------------------------------------------- 2956let genToken = 0; // bumped on every start and on cancel; stale runs check it 2957let makingStartedAt = 0; 2958let etaSmoothed = null; 2959let currentStage = 0; 2960let makingClipCount = 0; // clips in the run being made: decides the stage list 2961let wakeLock = null; 2962let lastFailKind = 'error'; 2963let hiddenDuringMaking = false; 2964let failedClipId = null; 2965 2966function initResultViews() { 2967 $('cancel-btn').addEventListener('click', cancelGeneration); 2968 ['failed-primary', 'failed-secondary'].forEach((id) => { 2969 $(id).addEventListener('click', () => runFailedAction($(id).dataset.action)); 2970 }); 2971 // A phone pauses a backgrounded page; if the run then fails, say so instead of a generic error. 2972 document.addEventListener('visibilitychange', () => { 2973 if (document.hidden && state.view === 'making') hiddenDuringMaking = true; 2974 }); 2975 2976 const video = $('result-video'); 2977 $('ready-play').addEventListener('click', () => { 2978 $('ready-play').hidden = true; 2979 video.controls = true; 2980 video.play().catch(() => { }); 2981 }); 2982 $('download-btn').addEventListener('click', downloadVideo); 2983 $('share-btn').addEventListener('click', shareVideo); 2984 $('new-video-btn').addEventListener('click', () => { 2985 trackAction('story_video_new'); 2986 resetAll(); 2987 }); 2988 $('change-video-btn').addEventListener('click', () => { 2989 discardResult(); 2990 goToStep(4); 2991 }); 2992} 2993 2994function stageLabelsFor() { 2995 const hasAudio = !!(state.selectedTrack && !state.skipAudio); 2996 const hasText = !!(state.textConfig.message || state.textConfig.name); 2997 const hasSticker = !!state.stickerConfig.id; 2998 // With video clips the work is: prepare each clip, then put everything together. 2999 const clips = itemCountText().clips; 3000 if (clips) { 3001 const rows = []; 3002 for (let i = 1; i <= clips; i++) rows.push(`Preparing video ${i} of ${clips}`); 3003 rows.push('Putting it all together'); 3004 return rows; 3005 } 3006 return [ 3007 'Getting your photos ready', 3008 hasAudio ? 'Adding the music' : 'No music to add', 3009 hasText && hasSticker ? 'Adding your message and sticker' 3010 : hasText ? 'Adding your message' 3011 : hasSticker ? 'Adding your sticker' : 'Putting your video together', 3012 'Finishing up' 3013 ]; 3014} 3015 3016function resetMaking() { 3017 const labels = stageLabelsFor(); 3018 makingClipCount = itemCountText().clips; 3019 $('stage-list').innerHTML = labels.map((label, i) => 3020 `<li class="fsm-stage" data-stage="${i + 1}"><span class="fsm-stage__icon"><i class="ph-bold ph-check" aria-hidden="true"></i></span>` + 3021 `<span class="fsm-stage__label">${escapeHtml(label)}</span></li>`).join(''); 3022 $('making-title').textContent = 'Making your videoâ¦'; 3023 currentStage = 0; 3024 etaSmoothed = null; 3025 makingStartedAt = performance.now(); 3026 $('progress-eta').textContent = 'Working it outâ¦'; 3027 updateProgress(0); 3028} 3029 3030// Advances the 4-row checklist on the Making screen. Monotonic: never goes back. 3031function setStage(n) { 3032 if (n <= currentStage) return; 3033 currentStage = n; 3034 const items = document.querySelectorAll('#stage-list .fsm-stage'); 3035 items.forEach(li => { 3036 const s = Number(li.dataset.stage); 3037 li.classList.toggle('is-done', s < n); 3038 li.classList.toggle('is-active', s === n); 3039 }); 3040 const active = items[n - 1];
3041 if (active) announce(active.querySelector('.fsm-stage__label').textContent); 3042} 3043 3044function completeStages() { 3045 currentStage = Infinity; 3046 document.querySelectorAll('#stage-list .fsm-stage').forEach(li => { 3047 li.classList.add('is-done'); 3048 li.classList.remove('is-active'); 3049 }); 3050} 3051 3052function updateProgress(ratio) { 3053 const pct = Math.min(100, Math.max(0, Math.floor(ratio * 100))); 3054 $('progress-bar').style.width = `${pct}%`; 3055 $('progress-percent').textContent = `${pct}%`; 3056 $('progress-track').setAttribute('aria-valuenow', String(pct)); 3057 3058 const elapsed = (performance.now() - makingStartedAt) / 1000; 3059 if (ratio >= 1) { 3060 $('progress-eta').textContent = 'Almost done'; 3061 } else if (ratio >= 0.1 && elapsed > 2) { 3062 const remaining = (elapsed * (1 - ratio)) / ratio; 3063 // Smooth it so the number doesn't jump around between progress callbacks 3064 etaSmoothed = etaSmoothed == null ? remaining : etaSmoothed * 0.7 + remaining * 0.3; 3065 const s = Math.max(1, Math.round(etaSmoothed)); 3066 $('progress-eta').textContent = s >= 60 3067 ? `About ${plural(Math.round(s / 60), 'minute')} left` 3068 : `About ${plural(s, 'second')} left`; 3069 } 3070 if (state.view === 'making' && !makingClipCount && ratio >= 0.92) setStage(4); 3071} 3072 3073async function acquireWakeLock() { 3074 try { 3075 if ('wakeLock' in navigator) wakeLock = await navigator.wakeLock.request('screen'); 3076 } catch (_) { /* not supported / denied â the on-screen reminder still applies */ } 3077} 3078 3079function releaseWakeLock() { 3080 try { if (wakeLock) wakeLock.release(); } catch (_) { } 3081 wakeLock = null; 3082} 3083 3084function showMaking() { 3085 stopAudio(); 3086 closePreviewDialog(); 3087 stickerTileAnims.forEach((anim) => anim.destroy()); 3088 stickerTileAnims = []; 3089 showView('making'); 3090 mountStage($('making-phone'), { interactive: false }); 3091 startPreviewCycle(false); 3092 updateTimeEstimate(); 3093 resetMaking(); 3094 hiddenDuringMaking = false; 3095 acquireWakeLock(); 3096} 3097 3098function cancelGeneration() { 3099 genToken++; // the in-flight run sees a stale token and stays silent 3100 state.ffmpegProgressActive = false; 3101 // ffmpeg.wasm 0.11 has no abort â terminating the worker is the only way to stop 3102 // a running encode. Then reset exactly like the failure path (LEARNINGS #31). 3103 try { if (ffmpeg) ffmpeg.exit(); } catch (_) { } 3104 ffmpeg = null; 3105 state.ffmpegLoaded = false; 3106 ffmpegLoadPromise = null; 3107 _ffmpegPreloadKicked = false; 3108 releaseWakeLock(); 3109 trackAction('story_video_cancelled', { progress: $('progress-percent').textContent }); 3110 goToStep(4); 3111 // Warm a fresh engine in the background so "Make my video" is quick next time. 3112 setTimeout(kickFFmpegPreloadIfNeeded, 500); 3113} 3114 3115function showReady(blob) { 3116 rememberRate((performance.now() - makingStartedAt) / 1000, state.videoDuration); 3117 releaseWakeLock(); 3118 completeStages(); 3119 stopPreviewCycle(); 3120 3121 window.finalVideoBlob = blob; 3122 if (window.finalVideoUrl) URL.revokeObjectURL(window.finalVideoUrl); 3123 window.finalVideoUrl = URL.createObjectURL(blob); 3124 3125 const video = $('result-video'); 3126 const first = readyItems()[0]; 3127 if (first) video.poster = first.poster || first.url; 3128 video.controls = false; 3129 video.src = window.finalVideoUrl; 3130 $('ready-play').hidden = false; 3131 3132 const lengthText = StoryTimeline.formatLength(state.videoDuration); 3133 $('ready-meta').textContent = isMobile() 3134 ? `${lengthText} · ready for Status, Reels and Shorts` 3135 : `${lengthText} · sized for WhatsApp Status, Reels and Shorts (720 à 1280)`; 3136 3137 // Share on WhatsApp = Web Share API with files. Only offered where the browser 3138 // can actually share a video file; otherwise Download is the one main action. 3139 let canShare = false; 3140 try { 3141 const probe = new File([blob], 'wishes-video.mp4', { type: 'video/mp4' }); 3142 canShare = !!(navigator.canShare && navigator.canShare({ files: [probe] })); 3143 } catch (_) { canShare = false; } 3144 3145 layoutReadyActions(canShare); 3146 3147 showView('ready'); 3148 announce('Your video is ready.'); 3149 const counts = itemCountText(); 3150 trackAction('story_video_ready', { 3151 imageCount: readyItems().length, canShare, 3152 photoCount: counts.photos, clipCount: counts.clips, 3153 totalSeconds: Math.round(state.videoDuration), 3154 outputMB: Math.round((blob.size / StoryTimeline.MB) * 10) / 10, 3155 encodeSeconds: Math.round((performance.now() - makingStartedAt) / 1000), 3156 tier: state.deviceTier, 3157 music: state.selectedTrack && !state.skipAudio ? state.selectedTrack.source : 'none' 3158 }); 3159} 3160 3161// Bottom-bar layout for the Ready view (design rev 3): 3162// desktop â [Share on WhatsApp (outline)] [Download video (primary)] 3163// mobile + can share â [save icon (outline, 60px)] [Share on WhatsApp (primary, wide)] 3164// no file sharing â [Download video / Save to my phone (primary)] only 3165function layoutReadyActions(canShare) { 3166 const actions = $('ready-actions'); 3167 const share = $('share-btn'); 3168 const download = $('download-btn'); 3169 const mob = isMobile(); 3170 share.hidden = !canShare; 3171 const saveLabel = mob ? 'Save to my phone' : 'Download video'; 3172 $('download-label').textContent = saveLabel; 3173 const iconOnly = mob && canShare; 3174 download.className = `fsm-btn ${iconOnly ? 'fsm-btn--outline-primary fsm-btn--icon' : 'fsm-btn--primary'}`; 3175 $('download-label').className = iconOnly ? 'fsm-btn__label' : ''; 3176 download.setAttribute('aria-label', saveLabel); 3177 share.className = `fsm-btn ${mob ? 'fsm-btn--primary' : 'fsm-btn--outline-primary'}`; 3178 if (mob) actions.append(download, share); else actions.append(share, download); 3179 lastCanShare = canShare; 3180} 3181let lastCanShare = false;
3182 3183function downloadVideo() { 3184 if (!window.finalVideoUrl) return; 3185 const a = document.createElement('a'); 3186 a.href = window.finalVideoUrl; 3187 a.download = 'wishes-video.mp4'; 3188 document.body.appendChild(a); 3189 a.click(); 3190 a.remove(); 3191 trackAction('story_video_downloaded'); 3192} 3193 3194async function shareVideo() { 3195 if (!window.finalVideoBlob) return; 3196 const file = new File([window.finalVideoBlob], 'wishes-video.mp4', { type: 'video/mp4' }); 3197 try { 3198 await navigator.share({ files: [file], title: 'My video' }); 3199 trackAction('story_video_shared'); 3200 } catch (err) { 3201 if (err && err.name === 'AbortError') return; // user closed the share sheet 3202 downloadVideo(); // share target refused the file â fall back to saving it 3203 } 3204} 3205 3206function discardResult() { 3207 const video = $('result-video'); 3208 video.pause(); 3209 video.removeAttribute('src'); 3210 video.load(); 3211 if (window.finalVideoUrl) URL.revokeObjectURL(window.finalVideoUrl); 3212 window.finalVideoUrl = null; 3213 window.finalVideoBlob = null; 3214} 3215 3216// "your photos, music and message are all still here" â built from what the user really has. 3217function stillHereSentence() { 3218 const c = itemCountText(); 3219 const things = []; 3220 if (c.photos) things.push('photos'); 3221 if (c.clips) things.push('videos'); 3222 if (state.selectedTrack && !state.skipAudio) things.push('music'); 3223 if (state.textConfig.message || state.textConfig.name) things.push('message'); 3224 if (!things.length) return ''; 3225 const list = things.length > 1 ? `${things.slice(0, -1).join(', ')} and ${things[things.length - 1]}` : things[0]; 3226 return `Donât worry â your ${list} ${things.length > 1 || things[0] !== 'message' ? 'are' : 'is'} ${things.length > 1 ? 'all ' : ''}still here.`; 3227} 3228 3229// One Failed screen per cause (spec §9). Each entry: headline, lead, tips, and what the two 3230// bottom-bar buttons do: 'retry' | 'step1' | 'step4' | 'reload'. 3231function failCopy(kind) { 3232 const dev = deviceWord(); 3233 const clips = itemCountText().clips > 0; 3234 const closeApps = isMobile() ? 'Close other apps, then try again.' : 'Close other apps or browser tabs, then try again.'; 3235 const lighter = clips 3236 ? [['ph-scissors', 'Trim your videos so they are shorter.'], ['ph-trash', 'Use fewer videos, or swap a video for a photo.'], ['ph-browsers', closeApps]] 3237 : [['ph-image', 'Use fewer photos â try 5 or less.'], ['ph-browsers', closeApps], ['ph-globe', 'Use an up-to-date browser, like Chrome.']]; 3238 const retry = ['Try again', 'retry', 'ph-arrow-clockwise']; 3239 const table = { 3240 heavy: { 3241 title: `Your video was too heavy for this ${dev}`, 3242 lead: `${isMobile() ? 'This phone' : 'It'} ran out of room while making it.`, 3243 tipsTitle: 'These usually help', tips: lighter, 3244 primary: [clips ? 'Trim or remove videos' : 'Use fewer photos', 'step1', clips ? 'ph-scissors' : 'ph-image'], secondary: retry 3245 }, 3246 slow: { 3247 title: `This is taking too long on this ${dev}`, 3248 lead: `We stopped so your ${dev} doesnât get stuck.`, 3249 tipsTitle: 'These usually help', tips: lighter, 3250 primary: ['Make it shorter', 'step1', 'ph-scissors'], secondary: retry 3251 }, 3252 noload: { 3253 title: 'We could not start the video maker', 3254 lead: 'Check your internet and try again.', 3255 tipsTitle: 'These usually help', 3256 tips: [['ph-wifi-high', 'Turn Wi-Fi or mobile data on, or move to a stronger signal.'], ['ph-clock', 'Wait a moment, then press Try again.']], 3257 primary: retry, secondary: ['Go back', 'step4', 'ph-arrow-left'] 3258 }, 3259 closed: { 3260 title: 'Making stopped because the screen was closed', 3261 lead: `Your ${dev} pauses this page when you switch apps or lock the screen.`, 3262 tipsTitle: 'Next time', 3263 tips: [['ph-device-mobile', 'Keep this screen open and unlocked until it finishes.'], ['ph-clock', `It takes about ${estimateWords(estimateSeconds())}.`]], 3264 primary: retry, secondary: ['Go back', 'step4', 'ph-arrow-left'] 3265 }, 3266 isolation: { 3267 title: 'Sorry, your video could not be made', 3268 lead: 'Video making could not start in this browser tab. Press âTry againâ to reload the page â if that doesnât help, close this tab and open the page again.', 3269 tipsTitle: '', tips: [], noReassure: true, 3270 primary: ['Try again', 'reload', 'ph-arrow-clockwise'], secondary: ['Go back', 'step4', 'ph-arrow-left'] 3271 }, 3272 clip: { 3273 title: 'One of your videos could not be used', 3274 lead: 'Everything else is ready.', 3275 tipsTitle: '', tips: [], 3276 primary: ['Remove it and continue', 'remove-clip', 'ph-trash'], secondary: ['Replace it', 'step1', 'ph-arrow-left'] 3277 }, 3278 error: { 3279 title: 'Sorry, your video could not be made', 3280 lead: 'Something went wrong.', 3281 tipsTitle: 'If it fails again, these usually help', 3282 tips: [['ph-browsers', 'Close other apps or browser tabs, then try again.'], ['ph-globe', 'Use an up-to-date browser, like Chrome.']], 3283 primary: retry, secondary: ['Go back and make changes', 'step4', 'ph-arrow-left'] 3284 } 3285 }; 3286 return table[kind] || table.error; 3287} 3288 3289function showFailed(kind, ctx) { 3290 lastFailKind = kind || 'error'; 3291 releaseWakeLock(); 3292 stopPreviewCycle(); 3293 // "One of your videos could not be used" needs to know which one; without it, fall back. 3294 failedClipId = null; 3295 const failedClip = lastFailKind === 'clip' ? itemById(ctx && ctx.clipId) : null; 3296 if (lastFailKind === 'clip' && !failedClip) lastFailKind = 'error'; 3297 const clipBox = $('failed-clip'); 3298 clipBox.hidden = !failedClip; 3299 if (failedClip) { 3300 failedClipId = failedClip.id; 3301 const number = state.items.indexOf(failedClip) + 1; 3302 const len = formatTime(Math.round(failedClip.trim.end - failedClip.trim.start)); 3303 clipBox.innerHTML = `<img src="${failedClip.poster || ''}" alt="">` + 3304 `<span><strong>Video ${number}</strong><span>${len} · this one could not be prepared</span></span>`;
3305 } 3306 const copy = failCopy(lastFailKind); 3307 $('failed-title').textContent = copy.title; 3308 $('failed-detail').textContent = [copy.lead, copy.noReassure ? '' : stillHereSentence()].filter(Boolean).join(' '); 3309 $('failed-tips-box').hidden = copy.tips.length === 0; 3310 $('failed-tips-title').textContent = copy.tipsTitle; 3311 $('failed-tips').innerHTML = copy.tips 3312 .map(([icon, text]) => `<li><i class="ph ${icon}" aria-hidden="true"></i><span>${escapeHtml(text)}</span></li>`).join(''); 3313 [['failed-primary', copy.primary], ['failed-secondary', copy.secondary]].forEach(([id, [label, action, icon]]) => { 3314 const btn = $(id); 3315 btn.dataset.action = action; 3316 btn.innerHTML = `<i class="ph-bold ${icon}" aria-hidden="true"></i><span>${escapeHtml(label)}</span>`; 3317 }); 3318 trackAction('story_video_failed', { kind: lastFailKind, stage: ctx && ctx.stage }); 3319 showView('failed'); 3320} 3321 3322function runFailedAction(action) { 3323 trackAction('story_failed_action', { kind: lastFailKind, action }); 3324 if (action === 'reload') window.location.reload(); 3325 else if (action === 'retry') startVideoGeneration(); 3326 else if (action === 'remove-clip') { 3327 const index = state.items.findIndex(it => it.id === failedClipId); 3328 if (index >= 0) removeImage(index); 3329 if (readyItems().length) startVideoGeneration(); 3330 else goToStep(1); 3331 } 3332 else if (action === 'step1') goToStep(1); 3333 else goToStep(4); 3334} 3335 3336function resetAll() { 3337 discardResult(); 3338 stopAudio(); 3339 state.items.forEach(releaseItem); 3340 state.items = []; 3341 state.photoSeconds = StoryTimeline.PHOTO_DEFAULT; 3342 state.photoSecondsAuto = true; 3343 addedFree.clear(); 3344 revokeOwnAudio(state.selectedTrack); 3345 state.selectedTrack = null; 3346 state.skipAudio = true; 3347 $('own-audio-error').hidden = true; 3348 state.textConfig = { occasion: '', message: '', name: '', color: 'white', animation: 'static', xPct: 50, yPct: 50 }; 3349 state.stickerConfig = { id: null, xPct: 25, yPct: 25, sizePct: 25, movement: 'fixed' }; 3350 $('wishes-message').value = ''; 3351 $('wishes-name').value = ''; 3352 document.querySelectorAll('#occasion-buttons .fsm-chip').forEach(b => b.setAttribute('aria-pressed', 'false')); 3353 syncNoMusic(); 3354 syncTextStyleControls(); 3355 syncStickerOptions(); 3356 updateTextConfig(); 3357 renderMusicResults(lastSearchResults); 3358 onItemsChanged(); 3359 goToStep(1); 3360} 3361 3362// ---------------------------------------------------------------------------- 3363// Mobile account sheet (opened from the light header's account button) 3364// ---------------------------------------------------------------------------- 3365function initAccountSheet() { 3366 const sheet = $('fsm-account-sheet'); 3367 if (!sheet || typeof sheet.showModal !== 'function') return; 3368 const close = () => { if (sheet.open) sheet.close(); }; 3369 3370 window.id8OpenAccountSheet = function () { 3371 const user = typeof window.id8GetUser === 'function' ? window.id8GetUser() : null; 3372 $('acct-out').hidden = !!user; 3373 $('acct-in').hidden = !user; 3374 $('acct-note').hidden = !!user; 3375 if (user) $('acct-name').textContent = `@${user.username}`; 3376 sheet.showModal(); 3377 }; 3378 3379 $('acct-close').addEventListener('click', close); 3380 $('acct-continue').addEventListener('click', close); 3381 sheet.addEventListener('click', (e) => { if (e.target === sheet) close(); }); 3382 $('acct-login').addEventListener('click', () => { close(); if (window.id8OpenAuth) window.id8OpenAuth('login'); }); 3383 $('acct-register').addEventListener('click', () => { close(); if (window.id8OpenAuth) window.id8OpenAuth('register'); }); 3384 $('acct-logout').addEventListener('click', () => { close(); if (window.id8Logout) window.id8Logout(); }); 3385} 3386 3387// ---------------------------------------------------------------------------- 3388// Step 2: Music â Preloading 3389// ---------------------------------------------------------------------------- 3390async function preloadMusicCategories() { 3391 // Fire all 5 searches in parallel. Results land in preloadedResults as they arrive. 3392 const fetches = PRELOAD_CATEGORIES.map(async (cat) => { 3393 try { 3394 const res = await searchFetch(`/api/wishes-music.php?q=${encodeURIComponent(cat)}&limit=5`); 3395 if (!res.ok) return; 3396 // Read as text first â if the server returns an HTML error page (PHP crash, 3397 // hosting error, or COI SW synthetic 502), JSON.parse catches it gracefully. 3398 const text = await res.text(); 3399 let data; 3400 try { data = JSON.parse(text); } catch (_) { return; } 3401 if (!data.error && data.tracks?.length) {
3402 preloadedResults[cat] = stampTracks(data.tracks, cat); 3403 3404 // 'happy' is the default category shown on Step 2. 3405 // Auto-render it only if the user hasn't interacted with search yet. 3406 if (cat === 'happy' && !autoResultRendered) { 3407 autoResultRendered = true; 3408 lastSearchResults = data.tracks; 3409 renderMusicResults(data.tracks); 3410 setActiveChip('happy'); 3411 } 3412 } 3413 } catch (_) { /* silent â user can still search manually */ } 3414 }); 3415 await Promise.all(fetches); 3416 3417 // If the 'happy' preload produced no results (cold cache, network hiccup, etc.), 3418 // retry once after 3 seconds so users aren't stuck with an empty track list. 3419 if (!preloadedResults['happy'] && !autoResultRendered) { 3420 await new Promise(resolve => setTimeout(resolve, 3000)); 3421 try { 3422 const res = await searchFetch(`/api/wishes-music.php?q=${encodeURIComponent('happy')}&limit=5`); 3423 if (!res.ok) return; 3424 const text = await res.text(); 3425 let data; 3426 try { data = JSON.parse(text); } catch (_) { return; } 3427 if (!data.error && data.tracks?.length) { 3428 preloadedResults['happy'] = stampTracks(data.tracks, 'happy'); 3429 if (!autoResultRendered) { 3430 autoResultRendered = true; 3431 lastSearchResults = data.tracks; 3432 renderMusicResults(data.tracks);
3433 setActiveChip('happy'); 3434 } 3435 } 3436 } catch (_) { /* silent â user can search manually */ } 3437 } 3438} 3439 3440async function preloadStickers() { 3441 const ids = Object.keys(STICKER_LIBRARY); 3442 await Promise.all(ids.map(async (id) => { 3443 if (stickerCache.has(id)) return; 3444 try { 3445 const resp = await fetch(`${window.location.origin}${STICKER_LIBRARY[id].path}`); 3446 const json = await resp.json(); 3447 // Strip solid background layers (ty=1) â some animations bake in a 3448 // white/opaque solid that should be transparent when used as a sticker. 3449 if (Array.isArray(json.layers)) { 3450 json.layers = json.layers.filter(l => l.ty !== 1); 3451 } 3452 stickerCache.set(id, json); 3453 } catch (e) { 3454 console.warn('Sticker preload failed:', id, e); 3455 } 3456 })); 3457 3458 if (state.view === 'step3') initStickerPicker(); 3459} 3460 3461// Reports a video-generation failure to New Relic Browser (if loaded) so failures are 3462// diagnosable without relying on users to describe an error message. Never throws â a 3463// broken reporting path must not mask or replace the original error. 3464function reportVideoGenError(err, stage, extra) { 3465 try { 3466 const detail = { 3467 stage: stage || 'unknown', 3468 message: err && err.message ? String(err.message) : String(err), 3469 ffmpegLogs: ffmpegLogs.slice(-30).join('\n'), 3470 ...extra 3471 }; 3472 if (window.newrelic && typeof window.newrelic.noticeError === 'function') { 3473 window.newrelic.noticeError(err instanceof Error ? err : new Error(detail.message), detail); 3474 } 3475 console.error('[free-story-maker] generation failed:', detail); 3476 } catch (_) { /* reporting must never throw */ } 3477} 3478 3479// Wraps a promise with a timeout. Rejects with `message` if `promise` hasn't settled 3480// within `ms`. Used anywhere a network fetch or WASM operation could stall indefinitely 3481// on a flaky mobile connection or a backgrounded/throttled tab â without this, such 3482// stalls previously hung the whole generation flow forever with no user feedback. 3483function withTimeout(promise, ms, message) { 3484 let timer; 3485 const timeout = new Promise((_, reject) => { 3486 timer = setTimeout(() => reject(new Error(message)), ms); 3487 }); 3488 return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); 3489} 3490 3491// Preload FFmpeg WASM in the background so it's ready when user clicks "Create". 3492// On first visit the ~25 MB WASM binary downloads from CDN; on repeat visits 3493// the browser's HTTP cache (or the COI service worker's Cache API on iOS) 3494// serves it instantly. After a successful load the core URLs are also written 3495// to Cache API from the main thread as insurance against HTTP-cache eviction 3496// on mobile devices with aggressive storage limits. 3497// Same two-CDN fallback rationale as FFMPEG_SDK_CDNS â the WASM core (~25 MB) is the 3498// far larger download and the one most likely to stall on a flaky mobile connection. 3499const FFMPEG_CORE_PATHS = [ 3500 'https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg-core.js', 3501 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/ffmpeg-core.js' 3502]; 3503const FFMPEG_CORE_LOAD_TIMEOUT_MS = 60000; 3504 3505// Creates and configures a fresh FFmpeg instance (progress + logger). 3506// Used by both preloadFFmpeg() and the startVideoGeneration() fallback path 3507// so the two never drift from each other. 3508// Where the current FFmpeg run sits on the Making progress bar (base ⦠base + span). 3509let progressBase = 0.2; 3510let progressSpan = 0.8; 3511let lastFFmpegSignalAt = 0; 3512let runSeconds = 0; // output length of the FFmpeg run in flight (for progress) 3513let genStage = 'final'; // 'prep-clip-N' | 'final' â where generateVideo() is, for failure reports 3514 3515// ffmpeg.run() with a watchdog instead of a fixed timeout: a long video may legitimately take 3516// minutes, but an encoder that has said nothing for 45 s (or overruns a generous ceiling for 3517// its length) is stuck â usually out of memory or a paused, backgrounded page. The rejection 3518// carries `timedOut` so the Failed screen can say "taking too long". 3519function runFFmpeg(args, { mediaSeconds }) { 3520 const hook = window.__fsmTest || {}; 3521 const stallMs = hook.stallMs || 45000; 3522 const ceilingMs = hook.ceilingMs || Math.max(180, 10 * mediaSeconds) * 1000; 3523 const startedAt = performance.now(); 3524 lastFFmpegSignalAt = startedAt; 3525 runSeconds = mediaSeconds; 3526 const instance = ffmpeg; 3527 return new Promise((resolve, reject) => { 3528 const timer = setInterval(() => { 3529 const now = performance.now(); 3530 if (now - lastFFmpegSignalAt > stallMs || now - startedAt > ceilingMs) { 3531 clearInterval(timer); 3532 const err = new Error('Video encoding stopped making progress.'); 3533 err.timedOut = true; 3534 reject(err); 3535 } 3536 }, 1000); 3537 Promise.resolve() 3538 .then(() => instance.run(...args)) 3539 .then((v) => { clearInterval(timer); resolve(v); }, (e) => { clearInterval(timer); reject(e); }); 3540 }); 3541} 3542 3543function createConfiguredFFmpeg(corePath) { 3544 const { createFFmpeg } = FFmpeg; 3545 const instance = createFFmpeg({ 3546 log: true, 3547 corePath: corePath || FFMPEG_CORE_PATHS[0], 3548 progress: () => { lastFFmpegSignalAt = performance.now(); } 3549 }); 3550 instance.setLogger(({ type, message }) => { 3551 lastFFmpegSignalAt = performance.now(); // any output means the encoder is alive 3552 // Progress comes from FFmpeg's own "time=" against the length this run is producing. 3553 // (The library's ratio divides by the first input's duration, which is wrong as soon as 3554 // there are several inputs of different lengths.) 3555 const m = state.ffmpegProgressActive && runSeconds > 0 && /time=(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(message || ''); 3556 if (m) { 3557 const done = (+m[1]) * 3600 + (+m[2]) * 60 + (+m[3]); 3558 updateProgress(progressBase + Math.min(1, done / runSeconds) * progressSpan); 3559 } 3560 if (type === 'fferr' || type === 'ffout') { 3561 ffmpegLogs.push(message); 3562 if (ffmpegLogs.length > 30) ffmpegLogs.shift(); 3563 } 3564 }); 3565 return instance; 3566} 3567 3568// Dynamically inject the ffmpeg.min.js SDK once. Returns a Promise that resolves 3569// when the FFmpeg global is available. Subsequent calls return the same Promise. 3570// Without this lazy loader, the 600 KB SDK was a synchronous <script> tag in HTML 3571// blocking first paint on the story-maker page â a critical INP failure on 3572// mid-range Android over 4G. 3573// Two CDN mirrors so a single CDN outage/slowdown (unpkg has been observed to be slow 3574// or briefly unreachable from Indian mobile networks) doesn't take down the whole 3575// feature. Each attempt is time-boxed â an unresponsive <script> tag (connection open, 3576// no bytes) used to hang this promise forever with no user feedback ("takes forever"). 3577const FFMPEG_SDK_CDNS = [ 3578 'https://unpkg.com/@ffmpeg/[email protected]/dist/ffmpeg.min.js', 3579 'https://cdn.jsdelivr.net/npm/@ffmpeg/[email protected]/dist/ffmpeg.min.js' 3580]; 3581const FFMPEG_SDK_LOAD_TIMEOUT_MS = 20000; 3582 3583function loadScriptOnce(src) { 3584 return new Promise((resolve, reject) => { 3585 const s = document.createElement('script'); 3586 s.src = src; 3587 s.crossOrigin = 'anonymous'; 3588 s.async = true; 3589 s.onload = () => resolve(); 3590 s.onerror = () => { 3591 if (s.parentNode) s.parentNode.removeChild(s); 3592 reject(new Error(`Failed to load script: ${src}`)); 3593 }; 3594 document.head.appendChild(s); 3595 }); 3596} 3597
3598let _ffmpegSdkPromise = null; 3599function loadFFmpegSdk() { 3600 if (_ffmpegSdkPromise) return _ffmpegSdkPromise; 3601 _ffmpegSdkPromise = (async () => { 3602 if (typeof FFmpeg !== 'undefined') return; 3603 let lastErr; 3604 for (const src of FFMPEG_SDK_CDNS) { 3605 try { 3606 await withTimeout(loadScriptOnce(src), FFMPEG_SDK_LOAD_TIMEOUT_MS, 3607 `Timed out loading FFmpeg SDK from ${src}`); 3608 if (typeof FFmpeg !== 'undefined') return; 3609 lastErr = new Error(`Script loaded but FFmpeg global missing: ${src}`); 3610 } catch (err) { 3611 lastErr = err; 3612 } 3613 } 3614 throw lastErr || new Error('Failed to load FFmpeg SDK from all CDNs'); 3615 })().catch((err) => { 3616 _ffmpegSdkPromise = null; // allow a future retry to try again from scratch 3617 throw err; 3618 }); 3619 return _ffmpegSdkPromise; 3620} 3621 3622// Idempotent â safe to call from goToStep(2) AND startVideoGeneration() AND elsewhere. 3623// First call kicks off the SDK + WASM core download in the background. Subsequent 3624// calls are no-ops while the preload is in progress. 3625let _ffmpegPreloadKicked = false; 3626async function kickFFmpegPreloadIfNeeded() { 3627 if (_ffmpegPreloadKicked) return; 3628 _ffmpegPreloadKicked = true; 3629 try { 3630 await loadFFmpegSdk(); 3631 preloadFFmpeg(); 3632 } catch (err) { 3633 console.warn('FFmpeg lazy load failed (will retry on generate):', err); 3634 _ffmpegPreloadKicked = false; // allow retry 3635 } 3636} 3637 3638// Best-effort: cache the WASM core files in Cache API so they survive HTTP-cache 3639// eviction on mobile devices with aggressive storage limits. caches.open/add may be 3640// unavailable in older browsers or private browsing â failures are silently ignored. 3641function primeCoreCache(corePath) { 3642 try { 3643 const base = corePath.replace(/ffmpeg-core\.js$/, ''); 3644 const urls = [corePath, `${base}ffmpeg-core.wasm`, `${base}ffmpeg-core.worker.js`]; 3645 caches.open(FFMPEG_CACHE_NAME).then((cache) => 3646 Promise.all(urls.map((u) => 3647 cache.match(u).then((hit) => { if (!hit) return cache.add(u).catch(() => { }); }) 3648 )) 3649 ).catch(() => { }); 3650 } catch (_) { } 3651} 3652 3653// Tries each FFMPEG_CORE_PATHS mirror in turn, time-boxing each attempt so a stalled 3654// fetch on one CDN doesn't hang forever â falls through to the next mirror instead. 3655// Returns a loaded, ready-to-use FFmpeg instance or throws the last error. 3656async function loadFFmpegCoreWithFallback() { 3657 let lastErr; 3658 for (const corePath of FFMPEG_CORE_PATHS) { 3659 const instance = createConfiguredFFmpeg(corePath); 3660 try { 3661 await withTimeout(instance.load(), FFMPEG_CORE_LOAD_TIMEOUT_MS, 3662 `Timed out loading FFmpeg core from ${corePath}`); 3663 primeCoreCache(corePath); 3664 return instance; 3665 } catch (err) { 3666 lastErr = err; 3667 try { instance.exit(); } catch (_) { /* best-effort teardown of the failed worker */ } 3668 } 3669 } 3670 throw lastErr || new Error('Failed to load FFmpeg core from all CDNs'); 3671} 3672 3673function preloadFFmpeg() { 3674 // SharedArrayBuffer is required â skip preload if not available (page will 3675 // show an error when user tries to generate; the COI service worker may 3676 // still be registering and will reload the page to enable SAB). 3677 if (typeof SharedArrayBuffer === 'undefined') return; 3678 if (typeof FFmpeg === 'undefined') return; 3679 3680 ffmpegLoadPromise = loadFFmpegCoreWithFallback().then((instance) => { 3681 ffmpeg = instance; 3682 state.ffmpegLoaded = true; 3683 }).catch((err) => { 3684 console.warn('FFmpeg preload failed (will retry on generate):', err); 3685 ffmpeg = null; 3686 state.ffmpegLoaded = false; 3687 ffmpegLoadPromise = null; 3688 }); 3689} 3690 3691// --- FFmpeg Pipeline --- 3692 3693let ffmpeg = null; 3694let ffmpegLogs = []; // Stores last 30 lines of FFmpeg stderr for error reporting 3695
3696async function renderStickerFrames(animData, token) { 3697 // Lottie's canvas renderer creates its OWN <canvas> inside the container. 3698 // We must NOT pass our own canvas â Lottie ignores rendererSettings.canvas 3699 // and always creates a fresh one. We find Lottie's canvas after DOMLoaded 3700 // and use it for frame capture. (Bug 4a fix â round 2) 3701 // 3702 // The container uses visibility:hidden (not opacity:0 off-screen) so the 3703 // browser assigns real layout dimensions (clientWidth/clientHeight). Lottie 3704 // reads these at loadAnimation() time to size its internal canvas. 3705 const container = document.createElement('div'); 3706 container.style.cssText = 'position:fixed;left:0;top:0;width:200px;height:200px;pointer-events:none;visibility:hidden;z-index:-9999;'; 3707 document.body.appendChild(container); 3708 3709 const anim = lottie.loadAnimation({ 3710 container, 3711 renderer: 'canvas', 3712 loop: false, 3713 autoplay: false, 3714 animationData: JSON.parse(JSON.stringify(animData)), 3715 rendererSettings: { clearCanvas: true, preserveAspectRatio: 'xMidYMid meet' } 3716 }); 3717 3718 try { 3719 await new Promise((resolve) => anim.addEventListener('DOMLoaded', resolve)); 3720 3721 // Lottie creates its own canvas inside the container â grab it 3722 const canvas = container.querySelector('canvas'); 3723 if (!canvas) throw new Error('Lottie did not create a canvas element'); 3724 3725 const totalFrames = Math.round(anim.totalFrames); 3726 const fps = Math.round(anim.frameRate) || 30; 3727 3728 for (let f = 0; f < totalFrames; f++) { 3729 anim.goToAndStop(f, true); 3730 // requestAnimationFrame is fully paused by browsers while a tab is 3731 // backgrounded or the screen is locked â extremely common mid-generation 3732 // on mobile (user alt-tabs, phone locks). Racing it against a timer keeps 3733 // this loop making forward progress instead of hanging indefinitely. 3734 await new Promise((resolve) => { 3735 requestAnimationFrame(resolve); 3736 setTimeout(resolve, 300); 3737 }); 3738 3739 const bytes = await new Promise((resolve, reject) => { 3740 canvas.toBlob((blob) => { 3741 if (!blob) { 3742 reject(new Error('Canvas toBlob returned null')); 3743 return; 3744 } 3745 const reader = new FileReader(); 3746 reader.onload = () => resolve(new Uint8Array(reader.result)); 3747 reader.onerror = reject; 3748 reader.readAsArrayBuffer(blob); 3749 }, 'image/png'); 3750 }); 3751 3752 // A cancelled run must not write into a newer run's FFmpeg instance. 3753 if (token !== undefined && token !== genToken) throw new Error('cancelled'); 3754 ffmpeg.FS('writeFile', `sf${String(f).padStart(3, '0')}.png`, bytes); 3755 } 3756 3757 return { frameCount: totalFrames, fps }; 3758 } finally { 3759 anim.destroy(); 3760 if (container.parentNode) container.parentNode.removeChild(container); 3761 } 3762} 3763 3764async function startVideoGeneration() { 3765 // FFmpeg.wasm requires SharedArrayBuffer, which needs crossOriginIsolated. 3766 // The server sets COEP:credentialless + COOP:same-origin for this page. 3767 // If those headers are missing (e.g. old CDN cache), surface a clear message. 3768 if (typeof SharedArrayBuffer === 'undefined') { 3769 showFailed('isolation'); 3770 return; 3771 } 3772 3773 const token = ++genToken; 3774 showMaking(); 3775 setStage(1); 3776 state.ffmpegProgressActive = true; 3777 3778 let stage = 'sdk-load'; 3779 try { 3780 // Ensure the FFmpeg SDK script is loaded. With lazy-loading, a user who 3781 // jumps straight from Step 1 to Render (rare) may not have triggered the 3782 // preload yet. loadFFmpegSdk() is idempotent and internally time-boxed with 3783 // a two-CDN fallback (see loadFFmpegSdk / FFMPEG_SDK_CDNS). 3784 if (typeof FFmpeg === 'undefined') { 3785 updateProgress(0.01); 3786 await loadFFmpegSdk(); 3787 } 3788 if (token !== genToken) return; // cancelled while loading 3789 3790 // Reuse the preloaded FFmpeg instance if available. If the background 3791 // preload (preloadFFmpeg()) is still in progress, wait for it here so the 3792 // user sees progress rather than a blank wait. 3793 if (!state.ffmpegLoaded) { 3794 stage = 'core-load'; 3795 updateProgress(0.02); 3796 if (ffmpegLoadPromise) { 3797 await ffmpegLoadPromise; 3798 } 3799 if (token !== genToken) return; 3800 3801 // If preload failed or wasn't started (SAB unavailable at page load 3802 // but available now after COI SW reload), load fresh â same time-boxed, 3803 // multi-CDN path as the background preload so failures behave identically. 3804 if (!ffmpeg || !state.ffmpegLoaded) { 3805 ffmpeg = null; 3806 state.ffmpegLoaded = false;
3807 ffmpegLoadPromise = null; 3808 const instance = await loadFFmpegCoreWithFallback(); 3809 if (token !== genToken) { try { instance.exit(); } catch (_) { } return; } 3810 ffmpeg = instance; 3811 state.ffmpegLoaded = true; 3812 } 3813 } 3814 3815 stage = 'final'; 3816 const blob = await generateVideo(token); 3817 if (token !== genToken || !blob) return; 3818 state.ffmpegProgressActive = false; 3819 showReady(blob); 3820 3821 } catch (err) { 3822 if (token !== genToken) return; // cancelled â the cancel path already reset everything 3823 state.ffmpegProgressActive = false; 3824 cleanupVFS(); 3825 if (stage === 'final') stage = genStage; // generateVideo() narrows it to the clip being prepared 3826 const failKind = StoryTimeline.classifyFailure({ 3827 stage, 3828 message: String((err && err.message) || err), 3829 hidden: hiddenDuringMaking, 3830 timedOut: !!(err && err.timedOut) 3831 }); 3832 reportVideoGenError(err, stage, { 3833 failKind, 3834 deviceTier: state.deviceTier, 3835 deviceMemory: navigator.deviceMemory, 3836 clipCount: itemCountText().clips, 3837 totalSeconds: Math.round(state.videoDuration), 3838 hidden: hiddenDuringMaking, 3839 imageCount: readyItems().length, 3840 hasSticker: !!state.stickerConfig.id, 3841 hasAudio: !!(state.selectedTrack && !state.skipAudio), 3842 animation: state.textConfig.animation 3843 }); 3844 // A worker that failed mid-load or mid-run may be left in a corrupted state 3845 // (see docs/LEARNINGS.md #31 for the load-time precedent). Reset fully so the 3846 // next attempt in this same tab session starts from a clean instance instead 3847 // of silently reusing a broken one and failing every time until page reload. 3848 try { if (ffmpeg) ffmpeg.exit(); } catch (_) { } 3849 ffmpeg = null; 3850 state.ffmpegLoaded = false; 3851 ffmpegLoadPromise = null; 3852 _ffmpegPreloadKicked = false; 3853 showFailed(failKind, { stage, clipId: err && err.clipId }); 3854 } 3855} 3856 3857// Returns the finished MP4 as a Blob. `token` guards against a cancelled run 3858// continuing to write into a fresh FFmpeg instance started by a later attempt. 3859async function generateVideo(token) { 3860 const { fetchFile } = FFmpeg; 3861 const ensureCurrent = () => { 3862 if (token !== genToken) throw new Error('cancelled'); 3863 }; 3864 3865 // Choose Font 3866 const fullText = state.textConfig.message + "\n" + state.textConfig.name; 3867 const hasDevanagari = /[à¤-ॿ]/.test(fullText); 3868 const fontName = hasDevanagari ? 'devanagari.ttf' : 'latin.ttf'; 3869 3870 // Use absolute URL: relative paths break when Amplitude monkey-patches fetch() 3871 // and resolves URLs without a proper base, producing file:/// garbage paths. 3872 const fontUrl = hasDevanagari 3873 ? `${window.location.origin}/assets/fonts/NotoSansDevanagari-Regular.ttf` 3874 : `${window.location.origin}/assets/fonts/NotoSans-Regular.ttf`; 3875 3876 const fontData = await fetchFile(fontUrl); 3877 ensureCurrent(); 3878 ffmpeg.FS('writeFile', fontName, fontData); 3879 3880 updateProgress(0.05); 3881 3882 // The timeline is fixed here for the whole run: file names and filtergraph labels use the 3883 // segment position (0â¦n-1), which skips items that are not ready. 3884 let tl = currentTimeline(); 3885 const segs = tl.segments; 3886 if (!segs.length) throw new Error('Nothing to make a video from.'); 3887 const clipSegs = segs.map((seg, i) => ({ seg, i })).filter(x => x.seg.kind === 'clip'); 3888 const hasClips = clipSegs.length > 0; 3889 3890 // Share of the progress bar for each FFmpeg run: proportional to the seconds it produces, 3891 // with the final pass (which redraws everything) weighted 1.5Ã. 3892 const PREP_FROM = 0.06; 3893 const clipSecondsTotal = clipSegs.reduce((sum, x) => sum + x.seg.show, 0); 3894 const weightTotal = clipSecondsTotal + tl.total * 1.5; 3895 const finalBase = hasClips ? PREP_FROM + (1 - PREP_FROM) * (clipSecondsTotal / weightTotal) : 0.2; 3896 3897 // Stage A â prepare each video clip on its own into a small uniform 720Ã1280 file, dropping 3898 // the original before the next is read. Peak memory then depen
3898ds on the largest single clip. 3899 let doneClipSeconds = 0; 3900 for (let k = 0; k < clipSegs.length; k++) { 3901 const { seg, i } = clipSegs[k]; 3902 genStage = `prep-clip-${k + 1}`; 3903 setStage(k + 1); 3904 progressBase = PREP_FROM + (1 - PREP_FROM) * (doneClipSeconds / weightTotal); 3905 progressSpan = (1 - PREP_FROM) * (seg.show / weightTotal); 3906 updateProgress(progressBase); 3907 await prepareClip(state.items[seg.index], i, token); 3908 ensureCurrent(); 3909 doneClipSeconds += seg.show; 3910 } 3911 genStage = 'final'; 3912 if (hasClips) { 3913 setStage(clipSegs.length + 1); 3914 $('making-title').textContent = 'Putting it all togetherâ¦'; 3915 updateProgress(finalBase); 3916 // Preparing a clip can discover it has no sound; the music-lowering windows follow that. 3917 tl = Object.assign({}, tl, { clipWindows: currentTimeline().clipWindows }); 3918 } else { 3919 updateProgress(0.10); 3920 } 3921 3922 // Photos (resized via canvas to prevent FFmpeg OOM) 3923 for (let i = 0; i < segs.length; i++) { 3924 if (segs[i].kind !== 'photo') continue; 3925 const resizedData = await composePhoto(state.items[segs[i].index], 828, 1472); 3926 ensureCurrent(); 3927 ffmpeg.FS('writeFile', `img${i}.jpg`, resizedData); 3928 } 3929 3930 // Write Audio. (Done before the sticker frames so the on-screen checklist reads 3931 // photos â music â message/sticker; the two VFS writes are independent.) 3932 if (!hasClips) setStage(2); 3933 if (state.selectedTrack && !state.skipAudio) { 3934 // Amplitude monkey-patches fetch() and breaks relative URL resolution. 3935 // Force absolute URL so fetchFile() takes the https:// branch directly. 3936 const track = state.selectedTrack; 3937 let audioData; 3938 if (track.source === 'own' && track.file) { 3939 // The user's own file: read straight from the device, never fetched or uploaded. 3940 audioData = new Uint8Array(await track.file.arrayBuffer()); 3941 } else { 3942 await refreshTrackLink(track); 3943 const audioSrc = track.audio.startsWith('http') ? track.audio : `${window.location.origin}${track.audio}`; 3944 // fetchFile() does not look at the status, so a refused or failed download would be 3945 // handed to FFmpeg as an empty song. Fetch it here and stop with a clear error instead. 3946 const audioRes = await withTimeout(fetch(audioSrc), 90000, 'The song took too long to download.'); 3947 if (!audioRes.ok) throw new Error(`The song could not be downloaded (error ${audioRes.status}).`); 3948 audioData = new Uint8Array(await audioRes.arrayBuffer()); 3949 } 3950 ensureCurrent(); 3951 vfsAudioName = audioFileName(track); 3952 ffmpeg.FS('writeFile', vfsAudioName, audioData); 3953 } 3954 if (!hasClips) updateProgress(0.13); 3955 3956 // Write Sticker frames (if selected) 3957 if (!hasClips) setStage(3); 3958 let stickerFps = 30; 3959 const hasSticker = !!state.stickerConfig.id; 3960 3961 if (hasSticker) { 3962 const animData = stickerCache.get(state.stickerConfig.id); 3963 if (!animData) throw new Error('Sticker animation data not loaded. Please try again.'); 3964 const result = await renderStickerFrames(animData, token); 3965 ensureCurrent(); 3966 stickerFps = result.fps; 3967 } 3968 3969 // Build filtergraph. Input k is segment k (a photo or a prepared clip), then the sticker 3970 // frames, then the music. 3971 const segCount = segs.length; 3972 const stickerInputIndex = hasSticker ? segCount : -1; 3973 const { filterComplex, finalLabel } = buildFiltergraph( 3974 segs, 3975 state.textConfig, 3976 fontName, 3977 hasSticker 3978 ? { 3979 stickerInputIndex, 3980 xPct: state.stickerConfig.xPct, 3981 yPct: state.stickerConfig.yPct, 3982 sizePct: state.stickerConfig.sizePct, 3983 movement: state.stickerConfig.movement 3984 } 3985 : null 3986 ); 3987 3988 // FFmpeg arguments 3989 const args = []; 3990 3991 // 1. One input per segment. A photo lasts its on-screen time plus the crossfade it gives up 3992 // to the next item (seg.input), so crossfades never shorten the video; a prepared clip is 3993 // extended the same way inside the filtergraph (tpad). 3994 for (let i = 0; i < segs.length; i++) { 3995 if (segs[i].kind === 'photo') args.push('-loop', '1', '-t', segs[i].input.toFixed(3), '-i', `img${i}.jpg`); 3996 else args.push('-i', `prep${i}.mp4`); 3997 } 3998 3999 const audioIndex = segCount + (hasSticker ? 1 : 0); 4000 4001 // 2. Sticker Input (image sequence, looped) 4002 if (hasSticker) { 4003 args.push('-stream_loop', '-1'); 4004 args.push('-f', 'image2'); 4005 args.push('-framerate', String(stickerFps)); 4006 args.push('-i', 'sf%03d.png'); 4007 } 4008
4009 // 3. Music input, then the audio graph: clip sound + music lowered under it 4010 const music = musicArgsAndFilter(tl, audioIndex, vfsAudioName); 4011 if (music) args.push(...music.inputArgs); 4012 const audio = buildAudioGraph(tl, music); 4013 4014 // 4. Filters & Mapping 4015 args.push('-filter_complex', audio ? `${filterComplex};${audio.filters.join(';')}` : filterComplex); 4016 args.push('-map', `[${finalLabel}]`); 4017 if (audio) args.push('-map', `[${audio.label}]`); 4018 4019 // 5. Encoding settings for output.mp4. The bitrate ceiling (by device tier) makes the 4020 // file size predictable from the length. The length is fixed by -t, never by the audio. 4021 const hasAudio = !!audio; 4022 const kbps = StoryTimeline.TIERS[state.deviceTier].videoKbps; 4023 args.push('-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '23', '-maxrate', `${kbps}k`, '-bufsize', `${kbps * 2}k`); 4024 if (hasAudio) args.push('-c:a', 'aac', '-b:a', '128k'); 4025 args.push('-pix_fmt', 'yuv420p', '-threads', '2'); 4026 args.push('-t', tl.total.toFixed(3)); 4027 args.push('output.mp4'); 4028 4029 if (!hasClips) updateProgress(0.20); 4030 4031 // RUN FFmpeg, under the watchdog (see runFFmpeg): a hung/OOM'd wasm worker previously left 4032 // the user staring at a stuck progress bar forever. On failure the startVideoGeneration() 4033 // catch block calls ffmpeg.exit() and rebuilds a fresh instance. 4034 progressBase = finalBase; 4035 progressSpan = 1 - finalBase; 4036 await runFFmpeg(args, { mediaSeconds: tl.total }); 4037 ensureCurrent(); 4038 4039 let data; 4040 try { data = ffmpeg.FS('readFile', 'output.mp4'); } catch (_) { data = null; } 4041 if (!data || data.length < 1000) throw new Error('The video maker finished without making a video.'); 4042 4043 // Completed! 4044 if (!hasClips) setStage(4); 4045 updateProgress(1.0); 4046 4047 const blob = new Blob([data.buffer], { type: 'video/mp4' }); 4048 // The Blob holds its own copy, so the VFS can be cleared right away (the old 4049 // flow waited for the Download click; Share/replay now need the blob instead). 4050 cleanupVFS(); 4051 return blob; 4052} 4053 4054// Stage A for one clip: trim it, frame it (Fit over a blurred still, or Fill), bring it to 4055// 720Ã1280 at 25 fps with one stereo 44.1 kHz audio track, and drop the original from memory. 4056// `n` is the clip's segment position; the result is prepN.mp4. 4057async function prepareClip(item, n, token) { 4058 const src = `clip${n}.src`; 4059 const out = `prep${n}.mp4`; 4060 const bg = `bg${n}.jpg`; 4061 const len = item.trim.end - item.trim.start; 4062 const lenText = len.toFixed(3); 4063 const fail = (message) => { const e = new Error(message); e.clipId = item.id; return e; }; 4064 4065 let bytes; 4066 try { bytes = new Uint8Array(await item.file.arrayBuffer()); } catch (_) { throw fail('The video file could not be read.'); } 4067 if (token !== genToken) throw new Error('cancelled'); 4068 ffmpeg.FS('writeFile', src, bytes); 4069 bytes = null; 4070 4071 const base = ['-ss', item.trim.start.toFixed(3), '-t', lenText, '-i', src]; 4072 let videoFilter; 4073 let nextInput = 1; 4074 if (item.fit === 'fit') { 4075 ffmpeg.FS('writeFile', bg, await composeBackdrop(item.poster, 720, 1280
4075)); 4076 base.push('-loop', '1', '-i', bg); 4077 nextInput = 2; 4078 // Scale along whichever side keeps the whole picture inside 720Ã1280. 4079 const scale = (item.width / item.height) >= (720 / 1280) ? 'scale=720:-2' : 'scale=-2:1280'; 4080 videoFilter = `[0:v]${scale},setsar=1,fps=${FPS}[c];[1:v][c]overlay=(W-w)/2:(H-h)/2:shortest=1[v]`; 4081 } else { 4082 videoFilter = `[0:v]scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280,setsar=1,fps=${FPS}[v]`; 4083 } 4084 4085 // Returns true when a usable prepN.mp4 exists. ffmpeg.wasm 0.11 resolves run() even when 4086 // FFmpeg exits with an error, so the output file is the only reliable signal. 4087 const attempt = async (useSilence) => { 4088 const args = base.slice(); 4089 if (useSilence) args.push('-f', 'lavfi', '-t', lenText, '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100'); 4090 args.push('-filter_complex', videoFilter, '-map', '[v]', '-map', useSilence ? `${nextInput}:a` : '0:a', 4091 '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '18', '-pix_fmt', 'yuv420p', '-threads', '2', 4092 '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2', '-t', lenText, out); 4093 try { ffmpeg.FS('unlink', out); } catch (_) { } 4094 await runFFmpeg(args, { mediaSeconds: len }); 4095 if (token !== genToken) throw new Error('cancelled'); 4096 try { return ffmpeg.FS('stat', out).size > 1000; } catch (_) { return false; } 4097 }; 4098 4099 try { 4100 const silent = item.muted || item.hasAudio === false; 4101 let ok = await attempt(silent); 4102 if (!ok && !silent) { 4103 // Most often: the clip has no audio track after all. Try again with silence. 4104 ok = await attempt(true); 4105 if (ok) item.hasAudio = false; 4106 } 4107 if (!ok) throw fail('no output'); 4108 } catch (err) { 4109 if (err && !err.clipId && !err.timedOut && err.message !== 'cancelled') err.clipId = item.id; 4110 throw err; 4111 } finally { 4112 try { ffmpeg.FS('unlink', src); } catch (_) { } 4113 try { ffmpeg.FS('unlink', bg); } catch (_) { } 4114 } 4115} 4116 4117// 720Ã1280 blurred, darkened still that sits behind a fitted clip (blurring every frame of a 4118// video is one of the slowest things to ask of the in-browser encoder; one still looks alike). 4119function composeBackdrop(posterUrl, targetW, targetH) { 4120 return new Promise((resolve, reject) => { 4121 const img = new Image(); 4122 img.onload = () => { 4123 const canvas = document.createElement('canvas'); 4124 canvas.width = targetW; 4125 canvas.height = targetH; 4126 drawBlurBackdrop(canvas.getContext('2d'), img, img.width, img.height, targetW, targetH); 4127 canvasToJpegBytes(canvas, 0.85).then(resolve, reject); 4128 }; 4129 img.onerror = () => reject(new Error('The video frame could not be read.')); 4130 img.src = posterUrl; 4131 }); 4132} 4133 4134// The audio of the finished video: the clips' own sound at its place on the timeline, plus the 4135// music (lowered while a clip with sound plays). Returns null when there is no sound at all. 4136// Input k is segment k, so the audio of the clip at segment k is that input's audio stream. 4137function buildAudioGraph(tl, music) { 4138 const total = tl.total.toFixed(3); 4139 const filters = []; 4140 const clipLabels = []; 4141 const audible = new Set(tl.clipWindows.map(w => w.start.toFixed(3))); 4142 tl.segments.forEach((seg, i) => { 4143 if (seg.kind !== 'clip' || !audible.has(seg.start.toFixed(3))) return; 4144 const fade = Math.min(0.25, seg.show / 4); 4145 const ms = Math.round(seg.start * 1000); 4146 filters.push(`[${i}:a]aresample=44100,aformat=channel_layouts=stereo,` + 4147 `afade=t=in:st=0:d=${fade.toFixed(3)},afade=t=out:st=${(seg.show - fade).toFixed(3)}:d=${fade.toFixed(3)},` + 4148 `atrim=0:${seg.show.toFixed(3)},adelay=${ms}|${ms},apad[ca${i}]`); 4149 clipLabels.push(`[ca${i}]`); 4150 }); 4151 4152 let clipBus = null; 4153 if (clipLabels.length === 1) { 4154 filters.push(`${clipLabels[0]}atrim=0:${total}[cb]`); 4155 clipBus = 'cb'; 4156 } else if (clipLabels.length > 1) { 4157 // amix divides by the number of inputs; they never overlap, so multiplying back is exact. 4158 filters.push(`${clipLabels.join('')}amix=inputs=${clipLabels.length}:dropout_transition=0,volume=${clipLabels.length},atrim=0:${total}[cb]`); 4159 clipBus = 'cb'; 4160 } 4161 4162 if (music) filters.push(music.filter); 4163 if (clipBus && music) { 4164 // A plain sum of the two stereo pairs (amix would halve both). 4165 filters.push(`[cb][${music.label}
4165]amerge=inputs=2,pan=stereo|c0=c0+c2|c1=c1+c3[aout]`); 4166 return { filters, label: 'aout' }; 4167 } 4168 if (clipBus) return { filters, label: clipBus }; 4169 if (music) return { filters, label: music.label }; 4170 return null; 4171} 4172 4173// Name of the audio file inside FFmpeg's in-memory file system (the extension tells FFmpeg 4174// how to read it; own files keep theirs). 4175let vfsAudioName = 'audio.mp3'; 4176function audioFileName(track) { 4177 const ext = track.source === 'own' ? String(track.name || '').split('.').pop().toLowerCase() : 'mp3'; 4178 return `audio.${/^[a-z0-9]{2,5}$/.test(ext) ? ext : 'mp3'}`; 4179} 4180 4181// Background music for the final pass. Shorter than the video â it repeats; longer â it starts 4182// at the chosen part and is cut to the video's length. Either way it fades out at the end. 4183function musicArgsAndFilter(tl, inputIndex, fileName) { 4184 const t = state.selectedTrack; 4185 if (!t || state.skipAudio) return null; 4186 const inputArgs = []; 4187 if (trackRepeats(t)) inputArgs.push('-stream_loop', '-1'); 4188 else if (t.crop.start > 0) inputArgs.push('-ss', t.crop.start.toFixed(3)); 4189 inputArgs.push('-i', fileName); 4190 const total = tl.total; 4191 const fade = Math.min(1, total / 2); 4192 // While a clip with sound plays, the music drops to a low level and comes back after. 4193 const duck = tl.clipWindows.length 4194 ? `volume=eval=frame:volume='${StoryTimeline.duckExpression(tl.clipWindows)}',` 4195 : ''; 4196 const filter = `[${inputIndex}:a]aresample=44100,aformat=channel_layouts=stereo,apad,atrim=0:${total.toFixed(3)},${duck}` + 4197 `afade=t=out:st=${(total - fade).toFixed(3)}:d=${fade.toFixed(3)}[mus]`; 4198 return { inputArgs, filter, label: 'mus' }; 4199} 4200 4201function buildFiltergraph(segs, textConfig, fontFile, stickerOpts) { 4202 const imageCount = segs.length; 4203 // Slow pan across the 828Ã1472 source; `frames` spreads it over the photo's whole time. 4204 const panFor = (i, frames) => [ 4205 `crop=${RESOLUTION}:x='0+108*n/${frames}':y='0+192*n/${frames}'`, // tl_br 4206 `crop=${RESOLUTION}:x='108-108*n/${frames}':y='192-192*n/${frames}'`, // br_tl 4207 `crop=${RESOLUTION}:x='0+108*n/${frames}':y='96'`, // l_r 4208 `crop=${RESOLUTION}:x='108-108*n/${frames}':y='96'`, // r_l 4209 ][i % 4]; 4210 4211 let filterParts = []; 4212 let lastLabel = ''; 4213 4214 // A: Photos get the slow pan. Prepared clips are already 720Ã1280; all but the last hold 4215 // their final frame for one crossfade so the fade costs none of their own footage. 4216 for (let i = 0; i < imageCount; i++) { 4217 if (segs[i].kind === 'clip') { 4218 const hold = i < imageCount - 1 ? `,tpad=stop_mode=clone:stop_duration=${CROSSFADE_DURATION}` : ''; 4219 filterParts.push(`[${i}:v]setsar=1,fps=${FPS}${hold}[v${i}]`); 4220 continue; 4221 } 4222 const frames = Math.max(1, Math.round(segs[i].input * FPS) - 1); 4223 filterParts.push(`[${i}:v]${panFor(i, frames)},setsar=1,fps=${FPS}[v${i}]`); 4224 } 4225 4226 // B: xfade chain 4227 for (let i = 0; i < imageCount - 1; i++) { 4228 const inputA = i === 0 ? `[v0]` : `[vfade${i - 1}]`; 4229 const inputB = `[v${i + 1}]`; 4230 // Every earlier input is one crossfade longer than its segment, so the chain so far 4231 // ends exactly one crossfade after this segment's end. 4232 const xfadeOffset = segs[i].end.toFixed(3); 4233 4234 const outLabel = `vfade${i}`; 4235 filterParts.push(`${inputA}${inputB}xfade=transition=fade:duration=${CROSSFADE_DURATION}:offset=${xfadeOffset}[${outLabel}]`); 4236 lastLabel = outLabel; 4237 } 4238 4239 if (imageCount === 1) lastLabel = 'v0'; // Fallback 4240 4241 // C: Sticker overlay (if selected) 4242 if (stickerOpts) { 4243 const { stickerInputIndex, xPct, yPct, sizePct, movement } = stickerOpts; 4244 const sz = Math.round((sizePct / 100) * 720); 4245 4246 let xExpr; 4247 let yExpr; 4248 if (movement === 'dance') { 4249 xExpr = `abs(mod(t*130\\,2*(W-${sz}))-(W-${sz}))`; 4250 yExpr = `abs(mod(t*90\\,2*(H-${sz}))-(H-${sz}))`; 4251 } else { 4252 xExpr = `${Math.round((720 * xPct) / 100 - sz / 2)}`; 4253 yExpr = `${Math.round((1280 * yPct) / 100 - sz / 2)}`; 4254 } 4255 4256 filterParts.push(`[${stickerInputIndex}:v]scale=${sz}:${sz}:flags=lanczos[stk]`); 4257 filterParts.push(`[${lastLabel}][stk]overlay=x='${xExpr}':y='${yExpr}':shortest=1[vstk]`); 4258 lastLabel = 'vstk'; 4259 } 4260 4261 // D: drawtext if required 4262 const hasText = textConfig.message !== '' || textConfig.name !== ''; 4263 let finalLabel = lastLabel; 4264 4265 if (hasText) { 4266 const textLines = buildDrawtextFilter(lastLabel, textConfig, fontFile); 4267 filterParts.push(...textLines.filters); 4268 finalLabel = textLines.outputLabel; 4269 } 4270 4271 return { 4272 filterComplex: filterParts.join(';'), 4273 finalLabel 4274 }; 4275} 4276 4277function buildDrawtextFilter(inputLabel, config, fontFile) { 4278 // Escape rules 4279 function escapeFFmpegText(text) { 4280 return text 4281 .replace(/\\/g, '\\\\') 4282 .replace(/'/g, "\\'") 4283 .replace(/:/g, '\\:') 4284 .replace(/\[/g, '\\[') 4285 .replace(/\]/g, '\\]'); 4286 } 4287 4288 const { message, name, color, animation, xPct, yPct } = config; 4289 4290 // Same map as the swatches and the CSS preview (TEXT_COLORS) so they always match. 4291 const hexColor = (TEXT_COLORS[color] || TEXT_COLORS.white).hex; 4292 const fontSize = 52; 4293 4294 const xExpr = `(w-text_w)*(${xPct}/100)`; 4295 const yExpr = `(h-text_h)*(${yPct}/100)`; 4296 // Name line sits below message by one font-size + small padding 4297 const yExprName = `(h-text_h)*(${yPct}/100)+${fontSize + 8}`; 4298 4299 let alphaExpr = '1'; 4300 let yExprAnimated = yExpr; 4301 let yExprAnimatedName = yExprName; 4302 4303 if (animation === 'fadein') { 4304 alphaExpr = `if(lt(t,1.0),t,1)`; 4305 } else if (animation === 'slideup') { 4306 yExprAnimated = `${yExpr}
4306+60*(1-min(t,1.0))`; 4307 yExprAnimatedName = `${yExprName}+60*(1-min(t,1.0))`; 4308 } 4309 4310 const commonOpts = `fontfile=${fontFile}:fontsize=${fontSize}:fontcolor=${hexColor}:borderw=3:[email protected]:[email protected]:shadowx=2:shadowy=2`; 4311 4312 const hasMessage = message !== ''; 4313 const hasName = name !== ''; 4314 4315 if (animation === 'typewriter') { 4316 // Typewriter: build char-by-char chain for message, then append name as a 4317 // single always-visible drawtext below it. Avoids \n escaping entirely. 4318 // (Bug 4b fix â previously joined with \n which was double-escaped by escapeFFmpegText) 4319 const rawChars = [...message]; 4320 const filters = []; 4321 let currentInput = inputLabel; 4322 const CHAR_DELAY = 0.15; 4323 4324 rawChars.forEach((c, i) => { 4325 const rawPrefix = rawChars.slice(0, i + 1).join(''); 4326 const prefix = escapeFFmpegText(rawPrefix); 4327 const enableExpr = `gte(t,${(i * CHAR_DELAY).toFixed(2)})`; 4328 const isLast = i === rawChars.length - 1; 4329 const outLabel = (isLast && !hasName) ? 'vout' : `vtw${i}`; 4330 4331 filters.push( 4332 `[${currentInput}]drawtext=${commonOpts}:text='${prefix}':x=${xExpr}:y=${yExprAnimated}:alpha='${enableExpr}'[${outLabel}]` 4333 ); 4334 currentInput = outLabel; 4335 }); 4336 4337 // Append name line AFTER the typewriter char-chain (only when there is a message chain) 4338 if (hasMessage && hasName) { 4339 filters.push( 4340 `[${currentInput}]drawtext=${commonOpts}:text='${escapeFFmpegText(name)}':x=${xExpr}:y=${yExprAnimatedName}:alpha='1'[vout]` 4341 ); 4342 } 4343 4344 if (!hasMessage && hasName) { 4345 // Only name, no message â char-loop was empty so currentInput is still inputLabel. 4346 // Emit a single drawtext directly to [vout]. 4347 filters.push( 4348 `[${inputLabel}]drawtext=${commonOpts}:text='${escapeFFmpegText(name)}':x=${xExpr}:y=${yExprAnimated}:alpha='1'[vout]` 4349 ); 4350 } 4351 4352 return { filters, outputLabel: 'vout' }; 4353 } 4354 4355 // Non-typewriter: use two separate drawtext filters â one for message, one for name. 4356 // This completely avoids the \n escape chain. Each filter is a clean, independent call. 4357 // (Bug 4b fix) 4358 const filters = []; 4359 let currentInput = inputLabel; 4360 4361 if (hasMessage) { 4362 const outLabel = hasName ? 'vmsg' : 'vout'; 4363 filters.push( 4364 `[${currentInput}]drawtext=${commonOpts}:text='${escapeFFmpegText(message)}':x=${xExpr}:y=${yExprAnimated}:alpha='${alphaExpr}'[${outLabel}]` 4365 ); 4366 currentInput = outLabel; 4367 } 4368 4369 if (hasName) { 4370 const yForName = hasMessage ? yExprAnimatedName : yExprAnimated; 4371 filters.push( 4372 `[${currentInput}]drawtext=${commonOpts}:text='${escapeFFmpegText(name)}':x=${xExpr}:y=${yForName}:alpha='${alphaExpr}'[vout]` 4373 ); 4374 } 4375 4376 return { filters, outputLabel: 'vout' }; 4377} 4378 4379function cleanupVFS() { 4380 if (!ffmpeg) return; 4381 try { ffmpeg.FS('unlink', 'output.mp4'); } catch (e) { } 4382 // Everything a run can leave behind, including a half-prepared clip after a cancel or failure. 4383 for (let i = 0; i <= state.items.length; i++) { 4384 ['img#.jpg', 'prep#.mp4', 'clip#.src', 'bg#.jpg'].forEach((name) => { 4385 try { ffmpeg.FS('unlink', name.replace('#', i)); } catch (e) { } 4386 }); 4387 } 4388 try { ffmpeg.FS('unlink', vfsAudioName); } catch (e) { } 4389 try { ffmpeg.FS('unlink', 'devanagari.ttf'); } catch (e) { } 4390 try { ffmpeg.FS('unlink', 'latin.ttf'); } catch (e) { } 4391 // Unlink sticker frames (sf000.png ⦠sfNNN.png) â loop until first missing file 4392 for (let i = 0; ; i++) { 4393 try { ffmpeg.FS('unlink', `sf${String(i).padStart(3, '0')}.png`); } catch (e) { break; } 4394 } 4395} 4396 4397// Blurred, darkened cover copy of `img` filling the canvas: the backdrop behind a fitted 4398// photo or video. The blur comes from a tiny intermediate canvas scaled back up, which works 4399// in every browser (ctx.filter is not reliable in Safari). 4400function drawBlurBackdrop(ctx, img, srcW, srcH, targetW, targetH) { 4401 const tiny = document.createElement('canvas'); 4402 tiny.width = 27; 4403 tiny.height = 48; 4404 const s = Math.max(tiny.width / srcW, tiny.height / srcH); 4405 tiny.getContext('2d').drawImage(img, (tiny.width - srcW * s) / 2, (tiny.height - srcH * s) / 2, srcW * s, srcH * s); 4406 ctx.imageSmoothingEnabled = true; 4407 ctx.imageSmoothingQuality = 'high'; 4408 ctx.drawImage(tiny, 0, 0, targetW, targetH); 4409 ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; 4410 ctx.fillRect(0, 0, targetW, targetH); 4411} 4412 4413function canvasToJpegBytes(canvas, quality) { 4414 return new Promise((resolve, reject) => { 4415 canvas.toBlob((blob) => { 4416 if (!blob) { reject(new Error('Canvas toBlob returned null')); return; } 4417 const reader = new FileReader(); 4418 reader.onload = () => resolve(new Uint8Array(reader.result)); 4419 reader.onerror = reject; 4420 reader.readAsArrayBuffer(blob); 4421 }, 'image/jpeg', quality); 4422 }); 4423} 4424 4425// Renders one photo to the encoder's source size (resized on a canvas to prevent FFmpeg OOM). 4426// Fill: cover crop. Fit: the whole picture centred over a blurred copy of itself. 4427function composePhoto(item, targetW, targetH) { 4428 return new Promise((resolve, reject) => { 4429 const img = new Image(); 4430 img.onload = () => { 4431 const canvas = document.createElement('canvas'); 4432 canvas.width = targetW; 4433 canvas.height = targetH; 4434 const ctx = canvas.getContext('2d'); 4435 4436 if (item.fit === 'fit') { 4437 drawBlurBackdrop(ctx, img, img.width, img.height, targetW, targetH); 4438 const s = Math.min(targetW / img.width, targetH / img.height); 4439 const w = img.width * s;
4440 const h = img.height * s; 4441 ctx.drawImage(img, (targetW - w) / 2, (targetH - h) / 2, w, h); 4442 } else { 4443 const s = Math.max(targetW / img.width, targetH / img.height); 4444 const w = img.width * s; 4445 const h = img.height * s; 4446 ctx.drawImage(img, (targetW - w) / 2, (targetH - h) / 2, w, h); 4447 } 4448 canvasToJpegBytes(canvas, 0.9).then(resolve, reject); 4449 }; 4450 img.onerror = () => reject(new Error('A photo could not be read.')); 4451 img.src = item.url; 4452 }); 4453}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.