PageSourceSearch

https://id8.co.in/assets/js/free-story-maker.js?v=299154a5

js id8.co.in collected 2026-10-06 03:07:33 UTC 196,257 bytes, 4,453 lines download raw bytes

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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} &#183; ${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.