feat(landing): 소개 영상 버튼과 재생 모달 추가
- 사용 가이드 옆 '▶ 소개 영상 1분' 버튼, 네이티브 dialog에서 재생 - 자체 호스팅 mp4(56초·1920×1080·H.264, 무음), 열 때만 로드(preload none), 무음 자동재생 - Esc·배경·닫기로 종료 시 일시정지·처음으로·포커스 복귀, 모션 감소 시 자동재생 안 함 - 자막 원본(.srt)은 보관만, 화면 미연결 - verify-intro-video.cjs: Chrome 17개 검사 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc520ec03d
commit
ef596650cc
6 files changed
+285
-1
No files matched your search
@@ -0,0 +1,100 @@
|
||||
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
|
||||
// Verifies the landing intro-video trigger + modal (lazy load, play, Esc/backdrop close, focus, layouts).
|
||||
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
|
||||
const out = path.resolve(__dirname, '../.verify/intro-video-20261109');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { checks: [], errors: [], metrics: {} };
|
||||
let ws, send;
|
||||
const delay = ms => new Promise(r => setTimeout(r, ms));
|
||||
(async () => {
|
||||
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
|
||||
ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
|
||||
let seq = 0; const pending = new Map();
|
||||
ws.onmessage = ({ data }) => {
|
||||
const m = JSON.parse(data), p = pending.get(m.id);
|
||||
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
|
||||
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
|
||||
};
|
||||
send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
|
||||
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
const ev = async expression => {
|
||||
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, userGesture: true });
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
|
||||
return r.result.value;
|
||||
};
|
||||
const until = async (expression, tries = 120) => {
|
||||
for (let i = 0; i < tries; i++) { if (await ev(expression)) return; await delay(150); }
|
||||
throw new Error('Wait ' + expression);
|
||||
};
|
||||
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
|
||||
const shot = async name => {
|
||||
const r = await send('Page.captureScreenshot', { format: 'png' });
|
||||
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
|
||||
};
|
||||
const viewport = (width, height, mobile = false) => send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile });
|
||||
const load = async () => {
|
||||
await send('Page.navigate', { url: process.env.PIERZAINER_URL || 'http://127.0.0.1:5173/' });
|
||||
await until(`!!document.querySelector('.landing-video-trigger')`);
|
||||
await delay(1800); // entry animations
|
||||
};
|
||||
const mp4Requests = `performance.getEntriesByType('resource').filter(e=>e.name.includes('pierzainer-intro')).length`;
|
||||
const pressEsc = async () => {
|
||||
for (const type of ['keyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
|
||||
};
|
||||
|
||||
await send('Page.enable'); await send('Runtime.enable');
|
||||
await viewport(1600, 1000); await load();
|
||||
await shot('01-landing-1600');
|
||||
report.metrics.triggers = await ev(`[...document.querySelectorAll('.landing-guide-trigger,.landing-video-trigger')].map(e=>{const r=e.getBoundingClientRect();return[e.className,Math.round(r.left),Math.round(r.top),Math.round(r.right),Math.round(r.bottom)];})`);
|
||||
check('trigger sits beside the guide button', await ev(`(()=>{const g=document.querySelector('.landing-guide-trigger').getBoundingClientRect(),v=document.querySelector('.landing-video-trigger').getBoundingClientRect();return v.width>0&&Math.abs(g.top-v.top)<1&&Math.abs(g.height-v.height)<1&&v.left>g.right;})()`));
|
||||
check('video is not downloaded before opening', await ev(mp4Requests) === 0 && await ev(`!document.querySelector('#landing-video video source')`));
|
||||
await shot('01-landing-1600');
|
||||
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`);
|
||||
check('modal opens with focus on close button', await ev(`document.getElementById('landing-video').open&&document.activeElement.classList.contains('landing-video-close')`));
|
||||
await until(`document.querySelector('#landing-video video').readyState>=3`, 200);
|
||||
await delay(2200);
|
||||
const media = await ev(`(()=>{const v=document.querySelector('#landing-video video');return{t:v.currentTime,d:v.duration,w:v.videoWidth,h:v.videoHeight,paused:v.paused,muted:v.muted,src:v.currentSrc.split('/').pop()};})()`);
|
||||
report.metrics.media = media;
|
||||
check('mp4 loads at 1920x1080 for about 56 s', media.w === 1920 && media.h === 1080 && Math.abs(media.d - 56) < 1);
|
||||
check('video autoplays muted after opening', !media.paused && media.muted && media.t > 1);
|
||||
check('modal fits the viewport', await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.top>=0&&r.right<=innerWidth&&r.bottom<=innerHeight;})()`));
|
||||
await shot('02-playing-1600');
|
||||
|
||||
await pressEsc(); await delay(200);
|
||||
check('Esc closes, pauses, rewinds and restores focus', await ev(`(()=>{const v=document.querySelector('#landing-video video');return !document.getElementById('landing-video').open&&v.paused&&v.currentTime===0&&document.activeElement.classList.contains('landing-video-trigger');})()`));
|
||||
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
|
||||
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: 8, y: 8, button: 'left', clickCount: 1 });
|
||||
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: 8, y: 8, button: 'left', clickCount: 1 });
|
||||
await delay(200);
|
||||
check('backdrop click closes the modal', await ev(`!document.getElementById('landing-video').open`));
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(300);
|
||||
await ev(`document.querySelector('.landing-video-close').click()`); await delay(200);
|
||||
check('close button closes and pauses', await ev(`!document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
|
||||
|
||||
for (const [w, h, mobile] of [[1280, 800, false], [1920, 1080, false], [390, 844, true]]) {
|
||||
await viewport(w, h, mobile); await load();
|
||||
check(`${w}px trigger visible without horizontal scroll`, await ev(`(()=>{const r=document.querySelector('.landing-video-trigger').getBoundingClientRect();return r.width>0&&r.right<=innerWidth&&document.documentElement.scrollWidth<=innerWidth;})()`));
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(500);
|
||||
check(`${w}px modal fits viewport`, await ev(`(()=>{const r=document.getElementById('landing-video').getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.bottom<=innerHeight+1;})()`));
|
||||
await shot(`03-modal-${w}`);
|
||||
await ev(`document.querySelector('.landing-video-close').click()`);
|
||||
}
|
||||
|
||||
await viewport(1600, 1000);
|
||||
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
|
||||
await load();
|
||||
await ev(`document.querySelector('.landing-video-trigger').click()`); await delay(1500);
|
||||
check('reduced motion: opens without autoplay', await ev(`document.getElementById('landing-video').open&&document.querySelector('#landing-video video').paused`));
|
||||
await send('Emulation.setEmulatedMedia', { features: [] });
|
||||
|
||||
check('no runtime exceptions', report.errors.length === 0);
|
||||
report.result = 'PASS';
|
||||
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
|
||||
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify({ result: report.result, failure: report.failure, metrics: report.metrics, errors: report.errors })); ws?.close();
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
1
|
||||
00:00:00,200 --> 00:00:01,700
|
||||
더 빠른 설계를 넘어,
|
||||
|
||||
2
|
||||
00:00:01,700 --> 00:00:03,200
|
||||
더 나은 설계 경험으로,
|
||||
|
||||
3
|
||||
00:00:03,200 --> 00:00:05,000
|
||||
그리고 새로운 가치로.
|
||||
|
||||
4
|
||||
00:00:04,650 --> 00:00:09,150
|
||||
Pierzainer는 교량 설계 플랫폼의 하부구조 설계 모듈입니다
|
||||
|
||||
5
|
||||
00:00:08,800 --> 00:00:13,800
|
||||
하부구조 설계 흐름
|
||||
|
||||
6
|
||||
00:00:13,450 --> 00:00:17,850
|
||||
기설계 교량 라이브러리에서 시작
|
||||
|
||||
7
|
||||
00:00:17,500 --> 00:00:20,500
|
||||
시트 1장 = 교각 1기
|
||||
|
||||
8
|
||||
00:00:20,500 --> 00:00:23,700
|
||||
지형 위에 3D 모델 자동 생성
|
||||
|
||||
9
|
||||
00:00:23,350 --> 00:00:28,150
|
||||
형상은 고르고, 값만 수정
|
||||
|
||||
10
|
||||
00:00:27,800 --> 00:00:31,500
|
||||
철근표가 자동으로 계산됩니다
|
||||
|
||||
11
|
||||
00:00:31,500 --> 00:00:33,800
|
||||
고른 철근만 모델에 표시
|
||||
|
||||
12
|
||||
00:00:33,800 --> 00:00:36,800
|
||||
근거와 함께 알려주는 알림
|
||||
|
||||
13
|
||||
00:00:36,450 --> 00:00:40,050
|
||||
일반도·단면도 자동 작성
|
||||
|
||||
14
|
||||
00:00:40,050 --> 00:00:42,650
|
||||
DWG·DXF로 바로 내보내기
|
||||
|
||||
15
|
||||
00:00:42,300 --> 00:00:47,500
|
||||
교각 전체 재료표·수량 집계
|
||||
|
||||
16
|
||||
00:00:47,150 --> 00:00:53,750
|
||||
설계 자동화에서, 새로운 가치를 만드는 플랫폼으로
|
||||
|
||||
17
|
||||
00:00:53,400 --> 00:00:56,000
|
||||
Pierzainer
|
||||
@@ -2,6 +2,7 @@ import '../pierzainer_ground/ground-link';
|
||||
import { loadSampleIntoApp } from './sample-loader';
|
||||
import { createSampleExplorer, sampleExplorerMarkup, type Sample } from './sample-explorer';
|
||||
import { createLandingGuide } from './landing-guide';
|
||||
import { createIntroVideo, introVideoTriggerMarkup } from './landing-video';
|
||||
import { createHeroMotion, heroMotionMarkup } from './landing-motion';
|
||||
import { heroCapture } from './assets/okseong-hero-capture';
|
||||
import './landing-white.css';
|
||||
@@ -44,7 +45,7 @@ const landingPanelMarkup = `
|
||||
<p class="landing-subtitle">
|
||||
설계정보 입력부터 3D 모델링, 구조검토, 철근배근, 도면작성 및 수량산출까지 하나의 플랫폼에서
|
||||
</p>
|
||||
<button class="landing-guide-trigger" type="button" aria-haspopup="dialog"><span aria-hidden="true">?</span>사용 가이드 <span class="landing-guide-trigger-arrow" aria-hidden="true">↗</span></button>
|
||||
<button class="landing-guide-trigger" type="button" aria-haspopup="dialog"><span aria-hidden="true">?</span>사용 가이드 <span class="landing-guide-trigger-arrow" aria-hidden="true">↗</span></button>${introVideoTriggerMarkup}
|
||||
</div>
|
||||
</div>
|
||||
<div class="landing-hero-visual" aria-hidden="true">
|
||||
@@ -123,6 +124,7 @@ const landingPanelMarkup = `
|
||||
landingOverlay.innerHTML = landingPanelMarkup;
|
||||
document.body.prepend(landingOverlay);
|
||||
const landingGuide = createLandingGuide(landingOverlay);
|
||||
const introVideo = createIntroVideo(landingOverlay, promoImageUrl);
|
||||
createHeroMotion(landingOverlay);
|
||||
|
||||
const selectPanel = (panelId: string): void => {
|
||||
@@ -143,6 +145,7 @@ const closePanels = (): void => {
|
||||
const showApp = (): void => {
|
||||
landingOverlay.querySelector<HTMLDialogElement>('.sample-library-popup')?.close();
|
||||
landingGuide.close(false);
|
||||
introVideo.close(false);
|
||||
if (appRoot) {
|
||||
appRoot.hidden = false;
|
||||
appRoot.setAttribute('aria-hidden', 'false');
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Intro video trigger + modal. Shares the guide trigger/dialog visual language (landing-white). */
|
||||
.landing-video-trigger {
|
||||
display: inline-flex; align-items: center; gap: 9px; min-height: 36px; margin: 13px 0 0 8px; padding: 7px 12px;
|
||||
border: 1px solid #d6e3ed; border-radius: 999px; background: #ffffffeb; color: #203b54;
|
||||
font-size: 11px !important; box-shadow: 0 3px 10px #27496207; cursor: pointer;
|
||||
transition: box-shadow .25s, background .25s, border-color .25s; animation: landing-enter .7s 1.45s both;
|
||||
}
|
||||
.landing-video-trigger > span:first-child {
|
||||
display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #087fcb; color: #fff; font-size: 7px; padding-left: 1px;
|
||||
}
|
||||
.landing-video-trigger-time { color: #64748b; font-variant-numeric: tabular-nums; }
|
||||
.landing-video-trigger:hover { background: #eff6ff; border-color: #9dc6e2; box-shadow: 0 4px 16px #267daa10; }
|
||||
.landing-video-trigger:focus-visible { outline: 2px solid #087fcb; outline-offset: 2px; }
|
||||
|
||||
.landing-video {
|
||||
width: min(1120px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); padding: 18px 20px 16px;
|
||||
border: 1px solid #d1e2ee; border-radius: 18px; color-scheme: light; color: #14263d;
|
||||
background: linear-gradient(145deg, #fff, #f8fbfe); box-shadow: 0 24px 90px #2247652b, inset 0 1px #fff;
|
||||
overflow: auto;
|
||||
}
|
||||
.landing-video::backdrop { background: #253f5966; backdrop-filter: blur(5px); }
|
||||
.landing-video-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.landing-video-kicker { display: block; color: #36779d; font-size: 11px; letter-spacing: .14em; font-weight: 600; }
|
||||
.landing-video-head h2 { margin: 4px 0 0; font-size: 18px; color: #14263d; }
|
||||
.landing-video-close {
|
||||
flex: none; width: 44px; height: 44px; border: 1px solid #d5e5ef; border-radius: 10px;
|
||||
background: #f5f9fc; color: #456478; font-size: 22px; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.landing-video-close:hover { background: #eaf3f9; }
|
||||
.landing-video-close:focus-visible { outline: 2px solid #087fcb; outline-offset: 2px; }
|
||||
.landing-video-frame {
|
||||
position: relative; aspect-ratio: 16 / 9; max-height: calc(100dvh - 190px); margin: 0 auto;
|
||||
border-radius: 12px; overflow: hidden; background: #f4f6f8; border: 1px solid #e2ebf2;
|
||||
}
|
||||
.landing-video-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #f4f6f8; }
|
||||
.landing-video-desc { margin: 10px 2px 0; color: #64748b; font-size: 12.5px; line-height: 1.5; }
|
||||
|
||||
/* Different icon glyphs give the two inline-flex pills different baselines; align their boxes instead. */
|
||||
.landing-brand-meta .landing-guide-trigger, .landing-video-trigger { vertical-align: middle; }
|
||||
/* Mirror the guide trigger's responsive margins (landing-page.css). */
|
||||
@media (max-width: 1100px) and (min-width: 761px) { .landing-video-trigger { margin-top: 8px; } }
|
||||
@media (max-height: 560px) { .landing-brand-meta .landing-video-trigger { margin-top: 6px; } }
|
||||
@media (max-width: 760px) {
|
||||
.landing-video-trigger { min-height: 30px; font-size: 9px !important; margin: 8px 0 0 6px; padding: 5px 9px; }
|
||||
.landing-video { padding: 12px; border-radius: 14px; }
|
||||
.landing-video-head h2 { font-size: 15px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.landing-video-trigger { animation: none; transition: none; }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import './landing-video.css';
|
||||
|
||||
/** Self-hosted intro video (56 s, 1920×1080 H.264, no audio track). Fetched only when opened. */
|
||||
export const introVideoUrl = new URL('./assets/pierzainer-intro.mp4', import.meta.url).href;
|
||||
|
||||
export const introVideoTriggerMarkup = `<button class="landing-video-trigger" type="button" aria-haspopup="dialog" aria-controls="landing-video"><span aria-hidden="true">▶</span>소개 영상 <span class="landing-video-trigger-time">1분</span></button>`;
|
||||
|
||||
/** Native dialog: Esc/backdrop close, pause + rewind on close, focus returns to the opener. */
|
||||
export function createIntroVideo(root: HTMLElement, poster: string): { open: () => void; close: (restoreFocus?: boolean) => void } {
|
||||
const trigger = root.querySelector<HTMLButtonElement>('.landing-video-trigger')!;
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'landing-video';
|
||||
dialog.id = 'landing-video';
|
||||
dialog.setAttribute('aria-labelledby', 'landing-video-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="landing-video-head">
|
||||
<div><span class="landing-video-kicker">PRODUCT TOUR / PIER & ABUT</span><h2 id="landing-video-title">Pierzainer 소개 영상</h2></div>
|
||||
<button type="button" class="landing-video-close" aria-label="소개 영상 닫기">×</button>
|
||||
</div>
|
||||
<div class="landing-video-frame">
|
||||
<video controls playsinline muted preload="none" poster="${poster}" aria-describedby="landing-video-desc"></video>
|
||||
</div>
|
||||
<p id="landing-video-desc" class="landing-video-desc">설계정보 입력부터 3D 모델링, 철근배근, 도면작성, 수량산출까지의 흐름을 1분 동안 소개합니다. (음성 없음)</p>`;
|
||||
root.append(dialog);
|
||||
const video = dialog.querySelector('video')!;
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
|
||||
const open = (): void => {
|
||||
if (dialog.open) return;
|
||||
if (!video.currentSrc && !video.querySelector('source')) {
|
||||
const source = document.createElement('source');
|
||||
source.src = introVideoUrl;
|
||||
source.type = 'video/mp4';
|
||||
video.append(source);
|
||||
video.load();
|
||||
}
|
||||
dialog.showModal();
|
||||
dialog.querySelector<HTMLButtonElement>('.landing-video-close')!.focus();
|
||||
if (!reducedMotion?.matches) void video.play().catch(() => undefined);
|
||||
};
|
||||
const stop = (): void => {
|
||||
video.pause();
|
||||
if (video.readyState > 0) video.currentTime = 0;
|
||||
};
|
||||
const close = (restoreFocus = true): void => {
|
||||
if (!dialog.open) return;
|
||||
dialog.close();
|
||||
if (restoreFocus && trigger.isConnected && !root.hidden) trigger.focus();
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', open);
|
||||
dialog.querySelector('.landing-video-close')!.addEventListener('click', () => close());
|
||||
dialog.addEventListener('close', stop);
|
||||
dialog.addEventListener('cancel', (event) => { event.preventDefault(); close(); });
|
||||
// Clicking the backdrop (outside the panel box) closes the dialog.
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target !== dialog) return;
|
||||
const box = dialog.getBoundingClientRect();
|
||||
const inside = event.clientX >= box.left && event.clientX <= box.right && event.clientY >= box.top && event.clientY <= box.bottom;
|
||||
if (!inside) close();
|
||||
});
|
||||
return { open, close };
|
||||
}
|
||||
Reference in new issue
Block a user