(ou import './vakana-intro.js') * * const intro = VakanaIntro.mount(document.body, { * onComplete() { demarrerApp(); } // le composant s'est déjà retiré du DOM * }); * * Options (voir DEFAULTS ci-dessous) : duration, autoDispose, fadeOut, * background, word, subWord, ink, sub, strandL, strandR, maxDpr. * * EXPORT VIDÉO (pubs/trailers, hors runtime one-shot) : * await VakanaIntro.recordWebM({fps:60, cycles:1, width:1920, height:1080}) * → télécharge vakana-intro.webm, puis : * ffmpeg -i vakana-intro.webm -c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart vakana-intro.mp4 * * API : VakanaIntro.mount(el, opts) → {canvas, play, pause, dispose} * VakanaIntro.renderFrame(canvasOrCtx, t, opts) VakanaIntro.recordWebM(opts) * VakanaIntro.ready() → Promise (polices chargées) */ (function () { 'use strict'; var DESIGN = { w: 528, h: 297 }; // espace de conception 16:9 var DEFAULTS = { duration: 2.8, // secondes, un seul passage (2–4 recommandé) autoDispose: true, // fondu puis retrait du DOM en fin de lecture fadeOut: 0.35, // secondes du fondu de sortie background: '#F5F1E8', // nacre strandL: '#17150F', // encre strandR: '#3A362B', ink: '#17150F', sub: '#8A8271', word: 'VAKANA', subWord: 'STUDIO', maxDpr: 2 }; // ---------- polices ---------- var fontsPromise = null; function ensureFonts() { if (fontsPromise) return fontsPromise; if (typeof document === 'undefined') return Promise.resolve(); if (!document.getElementById('vakana-intro-fonts')) { var l = document.createElement('link'); l.id = 'vakana-intro-fonts'; l.rel = 'stylesheet'; l.href = 'https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,800&family=IBM+Plex+Mono:wght@500&display=swap'; document.head.appendChild(l); } fontsPromise = Promise.all([ document.fonts.load('800 34px "Bricolage Grotesque"'), document.fonts.load('500 10px "IBM Plex Mono"') ]).catch(function () {}).then(function () {}); return fontsPromise; } // ---------- easing ---------- function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; } function outCubic(x) { return 1 - Math.pow(1 - x, 3); } function phase(t, a, b) { return clamp01((t - a) / (b - a)); } // ---------- primitives ---------- function roundRectPath(ctx, x, y, w, h, r) { r = Math.max(0, Math.min(r, w / 2, h / 2)); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } function strand(ctx, bx, by, angleDeg, grow, color) { if (grow <= 0) return; ctx.save(); ctx.translate(bx, by); ctx.rotate(angleDeg * Math.PI / 180); var h = 106 * grow; ctx.fillStyle = color; roundRectPath(ctx, -7, -h, 14, h, 7); ctx.fill(); ctx.restore(); } function pearl(ctx, cx, cy, r, sheenT) { ctx.save(); ctx.shadowColor = 'rgba(23,21,15,.28)'; ctx.shadowBlur = 5; ctx.shadowOffsetY = 2; var g = ctx.createRadialGradient(cx - r * 0.34, cy - r * 0.44, r * 0.06, cx, cy, r); g.addColorStop(0, '#FFFFFF'); g.addColorStop(0.30, '#F5EFE6'); g.addColorStop(0.62, '#DED2BE'); g.addColorStop(1, '#A89B85'); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); ctx.restore(); if (sheenT > 0 && sheenT < 1) { // reflet qui balaie la nacre ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); var off = -14 + 30 * sheenT; ctx.globalAlpha = Math.sin(sheenT * Math.PI); ctx.translate(cx + off, cy); ctx.rotate(0.35); var sg = ctx.createLinearGradient(-5, 0, 5, 0); sg.addColorStop(0, 'rgba(255,255,255,0)'); sg.addColorStop(0.5, 'rgba(255,255,255,.95)'); sg.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = sg; ctx.fillRect(-5, -r - 4, 10, 2 * r + 8); ctx.restore(); } } function drawSpaced(ctx, text, font, ls, cx, baseY, color, alpha) { if (alpha <= 0) return; ctx.save(); ctx.globalAlpha = alpha; ctx.font = font; ctx.fillStyle = color; ctx.textBaseline = 'alphabetic'; var widths = [], total = 0, i; for (i = 0; i < text.length; i++) { widths[i] = ctx.measureText(text[i]).width; total += widths[i]; } total += ls * (text.length - 1); var x = cx - total / 2; for (i = 0; i < text.length; i++) { ctx.fillText(text[i], x, baseY); x += widths[i] + ls; } ctx.restore(); } // ---------- une frame, déterministe ---------- // target : canvas ou contexte 2D ; t ∈ [0,1] ; opts : voir DEFAULTS function renderFrame(target, t, opts) { var o = Object.assign({}, DEFAULTS, opts || {}); var ctx = target.getContext ? target.getContext('2d') : target; var cw = ctx.canvas.width, ch = ctx.canvas.height; var s = Math.min(cw / DESIGN.w, ch / DESIGN.h); var ox = (cw - DESIGN.w * s) / 2, oy = (ch - DESIGN.h * s) / 2; t = clamp01(t); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, cw, ch); ctx.fillStyle = o.background; ctx.fillRect(0, 0, cw, ch); ctx.setTransform(s, 0, 0, s, ox, oy); // timeline « Éclat » : perle → brins depuis la perle → reflet → nom var pop = t < 0.09 ? 1.3 * outCubic(phase(t, 0, 0.09)) : t < 0.14 ? 1.3 - 0.3 * outCubic(phase(t, 0.09, 0.14)) : 1; var growL = outCubic(phase(t, 0.22, 0.54)); var growR = outCubic(phase(t, 0.26, 0.58)); var sheenT = phase(t, 0.55, 0.75); var wp = outCubic(phase(t, 0.66, 0.84)); strand(ctx, 255, 194, 21, growL, o.strandL); strand(ctx, 273, 194, -21, growR, o.strandR); if (pop > 0) pearl(ctx, 263, 186, 8 * pop, sheenT); ctx.save(); ctx.translate(0, 10 * (1 - wp)); drawSpaced(ctx, o.word, '800 34px "Bricolage Grotesque", Arial, sans-serif', 34 * 0.12, 264, 246, o.ink, wp); drawSpaced(ctx, o.subWord, '500 10px "IBM Plex Mono", monospace', 10 * 0.55, 264, 270, o.sub, wp); ctx.restore(); ctx.setTransform(1, 0, 0, 1, 0, 0); } // ---------- montage live (one-shot uniquement) ---------- function mount(container, opts) { var o = Object.assign({}, DEFAULTS, opts || {}); var canvas = document.createElement('canvas'); canvas.style.cssText = 'display:block;width:100%;height:100%;opacity:1;'; container.appendChild(canvas); var dpr = Math.min(window.devicePixelRatio || 1, o.maxDpr); function resize() { var r = container.getBoundingClientRect(); var w = Math.max(1, Math.round(r.width)), h = Math.max(1, Math.round(r.height || r.width * 9 / 16)); if (canvas.width !== w * dpr || canvas.height !== h * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; } } var ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; if (ro) ro.observe(container); resize(); var raf = 0, start = 0, playing = false, done = false; function finish() { renderFrame(canvas, 1, o); playing = false; if (o.autoDispose) { canvas.style.transition = 'opacity ' + o.fadeOut + 's ease'; canvas.getBoundingClientRect(); // force le recalcul de style avant de basculer l'opacité canvas.style.opacity = '0'; setTimeout(function () { ctrl.dispose(); if (o.onComplete) o.onComplete(); }, o.fadeOut * 1000); } else if (o.onComplete) { o.onComplete(); } } function frame(now) { if (!playing) return; var t = Math.min((now - start) / 1000 / o.duration, 1); if (t >= 1 && !done) { done = true; finish(); return; } renderFrame(canvas, t, o); raf = requestAnimationFrame(frame); } var ctrl = { canvas: canvas, play: function () { if (playing || done) return; playing = true; start = performance.now(); raf = requestAnimationFrame(frame); }, pause: function () { playing = false; cancelAnimationFrame(raf); }, dispose: function () { playing = false; cancelAnimationFrame(raf); if (ro) ro.disconnect(); canvas.remove(); } }; ensureFonts().then(function () { if (!playing && !done) renderFrame(canvas, 0, o); }); ctrl.play(); return ctrl; } // ---------- enregistrement WebM (outil de production, hors runtime) ---------- function recordWebM(opts) { var o = Object.assign({ fps: 60, cycles: 1, width: 1920, height: 1080, filename: 'vakana-intro.webm', download: true }, DEFAULTS, opts || {}); return ensureFonts().then(function () { return new Promise(function (resolve, reject) { var canvas = document.createElement('canvas'); canvas.width = o.width; canvas.height = o.height; var stream = canvas.captureStream(o.fps); var mime = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'].find(function (m) { return window.MediaRecorder && MediaRecorder.isTypeSupported(m); }); if (!mime) return reject(new Error('MediaRecorder non supporté')); var rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 12000000 }); var chunks = []; rec.ondataavailable = function (e) { if (e.data.size) chunks.push(e.data); }; rec.onstop = function () { var blob = new Blob(chunks, { type: 'video/webm' }); if (o.download) { var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = o.filename; a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000); } resolve(blob); }; var total = o.cycles * o.duration * 1000, t0 = 0; function draw(now) { if (!t0) t0 = now; var el = now - t0; if (el >= total) { renderFrame(canvas, 1, o); setTimeout(function () { rec.stop(); }, 120); return; } var t = (el / 1000 % o.duration) / o.duration; renderFrame(canvas, t, o); requestAnimationFrame(draw); } rec.start(200); requestAnimationFrame(draw); }); }); } // ---------- balise (usage déclaratif, one-shot) ---------- if (typeof customElements !== 'undefined' && !customElements.get('vakana-intro')) { var El = function () { return Reflect.construct(HTMLElement, [], El); }; El.prototype = Object.create(HTMLElement.prototype, { constructor: { value: El } }); Object.setPrototypeOf(El, HTMLElement); El.prototype.connectedCallback = function () { var self = this; this.style.display = 'block'; this.style.position = this.style.position || 'relative'; if (!this.style.height && !this.getAttribute('style')) this.style.aspectRatio = '16/9'; var o = { duration: parseFloat(this.getAttribute('duration')) || DEFAULTS.duration, background: this.getAttribute('bg') || DEFAULTS.background, onComplete: function () { self.dispatchEvent(new CustomEvent('complete', { bubbles: true })); self.remove(); // one-shot déclaratif : la balise elle-même disparaît } }; this.controller = mount(this, o); }; El.prototype.disconnectedCallback = function () { if (this.controller) this.controller.dispose(); }; customElements.define('vakana-intro', El); } var api = { version: '2.0.0', DESIGN: DESIGN, defaults: DEFAULTS, mount: mount, renderFrame: renderFrame, recordWebM: recordWebM, ready: ensureFonts }; if (typeof window !== 'undefined') window.VakanaIntro = api; })();