(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;
})();