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:
ypshimandClaude Haiku 5.5 committed 2026-11-09 18:01:12 +09:00
1 parent fc520ec03d
commit ef596650cc
6 files changed
+285 -1

No files matched your search

+100
View File
@@ -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
+4 -1
View File
@@ -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');
+51
View File
@@ -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; }
}
+63
View File
@@ -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 &amp; 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 };
}