Skip to content

Commit 4c50d7e

Browse files
committed
feat: make KERN macro optics live and phase-locked
1 parent c41023f commit 4c50d7e

3 files changed

Lines changed: 44 additions & 21 deletions

File tree

examples/kern-calibration/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ An original cinematic example website for the `cinematic-scroll` skill.
1313
- Generated precision-mechanical hero imagery converted into a seamless 11.667-second, 30 fps, 1080p locked-camera loop. The outer housing stays pixel-stable while the inner carrier, escapement and counter-rotating pinions move at exact integer loop ratios; the coil and emitter pulse without moving the camera.
1414
- Velocity-reactive playback: scrolling accelerates the loop up to ~1.9×, easing back to 1× at rest; the loops pause off-screen and resume in sync.
1515
- Muted inline autoplay plus pointer, touch, wheel, scroll, visibility and page-show recovery hooks.
16-
- Five selectable mechanical systems with live readings and macro crops.
16+
- Five selectable mechanical systems with live, phase-locked macro video crops and matching readings.
1717
- Procedural Web Audio ambience behind an explicit user-controlled sound toggle.
1818
- A complete website after the hero: system catalogue, calibration protocol and closing chapter.
1919
- Mobile and `prefers-reduced-motion` fallbacks.

examples/kern-calibration/index.html

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
.index{position:absolute;right:clamp(18px,3vw,42px);top:19%;z-index:20;text-align:right}.index .num{font:clamp(3.5rem,9vw,8.6rem)/.75 Arial,sans-serif;letter-spacing:-.08em;color:rgba(21,27,27,.09)}.index small{display:block;font:9px/1.4 "Courier New",monospace;letter-spacing:.18em;text-transform:uppercase;margin-top:11px}
4747
.panel{position:absolute;z-index:30;width:min(265px,25vw);border-top:1px solid var(--ink);padding-top:10px;backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);background:linear-gradient(to bottom,var(--panel),rgba(234,233,226,.15));opacity:0;transform:translateY(18px);will-change:transform,opacity}.panel-left{left:clamp(18px,3vw,44px);top:18%}.panel-right{right:clamp(18px,3vw,44px);bottom:16%}.panel-head{display:flex;justify-content:space-between;font:8px/1.2 "Courier New",monospace;letter-spacing:.16em;text-transform:uppercase}.blink{display:inline-flex;align-items:center;gap:7px}.blink:before{content:"";width:5px;height:5px;border-radius:50%;background:var(--cyan);box-shadow:0 0 10px var(--cyan)}.panel h2{font-size:clamp(17px,1.8vw,25px);font-weight:500;line-height:1;margin:24px 0 7px}.latin{font:italic 11px/1.3 Georgia,serif;color:var(--muted)}.metrics{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:20px}.metric{border-top:1px solid var(--line);padding-top:7px}.metric b{font:18px/1 Arial,sans-serif}.metric span{display:block;font:7px/1.2 "Courier New",monospace;letter-spacing:.12em;margin-top:5px;color:var(--muted)}
4848
.specimen-list{display:grid;gap:0;margin-top:18px;border-top:1px solid var(--line)}.specimen-btn{border:0;border-bottom:1px solid var(--line);background:transparent;padding:9px 0;display:flex;justify-content:space-between;text-align:left;cursor:pointer;font:8px/1.2 "Courier New",monospace;letter-spacing:.09em;text-transform:uppercase}.specimen-btn.active{color:#007e73}.specimen-btn.active:after{content:"●";color:var(--cyan)}
49-
.macro{height:125px;margin-top:16px;border:1px solid var(--line-hi);overflow:hidden;position:relative;background:#aeb1a9}.macro img{width:100%;height:100%;object-fit:cover;transform:scale(var(--mz,2.7));transform-origin:var(--mx,44%) var(--my,52%);filter:saturate(1.12) contrast(1.08);transition:transform .9s var(--ease),transform-origin .9s var(--ease)}.macro:before,.macro:after{content:"";position:absolute;z-index:2;pointer-events:none}.macro:before{inset:9px;border:1px solid rgba(0,230,210,.76)}.macro:after{left:50%;top:50%;width:14px;height:14px;border:1px solid var(--cyan);border-radius:50%;transform:translate(-50%,-50%)}.macro-meta{display:flex;justify-content:space-between;margin-top:7px;font:7px/1 "Courier New",monospace;letter-spacing:.1em;text-transform:uppercase}
49+
.macro{height:125px;margin-top:16px;border:1px solid var(--line-hi);overflow:hidden;position:relative;background:#aeb1a9}.macro video{display:block;width:100%;height:100%;object-fit:cover;transform:scale(var(--mz,2.7));transform-origin:var(--mx,44%) var(--my,52%);filter:saturate(1.12) contrast(1.08);transition:transform .9s var(--ease),transform-origin .9s var(--ease);will-change:transform}.macro:before,.macro:after{content:"";position:absolute;z-index:2;pointer-events:none}.macro:before{inset:9px;border:1px solid rgba(0,230,210,.76)}.macro:after{left:50%;top:50%;width:14px;height:14px;border:1px solid var(--cyan);border-radius:50%;transform:translate(-50%,-50%)}.macro-meta{display:flex;justify-content:space-between;margin-top:7px;font:7px/1 "Courier New",monospace;letter-spacing:.1em;text-transform:uppercase}
5050
.scroll-cue{position:absolute;right:clamp(18px,3vw,42px);bottom:clamp(22px,4.5vh,54px);z-index:20;display:flex;align-items:center;gap:10px;font:8px/1 "Courier New",monospace;letter-spacing:.17em;text-transform:uppercase}.scroll-cue i{width:42px;height:1px;background:var(--ink);display:block;transform-origin:left;animation:pulse 2.4s var(--ease-scan) infinite}@keyframes pulse{50%{transform:scaleX(.25);opacity:.4}}
5151
.section{padding:clamp(80px,13vw,170px) clamp(18px,5vw,72px);border-top:1px solid var(--line);position:relative}.section-tag{font:9px/1 "Courier New",monospace;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}.section h2{font-size:clamp(2.7rem,7vw,7rem);font-weight:500;line-height:.86;letter-spacing:-.065em;text-transform:uppercase;margin:.25em 0}.section-lede{font-size:clamp(1rem,1.7vw,1.4rem);line-height:1.45;max-width:46ch;color:#424b49}.catalogue{background:#171d1d;color:#e9e9e1}.catalogue .section-tag,.catalogue .section-lede{color:#929b98}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:rgba(255,255,255,.13);margin-top:clamp(44px,7vw,90px);border:1px solid rgba(255,255,255,.13)}.card{background:#171d1d;padding:clamp(20px,3vw,36px);min-height:310px;position:relative;overflow:hidden}.card-num{font:9px/1 "Courier New",monospace;letter-spacing:.18em;color:var(--cyan)}.card h3{font:500 clamp(1.5rem,3vw,3rem)/.9 Arial,sans-serif;letter-spacing:-.05em;text-transform:uppercase;margin:90px 0 13px}.card p{font-size:12px;line-height:1.5;color:#909997;max-width:28ch}.card-orbit{position:absolute;width:150px;height:150px;border:1px solid rgba(0,230,210,.17);border-radius:50%;right:-55px;top:-55px}.card-orbit:after{content:"";position:absolute;inset:28px;border:1px dashed rgba(0,230,210,.35);border-radius:50%}.card-orbit{transition:transform 1.2s var(--ease)}
5252
.method-grid{display:grid;grid-template-columns:.8fr 1.2fr;gap:clamp(40px,9vw,140px);align-items:start}.steps{border-top:1px solid var(--ink)}.step{display:grid;grid-template-columns:46px 1fr;gap:20px;border-bottom:1px solid var(--line);padding:23px 0}.step b{font:10px/1 "Courier New",monospace;color:#008b7e}.step h3{font-size:clamp(1.1rem,2vw,1.7rem);margin:0 0 7px;font-weight:500}.step p{font-size:12px;line-height:1.5;color:var(--muted);margin:0;max-width:46ch}.final{min-height:90svh;display:grid;place-items:center;text-align:center;background:var(--paper-hi)}.final h2{max-width:12ch}.final a{display:inline-flex;margin-top:25px;border:1px solid var(--ink);padding:15px 22px;text-decoration:none;font:9px/1 "Courier New",monospace;letter-spacing:.16em;text-transform:uppercase;transition:background .3s var(--ease),color .3s var(--ease)}
@@ -68,7 +68,7 @@
6868
<div class="index" aria-hidden="true"><div class="num">01</div><small>Tolerance ±0.002 μm<br/>Cycle 11.667 s</small></div>
6969
<div class="specimen-wrap" aria-hidden="true"><div class="specimen-stage"><video class="specimen base" src="./assets/kern-mono.mp4" poster="./assets/kern-calibration-unit.png" muted loop playsinline webkit-playsinline autoplay preload="auto" disablepictureinpicture></video><video class="specimen scan" src="./assets/kern-color.mp4" poster="./assets/kern-calibration-unit.png" muted loop playsinline webkit-playsinline autoplay preload="auto" disablepictureinpicture></video><div class="scan-plane"></div><div class="bloom-ring"></div><div class="bloom-ring"></div><div class="bloom-ring"></div><div class="bloom-ring"></div><div class="bloom-ring"></div><div class="target"><i></i></div><div class="connector"></div></div></div>
7070
<aside class="panel panel-left" aria-label="Calibration data"><div class="panel-head"><span>System index</span><span class="blink">Scan active</span></div><h2 id="speciesName">Copper coil</h2><div class="latin" id="speciesLatin">Electromagnetic impulse stage</div><div class="metrics"><div class="metric"><b id="metricVital">96</b><span>Stability</span></div><div class="metric"><b id="metricSignal">91</b><span>Torque</span></div><div class="metric"><b id="metricRisk">04</b><span>Drift</span></div></div><div class="specimen-list" role="list"><button class="specimen-btn active" data-index="0" type="button">01 Copper coil</button><button class="specimen-btn" data-index="1" type="button">02 Outer gimbal</button><button class="specimen-btn" data-index="2" type="button">03 Escapement</button><button class="specimen-btn" data-index="3" type="button">04 Ruby bearing</button><button class="specimen-btn" data-index="4" type="button">05 Cyan emitter</button></div></aside>
71-
<aside class="panel panel-right" aria-label="Macro optical view"><div class="panel-head"><span>Macro optics</span><span id="plateLabel">Plate 01/A</span></div><div class="macro"><img src="./assets/kern-calibration-unit.png" alt="Magnified precision mechanism" /></div><div class="macro-meta"><span id="magnification">MAG 4.8×</span><span>Tracking locked</span></div></aside>
71+
<aside class="panel panel-right" aria-label="Macro optical view"><div class="panel-head"><span>Macro optics</span><span id="plateLabel">Plate 01/A</span></div><div class="macro"><video class="macro-video" src="./assets/kern-color.mp4" poster="./assets/kern-calibration-unit.png" muted loop playsinline webkit-playsinline autoplay preload="auto" disablepictureinpicture aria-label="Live magnified precision mechanism"></video></div><div class="macro-meta"><span id="magnification">MAG 4.8×</span><span>Live / phase locked</span></div></aside>
7272
<div class="scroll-cue" aria-hidden="true"><i></i>Scroll to calibrate</div>
7373
</div>
7474
</section>
@@ -81,6 +81,7 @@
8181
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
8282
const track = document.querySelector('.hero-track'), scan = document.querySelector('.specimen.scan');
8383
const monoVideo = document.querySelector('video.specimen.base'), colorVideo = document.querySelector('video.specimen.scan');
84+
const macroVideo = document.querySelector('video.macro-video');
8485
const plane = document.querySelector('.scan-plane'), stage = document.querySelector('.specimen-stage');
8586
const target = document.querySelector('.target'), connector = document.querySelector('.connector');
8687
const heroTitle = document.querySelector('.hero-title');
@@ -139,25 +140,30 @@
139140
}
140141
function ping(freq=310){if(!audioCtx)return;const o=audioCtx.createOscillator(),g=audioCtx.createGain();o.frequency.value=freq;o.type='sine';g.gain.setValueAtTime(.025,audioCtx.currentTime);g.gain.exponentialRampToValueAtTime(.0001,audioCtx.currentTime+.22);o.connect(g).connect(audioCtx.destination);o.start();o.stop(audioCtx.currentTime+.23)}
141142
document.querySelectorAll('.specimen-btn').forEach(b=>b.addEventListener('click',()=>setSpecimen(+b.dataset.index,true)));
142-
function syncVideos(){
143-
if(monoVideo.readyState<2||colorVideo.readyState<2||!monoVideo.duration||!colorVideo.duration)return;
144-
const duration=Math.min(monoVideo.duration,colorVideo.duration);
145-
const raw=monoVideo.currentTime-colorVideo.currentTime;
143+
function syncFollower(video,duration){
144+
if(video.readyState<2||!video.duration)return;
145+
const raw=monoVideo.currentTime-video.currentTime;
146146
const delta=((raw+duration/2)%duration+duration)%duration-duration/2;
147-
colorVideo.playbackRate=monoVideo.playbackRate+Math.max(-.2,Math.min(.2,delta*1.6));
148-
if(monoVideo.paused!==colorVideo.paused){monoVideo.paused?colorVideo.pause():colorVideo.play().catch(()=>{});}
147+
video.playbackRate=monoVideo.playbackRate+Math.max(-.2,Math.min(.2,delta*1.6));
148+
if(monoVideo.paused!==video.paused){monoVideo.paused?video.pause():video.play().catch(()=>{});}
149+
}
150+
function syncVideos(){
151+
if(monoVideo.readyState<2||colorVideo.readyState<2||macroVideo.readyState<2||!monoVideo.duration)return;
152+
const duration=Math.min(monoVideo.duration,colorVideo.duration,macroVideo.duration);
153+
syncFollower(colorVideo,duration);
154+
syncFollower(macroVideo,duration);
149155
}
150156
const whenReady=(video)=>video.readyState>=2?Promise.resolve():new Promise(resolve=>video.addEventListener('loadeddata',resolve,{once:true}));
151157
let startPromise=null;
152158
function startVideos(){
153-
if(reduce){monoVideo.pause();colorVideo.pause();try{monoVideo.currentTime=0;colorVideo.currentTime=0}catch(_){}return Promise.resolve();}
154-
if(!monoVideo.paused&&!colorVideo.paused&&monoVideo.readyState>=2&&colorVideo.readyState>=2)return Promise.resolve();
159+
if(reduce){[monoVideo,colorVideo,macroVideo].forEach(video=>{video.pause();try{video.currentTime=0}catch(_){}});return Promise.resolve();}
160+
if([monoVideo,colorVideo,macroVideo].every(video=>!video.paused&&video.readyState>=2))return Promise.resolve();
155161
if(startPromise)return startPromise;
156162
startPromise=(async()=>{
157-
monoVideo.defaultMuted=colorVideo.defaultMuted=true;
158-
await Promise.all([whenReady(monoVideo),whenReady(colorVideo)]);
159-
if(Math.abs(monoVideo.currentTime-colorVideo.currentTime)>.08)colorVideo.currentTime=monoVideo.currentTime;
160-
await Promise.all([monoVideo.play(),colorVideo.play()]);
163+
[monoVideo,colorVideo,macroVideo].forEach(video=>video.defaultMuted=true);
164+
await Promise.all([monoVideo,colorVideo,macroVideo].map(whenReady));
165+
[colorVideo,macroVideo].forEach(video=>{if(Math.abs(monoVideo.currentTime-video.currentTime)>.08)video.currentTime=monoVideo.currentTime});
166+
await Promise.all([monoVideo,colorVideo,macroVideo].map(video=>video.play()));
161167
syncVideos();
162168
})().catch(()=>{/* posters remain visible until browser permits playback */}).finally(()=>{startPromise=null});
163169
return startPromise;
@@ -177,11 +183,11 @@
177183
document.addEventListener('visibilitychange',()=>{if(!document.hidden&&heroVisible)startVideos()});
178184
addEventListener('pageshow',startVideos,{once:true});
179185
// Persistent recovery: any interaction restarts a stalled loop while the hero is on screen.
180-
['pointerdown','touchstart','wheel','scroll'].forEach(type=>addEventListener(type,()=>{if(heroVisible&&!reduce&&(monoVideo.paused||colorVideo.paused))startVideos()},{passive:true}));
181-
// Decode budget: pause both loops when the hero leaves the viewport, resume in sync on return.
186+
['pointerdown','touchstart','wheel','scroll'].forEach(type=>addEventListener(type,()=>{if(heroVisible&&!reduce&&[monoVideo,colorVideo,macroVideo].some(video=>video.paused))startVideos()},{passive:true}));
187+
// Decode budget: pause all synchronized loops when the hero leaves the viewport, resume in sync on return.
182188
new IntersectionObserver(es=>{heroVisible=es[0].isIntersecting;
183189
if(reduce)return;
184-
if(heroVisible)startVideos();else{monoVideo.pause();colorVideo.pause();}
190+
if(heroVisible)startVideos();else [monoVideo,colorVideo,macroVideo].forEach(video=>video.pause());
185191
},{rootMargin:'15%'}).observe(stage);
186192
startVideos();
187193
const smooth=t=>t<=0?0:t>=1?1:t*t*(3-2*t);

examples/kern-calibration/test-mobile.mjs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ try {
1515
const wrap = document.querySelector('.specimen-wrap').getBoundingClientRect();
1616
const zoom = document.querySelector('.panel-right').getBoundingClientRect();
1717
const videos = [...document.querySelectorAll('video.specimen')];
18+
const macroVideo = document.querySelector('video.macro-video');
1819
const overlapWidth = Math.max(0, Math.min(wrap.right, zoom.right) - Math.max(wrap.left, zoom.left));
1920
const overlapHeight = Math.max(0, Math.min(wrap.bottom, zoom.bottom) - Math.max(wrap.top, zoom.top));
2021
return {
@@ -26,6 +27,12 @@ try {
2627
readyState: video.readyState,
2728
currentTime: video.currentTime,
2829
})),
30+
macroVideo: macroVideo && {
31+
paused: macroVideo.paused,
32+
readyState: macroVideo.readyState,
33+
currentTime: macroVideo.currentTime,
34+
duration: macroVideo.duration,
35+
},
2936
};
3037
});
3138

@@ -34,12 +41,22 @@ try {
3441
assert.ok(video.readyState >= 2, `mobile hero video is not decodable: readyState ${video.readyState}`);
3542
}
3643
assert.ok(state.overlapArea >= state.zoom.area * 0.8, `macro optics must visually share the mobile hero: overlap ${state.overlapArea}/${state.zoom.area}`);
44+
assert.ok(state.macroVideo, 'macro optics must use the live mechanical video, not a static image crop');
45+
assert.equal(state.macroVideo.paused, false, 'macro optics video must autoplay');
46+
assert.ok(state.macroVideo.readyState >= 2, `macro optics video is not decodable: readyState ${state.macroVideo.readyState}`);
3747

3848
const before = state.videos.map((video) => video.currentTime);
3949
await page.waitForTimeout(900);
40-
const after = await page.evaluate(() => [...document.querySelectorAll('video.specimen')].map((video) => video.currentTime));
41-
assert.ok(after[0] - before[0] > 0.5, 'mobile monochrome video did not advance');
42-
assert.ok(after[1] - before[1] > 0.5, 'mobile colour video did not advance');
50+
const after = await page.evaluate(() => ({
51+
hero: [...document.querySelectorAll('video.specimen')].map((video) => video.currentTime),
52+
macro: document.querySelector('video.macro-video').currentTime,
53+
}));
54+
assert.ok(after.hero[0] - before[0] > 0.5, 'mobile monochrome video did not advance');
55+
assert.ok(after.hero[1] - before[1] > 0.5, 'mobile colour video did not advance');
56+
assert.ok(after.macro - state.macroVideo.currentTime > 0.5, 'mobile macro optics video did not advance');
57+
const drift = Math.abs(after.hero[0] - after.macro);
58+
const circularDrift = Math.min(drift, state.macroVideo.duration - drift);
59+
assert.ok(circularDrift < 0.12, `macro optics drifted from the hero by ${circularDrift.toFixed(3)}s`);
4360

4461
console.log('PASS mobile autoplay and integrated macro-optics contract');
4562
} finally {

0 commit comments

Comments
 (0)