{ const d = document.createElement('div'); d.textContent = l; meta.appendChild(d); });
// ---- ASSET URL ----
function assetUrl(rel){
const path = (rel || '').replace(/^\/+/, '');
if (!path) return '';
if (/^https?:\/\//.test(path)) return path;
// Local dev (file:// OR localhost / 127.0.0.1 / 0.0.0.0):
// resolve relative to dist/, where ../projects/{name}/ lives.
const isLocal = location.protocol === 'file:'
|| /^(localhost|127\.0\.0\.1|0\.0\.0\.0)$/.test(location.hostname);
if (isLocal) return `../projects/${cfg.name}/${path}`;
// Production: use cfg.cdn_base_url if the project provides one, else
// fall back to projects.kubakub.art for backwards compatibility.
const base = (cfg.cdn_base_url || 'https://projects.kubakub.art').replace(/\/+$/, '');
return `${base}/${cfg.name}/${path}`;
}
// ---- VIDEO ----
// Simple iframe — no enablejsapi to dodge the YT player error 153 we saw
// earlier. Play/pause is implemented via visibility (mute=1 makes silent
// pausing acceptable; the playhead keeps advancing in the iframe but it's
// hidden, so visually it acts like a pause).
function parseYouTubeId(url){
if (!url) return null;
const m = url.match(/(?:youtu\.be\/|youtube\.com\/(?:embed\/|watch\?v=))([\w-]+)/);
return m ? m[1] : null;
}
// Pass the embed URL through unmodified — appending autoplay/mute/loop has
// triggered YT player error 153 on this video repeatedly. The user-verified
// share URL (with its si= token) works as-is when pasted into a browser, so
// we trust it. If the user provides a watch URL or short link, we build the
// simplest possible embed URL with no extra params.
function buildVideoSrc(url, id){
// enablejsapi=1 lets us control the player via postMessage (seek, mute,
// unmute, play, pause). loop=1 needs playlist=ID to actually loop.
// controls=0 hides YT's native chrome (progress bar + title strip that
// flashes in on seek/load) — our own play/sound buttons + timeline
// replace it. modestbranding+rel+iv_load_policy strip the rest.
const extras = 'enablejsapi=1&autoplay=1&mute=1&loop=1' +
'&playlist=' + (id || '') +
'&controls=0&playsinline=1&modestbranding=1&rel=0&iv_load_policy=3&disablekb=1' +
'&vq=hd2160&hd=1';
if (url && url.indexOf('/embed/') !== -1){
const sep = url.indexOf('?') !== -1 ? '&' : '?';
return url + sep + extras;
}
if (id) return `https://www.youtube.com/embed/${id}?${extras}`;
return null;
}
function sendYTCommand(func, args){
if (!videoIframe || !videoIframe.contentWindow) return;
try {
videoIframe.contentWindow.postMessage(JSON.stringify({
event: 'command', func: func, args: args || []
}), '*');
} catch(e) {}
}
const videoId = parseYouTubeId(cfg.video_url || '');
const docVideoId = docCfg ? parseYouTubeId(docCfg.video_url) : null;
const surfaceBg = document.getElementById('kk-surface-bg');
let videoIframe = null;
const projSrc = buildVideoSrc(cfg.video_url, videoId);
const docSrc = docCfg ? buildVideoSrc(docCfg.video_url, docVideoId) : null;
const initialSrc = (currentMode === 'doc') ? docSrc : projSrc;
if (initialSrc){
videoIframe = document.createElement('iframe');
videoIframe.id = 'kk-video-iframe';
videoIframe.src = initialSrc;
videoIframe.title = (currentMode === 'doc') ? 'ON_SITE documentary' : 'PROJECTION video';
videoIframe.setAttribute('frameborder', '0');
videoIframe.setAttribute('allow',
'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture');
videoIframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
videoIframe.setAttribute('allowfullscreen', '');
surfaceBg.appendChild(videoIframe);
}
// ---- FACADE INJECTION ----
const mount = document.getElementById('kk-facade-mount');
mount.innerHTML = svgText;
const svg = mount.querySelector('svg');
const vb = (svg && svg.getAttribute('viewBox') || '0 0 2048 3168').split(/\s+/).map(Number);
const VBX = vb[0] || 0, VBY = vb[1] || 0, VBW = vb[2] || 2048, VBH = vb[3] || 3168;
// ---- SURFACE ASPECT (projection mode) ----
// The projection surface takes the building's own aspect ratio, read from
// the facade.svg viewBox so a new project needs no template edit. A project
// may override with cfg.surface_aspect = [w, h] when the rendered video's
// frame differs from the facade artboard.
//
// The video is assumed to be published at the surface aspect and therefore
// pillar- or letterboxed inside YouTube's 16:9 player. We scale the iframe
// so the *content* area — not the player box — covers the surface exactly:
// pillarbox (a < 16/9): height 100%, width = (16/9)/a
// letterbox (a > 16/9): width 100%, height = (9/16)*a
(function applySurfaceAspect(){
const raw = Array.isArray(cfg.surface_aspect) && cfg.surface_aspect.length === 2
? cfg.surface_aspect.map(Number)
: [VBW, VBH];
const w = raw[0] > 0 ? raw[0] : 2048;
const h = raw[1] > 0 ? raw[1] : 3168;
const a = w / h, PLAYER = 16 / 9;
const zoomW = a <= PLAYER ? (PLAYER / a) * 100 : 100;
const zoomH = a <= PLAYER ? 100 : (a / PLAYER) * 100;
const st = document.createElement('style');
st.textContent = '.kk-surface[data-mode="projection"]{' +
'--surface-aspect-w:' + w + ';' +
'--surface-aspect-h:' + h + ';' +
'--video-zoom:' + zoomW.toFixed(3) + '%;' +
'--video-zoom-h:' + zoomH.toFixed(3) + '%;}';
document.head.appendChild(st);
})();
function ensureDefs(s){
let d = s.querySelector('defs');
if (!d){
d = document.createElementNS(SVG_NS, 'defs');
s.insertBefore(d, s.firstChild);
}
return d;
}
let stillOverlay = null;
if (svg){
const defs = ensureDefs(svg);
(cfg.zones || []).forEach(zone => {
if (Array.isArray(zone.bbox) && zone.bbox.length === 4){
const [x0,y0,x1,y1] = zone.bbox;
const clip = document.createElementNS(SVG_NS, 'clipPath');
clip.setAttribute('id', `kk-clip-${zone.id}`);
clip.setAttribute('clipPathUnits', 'userSpaceOnUse');
const r = document.createElementNS(SVG_NS, 'rect');
r.setAttribute('x', x0); r.setAttribute('y', y0);
r.setAttribute('width', x1 - x0);
r.setAttribute('height', y1 - y0);
clip.appendChild(r);
defs.appendChild(clip);
}
if (zone.mask){
const mask = document.createElementNS(SVG_NS, 'mask');
mask.setAttribute('id', `kk-mask-${zone.id}`);
mask.setAttribute('maskUnits', 'userSpaceOnUse');
mask.setAttribute('mask-type', 'alpha');
mask.setAttribute('x', VBX); mask.setAttribute('y', VBY);
mask.setAttribute('width', VBW); mask.setAttribute('height', VBH);
const maskImg = document.createElementNS(SVG_NS, 'image');
const maskUrl = assetUrl(zone.mask);
maskImg.setAttributeNS(XLINK_NS, 'href', maskUrl);
maskImg.setAttribute('href', maskUrl);
maskImg.setAttribute('x', VBX); maskImg.setAttribute('y', VBY);
maskImg.setAttribute('width', VBW); maskImg.setAttribute('height', VBH);
maskImg.setAttribute('preserveAspectRatio', 'xMidYMid slice');
mask.appendChild(maskImg);
defs.appendChild(mask);
}
});
// Insert overlay right after defs so the wireframe paints on top.
stillOverlay = document.createElementNS(SVG_NS, 'image');
stillOverlay.setAttribute('x', VBX);
stillOverlay.setAttribute('y', VBY);
stillOverlay.setAttribute('width', VBW);
stillOverlay.setAttribute('height', VBH);
stillOverlay.setAttribute('preserveAspectRatio', 'xMidYMid slice');
stillOverlay.setAttribute('class', 'kk-still-overlay');
defs.parentNode.insertBefore(stillOverlay, defs.nextSibling);
// Click targets last so they sit on top for click capture.
(cfg.zones || []).forEach(zone => {
if (!Array.isArray(zone.bbox) || zone.bbox.length !== 4) return;
const [x0,y0,x1,y1] = zone.bbox;
const t = document.createElementNS(SVG_NS, 'rect');
t.setAttribute('x', x0); t.setAttribute('y', y0);
t.setAttribute('width', x1 - x0);
t.setAttribute('height', y1 - y0);
t.setAttribute('class', 'kk-zone-target');
t.setAttribute('data-zone', zone.id);
t.addEventListener('click', () => activateZone(zone));
svg.appendChild(t);
});
}
// ---- CHIPS ----
const chips = document.getElementById('kk-chips');
(cfg.zones || []).forEach(zone => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'kk-chip';
btn.textContent = zone.label;
btn.dataset.zoneId = zone.id;
btn.addEventListener('click', () => activateZone(zone));
chips.appendChild(btn);
});
// ---- STATE ----
let activeFrame = null;
let activeZone = null;
// ---- WIRE / VIDEO toggles (independent) ----
const surfaceEl = document.getElementById('kk-surface');
surfaceEl.dataset.mode = currentMode;
document.querySelectorAll('.kk-toggle-btn[data-toggle]').forEach(btn => {
const layer = btn.dataset.toggle; // 'wire' or 'video'
btn.addEventListener('click', () => {
const cur = surfaceEl.dataset[layer];
const next = cur === 'on' ? 'off' : 'on';
surfaceEl.dataset[layer] = next;
btn.classList.toggle('active', next === 'on');
// If we just hid the wireframe, drop any active zone visuals.
if (layer === 'wire' && next === 'off') clearZone();
});
});
// ---- MODE SWITCH (PROJECTION / ON_SITE) — only when documentary configured ----
const modeSwitch = document.getElementById('kk-mode-switch');
const modeBtns = modeSwitch ? modeSwitch.querySelectorAll('.kk-mode-btn') : [];
if (docCfg && modeSwitch){
modeSwitch.hidden = false;
modeBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === currentMode);
btn.addEventListener('click', () => setMode(btn.dataset.mode));
});
}
function setMode(mode){
if (mode === currentMode) return;
if (mode !== 'doc' && mode !== 'projection') return;
// Snapshot the current playback moment (projection-clock seconds) BEFORE
// flipping currentMode so timeline + iframe both pick up there.
const projAtSwitch = estimatedProjSec();
currentMode = mode;
surfaceEl.dataset.mode = mode;
modeBtns.forEach(b => b.classList.toggle('active', b.dataset.mode === mode));
// Swap the iframe to the new video. Setting src to '' first detaches the
// previous YT player so we don't leak a second instance into the page.
// soundOn is preserved across switches — applySoundState() (wired to the
// iframe's load event) re-applies it once the new YT player is ready.
// The new src appends &start=N (seconds, integer) so playback resumes
// at the equivalent moment instead of restarting at 0. start= is YT's
// only reliably honoured initial-seek mechanism for embeds.
const baseSrc = (mode === 'doc') ? docSrc : projSrc;
const targetVideoSec = (mode === 'doc') ? (projAtSwitch + docOffset) : projAtSwitch;
const targetMaxDur = (mode === 'doc') ? docDuration : duration;
const startSec = Math.max(0,
Math.min(Math.floor(targetVideoSec),
Math.max(0, Math.floor(targetMaxDur) - 1)));
const startedSrc = baseSrc
? baseSrc + (baseSrc.indexOf('?') !== -1 ? '&' : '?') + 'start=' + startSec
: null;
if (videoIframe && startedSrc){
videoIframe.src = '';
videoIframe.src = startedSrc;
videoIframe.title = (mode === 'doc') ? 'ON_SITE documentary' : 'PROJECTION video';
videoPlaying = true;
if (playBtn) playBtn.textContent = '⏸';
if (soundBtn) soundBtn.textContent = soundOn ? '🔊' : '🔇';
}
// Active timeline row follows the active mode.
const docRow = document.getElementById('kk-track-row-doc');
const projRow = document.getElementById('kk-track-row-projection');
if (docRow) docRow.dataset.active = (mode === 'doc') ? 'true' : 'false';
if (projRow) projRow.dataset.active = (mode === 'projection') ? 'true' : 'false';
const labelEl = document.getElementById('kk-surface-label');
if (labelEl) labelEl.textContent = (mode === 'doc')
? ((docCfg && docCfg.label) || 'ON_SITE_DOC')
: (cfg.surface_label || 'SURFACE_MAP');
// The still-overlay clip-path is tied to the building coordinate space
// and won't make sense over crowd cameras, so drop any active zone.
if (mode === 'doc') clearZone();
// Re-anchor visualisation + wall clock at the switch point. The new YT
// player resumes from start=N on autoplay.
applyTime(projAtSwitch);
setProjTime(projAtSwitch);
}
// ---- PLAY / PAUSE / SOUND (via YT JS API postMessage) ----
// Sound default is ON. The iframe URL still carries mute=1 (browsers hard-
// block autoplay-with-sound on initial load) — we re-apply soundOn after
// the YT player loads, which actually takes effect once any user gesture
// has happened in the document. Mode switches preserve soundOn so audio
// continuity carries across the toggle.
const playBtn = document.getElementById('kk-play-pause');
const soundBtn = document.getElementById('kk-sound');
// Playback state is reported BY the player (see the YT state channel
// below), not assumed. It starts false so the playhead cannot run against
// a video that never autoplayed — the symptom when a browser refuses the
// autoplay grant. ytChannelLive flips once the player actually talks to us;
// if it never does, a fallback timer restores the optimistic assumption so
// the timeline still moves rather than sitting dead.
let videoPlaying = false;
let ytChannelLive = false;
let soundOn = true;
// Set while any track is being scrubbed, so the playhead clock yields to
// the drag instead of fighting it for the same element. Declared here so
// the async message handler below is never inside its temporal dead zone.
let scrubDragging = false;
// Track playback position so mode switches can resume at the equivalent
// timeline moment instead of restarting at zero. We don't poll the YT
// player for currentTime (no event-channel listener wired), so we
// estimate by anchoring at every committed seek and extrapolating from
// wall time while videoPlaying is true. The unit is projection-clock
// seconds — the doc track converts via docOffset on either side of any
// mode boundary.
let lastProjSec = 0;
let lastTickWall = Date.now();
function setProjTime(projSec){
lastProjSec = Math.max(0, projSec);
lastTickWall = Date.now();
}
function estimatedProjSec(){
if (!videoPlaying) return lastProjSec;
const dt = (Date.now() - lastTickWall) / 1000;
return lastProjSec + dt;
}
// ---- YT STATE CHANNEL ----
// The embed already carries enablejsapi=1, which means the player will
// post its own events back to us — we just have to open the channel by
// sending it a 'listening' handshake. This is the same protocol the
// official IFrame API library speaks, so we get player state and real
// playback position without loading an external script (the framework
// deliberately ships no third-party JS).
//
// Two messages matter:
// onStateChange -> 1 playing, 2 paused, 0 ended, 3 buffering, 5 cued
// infoDelivery -> carries currentTime a few times a second
// currentTime is authoritative: it removes the estimator's drift entirely
// and follows the loop (loop=1) back to zero on wrap, which a wall-clock
// estimate cannot do on its own.
function ytHandshake(){
if (!videoIframe || !videoIframe.contentWindow) return;
try {
videoIframe.contentWindow.postMessage(JSON.stringify({
event: 'listening', id: 'kk-player', channel: 'widget'
}), '*');
} catch(e) {}
}
function setPlayingFromPlayer(state){
// 1 = playing. Buffering (3) counts as playing so the playhead doesn't
// stutter on every network hiccup; everything else halts it.
if (state === 1 || state === 3) videoPlaying = true;
else if (state === 2 || state === 0 || state === 5 || state === -1) videoPlaying = false;
if (playBtn) playBtn.textContent = videoPlaying ? '⏸' : '▶';
}
window.addEventListener('message', e => {
// Only trust messages that actually came from the embedded player.
if (!/^https:\/\/(www\.)?youtube(-nocookie)?\.com$/.test(e.origin)) return;
if (!videoIframe || e.source !== videoIframe.contentWindow) return;
let data = e.data;
if (typeof data === 'string'){
try { data = JSON.parse(data); } catch(err) { return; }
}
if (!data || typeof data !== 'object') return;
ytChannelLive = true;
const info = data.info;
if (data.event === 'onStateChange' && typeof info === 'number'){
setPlayingFromPlayer(info);
}
if (data.event === 'infoDelivery' && info && typeof info === 'object'){
if (typeof info.playerState === 'number') setPlayingFromPlayer(info.playerState);
// Don't overwrite the clock mid-drag: the user owns the playhead then.
if (typeof info.currentTime === 'number' && !scrubDragging){
const projSec = (currentMode === 'doc')
? (info.currentTime - docOffset)
: info.currentTime;
setProjTime(projSec);
}
}
});
// Open the channel once the player is up, and keep knocking briefly in case
// the iframe wasn't ready for the first few. Stop as soon as it answers.
let ytKnocks = 0;
const ytKnocker = setInterval(() => {
if (ytChannelLive || ytKnocks++ > 40){ clearInterval(ytKnocker); return; }
ytHandshake();
}, 250);
if (videoIframe) videoIframe.addEventListener('load', ytHandshake);
// If the player never opens the channel (blocked postMessage, an embed
// variant that doesn't report), fall back to assuming playback so the
// timeline still advances instead of sitting frozen.
setTimeout(() => {
if (!ytChannelLive){
videoPlaying = true;
setProjTime(lastProjSec);
}
}, 3000);
playBtn.addEventListener('click', () => {
// Snapshot current estimate before flipping so the wall clock freezes
// on pause and resumes from the snapshot on play.
setProjTime(estimatedProjSec());
videoPlaying = !videoPlaying;
sendYTCommand(videoPlaying ? 'playVideo' : 'pauseVideo');
playBtn.textContent = videoPlaying ? '⏸' : '▶';
});
soundBtn.addEventListener('click', () => {
soundOn = !soundOn;
if (soundOn){
sendYTCommand('unMute');
sendYTCommand('setVolume', [100]);
} else {
sendYTCommand('mute');
}
soundBtn.textContent = soundOn ? '🔊' : '🔇';
});
// Re-apply sound state once the iframe has finished loading the YT player.
// Called on initial creation and every time setMode swaps src. The small
// timeout gives the YT player a moment to register its postMessage handler.
// Browsers grant autoplay only because the URL carries mute=1; if we send
// unMute before any user gesture has happened, the autoplay grant is
// revoked and the video pauses a few hundred ms in. So we gate unMute on
// firstGestureFired — the initial muted-autoplay stays muted until the
// viewer interacts at least once.
function applySoundState(){
setTimeout(() => {
if (!firstGestureFired){
// Pre-gesture: confirm muted state, never attempt unmute.
sendYTCommand('mute');
return;
}
if (soundOn){
sendYTCommand('unMute');
sendYTCommand('setVolume', [100]);
} else {
sendYTCommand('mute');
}
}, 400);
}
// Hint YT to serve the highest available tier on every iframe load. The
// call is advisory — YT's auto-quality picker may downgrade based on
// bandwidth or container pixel size — but firing it twice (early + late)
// catches the player both pre- and post-ready and tends to nudge it
// toward HD instead of 480/720p, which is helpful since each mode swap
// reloads the iframe and resets the quality decision.
function nudgeQuality(){
setTimeout(() => sendYTCommand('setPlaybackQuality', ['hd2160']), 600);
setTimeout(() => sendYTCommand('setPlaybackQuality', ['hd2160']), 1800);
}
if (videoIframe){
videoIframe.addEventListener('load', applySoundState);
videoIframe.addEventListener('load', nudgeQuality);
}
// Browser autoplay policy blocks unmute pre-gesture, so the initial
// applySoundState() may be silently ignored. Re-fire on the first user
// gesture so sound kicks in the moment the viewer touches anything.
let firstGestureFired = false;
function unmuteOnFirstGesture(){
if (firstGestureFired) return;
firstGestureFired = true;
document.removeEventListener('click', unmuteOnFirstGesture);
document.removeEventListener('keydown', unmuteOnFirstGesture);
document.removeEventListener('touchstart', unmuteOnFirstGesture);
if (soundOn){
sendYTCommand('unMute');
sendYTCommand('setVolume', [100]);
}
}
document.addEventListener('click', unmuteOnFirstGesture);
document.addEventListener('keydown', unmuteOnFirstGesture);
document.addEventListener('touchstart', unmuteOnFirstGesture, { passive: true });
// ---- FULLSCREEN ----
// Fullscreens the whole #kk-app container so mode switch + timeline stay
// accessible alongside the larger video. Cargo embed snippet already
// declares allow="fullscreen" so the inner request passes through.
const fullBtn = document.getElementById('kk-fullscreen');
const appEl = document.getElementById('kk-app');
function isFullscreen(){
return !!(document.fullscreenElement
|| document.webkitFullscreenElement
|| document.mozFullScreenElement
|| document.msFullscreenElement);
}
function enterFullscreen(el){
const fn = el.requestFullscreen
|| el.webkitRequestFullscreen
|| el.mozRequestFullScreen
|| el.msRequestFullscreen;
if (fn) fn.call(el);
}
function exitFullscreen(){
const fn = document.exitFullscreen
|| document.webkitExitFullscreen
|| document.mozCancelFullScreen
|| document.msExitFullscreen;
if (fn) fn.call(document);
}
if (fullBtn){
fullBtn.addEventListener('click', () => {
if (isFullscreen()) exitFullscreen();
else enterFullscreen(appEl);
});
['fullscreenchange','webkitfullscreenchange','mozfullscreenchange','msfullscreenchange']
.forEach(evt => document.addEventListener(evt, () => {
fullBtn.textContent = isFullscreen() ? '⤡' : '⤢';
}));
}
// ---- ZONE / OVERLAY ----
function clearZone(){
activeZone = null;
if (stillOverlay) stillOverlay.classList.remove('kk-shown');
if (svg) svg.querySelectorAll('.kk-zone-target')
.forEach(el => el.classList.remove('kk-active'));
chips.querySelectorAll('.kk-chip').forEach(c => c.classList.remove('kk-active'));
document.getElementById('kk-zone-readout').textContent = 'CLICK A ZONE TO INSPECT';
}
function activateZone(zone){
// Make sure the wireframe layer is visible so the still overlay shows.
if (surfaceEl.dataset.wire === 'off'){
surfaceEl.dataset.wire = 'on';
const wireBtn = document.querySelector('.kk-toggle-btn[data-toggle="wire"]');
if (wireBtn) wireBtn.classList.add('active');
}
activeZone = zone;
if (svg){
svg.querySelectorAll('.kk-zone-target').forEach(el =>
el.classList.toggle('kk-active', el.getAttribute('data-zone') === zone.id));
}
chips.querySelectorAll('.kk-chip').forEach(c =>
c.classList.toggle('kk-active', c.dataset.zoneId === zone.id));
document.getElementById('kk-zone-readout').textContent = zone.label;
renderOverlay();
}
function renderOverlay(){
if (!stillOverlay) return;
const frame = activeFrame || allFrames[0];
if (!frame || !activeZone){
stillOverlay.classList.remove('kk-shown');
return;
}
const url = assetUrl(frame.full || frame.thumb);
if (!url){
stillOverlay.classList.remove('kk-shown');
return;
}
stillOverlay.setAttributeNS(XLINK_NS, 'href', url);
stillOverlay.setAttribute('href', url);
if (activeZone.mask){
stillOverlay.removeAttribute('clip-path');
stillOverlay.setAttribute('mask', `url(#kk-mask-${activeZone.id})`);
} else {
stillOverlay.removeAttribute('mask');
stillOverlay.setAttribute('clip-path', `url(#kk-clip-${activeZone.id})`);
}
stillOverlay.classList.add('kk-shown');
}
// ---- STILLS PANEL ----
const grid = document.getElementById('kk-stills-grid');
const countEl = document.getElementById('kk-stills-count');
const allFrames = (manifest && Array.isArray(manifest.frames)) ? manifest.frames : [];
const total = allFrames.length;
let io = null;
function getObserver(){
if (io) return io;
io = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
if (img.dataset.src){
img.addEventListener('load', () => img.classList.add('kk-loaded'), { once: true });
img.src = img.dataset.src;
img.removeAttribute('data-src');
}
io.unobserve(img);
});
}, { rootMargin: '200px', root: grid });
return io;
}
let activeStillEl = null;
function setActiveStillEl(card){
if (activeStillEl) activeStillEl.classList.remove('kk-still-active');
activeStillEl = card;
if (card) card.classList.add('kk-still-active');
}
// ---- HOVER PREVIEW (desktop only) ----
// On hover, show a larger version of the still in a fixed-position popup
// anchored to the left edge of the stills panel. Disabled on touch-only
// devices where hover doesn't behave reliably.
const previewEl = document.getElementById('kk-still-preview');
const previewImg = previewEl ? previewEl.querySelector('img') : null;
const previewIdEl = previewEl ? previewEl.querySelector('.kk-still-preview-id') : null;
const previewTcEl = previewEl ? previewEl.querySelector('.kk-still-preview-time') : null;
const canHover = (typeof window.matchMedia === 'function')
&& window.matchMedia('(hover: hover)').matches;
function showStillPreview(frame){
if (!previewEl || !previewImg) return;
const url = assetUrl(frame.full || frame.thumb);
if (!url) return;
previewImg.src = url;
if (previewIdEl) previewIdEl.textContent = frame.id || '';
if (previewTcEl) previewTcEl.textContent =
(typeof frame.timestamp_seconds === 'number') ? fmtMMSS(frame.timestamp_seconds) : '';
previewEl.hidden = false;
// Position to the LEFT of the stills panel, top-aligned with it. If
// there's not enough room (narrow viewport), fall back to viewport
// top-left so it never overflows offscreen on the right side.
const stillsRect = grid.getBoundingClientRect();
const w = previewEl.offsetWidth || 380;
let left = stillsRect.left - w - 12;
if (left < 12) left = 12;
previewEl.style.left = left + 'px';
previewEl.style.top = Math.max(12, stillsRect.top) + 'px';
}
function hideStillPreview(){
if (previewEl) previewEl.hidden = true;
}
function renderStills(){
grid.innerHTML = '';
activeStillEl = null;
countEl.textContent = `${allFrames.length} / ${total}`;
if (allFrames.length === 0){
const empty = document.createElement('div');
empty.className = 'kk-stills-empty';
empty.textContent = 'NO STILLS INDEXED';
grid.appendChild(empty);
return;
}
const obs = getObserver();
allFrames.forEach(frame => {
const card = document.createElement('div');
card.className = 'kk-still';
const url = assetUrl(frame.thumb);
if (url){
const img = document.createElement('img');
img.alt = frame.id || '';
img.dataset.src = url;
card.appendChild(img);
obs.observe(img);
}
const lbl = document.createElement('div');
lbl.className = 'kk-still-label';
lbl.textContent = frame.id || '';
card.appendChild(lbl);
if (typeof frame.timestamp_seconds === 'number'){
const tc = document.createElement('div');
tc.className = 'kk-still-time';
tc.textContent = fmtMMSS(frame.timestamp_seconds);
card.appendChild(tc);
}
card.addEventListener('click', () => activateStill(frame, card));
if (canHover){
card.addEventListener('mouseenter', () => showStillPreview(frame));
card.addEventListener('mouseleave', hideStillPreview);
}
grid.appendChild(card);
});
}
function activateStill(frame, card){
activeFrame = frame;
setActiveStillEl(card);
const ts = frame.timestamp_seconds;
if (typeof ts === 'number'){
applyTime(ts);
sendYTCommand('seekTo', [activeVideoSec(ts), true]);
setProjTime(ts);
}
if (activeZone) renderOverlay();
}
renderStills();
// ---- TIMELINE ----
// Single source of truth: projection-clock seconds (the projection piece's
// local time, 0..duration). Both timeline strips visualise it; the doc
// strip applies docOffset internally so the same still cues land at the
// right offset on each track. Drag/click handlers per track translate
// their local pct back to projection-seconds so seekTo can be issued
// against whichever video is currently active (active mode determines
// which video receives the seek; activeVideoSec adds docOffset when in
// doc mode).
const duration = Math.max(1, cfg.duration_seconds || 0);
const bpm = Math.max(1, cfg.bpm || 120);
const totalBars = Math.max(1, Math.round((duration * bpm) / 60 / 4));
const totalStr = fmtMMSS(duration);
const docTotalStr = fmtMMSS(docDuration);
const timeReadout = document.getElementById('kk-time-readout');
const projPlayhead = document.getElementById('kk-playhead');
const projProgress = document.getElementById('kk-progress');
const projTrackEl = document.getElementById('kk-scrub-track');
const docPlayhead = document.getElementById('kk-playhead-doc');
const docProgress = document.getElementById('kk-progress-doc');
const docTrackEl = document.getElementById('kk-scrub-track-doc');
const projLenEl = document.getElementById('kk-track-len-projection');
const docLenEl = document.getElementById('kk-track-len-doc');
if (projLenEl) projLenEl.textContent = totalStr;
if (docLenEl) docLenEl.textContent = docTotalStr;
function buildTimelineTrack(beatsId, axisId, dur, cueOffset){
const beatsEl = document.getElementById(beatsId);
const axisEl = document.getElementById(axisId);
if (!beatsEl || !axisEl) return;
beatsEl.innerHTML = '';
axisEl.innerHTML = '';
const tickCount = 33;
for (let i = 0; i < tickCount; i++){
const t = document.createElement('div');
t.className = 'kk-tick ' + (i % 4 === 0 ? 'major' : 'minor');
t.style.left = ((i / (tickCount - 1)) * 100) + '%';
beatsEl.appendChild(t);
}
allFrames.forEach(frame => {
const ts = frame.timestamp_seconds;
if (typeof ts !== 'number' || dur <= 0) return;
const pos = ts + (cueOffset || 0);
if (pos < 0 || pos > dur) return;
const m = document.createElement('div');
m.className = 'kk-still-mark';
m.style.left = (clamp01(pos / dur) * 100) + '%';
beatsEl.appendChild(m);
});
const labelStops = 5;
for (let i = 0; i < labelStops; i++){
const span = document.createElement('span');
span.textContent = fmtMMSS(Math.round(dur * (i / (labelStops - 1))));
axisEl.appendChild(span);
}
}
buildTimelineTrack('kk-beats', 'kk-time-axis', duration, 0);
if (docCfg){
document.getElementById('kk-track-row-doc').hidden = false;
buildTimelineTrack('kk-beats-doc', 'kk-time-axis-doc', docDuration, docOffset);
}
function applyTime(projSec){
projSec = Math.max(0, projSec);
const projPct = clamp01(projSec / duration);
if (projPlayhead) projPlayhead.style.left = (projPct * 100) + '%';
if (projProgress) projProgress.style.width = (projPct * 100) + '%';
if (docCfg){
const docPct = clamp01((projSec + docOffset) / docDuration);
if (docPlayhead) docPlayhead.style.left = (docPct * 100) + '%';
if (docProgress) docProgress.style.width = (docPct * 100) + '%';
}
const bar = Math.min(totalBars, Math.floor(projPct * totalBars) + 1);
let displaySec, displayTotalStr;
if (currentMode === 'doc'){
displaySec = Math.floor(clamp01((projSec + docOffset) / docDuration) * docDuration);
displayTotalStr = docTotalStr;
} else {
displaySec = Math.floor(projPct * duration);
displayTotalStr = totalStr;
}
timeReadout.textContent =
`${fmtMMSS(displaySec)} / ${displayTotalStr} · BAR ${String(bar).padStart(3,'0')}`;
}
function pctFromEvent(trackEl, e){
const r = trackEl.getBoundingClientRect();
const cx = (e.clientX !== undefined) ? e.clientX
: (e.touches && e.touches[0] ? e.touches[0].clientX : 0);
return clamp01((cx - r.left) / r.width);
}
function isTrackActive(trackEl){
const row = trackEl.closest('.kk-track-row');
return !row || row.dataset.active !== 'false';
}
function activeVideoSec(projSec){
return (currentMode === 'doc') ? (projSec + docOffset) : projSec;
}
function attachTrackHandlers(trackEl, pctToProjSec){
if (!trackEl) return;
let dragging = false;
function seekFromPct(pct){
const projSec = pctToProjSec(pct);
applyTime(projSec);
sendYTCommand('seekTo', [activeVideoSec(projSec), true]);
setProjTime(projSec);
}
trackEl.addEventListener('mousedown', e => {
if (!isTrackActive(trackEl)) return;
dragging = true; scrubDragging = true;
applyTime(pctToProjSec(pctFromEvent(trackEl, e)));
});
window.addEventListener('mousemove', e => {
if (dragging) applyTime(pctToProjSec(pctFromEvent(trackEl, e)));
});
window.addEventListener('mouseup', e => {
if (!dragging) return;
dragging = false; scrubDragging = false;
seekFromPct(pctFromEvent(trackEl, e));
});
trackEl.addEventListener('touchstart', e => {
if (!isTrackActive(trackEl)) return;
dragging = true; scrubDragging = true;
applyTime(pctToProjSec(pctFromEvent(trackEl, e)));
}, {passive:true});
window.addEventListener('touchmove', e => {
if (dragging) applyTime(pctToProjSec(pctFromEvent(trackEl, e)));
}, {passive:true});
window.addEventListener('touchend', e => {
if (!dragging) return;
dragging = false; scrubDragging = false;
const t = e.changedTouches && e.changedTouches[0];
if (t) seekFromPct(pctFromEvent(trackEl, t));
});
}
attachTrackHandlers(projTrackEl, pct => duration * pct);
if (docCfg) attachTrackHandlers(docTrackEl, pct => (docDuration * pct) - docOffset);
// Initial track-active state must match the runtime default mode (the HTML
// defaults projection=active, but when documentary is configured the
// runtime default is doc).
if (docCfg){
const docRow0 = document.getElementById('kk-track-row-doc');
const projRow0 = document.getElementById('kk-track-row-projection');
if (docRow0) docRow0.dataset.active = (currentMode === 'doc') ? 'true' : 'false';
if (projRow0) projRow0.dataset.active = (currentMode === 'projection') ? 'true' : 'false';
}
applyTime(0);
setProjTime(0);
// ---- PLAYHEAD CLOCK ----
// applyTime() only runs on discrete events (scrub, still click, mode
// switch), so without a ticker the playhead sits still while the video
// plays. estimatedProjSec() already extrapolates the current position from
// the last committed seek plus wall time; this drives the render from it.
//
// rAF rather than setInterval: it suspends in background tabs, and because
// the estimate is wall-clock based the playhead snaps straight to the right
// position when the tab is foregrounded again rather than replaying a
// backlog. The repaint is gated on ~50ms of real movement so a 120Hz
// display doesn't write the same percentage every frame.
//
// Position is estimated, not read back from YouTube: there is no event
// listener on the player, so a viewer pausing via YouTube's own controls
// (rather than this UI's play button) will drift until the next seek.
(function startPlayheadClock(){
let lastPainted = -1;
function tick(){
requestAnimationFrame(tick);
if (!videoPlaying || scrubDragging) return;
const sec = Math.min(estimatedProjSec(), duration);
if (Math.abs(sec - lastPainted) < 0.05) return;
lastPainted = sec;
applyTime(sec);
}
requestAnimationFrame(tick);
})();
// ---- SURFACE LABEL ----
document.getElementById('kk-surface-label').textContent =
(currentMode === 'doc')
? ((docCfg && docCfg.label) || 'ON_SITE_DOC')
: (cfg.surface_label || 'SURFACE_MAP');
// ---- CREDITS ----
const credEl = document.getElementById('kk-credits');
const credTitle = (cfg.credits_title || '').trim();
const credDesc = (cfg.description || '').trim();
const credLines = Array.isArray(cfg.credits_lines) ? cfg.credits_lines : [];
const hasCreditsContent = credTitle || credDesc || credLines.length;
// hide_credits no longer means "never build it" — it means "start closed".
// The panel is populated whenever there is content so a CREDITS bottom link
// has something to reveal; with no content at all it stays permanently off.
credEl.hidden = true;
if (hasCreditsContent){
if (credTitle){
const h = document.createElement('div');
h.className = 'kk-credits-title';
h.textContent = credTitle;
credEl.appendChild(h);
}
if (credDesc){
const p = document.createElement('div');
p.className = 'kk-credits-desc';
p.textContent = credDesc;
credEl.appendChild(p);
}
if (credLines.length){
const wrap = document.createElement('div');
wrap.className = 'kk-credits-lines';
credLines.forEach(line => {
const d = document.createElement('div');
d.textContent = line;
wrap.appendChild(d);
});
credEl.appendChild(wrap);
}
if (!cfg.hide_credits) credEl.hidden = false;
}
// ---- ZONE CONTEXT / INSTRUCTIONS ----
// Sits under the stills grid as a usage hint. Project-overridable via
// cfg.instructions; falls back to a generic line otherwise.
const ctxEl = document.getElementById('kk-zone-context');
if (ctxEl){
ctxEl.textContent = cfg.instructions
|| 'Click a zone to view that area as a still over the video. Click a still to scrub the timeline.';
}
// ---- HEADER LOGO ----
// studio_logo lives in the top-left of the header at top-bar height.
// If unset, the slot collapses (CSS :empty rule) so other projects
// without a logo don't see a gap.
const headerLogoEl = document.getElementById('kk-header-logo');
if (headerLogoEl && cfg.studio_logo){
const logo = document.createElement('img');
logo.src = assetUrl(cfg.studio_logo);
logo.alt = cfg.studio_name || '';
headerLogoEl.appendChild(logo);
}
// ---- FOOTER STUDIO MARK ----
// Plain text fallback in the footer right corner. Source is studio_name;
// empty / unset leaves the corner blank.
const studioEl = document.getElementById('kk-footer-studio');
if (studioEl && cfg.studio_name){
studioEl.textContent = cfg.studio_name;
}
// ---- BOTTOM LINKS ----
const links = document.getElementById('kk-footer-links');
(cfg.bottom_links || []).forEach(link => {
const label = link.label || '';
// A link with no url whose label reads CREDITS drives the credits panel
// rather than dead-ending on '#': it opens the panel and scrolls to it,
// or closes it again. Projects that set hide_credits keep a minimal
// player on load while still exposing the text on demand. Without
// credits content the entry is skipped so no dead label ships.
const isCreditsToggle = !link.url && /credit/i.test(label);
if (isCreditsToggle && !hasCreditsContent) return;
const a = document.createElement('a');
a.className = 'kk-link';
a.textContent = label;
if (isCreditsToggle){
a.href = '#';
a.setAttribute('aria-expanded', String(!credEl.hidden));
a.addEventListener('click', e => {
e.preventDefault();
credEl.hidden = !credEl.hidden;
a.setAttribute('aria-expanded', String(!credEl.hidden));
if (!credEl.hidden) credEl.scrollIntoView({behavior:'smooth', block:'nearest'});
});
} else {
a.href = link.url || '#';
if (link.url) a.target = '_blank';
}
links.appendChild(a);
});
})();