clipwise 0.7.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +142 -13
- package/README.md +119 -14
- package/dist/cli/index.js +2967 -1834
- package/dist/compose/frame-worker.js +88 -38
- package/dist/index.d.ts +6275 -374
- package/dist/index.js +750 -65
- package/package.json +3 -2
- package/skills/clipwise.md +83 -8
- package/templates/motion/feature-callout.html +81 -0
- package/templates/motion/intro-title.html +146 -0
- package/templates/motion/kinetic-type.html +205 -0
- package/templates/motion/vignette.html +288 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clipwise",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Scriptable cinematic screen recorder for product demos — YAML in, polished MP4 out",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
"dist",
|
|
13
13
|
"skills",
|
|
14
14
|
"README.md",
|
|
15
|
-
"LICENSE"
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"templates"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
package/skills/clipwise.md
CHANGED
|
@@ -46,6 +46,20 @@ effects: # All optional, sensible defaults
|
|
|
46
46
|
watermark: ...
|
|
47
47
|
speedRamp: ...
|
|
48
48
|
|
|
49
|
+
prepare: # Optional — recording-time injection (app code untouched)
|
|
50
|
+
hide: ["#cookie-banner"] # CSS selectors hidden during recording
|
|
51
|
+
freezeTime: "2026-06-10T09:00:00Z" # freeze Date/Date.now (ISO 8601)
|
|
52
|
+
seedRandom: 42 # deterministic Math.random
|
|
53
|
+
storage: # seeded before app boots
|
|
54
|
+
localStorage: { onboarding_done: "true" }
|
|
55
|
+
mock: # network mocking — demo data without DB seeding
|
|
56
|
+
- url: "/api/stats" # URL substring match
|
|
57
|
+
fixture: ../fixtures/stats.json # JSON file (relative to the YAML), or:
|
|
58
|
+
# body: { inline: data } # inline body (fixture takes precedence)
|
|
59
|
+
inject: # arbitrary CSS/JS files (relative to the YAML)
|
|
60
|
+
css: ../prepare/demo.css
|
|
61
|
+
js: ../prepare/demo.js
|
|
62
|
+
|
|
49
63
|
output:
|
|
50
64
|
format: mp4 # mp4 | gif | png-sequence
|
|
51
65
|
width: 1280 # Output width
|
|
@@ -53,7 +67,7 @@ output:
|
|
|
53
67
|
fps: 30 # 1-60
|
|
54
68
|
preset: balanced # social | balanced | archive
|
|
55
69
|
codec: auto # auto | h264 | hevc | av1
|
|
56
|
-
outputDir: "
|
|
70
|
+
outputDir: ".clipwise/output" # default
|
|
57
71
|
filename: "my-recording"
|
|
58
72
|
|
|
59
73
|
steps: [] # Array of steps (min 1, first must have navigate)
|
|
@@ -231,6 +245,7 @@ deviceFrame:
|
|
|
231
245
|
enabled: true
|
|
232
246
|
type: browser # browser | iphone | ipad | android | none
|
|
233
247
|
darkMode: true
|
|
248
|
+
url: "app.example.com" # address-bar display URL (default: localhost)
|
|
234
249
|
```
|
|
235
250
|
|
|
236
251
|
| Type | Description |
|
|
@@ -325,6 +340,62 @@ steps:
|
|
|
325
340
|
| balanced | General purpose, portfolio | ~4-6 MB |
|
|
326
341
|
| archive | High-fidelity storage | larger |
|
|
327
342
|
|
|
343
|
+
## Scenes — Keynote-Style Launch Videos (v0.9)
|
|
344
|
+
|
|
345
|
+
When the user wants a **launch/intro video** (not just a screen recording), use a
|
|
346
|
+
`scenes:` timeline. One `clipwise record` renders: kinetic typography → footage
|
|
347
|
+
vignettes (crop/push-in/split + line annotations) → outro, connected by an ink
|
|
348
|
+
thread that travels across cuts.
|
|
349
|
+
|
|
350
|
+
```yaml
|
|
351
|
+
viewport: { width: 1280, height: 800, deviceScaleFactor: 2 } # 2 = retina quality
|
|
352
|
+
|
|
353
|
+
scenes:
|
|
354
|
+
# footage take — recorded once, never shown directly; vignettes quote it
|
|
355
|
+
- type: screen
|
|
356
|
+
id: demo
|
|
357
|
+
steps: [...] # normal steps (first must navigate)
|
|
358
|
+
|
|
359
|
+
# kinetic typography card (built-ins: kinetic-type, intro-title, feature-callout)
|
|
360
|
+
- type: motion
|
|
361
|
+
template: kinetic-type
|
|
362
|
+
duration: 2200
|
|
363
|
+
props:
|
|
364
|
+
lines: "Ship *demos*,||not edits." # || = line break, *word* = serif-italic accent
|
|
365
|
+
size: 86
|
|
366
|
+
# fx: marker # underline (default) | marker | off
|
|
367
|
+
# sub: "npx my-app init" # outro command pill
|
|
368
|
+
|
|
369
|
+
# footage as a layer — declarative camera
|
|
370
|
+
- type: vignette
|
|
371
|
+
footage: demo
|
|
372
|
+
duration: 4200
|
|
373
|
+
layout: crop # hero (full window) | crop (close-up) | split (code × footage)
|
|
374
|
+
num: "02"
|
|
375
|
+
label: "Smart Speed"
|
|
376
|
+
caption: "Loading compressed, *results crisp*"
|
|
377
|
+
crop: { selector: ".panel", pad: 14, maxH: 250 } # selector-measured, never guess pixels
|
|
378
|
+
push: { from: 1.05, to: 1 } # push-in/out camera
|
|
379
|
+
start: { step: 3, offset: 0 } # quote footage from a step boundary (or seconds)
|
|
380
|
+
rate: 1.15 # playback speed of the quoted footage
|
|
381
|
+
fx: # line-draw annotations on the footage
|
|
382
|
+
- { kind: circle, selector: "#revenue", delay: 2500 }
|
|
383
|
+
- { kind: arrow, selector: ".panel", delay: 2900 }
|
|
384
|
+
# code: ["prepare:", " hide: [...]"] # split layout left code card
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### High-quality keynote recipe (follow ALL of these)
|
|
388
|
+
|
|
389
|
+
1. **`viewport.deviceScaleFactor: 2`** — without it the footage looks blurry in close-ups
|
|
390
|
+
2. **`prepare:`** — hide cookie banners/dev overlays, `freezeTime`, `seedRandom`, `mock` APIs
|
|
391
|
+
3. **`.clipwise/brand.yaml`** — tone/accent/font (`editorial` = Inter + Fraunces) + catchphrases; annotations & thread auto-apply
|
|
392
|
+
4. **Structure** (≈23s): kinetic hook (2.2s) → hero push-in vignette (4.2s) → close-up vignette
|
|
393
|
+
with circle fx (3.6s) → result vignette (4.2s) → kinetic interstitial (1.9s) →
|
|
394
|
+
split YAML × footage (4.4s) → outro with `sub:` command pill (2.8s)
|
|
395
|
+
5. **Footage effects**: in scenes mode set only `cursor:` (highlight: false) — zoom/frame/background
|
|
396
|
+
are handled by the vignette compositor, not the recorder
|
|
397
|
+
6. Keep one screen take (~12-15s) and let vignettes quote segments via `start: { step: N }`
|
|
398
|
+
|
|
328
399
|
## Critical Rules
|
|
329
400
|
|
|
330
401
|
1. **First step MUST contain a `navigate` action** — the browser needs a page to start
|
|
@@ -343,6 +414,8 @@ steps:
|
|
|
343
414
|
14. **Auto loader detection**: CSS spinners (`@keyframes spin/rotate/pulse/bounce`) are passively detected via CDP and auto-marked for smartSpeed compression
|
|
344
415
|
15. **Codec choice**: `av1` gives 40-60% smaller files but slower encode; `hevc` provides 10-bit color (no gradient banding); `auto` picks h264 for compatibility
|
|
345
416
|
16. **smartWait over wait**: prefer `smartWait` over fixed `wait` for API calls and loading states — it captures real frames and auto-compresses them
|
|
417
|
+
17. **Never suggest modifying the user's app code for a demo** — use `prepare:` instead: `hide:` for cookie banners/dev overlays, `mock:` for demo data (no DB seeding), `freezeTime:`/`seedRandom:` for deterministic dates and charts, `storage:` to skip onboarding. Keep prepare assets (fixtures, CSS) inside `.clipwise/`
|
|
418
|
+
18. **Zero footprint**: scenarios live in `.clipwise/scenarios/`, fixtures in `.clipwise/fixtures/`, output defaults to `.clipwise/output/`. Scaffold with `npx clipwise init`; everything is removed with `rm -rf .clipwise`
|
|
346
419
|
|
|
347
420
|
## Timing Presets
|
|
348
421
|
|
|
@@ -359,15 +432,15 @@ steps:
|
|
|
359
432
|
## CLI Commands
|
|
360
433
|
|
|
361
434
|
```bash
|
|
362
|
-
# Record from YAML scenario
|
|
363
|
-
npx clipwise record <scenario.yaml> -f mp4
|
|
435
|
+
# Record from YAML scenario (output defaults to .clipwise/output)
|
|
436
|
+
npx clipwise record <scenario.yaml> -f mp4
|
|
364
437
|
|
|
365
438
|
# Instant demo with built-in dashboard
|
|
366
439
|
npx clipwise demo
|
|
367
440
|
npx clipwise demo --device iphone
|
|
368
441
|
npx clipwise demo --url https://my-app.com
|
|
369
442
|
|
|
370
|
-
#
|
|
443
|
+
# Scaffold .clipwise/ (scenarios, fixtures, prepare assets, auth)
|
|
371
444
|
npx clipwise init
|
|
372
445
|
|
|
373
446
|
# Validate scenario without recording
|
|
@@ -377,10 +450,12 @@ npx clipwise validate <scenario.yaml>
|
|
|
377
450
|
## Workflow
|
|
378
451
|
|
|
379
452
|
1. Ask the user for: target URL, what actions to demo, and preferred style (snappy/cinematic)
|
|
380
|
-
2.
|
|
381
|
-
3.
|
|
382
|
-
4.
|
|
383
|
-
5. If
|
|
453
|
+
2. If `.clipwise/` doesn't exist, run `npx clipwise init` first
|
|
454
|
+
3. Generate a complete scenario at `.clipwise/scenarios/<name>.yaml`
|
|
455
|
+
4. Run `npx clipwise validate .clipwise/scenarios/<name>.yaml` to check for errors
|
|
456
|
+
5. If valid, run `npx clipwise record .clipwise/scenarios/<name>.yaml -f mp4`
|
|
457
|
+
6. If the user has specific selectors, use them. Otherwise suggest inspecting the page first
|
|
458
|
+
7. If the page shows cookie banners, dev overlays, live dates, or random data — add a `prepare:` block instead of asking the user to change their app
|
|
384
459
|
|
|
385
460
|
## Selector Discovery
|
|
386
461
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!--
|
|
3
|
+
feature-callout — 챕터 구분 설명 카드 (motion scene 템플릿)
|
|
4
|
+
|
|
5
|
+
데모 신 사이에 들어가 "지금부터 보여줄 것"을 설명한다.
|
|
6
|
+
규약: CSS @keyframes만 사용, animation-fill-mode: both (deterministic seek 호환)
|
|
7
|
+
|
|
8
|
+
Props (query param): num, title, desc, accent
|
|
9
|
+
-->
|
|
10
|
+
<html lang="ko">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8" />
|
|
13
|
+
<style>
|
|
14
|
+
:root { --accent: #6366f1; --bg: #0a0a0f; --fg: #f5f5f7;
|
|
15
|
+
--dim: rgba(245, 245, 247, 0.55); --glow-op: 0.09; }
|
|
16
|
+
html[data-tone="daylight"] { --bg: #faf9f7; --fg: #18181c;
|
|
17
|
+
--dim: rgba(24, 24, 28, 0.55); --glow-op: 0.06; }
|
|
18
|
+
html[data-tone="neon"] { --bg: #0c0616; --fg: #ffffff;
|
|
19
|
+
--dim: rgba(255, 255, 255, 0.6); --glow-op: 0.2; }
|
|
20
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
21
|
+
html, body { width: 100%; height: 100%; overflow: hidden; background: var(--bg);
|
|
22
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Pretendard", sans-serif; }
|
|
23
|
+
|
|
24
|
+
.stage { width: 100%; height: 100%; display: flex; align-items: center; }
|
|
25
|
+
.inner { padding-left: 14%; max-width: 76%; }
|
|
26
|
+
|
|
27
|
+
.num-row { display: flex; align-items: center; gap: 18px; margin-bottom: 22px;
|
|
28
|
+
animation: rise 900ms cubic-bezier(0.16, 1, 0.3, 1) 100ms both; }
|
|
29
|
+
.num { font-size: 15px; font-weight: 700; letter-spacing: 0.18em; color: var(--accent);
|
|
30
|
+
font-variant-numeric: tabular-nums; }
|
|
31
|
+
.num-line { width: 0; height: 1.5px; background: var(--accent); opacity: 0.6;
|
|
32
|
+
animation: grow 900ms cubic-bezier(0.16, 1, 0.3, 1) 250ms both; }
|
|
33
|
+
|
|
34
|
+
.title { font-size: 56px; font-weight: 700; letter-spacing: -0.02em; color: var(--fg);
|
|
35
|
+
margin-bottom: 18px; animation: rise 1000ms cubic-bezier(0.16, 1, 0.3, 1) 280ms both; }
|
|
36
|
+
|
|
37
|
+
.desc { font-size: 21px; font-weight: 400; line-height: 1.55;
|
|
38
|
+
color: var(--dim);
|
|
39
|
+
animation: rise 1000ms cubic-bezier(0.16, 1, 0.3, 1) 480ms both; }
|
|
40
|
+
|
|
41
|
+
html[data-tone="neon"] .title {
|
|
42
|
+
background: linear-gradient(92deg, var(--accent), #22d3ee);
|
|
43
|
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.glow { position: absolute; right: -200px; top: 50%; width: 700px; height: 700px;
|
|
47
|
+
margin-top: -350px; border-radius: 50%;
|
|
48
|
+
background: radial-gradient(circle, var(--accent) 0%, transparent 62%);
|
|
49
|
+
animation: glow-in 2200ms ease-out both; }
|
|
50
|
+
|
|
51
|
+
@keyframes rise { from { opacity: 0; transform: translateY(26px); }
|
|
52
|
+
to { opacity: 1; transform: translateY(0); } }
|
|
53
|
+
@keyframes grow { from { width: 0; } to { width: 56px; } }
|
|
54
|
+
@keyframes glow-in { 0% { opacity: 0; transform: scale(0.7); }
|
|
55
|
+
100% { opacity: var(--glow-op); transform: scale(1); } }
|
|
56
|
+
</style>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<div class="stage">
|
|
60
|
+
<div class="glow"></div>
|
|
61
|
+
<div class="inner">
|
|
62
|
+
<div class="num-row"><span class="num">01</span><span class="num-line"></span></div>
|
|
63
|
+
<div class="title">Title</div>
|
|
64
|
+
<div class="desc">Description</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<script>
|
|
69
|
+
const p = new URLSearchParams(location.search);
|
|
70
|
+
if (p.get("num")) document.querySelector(".num").textContent = p.get("num");
|
|
71
|
+
if (p.get("title")) document.querySelector(".title").textContent = p.get("title");
|
|
72
|
+
if (p.get("desc")) document.querySelector(".desc").textContent = p.get("desc");
|
|
73
|
+
if (p.get("accent")) document.documentElement.style.setProperty("--accent", p.get("accent"));
|
|
74
|
+
if (p.get("tone")) document.documentElement.dataset.tone = p.get("tone");
|
|
75
|
+
|
|
76
|
+
window.__clipwiseSeek = (t) => {
|
|
77
|
+
for (const a of document.getAnimations()) { a.pause(); a.currentTime = t; }
|
|
78
|
+
};
|
|
79
|
+
</script>
|
|
80
|
+
</body>
|
|
81
|
+
</html>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!--
|
|
3
|
+
intro-title — motion scene 프로토타입 템플릿
|
|
4
|
+
|
|
5
|
+
규약 (deterministic seek 캡처 호환):
|
|
6
|
+
- 애니메이션은 CSS @keyframes / WAAPI 만 사용 (rAF 루프 금지)
|
|
7
|
+
- 모든 애니메이션에 animation-fill-mode: both (seek 시 in-effect 유지)
|
|
8
|
+
- 시스템 폰트만 사용 (웹폰트 로딩 비결정성 제거)
|
|
9
|
+
|
|
10
|
+
Props는 query param으로 주입: ?title=...&subtitle=...&accent=%236366f1
|
|
11
|
+
-->
|
|
12
|
+
<html lang="ko">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="utf-8" />
|
|
15
|
+
<style>
|
|
16
|
+
/* ── 톤앤매너 프리셋 (Brand Kit) ──
|
|
17
|
+
midnight(기본): 딥블랙 + 소프트 글로우 — 발표 영상 톤
|
|
18
|
+
daylight: 라이트 에디토리얼 — 문서/블로그 톤
|
|
19
|
+
neon: 딥퍼플 + 그라데이션 타이포 — 런치 하이프 톤 */
|
|
20
|
+
:root {
|
|
21
|
+
--accent: #6366f1;
|
|
22
|
+
--bg: #0a0a0f;
|
|
23
|
+
--fg: #f5f5f7;
|
|
24
|
+
--fg-dim: rgba(245, 245, 247, 0.55);
|
|
25
|
+
--glow-op: 0.10;
|
|
26
|
+
--title-weight: 700;
|
|
27
|
+
}
|
|
28
|
+
html[data-tone="daylight"] {
|
|
29
|
+
--bg: #faf9f7;
|
|
30
|
+
--fg: #18181c;
|
|
31
|
+
--fg-dim: rgba(24, 24, 28, 0.55);
|
|
32
|
+
--glow-op: 0.07;
|
|
33
|
+
}
|
|
34
|
+
html[data-tone="neon"] {
|
|
35
|
+
--bg: #0c0616;
|
|
36
|
+
--fg: #ffffff;
|
|
37
|
+
--fg-dim: rgba(255, 255, 255, 0.6);
|
|
38
|
+
--glow-op: 0.22;
|
|
39
|
+
--title-weight: 800;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
43
|
+
|
|
44
|
+
html, body {
|
|
45
|
+
width: 100%;
|
|
46
|
+
height: 100%;
|
|
47
|
+
overflow: hidden;
|
|
48
|
+
background: var(--bg);
|
|
49
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Pretendard", sans-serif;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.stage {
|
|
53
|
+
width: 100%;
|
|
54
|
+
height: 100%;
|
|
55
|
+
display: flex;
|
|
56
|
+
flex-direction: column;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: center;
|
|
59
|
+
gap: 28px;
|
|
60
|
+
position: relative;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* 은은한 배경 글로우 — 천천히 확장 */
|
|
64
|
+
.glow {
|
|
65
|
+
position: absolute;
|
|
66
|
+
width: 900px;
|
|
67
|
+
height: 900px;
|
|
68
|
+
border-radius: 50%;
|
|
69
|
+
background: radial-gradient(circle, var(--accent) 0%, transparent 60%);
|
|
70
|
+
opacity: 0;
|
|
71
|
+
animation: glow-in 2500ms ease-out both;
|
|
72
|
+
}
|
|
73
|
+
@keyframes glow-in {
|
|
74
|
+
0% { opacity: 0; transform: scale(0.6); }
|
|
75
|
+
100% { opacity: var(--glow-op); transform: scale(1); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.accent-line {
|
|
79
|
+
width: 0;
|
|
80
|
+
height: 3px;
|
|
81
|
+
border-radius: 2px;
|
|
82
|
+
background: var(--accent);
|
|
83
|
+
animation: line-grow 900ms cubic-bezier(0.16, 1, 0.3, 1) 200ms both;
|
|
84
|
+
}
|
|
85
|
+
@keyframes line-grow {
|
|
86
|
+
from { width: 0; opacity: 0; }
|
|
87
|
+
to { width: 64px; opacity: 1; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.title {
|
|
91
|
+
font-size: 72px;
|
|
92
|
+
font-weight: var(--title-weight);
|
|
93
|
+
letter-spacing: -0.02em;
|
|
94
|
+
color: var(--fg);
|
|
95
|
+
opacity: 0;
|
|
96
|
+
animation: rise-in 1100ms cubic-bezier(0.16, 1, 0.3, 1) 350ms both;
|
|
97
|
+
}
|
|
98
|
+
/* neon 톤: 그라데이션 타이포 */
|
|
99
|
+
html[data-tone="neon"] .title {
|
|
100
|
+
background: linear-gradient(92deg, var(--accent), #22d3ee);
|
|
101
|
+
-webkit-background-clip: text;
|
|
102
|
+
background-clip: text;
|
|
103
|
+
color: transparent;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.subtitle {
|
|
107
|
+
font-size: 26px;
|
|
108
|
+
font-weight: 400;
|
|
109
|
+
color: var(--fg-dim);
|
|
110
|
+
opacity: 0;
|
|
111
|
+
animation: rise-in 1100ms cubic-bezier(0.16, 1, 0.3, 1) 600ms both;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@keyframes rise-in {
|
|
115
|
+
from { opacity: 0; transform: translateY(28px); }
|
|
116
|
+
to { opacity: 1; transform: translateY(0); }
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="stage">
|
|
122
|
+
<div class="glow"></div>
|
|
123
|
+
<div class="accent-line"></div>
|
|
124
|
+
<h1 class="title">Title</h1>
|
|
125
|
+
<p class="subtitle">Subtitle</p>
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
<script>
|
|
129
|
+
// Props 주입 (애니메이션 seek 전, 로드 시 1회)
|
|
130
|
+
const params = new URLSearchParams(location.search);
|
|
131
|
+
if (params.get("title")) document.querySelector(".title").textContent = params.get("title");
|
|
132
|
+
if (params.get("subtitle")) document.querySelector(".subtitle").textContent = params.get("subtitle");
|
|
133
|
+
if (params.get("accent")) document.documentElement.style.setProperty("--accent", params.get("accent"));
|
|
134
|
+
if (params.get("tone")) document.documentElement.dataset.tone = params.get("tone");
|
|
135
|
+
|
|
136
|
+
// deterministic seek 훅 — Scene System의 핵심 메커니즘.
|
|
137
|
+
// 모든 문서 애니메이션을 pause 상태로 고정하고 currentTime만 외부에서 제어한다.
|
|
138
|
+
window.__clipwiseSeek = (t) => {
|
|
139
|
+
for (const a of document.getAnimations()) {
|
|
140
|
+
a.pause();
|
|
141
|
+
a.currentTime = t;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
</script>
|
|
145
|
+
</body>
|
|
146
|
+
</html>
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!--
|
|
3
|
+
keynote/kinetic-type — 키네틱 타이포 인터스티셜 (Anthropic 키노트 문법)
|
|
4
|
+
|
|
5
|
+
아이보리 무대 위 짧은 선언문. 단어 단위 스태거 등장, *단어*는 악센트 세리프 이탤릭.
|
|
6
|
+
Props (query): lines ("||"로 줄 구분), accent, size, font, fx, sub,
|
|
7
|
+
threadFrom/threadTo (0..1 — 장면을 관통하는 연결 선의 진행 구간)
|
|
8
|
+
-->
|
|
9
|
+
<html lang="ko">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="utf-8" />
|
|
12
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
13
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
14
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400..900&family=Space+Grotesk:wght@400..700&family=Fraunces:ital,opsz,wght@1,9..144,400..700&family=JetBrains+Mono:wght@400;600&display=block" rel="stylesheet" />
|
|
15
|
+
<link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" rel="stylesheet" />
|
|
16
|
+
<style>
|
|
17
|
+
:root { --ivory: #faf9f5; --ink: #141413; --accent: #6366f1; }
|
|
18
|
+
|
|
19
|
+
/* ── 폰트 프리셋 (Brand Kit `font:`) ──
|
|
20
|
+
editorial(기본): Inter 800 + Fraunces 이탤릭 강조 — 곡선이 풍부한 디스플레이 세리프
|
|
21
|
+
grotesk: Space Grotesk 디스플레이 — 테크 런치 무드
|
|
22
|
+
system: 시스템 스택 (네트워크 불필요 폴백) */
|
|
23
|
+
:root {
|
|
24
|
+
--sans: "Inter", "Pretendard Variable", -apple-system, sans-serif;
|
|
25
|
+
--em: "Fraunces", Georgia, serif;
|
|
26
|
+
--em-style: italic;
|
|
27
|
+
--em-weight: 560;
|
|
28
|
+
--mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
|
|
29
|
+
}
|
|
30
|
+
html[data-font="grotesk"] {
|
|
31
|
+
--sans: "Space Grotesk", "Pretendard Variable", -apple-system, sans-serif;
|
|
32
|
+
--em: "Space Grotesk", "Pretendard Variable", sans-serif;
|
|
33
|
+
--em-style: normal;
|
|
34
|
+
--em-weight: 700;
|
|
35
|
+
}
|
|
36
|
+
html[data-font="system"] {
|
|
37
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Pretendard", sans-serif;
|
|
38
|
+
--em: Georgia, "Times New Roman", serif;
|
|
39
|
+
--mono: "SF Mono", ui-monospace, Menlo, monospace;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
43
|
+
html, body { width: 100%; height: 100%; overflow: hidden; background: var(--ivory);
|
|
44
|
+
font-family: var(--sans);
|
|
45
|
+
/* 한글 폴백에 가짜 기울임 합성 금지 — 강조 단어가 한글이면 정자 + 색/주석만 */
|
|
46
|
+
font-synthesis: none; }
|
|
47
|
+
|
|
48
|
+
/* 종이 그레인 + 비네팅 — 무대에 필름 질감 (정적, 비용 0) */
|
|
49
|
+
body::before { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 10;
|
|
50
|
+
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="220" height="220"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2"/></filter><rect width="220" height="220" filter="url(%23n)" opacity="0.5"/></svg>');
|
|
51
|
+
opacity: 0.05; }
|
|
52
|
+
body::after { content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 10;
|
|
53
|
+
background: radial-gradient(ellipse 130% 110% at 50% 42%, transparent 62%, rgba(20,20,19,0.07) 100%); }
|
|
54
|
+
|
|
55
|
+
.stage { width: 100%; height: 100%; display: flex; flex-direction: column;
|
|
56
|
+
align-items: center; justify-content: center; gap: 6px; position: relative; }
|
|
57
|
+
.line { display: flex; gap: 0.28em; position: relative; z-index: 3; }
|
|
58
|
+
.w { font-size: var(--size, 76px); font-weight: 800; letter-spacing: -0.035em;
|
|
59
|
+
color: var(--ink); line-height: 1.18;
|
|
60
|
+
opacity: 0; animation: word-in 650ms cubic-bezier(0.16, 1, 0.3, 1) both; }
|
|
61
|
+
.w.em { position: relative; color: var(--accent); font-family: var(--em);
|
|
62
|
+
font-style: var(--em-style); font-weight: var(--em-weight);
|
|
63
|
+
font-size: calc(var(--size, 76px) * 1.05); letter-spacing: -0.015em; }
|
|
64
|
+
|
|
65
|
+
/* ── 선 드로잉 강조 (fx=underline) — 폰트 로딩 후 px 공간에서 단일 곡선으로
|
|
66
|
+
그린다 (비균등 viewBox 확대 + non-scaling-stroke는 선이 끊겨 보였음) ── */
|
|
67
|
+
.w.em svg.underline { position: absolute; overflow: visible; pointer-events: none; }
|
|
68
|
+
.w.em svg.underline path { stroke: var(--accent); stroke-width: 4; fill: none;
|
|
69
|
+
stroke-linecap: round;
|
|
70
|
+
stroke-dasharray: 1; stroke-dashoffset: 1;
|
|
71
|
+
animation: draw 460ms cubic-bezier(0.4, 0, 0.2, 1) both; }
|
|
72
|
+
|
|
73
|
+
/* ── 마커 스와이프 강조 (fx=marker) — 형광펜이 칠해짐 ── */
|
|
74
|
+
.w.em .marker { position: absolute; inset: 0.04em -0.12em -0.02em -0.12em; z-index: -1;
|
|
75
|
+
background: color-mix(in srgb, var(--accent) 26%, transparent);
|
|
76
|
+
border-radius: 0.08em; transform: scaleX(0) skewX(-2deg); transform-origin: left center;
|
|
77
|
+
animation: swipe 420ms cubic-bezier(0.4, 0, 0.2, 1) both; }
|
|
78
|
+
|
|
79
|
+
@keyframes draw { to { stroke-dashoffset: 0; } }
|
|
80
|
+
@keyframes swipe { to { transform: scaleX(1) skewX(-2deg); } }
|
|
81
|
+
.sub { margin-top: 30px; font-family: var(--mono);
|
|
82
|
+
font-size: 19px; color: var(--ink); background: #ffffff;
|
|
83
|
+
border: 1px solid #e8e6dc; border-radius: 12px; padding: 13px 26px;
|
|
84
|
+
box-shadow: 0 10px 30px -12px rgba(20, 20, 19, 0.18);
|
|
85
|
+
opacity: 0; animation: word-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
86
|
+
position: relative; z-index: 3; }
|
|
87
|
+
.sub .dollar { color: var(--accent); margin-right: 9px; }
|
|
88
|
+
@keyframes word-in { from { opacity: 0; transform: translateY(0.45em) rotate(0.4deg); }
|
|
89
|
+
to { opacity: 1; transform: translateY(0) rotate(0); } }
|
|
90
|
+
|
|
91
|
+
/* ── 스레드 — 영상 전체를 관통하는 한 줄의 잉크 선.
|
|
92
|
+
threadFrom→threadTo 구간을 신 길이 동안 선형으로 전진시키면
|
|
93
|
+
하드컷을 넘어도 같은 경로 위에서 끊김 없이 이어진다. ── */
|
|
94
|
+
.thread { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
|
|
95
|
+
.thread path { fill: none; stroke: var(--accent); stroke-width: 2.5; opacity: 0.55;
|
|
96
|
+
stroke-linecap: round;
|
|
97
|
+
animation: thread-go linear both; }
|
|
98
|
+
@keyframes thread-go {
|
|
99
|
+
from { stroke-dasharray: var(--tf) 1; }
|
|
100
|
+
to { stroke-dasharray: var(--tt) 1; }
|
|
101
|
+
}
|
|
102
|
+
.thread-dot { position: absolute; width: 9px; height: 9px; border-radius: 50%;
|
|
103
|
+
background: var(--accent); box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 60%, transparent);
|
|
104
|
+
z-index: 2; offset-rotate: 0deg;
|
|
105
|
+
animation: dot-go linear both; }
|
|
106
|
+
@keyframes dot-go {
|
|
107
|
+
from { offset-distance: calc(var(--tf) * 100%); }
|
|
108
|
+
to { offset-distance: calc(var(--tt) * 100%); }
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
111
|
+
</head>
|
|
112
|
+
<body>
|
|
113
|
+
<div class="stage" id="stage"></div>
|
|
114
|
+
<script>
|
|
115
|
+
const P = new URLSearchParams(location.search);
|
|
116
|
+
document.documentElement.style.setProperty("--accent", P.get("accent") || "#6366f1");
|
|
117
|
+
if (P.get("size")) document.documentElement.style.setProperty("--size", P.get("size") + "px");
|
|
118
|
+
document.documentElement.dataset.font = P.get("font") || "editorial";
|
|
119
|
+
const FX = P.get("fx") || "underline"; // underline | marker | off
|
|
120
|
+
|
|
121
|
+
const stage = document.getElementById("stage");
|
|
122
|
+
const emWords = []; // 폰트 로딩 후 px 공간에서 밑줄을 그릴 대상
|
|
123
|
+
let delay = 120;
|
|
124
|
+
for (const lineText of (P.get("lines") || "Lines missing").split("||")) {
|
|
125
|
+
const line = document.createElement("div");
|
|
126
|
+
line.className = "line";
|
|
127
|
+
for (const token of lineText.split(" ")) {
|
|
128
|
+
const w = document.createElement("span");
|
|
129
|
+
// *단어* 강조 — 뒤따르는 구두점(쉼표/마침표 등)은 강조 밖으로
|
|
130
|
+
const m = token.match(/^\*(.+)\*(.*)$/);
|
|
131
|
+
w.className = m ? "w em" : "w";
|
|
132
|
+
w.textContent = m ? m[1] : token;
|
|
133
|
+
w.style.animationDelay = delay + "ms";
|
|
134
|
+
if (m && FX === "underline") {
|
|
135
|
+
emWords.push({ el: w, delay: delay + 420 });
|
|
136
|
+
} else if (m && FX === "marker") {
|
|
137
|
+
const mk = document.createElement("i");
|
|
138
|
+
mk.className = "marker";
|
|
139
|
+
mk.style.animationDelay = delay + 380 + "ms";
|
|
140
|
+
w.appendChild(mk);
|
|
141
|
+
}
|
|
142
|
+
delay += 90;
|
|
143
|
+
line.appendChild(w);
|
|
144
|
+
if (m && m[2]) {
|
|
145
|
+
const tail = document.createElement("span");
|
|
146
|
+
tail.className = "w";
|
|
147
|
+
tail.textContent = m[2];
|
|
148
|
+
tail.style.animationDelay = delay - 90 + "ms";
|
|
149
|
+
tail.style.marginLeft = "-0.22em";
|
|
150
|
+
line.appendChild(tail);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
stage.appendChild(line);
|
|
154
|
+
delay += 60;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 아웃트로용 커맨드 필 (sub 파라미터)
|
|
158
|
+
if (P.get("sub")) {
|
|
159
|
+
const sub = document.createElement("div");
|
|
160
|
+
sub.className = "sub";
|
|
161
|
+
sub.innerHTML = `<span class="dollar">$</span>${P.get("sub")}`;
|
|
162
|
+
sub.style.animationDelay = delay + 350 + "ms";
|
|
163
|
+
stage.appendChild(sub);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── 스레드: 모든 신이 공유하는 동일 경로 — 구간만 다르게 전진 ──
|
|
167
|
+
const THREAD_PATH = "M -6 716 C 150 704, 320 730, 480 714 S 770 724, 940 706 S 1180 728, 1286 712";
|
|
168
|
+
const tf = P.get("threadFrom"), tt = P.get("threadTo");
|
|
169
|
+
if (tf !== null && tt !== null) {
|
|
170
|
+
const durS = parseFloat(P.get("dur") || "3");
|
|
171
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
172
|
+
svg.setAttribute("class", "thread");
|
|
173
|
+
svg.setAttribute("viewBox", "0 0 1280 800");
|
|
174
|
+
svg.innerHTML = `<path d="${THREAD_PATH}" pathLength="1"
|
|
175
|
+
style="--tf:${tf};--tt:${tt};animation-duration:${durS}s"/>`;
|
|
176
|
+
stage.appendChild(svg);
|
|
177
|
+
const dot = document.createElement("div");
|
|
178
|
+
dot.className = "thread-dot";
|
|
179
|
+
dot.style.cssText = `--tf:${tf};--tt:${tt};offset-path:path('${THREAD_PATH}');animation-duration:${durS}s`;
|
|
180
|
+
stage.appendChild(dot);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── 밑줄: 폰트 로딩 후 실측 너비로 단일 곡선을 그린다 ──
|
|
184
|
+
const ready = document.fonts.ready.then(() => {
|
|
185
|
+
for (const { el, delay: d } of emWords) {
|
|
186
|
+
const w = el.getBoundingClientRect().width;
|
|
187
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
188
|
+
svg.setAttribute("class", "underline");
|
|
189
|
+
svg.setAttribute("width", String(w + 12));
|
|
190
|
+
svg.setAttribute("height", "16");
|
|
191
|
+
svg.style.left = "-6px";
|
|
192
|
+
svg.style.bottom = "-0.16em";
|
|
193
|
+
svg.innerHTML = `<path d="M 3 10 Q ${(w + 12) / 2} ${10 - Math.min(7, w * 0.03)} ${w + 9} 8"
|
|
194
|
+
pathLength="1" style="animation-delay:${d}ms"/>`;
|
|
195
|
+
el.appendChild(svg);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
window.__clipwiseSeek = async (t) => {
|
|
200
|
+
await ready;
|
|
201
|
+
for (const a of document.getAnimations()) { a.pause(); a.currentTime = t; }
|
|
202
|
+
};
|
|
203
|
+
</script>
|
|
204
|
+
</body>
|
|
205
|
+
</html>
|