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

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 };
}