diff --git a/scripts/verify-intro-video.cjs b/scripts/verify-intro-video.cjs new file mode 100644 index 0000000..d4ff73d --- /dev/null +++ b/scripts/verify-intro-video.cjs @@ -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(); +}); diff --git a/랜딩페이지/assets/pierzainer-intro.mp4 b/랜딩페이지/assets/pierzainer-intro.mp4 new file mode 100644 index 0000000..79046fe Binary files /dev/null and b/랜딩페이지/assets/pierzainer-intro.mp4 differ diff --git a/랜딩페이지/assets/pierzainer-intro.srt b/랜딩페이지/assets/pierzainer-intro.srt new file mode 100644 index 0000000..27f6e87 --- /dev/null +++ b/랜딩페이지/assets/pierzainer-intro.srt @@ -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 diff --git a/랜딩페이지/landing-page.ts b/랜딩페이지/landing-page.ts index 639c78d..eb48acc 100644 --- a/랜딩페이지/landing-page.ts +++ b/랜딩페이지/landing-page.ts @@ -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 = `
설계정보 입력부터 3D 모델링, 구조검토, 철근배근, 도면작성 및 수량산출까지 하나의 플랫폼에서
- + ${introVideoTriggerMarkup}