kasaframe 0.1.1 → 0.1.3
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/NOTICE +10 -8
- package/README.md +16 -1
- package/app/server/engine.ts +133 -15
- package/app/server/main.ts +7 -3
- package/app/web/main.ts +218 -36
- package/app/web/style.css +37 -4
- package/bin/hooks.mjs +14 -1
- package/bin/kf.mjs +20 -2
- package/docs/AGENTS.md +64 -16
- package/music/README.md +31 -7
- package/package.json +2 -2
- package/packages/adapters/src/project.ts +1 -1
- package/packages/check/src/check.ts +10 -3
- package/packages/check/src/rules/assets.ts +4 -1
- package/packages/check/src/rules/frames.ts +2 -1
- package/packages/check/src/rules/layout.ts +30 -6
- package/packages/cli/package.json +2 -1
- package/packages/cli/src/blur.ts +15 -3
- package/packages/cli/src/commands/bench.ts +123 -0
- package/packages/cli/src/commands/blender.ts +72 -49
- package/packages/cli/src/commands/direct.ts +210 -0
- package/packages/cli/src/commands/doctor.ts +9 -1
- package/packages/cli/src/commands/look.ts +9 -2
- package/packages/cli/src/commands/lyrics.ts +47 -1
- package/packages/cli/src/commands/make.ts +729 -150
- package/packages/cli/src/commands/music.ts +78 -0
- package/packages/cli/src/commands/narrate.ts +238 -0
- package/packages/cli/src/commands/new.ts +14 -5
- package/packages/cli/src/commands/render.ts +94 -60
- package/packages/cli/src/commands/series.ts +110 -0
- package/packages/cli/src/commands/setup.ts +42 -3
- package/packages/cli/src/commands/theme.ts +89 -2
- package/packages/cli/src/deps.ts +6 -6
- package/packages/cli/src/kf.ts +14 -3
- package/packages/cli/src/mix.ts +131 -0
- package/packages/cli/src/pool.ts +34 -0
- package/packages/cli/src/segments.ts +402 -0
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/project.ts +11 -0
- package/packages/director/package.json +23 -0
- package/packages/director/src/assemble.ts +361 -0
- package/packages/director/src/brief.ts +122 -0
- package/packages/director/src/catalog.ts +49 -0
- package/packages/director/src/content.ts +343 -0
- package/packages/director/src/fallback.ts +58 -0
- package/packages/director/src/index.ts +13 -0
- package/packages/director/src/pattern.ts +90 -0
- package/packages/director/src/patterns/fill/cells.ts +115 -0
- package/packages/director/src/patterns/formula/stack.ts +93 -0
- package/packages/director/src/patterns/util.ts +58 -0
- package/packages/director/src/scene.ts +62 -0
- package/packages/director/src/select.ts +163 -0
- package/packages/director/src/transitions.ts +115 -0
- package/packages/gen/PROVIDERS.md +39 -4
- package/packages/gen/src/ask.ts +132 -0
- package/packages/gen/src/audio.ts +91 -0
- package/packages/gen/src/blender.ts +54 -0
- package/packages/gen/src/capabilities.ts +151 -0
- package/packages/gen/src/generate.ts +4 -2
- package/packages/gen/src/image/codex.ts +117 -0
- package/packages/gen/src/index.ts +10 -2
- package/packages/gen/src/keys.ts +26 -0
- package/packages/gen/src/music/elevenlabs.ts +41 -0
- package/packages/gen/src/music/index.ts +120 -0
- package/packages/gen/src/music/library.ts +73 -0
- package/packages/gen/src/music/lyria.ts +52 -0
- package/packages/gen/src/music/types.ts +28 -0
- package/packages/gen/src/registry.ts +6 -3
- package/packages/gen/src/tts/dashscope.ts +64 -0
- package/packages/gen/src/tts/elevenlabs.ts +84 -0
- package/packages/gen/src/tts/gemini.ts +73 -0
- package/packages/gen/src/tts/index.ts +241 -0
- package/packages/gen/src/tts/local.ts +42 -0
- package/packages/gen/src/tts/types.ts +44 -0
- package/packages/gen/src/types.ts +4 -0
- package/packages/gen/src/video/h3.ts +5 -0
- package/packages/gen/src/video/http.ts +1 -1
- package/packages/jizura/src/index.ts +2 -2
- package/packages/jizura/src/install.ts +198 -155
- package/packages/jizura/src/layer.ts +75 -32
- package/packages/jizura/src/pin.ts +4 -1
- package/packages/jizura/src/vendor.ts +140 -0
- package/packages/jizura/src/vite.ts +34 -7
- package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
- package/packages/jizura/vendor/NOTICE +43 -0
- package/packages/jizura/vendor/engine.json +47 -0
- package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
- package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
- package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
- package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
- package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
- package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
- package/packages/jizura/vendor/fonts/fonts.json +52 -0
- package/packages/jizura/vendor/jizura_engine.js +35024 -0
- package/packages/motion/README.md +11 -0
- package/packages/motion/package.json +1 -0
- package/packages/motion/src/captions.ts +32 -24
- package/packages/motion/src/moves/brushWipe.ts +14 -1
- package/packages/motion/src/moves/dress.ts +191 -0
- package/packages/motion/src/moves/echoStack.ts +3 -3
- package/packages/motion/src/moves/fit.ts +36 -0
- package/packages/motion/src/moves/ghostStack.ts +14 -5
- package/packages/motion/src/moves/imageSwarm.ts +8 -4
- package/packages/motion/src/moves/index.ts +23 -4
- package/packages/motion/src/moves/karaokeLine.ts +17 -3
- package/packages/motion/src/moves/menu.ts +138 -0
- package/packages/motion/src/moves/neonSign.ts +2 -1
- package/packages/motion/src/moves/slamWords.ts +14 -7
- package/packages/motion/src/moves/sparkTrail.ts +16 -0
- package/packages/motion/src/moves/splitFlap.ts +4 -2
- package/packages/motion/src/moves/stepWords.ts +19 -8
- package/packages/motion/src/moves/tickerRows.ts +13 -9
- package/packages/motion/src/moves/tokenPicker.ts +4 -3
- package/packages/motion/src/moves/wordWall.ts +23 -21
- package/packages/motion/src/moves/worldRoll.ts +2 -1
- package/packages/motion/src/transition3d/index.ts +10 -3
- package/packages/motion/src/transition3d/rig.ts +107 -9
- package/packages/motion/src/transition3d/shatter.ts +5 -2
- package/packages/motion/src/transition3d/space.ts +173 -0
- package/packages/motion/src/transition3d/whip.ts +19 -6
- package/packages/motion/src/transition3d/world.ts +54 -0
- package/packages/motion/src/transitions2d/catalog.ts +1 -1
- package/packages/patterns/package.json +15 -0
- package/packages/patterns/src/bars/rank.ts +119 -0
- package/packages/patterns/src/before-after/shrink.ts +127 -0
- package/packages/patterns/src/before-after/strike.ts +125 -0
- package/packages/patterns/src/code/terminal.ts +133 -0
- package/packages/patterns/src/compare/bars.ts +142 -0
- package/packages/patterns/src/compare/duel.ts +103 -0
- package/packages/patterns/src/end/lockup.ts +85 -0
- package/packages/patterns/src/end/slam.ts +91 -0
- package/packages/patterns/src/flow/graph.ts +250 -0
- package/packages/patterns/src/hook-number/counter.ts +101 -0
- package/packages/patterns/src/hook-number/roll.ts +179 -0
- package/packages/patterns/src/hook-number/slam.ts +91 -0
- package/packages/patterns/src/index.ts +38 -0
- package/packages/patterns/src/kit.ts +449 -0
- package/packages/patterns/src/limit/gauge.ts +117 -0
- package/packages/patterns/src/list/cards.ts +131 -0
- package/packages/patterns/src/list/cycle.ts +22 -0
- package/packages/patterns/src/media/panels.ts +100 -0
- package/packages/patterns/src/objects.ts +234 -0
- package/packages/patterns/src/quote/karaoke.ts +126 -0
- package/packages/patterns/src/quote/slam.ts +22 -0
- package/packages/patterns/src/screen/focus.ts +85 -0
- package/packages/patterns/src/steps/path.ts +137 -0
- package/packages/patterns/src/steps/stack.ts +111 -0
- package/packages/patterns/src/term-reveal/chips.ts +92 -0
- package/packages/patterns/src/term-reveal/expand.ts +127 -0
- package/packages/patterns/src/timeline/axis.ts +144 -0
- package/packages/patterns/src/title/mask.ts +85 -0
- package/packages/patterns/src/title/slam.ts +23 -0
- package/packages/patterns/src/toggle-sim/switch.ts +92 -0
- package/packages/patterns/src/trend/columns.ts +108 -0
- package/packages/patterns/src/trend/draw.ts +140 -0
- package/packages/patterns/src/types.ts +67 -0
- package/packages/patterns/src/words/moves.ts +42 -0
- package/packages/patterns/src/words/slam.ts +4 -0
- package/packages/plan/src/asks.ts +256 -0
- package/packages/plan/src/brief.ts +136 -7
- package/packages/plan/src/captions.ts +5 -2
- package/packages/plan/src/index.ts +6 -2
- package/packages/plan/src/llm.ts +64 -2
- package/packages/plan/src/look.ts +3 -1
- package/packages/plan/src/plan.ts +67 -13
- package/packages/plan/src/series.ts +76 -0
- package/packages/plan/src/types.ts +53 -2
- package/packages/themes/package.json +5 -2
- package/packages/themes/src/index.ts +9 -0
- package/packages/themes/src/slotkit.ts +79 -0
- package/packages/themes/src/slots.ts +46 -0
- package/packages/themes/src/stage3d.ts +20 -0
- package/series/og-shorts.json +14 -0
- package/skills/kasaframe/SKILL.md +4 -3
- package/templates/_shared/project/lib/stage.ts +70 -50
- package/templates/_shared/scenes/default.ts +10 -7
- package/templates/_shared/scenes/drop.ts +11 -8
- package/templates/_shared/scenes/end.ts +9 -6
- package/templates/_shared/scenes/hook.ts +8 -6
- package/templates/_shared/scenes/step.ts +13 -4
- package/templates/ad/template.json +20 -1
- package/templates/demo/template.json +11 -0
- package/templates/explainer/template.json +54 -3
- package/templates/launch/template.json +34 -1
- package/templates/mv/scenes/default.ts +11 -8
- package/templates/mv/template.json +4 -2
- package/templates/promo/template.json +42 -1
- package/templates/shorts/scenes/hook.ts +8 -5
- package/templates/shorts/template.json +72 -3
- package/templates/square/template.json +21 -1
- package/themes/debug-hud/slots.ts +99 -0
- package/themes/og-measure/slots.ts +88 -0
- package/themes/riso-zine/slots.ts +81 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clamp, ease, safeTime } from "@kasaframe/core";
|
|
1
|
+
import { clamp, ease, lerp, safeTime } from "@kasaframe/core";
|
|
2
2
|
import { div, mark } from "./fit.ts";
|
|
3
3
|
|
|
4
4
|
export interface KaraokeLine {
|
|
@@ -53,17 +53,22 @@ export function karaokeLine(el: HTMLElement, o: KaraokeLineOpts): (t: number) =>
|
|
|
53
53
|
const band = div(
|
|
54
54
|
doc,
|
|
55
55
|
`position:absolute;left:50%;top:${y}px;display:flex;gap:.3em;white-space:nowrap;padding:.22em .6em;border-radius:.5em;` +
|
|
56
|
-
`background:rgba(10,10,14,.72);font-family:var(--kf-font-display);font-weight:800;font-size:${size}px;line-height:1.1;color:rgba(242,243,245,.
|
|
56
|
+
`background:rgba(10,10,14,.72);font-family:var(--kf-font-display);font-weight:800;font-size:${size}px;line-height:1.1;color:rgba(242,243,245,.42)`,
|
|
57
57
|
);
|
|
58
|
+
// 지금 부르는 낱말 뒤 알약 — 낱말에서 낱말로 옮겨 가며 부르는 자리를 짚는다. 안 부른 글은 흐리게 둬 차오름이 밝기로도 보인다
|
|
59
|
+
const pill = div(doc, "position:absolute;left:0;top:.12em;height:1.3em;border-radius:.4em;background:color-mix(in srgb, var(--kf-accent) 30%, transparent)");
|
|
60
|
+
band.append(pill);
|
|
61
|
+
const spans: HTMLDivElement[] = [];
|
|
58
62
|
const words = l.words.map((w) => {
|
|
59
63
|
const span = div(doc, "position:relative;display:inline-block", w);
|
|
60
64
|
const fill = div(doc, "position:absolute;left:0;top:0;color:var(--kf-accent)", w);
|
|
61
65
|
span.append(fill);
|
|
62
66
|
band.append(span);
|
|
67
|
+
spans.push(span);
|
|
63
68
|
return fill;
|
|
64
69
|
});
|
|
65
70
|
layer.append(band);
|
|
66
|
-
return { band, words };
|
|
71
|
+
return { band, words, pill, spans };
|
|
67
72
|
});
|
|
68
73
|
|
|
69
74
|
return (t: number) => {
|
|
@@ -81,13 +86,22 @@ export function karaokeLine(el: HTMLElement, o: KaraokeLineOpts): (t: number) =>
|
|
|
81
86
|
if (inP < 1 || outP > 0) moving = true;
|
|
82
87
|
r.band.style.opacity = (inP * (1 - outP)).toFixed(3);
|
|
83
88
|
r.band.style.transform = `translate(-50%, ${(-50 + 40 * (1 - inP) - 60 * outP).toFixed(1)}%)`;
|
|
89
|
+
let k = 0;
|
|
84
90
|
r.words.forEach((f, j) => {
|
|
85
91
|
const a = l.times[j];
|
|
86
92
|
const b = Math.min(l.times[j + 1] ?? l.end, a + 0.6);
|
|
87
93
|
const p = clamp((t - a) / Math.max(0.05, b - a));
|
|
88
94
|
if (p > 0 && p < 1) moving = true;
|
|
95
|
+
if (t >= a) k = j;
|
|
89
96
|
f.style.clipPath = `inset(0 ${((1 - p) * 100).toFixed(2)}% 0 0)`;
|
|
90
97
|
});
|
|
98
|
+
// 알약은 부르는 낱말 자리로 미끄러져 간다 — 앞 낱말 자리에서 0.18초에 걸쳐
|
|
99
|
+
const cur = r.spans[k];
|
|
100
|
+
const prev = r.spans[Math.max(0, k - 1)];
|
|
101
|
+
const m = k > 0 ? ease.outCubic(clamp((t - l.times[k]) / 0.18)) : 1;
|
|
102
|
+
const pad = size * 0.18;
|
|
103
|
+
r.pill.style.transform = `translateX(${(lerp(prev.offsetLeft, cur.offsetLeft, m) - pad).toFixed(1)}px)`;
|
|
104
|
+
r.pill.style.width = `${(lerp(prev.offsetWidth, cur.offsetWidth, m) + pad * 2).toFixed(1)}px`;
|
|
91
105
|
});
|
|
92
106
|
mark("karaokeLine", o.scene, moving);
|
|
93
107
|
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { rand } from "@kasaframe/core";
|
|
2
|
+
|
|
3
|
+
// 뼈대 장면이 바로 거는 연출 수 메뉴. 「대충 한 요청」에서도 좋은 수가 나오게 kf new 가 장면마다 고르고(같은 요청이면 같은 수),
|
|
4
|
+
// 지시서(MAKE.md)가 같은 메뉴를 보여 준다. DOM 을 안 만져 Node(계획·지시서)에서도 읽는다 — 실제로 거는 것은 dress.ts.
|
|
5
|
+
|
|
6
|
+
export type DressKind = "bg" | "text" | "wipe";
|
|
7
|
+
|
|
8
|
+
export interface MenuItem {
|
|
9
|
+
name: string;
|
|
10
|
+
kind: DressKind;
|
|
11
|
+
does: string;
|
|
12
|
+
/** examples/moves/<example>/ — 수만 따로 본 3–5초 예시 */
|
|
13
|
+
example?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 바탕(bg)은 장면 전체 뒤 — 정지만 둔다. 뜻 없이 떠도는 장식(빛 방울·도는 줄무늬·흐르는 모눈)은 보는 사람이 무엇인지 묻게 만든다.
|
|
17
|
+
// 움직이는 바탕 수(sunburst·speedLines·gridFloor·truchet)는 장면이 이름으로 부를 때만 dressBg 가 건다.
|
|
18
|
+
// 글(text)은 안전 영역 안, 덮기(wipe)는 장면 경계를 걸쳐 앞 장면 끝을 덮고 뒤 장면 처음에 걷힌다
|
|
19
|
+
export const MENU: readonly MenuItem[] = [
|
|
20
|
+
{ name: "still", kind: "bg", does: "정지 바탕 — 바탕색 위 아주 옅은 점 질감. 쉬지 않기는 글·숫자 수가 진다" },
|
|
21
|
+
{ name: "bigWord", kind: "text", does: "큰 낱말 하나 + 등장 움직임(무대 기본)" },
|
|
22
|
+
{ name: "slamWords", kind: "text", does: "박마다 낱말이 화면을 꽉 채우며 쾅 — 판과 글색이 뒤집힌다", example: "slamWords" },
|
|
23
|
+
{ name: "echoStack", kind: "text", does: "같은 낱말이 박마다 한 벌씩 메아리치며 쌓인다", example: "echoStack" },
|
|
24
|
+
{ name: "splitFlap", kind: "text", does: "판 넘김 글자판 — 칸마다 접히며 글자가 넘어가 앉는다", example: "splitFlap" },
|
|
25
|
+
{ name: "ghostStack", kind: "text", does: "외곽선 유령 줄이 먼저 깔리고 박에 채워지며 위로 밀린다", example: "ghostStack" },
|
|
26
|
+
{ name: "ransomLetters", kind: "text", does: "오려 붙인 글자 — 박마다 글자 모양을 다시 뽑는다", example: "ransomLetters" },
|
|
27
|
+
{ name: "neonSign", kind: "text", does: "네온 관 글자가 시동 걸리듯 깜빡이다 켜진다", example: "neonSign" },
|
|
28
|
+
{ name: "ledBoard", kind: "text", does: "LED 점 글판에 한 칸씩 찍힌다", example: "ledBoard" },
|
|
29
|
+
{ name: "none", kind: "wipe", does: "덮지 않고 바로 컷" },
|
|
30
|
+
{ name: "brushWipe", kind: "wipe", does: "붓 획 띠가 번갈아 덮었다가 꼬리가 빠지며 걷힌다", example: "brushWipe" },
|
|
31
|
+
{ name: "irisBlades", kind: "wipe", does: "여섯 날 조리개가 닫혔다 열린다", example: "irisBlades" },
|
|
32
|
+
{ name: "flood", kind: "wipe", does: "가운데 점이 화면을 넘치게 덮었다가 오므라든다", example: "flood" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const menuItem = (name: string): MenuItem | undefined => MENU.find((m) => m.name === name);
|
|
36
|
+
|
|
37
|
+
export interface Pool {
|
|
38
|
+
bg?: string[];
|
|
39
|
+
text?: string[];
|
|
40
|
+
wipe?: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 용도 틀(template.json moves)·테마가 주는 추천 묶음. roles 는 장면 역할(hook·drop·end …)마다 덮어쓴다 */
|
|
44
|
+
export interface Bundle extends Pool {
|
|
45
|
+
roles?: Record<string, Pool>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 틀에 moves 가 없을 때 — 어느 용도에도 무난한 묶음
|
|
49
|
+
const BASE: Required<Pool> = { bg: ["still"], text: ["bigWord", "echoStack", "splitFlap", "ghostStack"], wipe: ["brushWipe", "irisBlades", "flood"] };
|
|
50
|
+
|
|
51
|
+
// 테마마다 비유에 맞는 수 — 테마를 고르면 용도 묶음보다 앞에 둔다(테마 효과와 결이 같은 것만)
|
|
52
|
+
export const THEME_POOLS: Readonly<Record<string, Pool>> = {
|
|
53
|
+
"debug-hud": { text: ["splitFlap", "ledBoard", "ghostStack"], wipe: ["irisBlades", "flood"] },
|
|
54
|
+
"og-measure": { text: ["splitFlap", "ghostStack", "echoStack"], wipe: ["irisBlades", "flood"] },
|
|
55
|
+
"kasaterm-cells": { text: ["ledBoard", "splitFlap", "slamWords"], wipe: ["flood", "irisBlades"] },
|
|
56
|
+
"kawaii-sticker": { text: ["ransomLetters", "slamWords", "echoStack"], wipe: ["brushWipe", "flood"] },
|
|
57
|
+
"riso-zine": { text: ["ransomLetters", "echoStack", "slamWords"], wipe: ["brushWipe", "flood"] },
|
|
58
|
+
"depth-flight": { text: ["neonSign", "ghostStack", "splitFlap"], wipe: ["irisBlades", "flood"] },
|
|
59
|
+
"iso-diorama": { text: ["splitFlap", "echoStack", "ghostStack"], wipe: ["irisBlades", "brushWipe"] },
|
|
60
|
+
"glass-light": { text: ["ghostStack", "echoStack", "splitFlap"], wipe: ["irisBlades", "flood"] },
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const known = (kind: DressKind) => (n: string) => menuItem(n)?.kind === kind;
|
|
64
|
+
|
|
65
|
+
/** 틀 묶음 + 테마 묶음 → 한 벌. 테마 수를 앞에, 메뉴에 없는 이름은 버린다 */
|
|
66
|
+
export function bundleFor(tpl?: Bundle, theme?: string): Required<Bundle> {
|
|
67
|
+
const th = theme ? THEME_POOLS[theme] : undefined;
|
|
68
|
+
const merge = (kind: DressKind, ...lists: (string[] | undefined)[]) => [...new Set(lists.flatMap((l) => l ?? []))].filter(known(kind));
|
|
69
|
+
const roles: Record<string, Pool> = {};
|
|
70
|
+
for (const [role, p] of Object.entries(tpl?.roles ?? {})) {
|
|
71
|
+
// 역할 몫은 틀이 이유를 대고 고른 것(훅은 첫 프레임부터 꽉 차는 글) — 테마 수는 그 뒤에 섞지 않는다
|
|
72
|
+
roles[role] = { bg: p.bg?.filter(known("bg")), text: p.text?.filter(known("text")), wipe: p.wipe?.filter(known("wipe")) };
|
|
73
|
+
}
|
|
74
|
+
const bg = merge("bg", th?.bg, tpl?.bg ?? BASE.bg);
|
|
75
|
+
return {
|
|
76
|
+
bg: bg.length ? bg : BASE.bg,
|
|
77
|
+
text: merge("text", th?.text, tpl?.text ?? BASE.text),
|
|
78
|
+
wipe: merge("wipe", th?.wipe, tpl?.wipe ?? BASE.wipe),
|
|
79
|
+
roles,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface Dress {
|
|
84
|
+
bg: string;
|
|
85
|
+
text: string;
|
|
86
|
+
/** 앞 장면 경계에서 걷히는 덮기 — 앞 장면의 wipeOut 과 같은 이름이라 이어진다 */
|
|
87
|
+
wipeIn: string;
|
|
88
|
+
wipeOut: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const NO_DRESS: Dress = { bg: "still", text: "bigWord", wipeIn: "none", wipeOut: "none" };
|
|
92
|
+
|
|
93
|
+
/** 같은 요청·용도·테마면 같은 글이 나오게 공백만 고른다 */
|
|
94
|
+
export const dressSeed = (...parts: (string | undefined | null)[]): string =>
|
|
95
|
+
parts
|
|
96
|
+
.map((p) => (p ?? "").trim().replace(/\s+/g, " "))
|
|
97
|
+
.join("|");
|
|
98
|
+
|
|
99
|
+
// 이어지는 장면이 같은 수를 쓰지 않게 — 고를 게 하나뿐이면 그대로
|
|
100
|
+
function choose(seed: string, i: number, list: readonly string[], avoid: string | undefined): string {
|
|
101
|
+
const pool = list.length > 1 && avoid ? list.filter((x) => x !== avoid) : list;
|
|
102
|
+
return pool[Math.min(pool.length - 1, Math.floor(rand(seed, i) * pool.length))];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 장면마다 바탕·글·경계 덮기를 고른다. 시드(요청 글·용도·테마)가 같으면 늘 같은 고름이다.
|
|
107
|
+
* - 이음매(seam)는 3D 전환이 맡아 아무것도 걸지 않고, 그 앞뒤 경계도 덮지 않는다(전환이 앞뒤 화면을 그대로 잡아야 한다).
|
|
108
|
+
* - 드롭으로 들어가는 경계는 덮지 않는다 — 드롭 첫 박은 맨 화면으로 터져야 한다. 첫 장면은 첫 프레임부터 내용이 있어야 해서 덮고 시작하지 않는다.
|
|
109
|
+
* - 되돌이(loop)면 마지막 장면 끝도 덮지 않는다(끝 화면이 첫 화면으로 이어진다).
|
|
110
|
+
*/
|
|
111
|
+
export function pickDress(seed: string, scenes: readonly { id: string; role: string; dur: number }[], bundle: Required<Bundle>): Dress[] {
|
|
112
|
+
const out: Dress[] = [];
|
|
113
|
+
for (const [i, s] of scenes.entries()) {
|
|
114
|
+
if (s.role === "seam") {
|
|
115
|
+
out.push({ ...NO_DRESS });
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const role = bundle.roles[s.role] ?? {};
|
|
119
|
+
const prev = out[i - 1];
|
|
120
|
+
const bg = choose(seed, i * 4, role.bg?.length ? role.bg : bundle.bg, prev?.bg);
|
|
121
|
+
const text = choose(seed, i * 4 + 1, role.text?.length ? role.text : bundle.text, prev?.text);
|
|
122
|
+
out.push({ bg, text, wipeIn: "none", wipeOut: "none" });
|
|
123
|
+
}
|
|
124
|
+
let last = "";
|
|
125
|
+
for (let i = 0; i + 1 < scenes.length; i++) {
|
|
126
|
+
const a = scenes[i];
|
|
127
|
+
const b = scenes[i + 1];
|
|
128
|
+
if (a.role === "seam" || b.role === "seam" || b.role === "drop" || a.dur < 1.2 || b.dur < 1.2) continue;
|
|
129
|
+
const list = (bundle.roles[b.role]?.wipe?.length ? bundle.roles[b.role].wipe : bundle.wipe) ?? [];
|
|
130
|
+
if (!list.length) continue;
|
|
131
|
+
const w = choose(seed, i * 4 + 2, list, last);
|
|
132
|
+
if (w === "none") continue;
|
|
133
|
+
out[i].wipeOut = w;
|
|
134
|
+
out[i + 1].wipeIn = w;
|
|
135
|
+
last = w;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
@@ -64,7 +64,8 @@ export function neonSign(el: HTMLElement, o: NeonSignOpts): (t: number) => void
|
|
|
64
64
|
if (cyc > 0.9 && cyc < 0.98) on = false;
|
|
65
65
|
}
|
|
66
66
|
if (on) lit++;
|
|
67
|
-
|
|
67
|
+
// 꺼진 관도 읽혀야 한다 — 어두운 벽 위 3:1 넘는 흐린 유리색(검사 text.contrast)
|
|
68
|
+
l.s.style.color = on ? "#FFF3F6" : "#9A6478";
|
|
68
69
|
l.s.style.textShadow = on ? glow : "none";
|
|
69
70
|
});
|
|
70
71
|
// 벽 번짐은 켜진 관 수만큼, 미세하게 웅웅거린다
|
|
@@ -13,13 +13,14 @@ export interface SlamWordsOpts {
|
|
|
13
13
|
scene?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// 바탕 밝기는 거의 그대로 두고 글색만 뒤집는다 — 박마다 화면 전체가 강조색 바탕으로 번쩍이면 1초에 섬광 세 번을 넘는다(frame.flash)
|
|
16
17
|
const PALETTES: [string, string][] = [
|
|
17
18
|
["var(--kf-bg)", "var(--kf-ink)"],
|
|
18
|
-
["var(--kf-
|
|
19
|
+
["var(--kf-raised)", "var(--kf-accent)"],
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
|
-
* 박마다 낱말 하나가 화면을 꽉 채우며 쾅 — 1.22배에서 내려앉고, 글자는 바닥에서 꼬리를 끌며 차례로 솟고,
|
|
23
|
+
* 박마다 낱말 하나가 화면을 꽉 채우며 쾅 — 1.22배에서 내려앉고, 글자는 바닥에서 꼬리를 끌며 차례로 솟고, 판과 글색이 뒤집힌다.
|
|
23
24
|
* 다음 박까지는 다가가며 옆으로 흘러 멈추지 않는다. (mexicat 「I'm Upping My P(doom)」 59–60초의 훅)
|
|
24
25
|
*/
|
|
25
26
|
export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => void {
|
|
@@ -30,7 +31,8 @@ export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => voi
|
|
|
30
31
|
const layer = div(doc, "position:absolute;inset:0;overflow:hidden");
|
|
31
32
|
el.append(layer);
|
|
32
33
|
const words = o.words.slice(0, o.hits.length).map((text) => {
|
|
33
|
-
|
|
34
|
+
// 폭 80% — 1.22배로 내려앉는 순간에도 화면 안(글자가 화면 끝을 넘나들면 잘림·섬광 검사에 걸린다)
|
|
35
|
+
const px = fitSize(text, W, H, { share: 0.8, hShare: 0.72 });
|
|
34
36
|
const box = div(
|
|
35
37
|
doc,
|
|
36
38
|
`position:absolute;left:50%;top:50%;display:flex;white-space:nowrap;font-family:var(--kf-font-display);font-size:${px}px;` +
|
|
@@ -70,9 +72,13 @@ export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => voi
|
|
|
70
72
|
wd.box.style.color = ink;
|
|
71
73
|
const land = ease.outCubic(clamp(local / 0.2));
|
|
72
74
|
const after = Math.max(0, local - 0.2);
|
|
73
|
-
// 박 사이에도 다가가며 옆으로 흘러야 0.1초 멈춤에 안
|
|
74
|
-
|
|
75
|
-
const
|
|
75
|
+
// 박 사이에도 다가가며 옆으로 흘러야 0.1초 멈춤에 안 걸린다. 얼마나 다가가나는 다음 쾅까지의 몫으로 —
|
|
76
|
+
// 쾅 사이가 길어도 낱말이 화면 밖으로 커지지 않는다(마지막 낱말은 1초 몫, 그 뒤로는 아주 천천히)
|
|
77
|
+
const hold = Math.max(0.3, (o.hits[i + 1] ?? o.hits[i] + 1) - o.hits[i] - 0.2);
|
|
78
|
+
const q = after / hold;
|
|
79
|
+
const grow = 0.12 * Math.min(q, 1) + 0.03 * Math.max(0, q - 1);
|
|
80
|
+
const scale = (1.22 - 0.22 * land) * (1 + grow);
|
|
81
|
+
const dx = (i % 2 ? -1 : 1) * W * 0.035 * Math.min(q, 1.3);
|
|
76
82
|
wd.box.style.transform = `translate(calc(-50% + ${dx.toFixed(1)}px),-50%) scale(${scale.toFixed(4)})`;
|
|
77
83
|
const each = Math.min(0.035, 0.12 / wd.letters.length);
|
|
78
84
|
wd.letters.forEach((l, j) => {
|
|
@@ -81,7 +87,8 @@ export function slamWords(el: HTMLElement, o: SlamWordsOpts): (t: number) => voi
|
|
|
81
87
|
l.span.style.opacity = p > 0 ? "1" : "0";
|
|
82
88
|
l.span.style.transform = `translateY(${((1 - e) * wd.px * 0.7).toFixed(1)}px)`;
|
|
83
89
|
l.tail.style.transform = `scaleY(${(1 - e).toFixed(3)})`;
|
|
84
|
-
|
|
90
|
+
// 꼬리 줄무늬는 옅게 — 진한 줄무늬가 솟으며 칸마다 밝음·어둠을 오가 섬광 검사(frame.flash)에 걸렸다
|
|
91
|
+
l.tail.style.opacity = (0.35 * (1 - e)).toFixed(3);
|
|
85
92
|
});
|
|
86
93
|
mark("slamWords", o.scene, local < 0.25);
|
|
87
94
|
});
|
|
@@ -14,6 +14,8 @@ export interface SparkTrailOpts {
|
|
|
14
14
|
color?: string;
|
|
15
15
|
width?: number;
|
|
16
16
|
sparks?: number;
|
|
17
|
+
/** 선 아래(이 y 까지)를 옅게 채운다 — 값의 흐름을 그린 선이면 그래프 면적이 머리를 따라 차오른다 */
|
|
18
|
+
area?: { to: number; color?: string };
|
|
17
19
|
w?: number;
|
|
18
20
|
h?: number;
|
|
19
21
|
seed?: string | number;
|
|
@@ -53,6 +55,20 @@ export function sparkTrail(el: HTMLElement, o: SparkTrailOpts): (t: number) => v
|
|
|
53
55
|
if (!color) color = getComputedStyle(el.ownerDocument.documentElement).getPropertyValue("--kf-accent").trim() || "#ff6b2c";
|
|
54
56
|
const head = sAt(t);
|
|
55
57
|
const from = o.tail !== undefined ? Math.max(0, sAt(t - o.tail)) : 0;
|
|
58
|
+
if (o.area && head > 0) {
|
|
59
|
+
g.beginPath();
|
|
60
|
+
const p0 = pointAt(pts, cum, 0);
|
|
61
|
+
g.moveTo(p0.x, o.area.to);
|
|
62
|
+
g.lineTo(p0.x, p0.y);
|
|
63
|
+
for (let i = 0; i < pts.length && cum[i] < head; i++) g.lineTo(pts[i][0], pts[i][1]);
|
|
64
|
+
const ph = pointAt(pts, cum, head);
|
|
65
|
+
g.lineTo(ph.x, ph.y);
|
|
66
|
+
g.lineTo(ph.x, o.area.to);
|
|
67
|
+
g.closePath();
|
|
68
|
+
g.globalAlpha = 1;
|
|
69
|
+
g.fillStyle = o.area.color ?? `color-mix(in srgb, ${color} 22%, transparent)`;
|
|
70
|
+
g.fill();
|
|
71
|
+
}
|
|
56
72
|
g.lineCap = "round";
|
|
57
73
|
g.lineJoin = "round";
|
|
58
74
|
g.strokeStyle = color;
|
|
@@ -139,9 +139,11 @@ export function splitFlap(el: HTMLElement, o: SplitFlapOpts): (t: number) => voi
|
|
|
139
139
|
flapBottom.box.style.transform = `rotateX(${(90 * (1 - b)).toFixed(2)}deg)`;
|
|
140
140
|
flapBottom.box.style.filter = `brightness(${(0.6 + 0.4 * b).toFixed(3)})`;
|
|
141
141
|
});
|
|
142
|
-
// 판 위로 빛이 늘
|
|
142
|
+
// 판 위로 빛이 늘 지나가고, 넘김 사이에는 판이 다가가다 다음 넘김에 제자리로 — 글자가 다 앉은 뒤에도 멈추지 않는다.
|
|
143
|
+
// 밝은 바탕에서 판 넘김·빛만으로는 160px 썸네일의 평균 밝기 차 0.35 를 못 넘어 멈춤(frame.freeze)으로 잡혔다
|
|
143
144
|
gloss.style.backgroundPosition = `${(100 - ((t * 45) % 200)).toFixed(1)}% 0`;
|
|
144
|
-
|
|
145
|
+
const since = k < 0 ? t : t - o.times[k];
|
|
146
|
+
board.style.transform = `scale(${(1 + 0.06 * Math.min(since, 1.5)).toFixed(4)})`;
|
|
145
147
|
mark("splitFlap", o.scene, moving);
|
|
146
148
|
};
|
|
147
149
|
}
|
|
@@ -99,19 +99,30 @@ export function stepWords(el: HTMLElement, o: StepWordsOpts): (t: number) => voi
|
|
|
99
99
|
const hp = pointAt(corners, cum, reach);
|
|
100
100
|
dot.setAttribute("cx", hp.x.toFixed(1));
|
|
101
101
|
dot.setAttribute("cy", hp.y.toFixed(1));
|
|
102
|
-
tags.forEach((g, i) => {
|
|
103
|
-
const local = t - o.steps[i].at;
|
|
104
|
-
const p = clamp(local / 0.22);
|
|
105
|
-
const [cx, cy] = corners[ends[i]];
|
|
106
|
-
g.tag.style.opacity = local >= 0 ? "1" : "0";
|
|
107
|
-
g.tag.style.transform = `translate(${(cx + 28).toFixed(1)}px,${(cy - size * 0.62).toFixed(1)}px) rotate(${g.tilt.toFixed(2)}deg) scale(${ease.outBack(p).toFixed(4)})`;
|
|
108
|
-
});
|
|
109
102
|
// 카메라: 그리는 머리를 화면 가운데 쪽으로 천천히 따라간다
|
|
110
103
|
const fx = W * 0.5 - hp.x;
|
|
111
104
|
const fy = H * 0.5 - hp.y;
|
|
112
105
|
const follow = 0.85;
|
|
113
106
|
// 마지막 칸 뒤에도 떠다녀 멈추지 않는다
|
|
114
|
-
|
|
107
|
+
const camX = fx * follow + 24 * Math.sin(t * 1.4);
|
|
108
|
+
const camY = fy * follow + 14 * Math.sin(t * 1.9);
|
|
109
|
+
const zoom = 1 + 0.03 * t;
|
|
110
|
+
cam.style.transform = `translate(${camX.toFixed(1)}px,${camY.toFixed(1)}px) scale(${zoom.toFixed(4)})`;
|
|
111
|
+
const toX = (x: number) => W / 2 + (x - W / 2) * zoom + camX;
|
|
112
|
+
const toY = (y: number) => H / 2 + (y - H / 2) * zoom + camY;
|
|
113
|
+
tags.forEach((g, i) => {
|
|
114
|
+
const local = t - o.steps[i].at;
|
|
115
|
+
const p = clamp(local / 0.22);
|
|
116
|
+
const [cx, cy] = corners[ends[i]];
|
|
117
|
+
const x = cx + 28;
|
|
118
|
+
const y = cy - size * 0.62;
|
|
119
|
+
// 카메라가 앞으로 가면 지난 칸은 화면 밖으로 빠진다 — 끝에 걸리기 전에 흐려진다(잘린 글이 남지 않게)
|
|
120
|
+
const tw = g.tag.offsetWidth || size * 3;
|
|
121
|
+
const th = g.tag.offsetHeight || size * 1.2;
|
|
122
|
+
const edge = Math.min(toX(x), toY(y), W - toX(x + tw), H - toY(y + th));
|
|
123
|
+
g.tag.style.opacity = local >= 0 ? clamp(edge / (size * 0.5)).toFixed(3) : "0";
|
|
124
|
+
g.tag.style.transform = `translate(${x.toFixed(1)}px,${y.toFixed(1)}px) rotate(${g.tilt.toFixed(2)}deg) scale(${ease.outBack(p).toFixed(4)})`;
|
|
125
|
+
});
|
|
115
126
|
mark("stepWords", o.scene, k < n - 1 || t - (times[n - 1] ?? 0) < 0.22);
|
|
116
127
|
};
|
|
117
128
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { clamp, ease, safeTime } from "@kasaframe/core";
|
|
2
|
-
import { div, mark, textEm } from "./fit.ts";
|
|
2
|
+
import { div, mark, strip, textEm } from "./fit.ts";
|
|
3
3
|
|
|
4
4
|
export interface TickerRow {
|
|
5
5
|
text: string;
|
|
@@ -24,7 +24,6 @@ export interface TickerRowsOpts {
|
|
|
24
24
|
at?: number;
|
|
25
25
|
w?: number;
|
|
26
26
|
h?: number;
|
|
27
|
-
dur?: number;
|
|
28
27
|
scene?: string;
|
|
29
28
|
}
|
|
30
29
|
|
|
@@ -40,7 +39,6 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
|
|
|
40
39
|
const gap = o.gap ?? 22;
|
|
41
40
|
const top = o.top ?? (H - o.rows.length * (rowH + gap) + gap) / 2;
|
|
42
41
|
const at = o.at ?? 0;
|
|
43
|
-
const dur = o.dur ?? 30;
|
|
44
42
|
const fs = rowH * 0.46;
|
|
45
43
|
const layer = div(doc, "position:absolute;inset:0;overflow:hidden");
|
|
46
44
|
el.append(layer);
|
|
@@ -48,14 +46,16 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
|
|
|
48
46
|
const speed = r.speed ?? (i % 2 ? -1 : 1) * (150 + 40 * i);
|
|
49
47
|
const unit = `${r.text}\u00a0\u00a0\u2022\u00a0\u00a0`;
|
|
50
48
|
const unitW = textEm(unit) * fs;
|
|
51
|
-
const reps = Math.ceil((W * 1.6 + Math.abs(speed) * dur) / unitW) + 2;
|
|
52
49
|
const band = div(
|
|
53
50
|
doc,
|
|
54
51
|
`position:absolute;left:${-W * 0.1}px;width:${W * 1.2}px;top:${top + i * (rowH + gap)}px;height:${rowH}px;overflow:hidden;` +
|
|
55
52
|
`background:${r.band ?? "var(--kf-surface)"};color:${r.ink ?? "var(--kf-ink)"};border-top:3px solid var(--kf-ink);border-bottom:3px solid var(--kf-ink);transform-origin:50% 50%`,
|
|
56
53
|
);
|
|
57
|
-
const
|
|
58
|
-
band.append(
|
|
54
|
+
const line = div(doc, `position:absolute;left:0;top:0;width:100%;height:100%;font-family:var(--kf-font-mono);font-weight:600;font-size:${fs}px`);
|
|
55
|
+
band.append(line);
|
|
56
|
+
// 낱말마다 조각 — 띠 끝에서 낱말 하나씩 흐려진다
|
|
57
|
+
const pieces = unit.split(/(?<=\s)(?=\S)/);
|
|
58
|
+
const flow = strip(line, pieces, Math.ceil((W * 1.2) / unitW) + 2, (x) => textEm(x) * fs);
|
|
59
59
|
let chip: HTMLDivElement | null = null;
|
|
60
60
|
if (r.label) {
|
|
61
61
|
chip = div(
|
|
@@ -66,9 +66,13 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
|
|
|
66
66
|
band.append(chip);
|
|
67
67
|
}
|
|
68
68
|
layer.append(band);
|
|
69
|
-
return { band,
|
|
69
|
+
return { band, flow, chip, speed, angle: r.angle ?? 0 };
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
// 칩이 덮은 띠 머리 — 그 밑으로 지나는 조각도 화면 끝처럼 흐린다(칩 뒤에 반쯤 가린 글이 남지 않게)
|
|
73
|
+
const chipEnd = (i: number) => (o.rows[i].label ? Math.max(0, chips[i]?.offsetWidth ?? 0) : 0);
|
|
74
|
+
const chips = rows.map((r) => r.chip);
|
|
75
|
+
|
|
72
76
|
return (t: number) => {
|
|
73
77
|
t = safeTime(t);
|
|
74
78
|
let moving = false;
|
|
@@ -79,8 +83,8 @@ export function tickerRows(el: HTMLElement, o: TickerRowsOpts): (t: number) => v
|
|
|
79
83
|
// 띠 전체가 옆에서 밀려 들어오고, 안의 글은 그 뒤로도 늘 흐른다
|
|
80
84
|
const enterX = (1 - p) * (i % 2 ? W : -W) * 1.2;
|
|
81
85
|
r.band.style.transform = `translateX(${enterX.toFixed(1)}px) rotate(${r.angle}deg)`;
|
|
82
|
-
|
|
83
|
-
r.
|
|
86
|
+
// 띠는 화면보다 양옆 10% 넓다 — 띠 안 x 를 화면 x 로 옮겨 화면 끝에 걸친 조각을 흐린다(칩에 가린 왼쪽 끝도)
|
|
87
|
+
r.flow(r.speed * t, (x) => x - W * 0.1 + enterX, [chipEnd(i), W], fs * 1.6);
|
|
84
88
|
});
|
|
85
89
|
mark("tickerRows", o.scene, moving);
|
|
86
90
|
};
|
|
@@ -80,14 +80,15 @@ export function tokenPicker(el: HTMLElement, o: TokenPickerOpts): (t: number) =>
|
|
|
80
80
|
const text = o.steps.slice(0, k + 1).map((s) => s.token).join("");
|
|
81
81
|
typed.textContent = text;
|
|
82
82
|
caret.style.opacity = Math.floor(t * 2.2) % 2 === 0 || t - (o.times[k] ?? -9) < 0.4 ? "1" : "0.15";
|
|
83
|
-
// 다음 토큰 —
|
|
83
|
+
// 다음 토큰 — 앞 토큰이 붙은 뒤부터 다음 토큰까지 후보 확률이 끓고(모델이 고르는 동안 숫자가 구른다), 0.08초 전에 하나가 켜진다
|
|
84
84
|
const j = k + 1 < n ? k + 1 : -1;
|
|
85
|
-
const
|
|
85
|
+
const from = j >= 0 ? Math.min(o.times[j] - 0.32, k >= 0 ? o.times[k] + 0.12 : o.times[j] - 0.6) : 0;
|
|
86
|
+
const local = j >= 0 ? t - from : -1;
|
|
86
87
|
const show = j >= 0 && local >= 0;
|
|
87
88
|
panel.style.display = show ? "block" : "none";
|
|
88
89
|
if (show) {
|
|
89
90
|
const step = o.steps[j];
|
|
90
|
-
const pick = ease.outCubic(clamp((
|
|
91
|
+
const pick = ease.outCubic(clamp((t - (o.times[j] - 0.08)) / 0.08));
|
|
91
92
|
const x = Math.min(W - size * 7.6, Math.max(W * 0.04, caretX(text) - size * 1.2));
|
|
92
93
|
panel.style.left = `${x.toFixed(1)}px`;
|
|
93
94
|
panel.style.transform = `translateY(${((1 - ease.outCubic(clamp(local / 0.1))) * 18).toFixed(1)}px)`;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { clamp, ease, lerp, safeTime } from "@kasaframe/core";
|
|
2
|
-
import { div, lastIndex, mark, textEm } from "./fit.ts";
|
|
2
|
+
import { div, lastIndex, mark, strip, textEm } from "./fit.ts";
|
|
3
3
|
|
|
4
4
|
export interface WordWallOpts {
|
|
5
5
|
word: string;
|
|
@@ -13,8 +13,6 @@ export interface WordWallOpts {
|
|
|
13
13
|
speed?: number;
|
|
14
14
|
w?: number;
|
|
15
15
|
h?: number;
|
|
16
|
-
/** 장면 길이 — 띠가 끝까지 모자라지 않게 */
|
|
17
|
-
dur?: number;
|
|
18
16
|
scene?: string;
|
|
19
17
|
}
|
|
20
18
|
|
|
@@ -31,23 +29,23 @@ export function wordWall(el: HTMLElement, o: WordWallOpts): (t: number) => void
|
|
|
31
29
|
const base = H / counts[0];
|
|
32
30
|
const F0 = base * 0.84;
|
|
33
31
|
const speed = (o.speed ?? 90) * (H / 1080);
|
|
34
|
-
const dur = o.dur ?? 30;
|
|
35
32
|
const unit = `${o.word}\u00a0\u00a0`;
|
|
36
33
|
const minScale = counts[0] / maxRows;
|
|
37
|
-
const
|
|
34
|
+
const unitGuess = textEm(unit) * F0;
|
|
35
|
+
// 가장 작아졌을 때(줄 수가 가장 많을 때)도 화면 폭을 덮는 조각 수
|
|
36
|
+
const count = Math.ceil(W / minScale / unitGuess) + 2;
|
|
37
|
+
const guess = (x: string) => textEm(x) * F0;
|
|
38
38
|
const layer = div(doc, "position:absolute;inset:0;overflow:hidden;color:var(--kf-ink)");
|
|
39
39
|
el.append(layer);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
`position:absolute;left:0;top:0;white-space:nowrap;font-family:var(--kf-font-display);font-weight:900;font-size:${F0}px;line-height:1;letter-spacing:-.03em;transform-origin:0 50%`,
|
|
44
|
-
unit.repeat(reps),
|
|
45
|
-
);
|
|
40
|
+
const rowCss = `position:absolute;left:0;top:0;height:${F0}px;font-family:var(--kf-font-display);font-weight:900;font-size:${F0}px;line-height:1;letter-spacing:-.03em;transform-origin:0 50%`;
|
|
41
|
+
const strips = Array.from({ length: maxRows }, () => {
|
|
42
|
+
const s = div(doc, rowCss);
|
|
46
43
|
layer.append(s);
|
|
47
|
-
return s;
|
|
44
|
+
return { s, flow: strip(s, [unit], count, guess) };
|
|
48
45
|
});
|
|
49
|
-
const
|
|
50
|
-
layer.append(
|
|
46
|
+
const hot = div(doc, `${rowCss};color:var(--kf-accent)`);
|
|
47
|
+
layer.append(hot);
|
|
48
|
+
const hotFlow = strip(hot, [`${o.hot ?? o.word}\u00a0\u00a0`], count, guess);
|
|
51
49
|
const yOf = (i: number, n: number) => (i + 0.5) * (H / n);
|
|
52
50
|
|
|
53
51
|
return (t: number) => {
|
|
@@ -58,18 +56,22 @@ export function wordWall(el: HTMLElement, o: WordWallOpts): (t: number) => void
|
|
|
58
56
|
const p = k > 0 || t >= (o.hits[0] ?? 0) ? ease.outCubic(clamp((t - (o.hits[k] ?? 0)) / 0.28)) : 1;
|
|
59
57
|
const scale = lerp(n0 > 0 ? counts[0] / n0 : 1, counts[0] / n1, p);
|
|
60
58
|
const hotRow = Math.floor(n1 / 2);
|
|
61
|
-
|
|
59
|
+
const toScreen = (x: number) => x * scale;
|
|
60
|
+
const fade = W * 0.06;
|
|
61
|
+
strips.forEach(({ s, flow }, i) => {
|
|
62
62
|
const y = i < n0 ? lerp(yOf(i, n0), yOf(i, n1), p) : yOf(i, n1);
|
|
63
63
|
const show = i < n1 ? (i < n0 ? 1 : p) : 0;
|
|
64
64
|
const dir = i % 2 ? 1 : -1;
|
|
65
|
-
// 시작 자리를 줄마다 어긋나게
|
|
66
|
-
const
|
|
67
|
-
|
|
65
|
+
// 시작 자리를 줄마다 어긋나게
|
|
66
|
+
const offset = (dir * speed * t + i * 137) / scale;
|
|
67
|
+
// 크기 기준점이 줄 판의 세로 가운데(transform-origin 0 50%)라 판 높이 절반만 빼면 줄 가운데가 y 에 선다
|
|
68
|
+
s.style.transform = `translate(0px,${(y - F0 / 2).toFixed(1)}px) scale(${scale.toFixed(4)})`;
|
|
69
|
+
s.style.display = show > 0 || i === hotRow ? "block" : "none";
|
|
68
70
|
s.style.opacity = (i === hotRow ? 0 : show * 0.32).toFixed(3);
|
|
69
71
|
if (i === hotRow) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
72
|
+
hot.style.transform = s.style.transform;
|
|
73
|
+
hotFlow(offset, toScreen, [0, W], fade);
|
|
74
|
+
} else if (show > 0) flow(offset, toScreen, [0, W], fade);
|
|
73
75
|
});
|
|
74
76
|
mark("wordWall", o.scene, p < 1);
|
|
75
77
|
};
|
|
@@ -24,7 +24,8 @@ export function worldRoll(t: number, o: WorldRollOpts): Style {
|
|
|
24
24
|
if (local <= 0) continue;
|
|
25
25
|
rotate += turn * spring(local, { from: 0, to: 1, stiffness: 120, damping: 13 });
|
|
26
26
|
dip = Math.max(dip, Math.sin(Math.PI * clamp(local / 0.45)));
|
|
27
|
-
|
|
27
|
+
// 스프링 꼬리(넘쳤다 돌아오기)까지 굴림이다 — 그동안도 카메라 이벤트를 남겨 이유 없는 이동(camera.wander)으로 안 센다
|
|
28
|
+
if (local < 1.1) moving = true;
|
|
28
29
|
}
|
|
29
30
|
if (moving) emit({ kind: "camera", source: "worldRoll", scene: o.scene, value: 1 });
|
|
30
31
|
return { rotate, scale: 1 - 0.16 * dip };
|
|
@@ -11,6 +11,7 @@ import { portal } from "./portal.ts";
|
|
|
11
11
|
import { shatter } from "./shatter.ts";
|
|
12
12
|
import { zoomOut } from "./zoomOut.ts";
|
|
13
13
|
import { whip } from "./whip.ts";
|
|
14
|
+
import { land, rise, type HeroRect } from "./space.ts";
|
|
14
15
|
import { GL_NAMES, SMOOTH_NAMES, glKind, smoothKind, type GlValue } from "./gl.ts";
|
|
15
16
|
|
|
16
17
|
export { TRANSITIONS_3D, DUR_3D, FIT, FOV, CHAIN_GAP, durOf, resolveTiming, rawProgress, arrivalsOn } from "./rig.ts";
|
|
@@ -22,6 +23,7 @@ export type { Painter, Source, Palette, LiveDom, VideoSource } from "./world.ts"
|
|
|
22
23
|
export { elementTextures } from "./world.ts";
|
|
23
24
|
export { sceneSource, liveNeighbors } from "./live.ts";
|
|
24
25
|
export type { LiveProject } from "./live.ts";
|
|
26
|
+
export type { HeroRect } from "./space.ts";
|
|
25
27
|
|
|
26
28
|
export interface Transition3dOpts extends TimingOpts {
|
|
27
29
|
/** 기본 dive */
|
|
@@ -46,6 +48,8 @@ export interface Transition3dOpts extends TimingOpts {
|
|
|
46
48
|
window?: WindowRect;
|
|
47
49
|
/** fold — accordion 접기 · roll 말기 */
|
|
48
50
|
fold?: FoldStyle;
|
|
51
|
+
/** rise·land — 3D 로 서거나 내려앉을 주인공 상자(화면 비율, 왼쪽 위 0). 안 주면 살아 있는 장면의 [data-kf-hero] 를 프레임마다 잰다 */
|
|
52
|
+
hero?: HeroRect;
|
|
49
53
|
/** gl:<이름> — 셰이더 매개변수 덮기(uniform 이름 → 값) */
|
|
50
54
|
params?: Record<string, GlValue>;
|
|
51
55
|
/** 빠르기 곡선 — ramp(기본): 천천히 들어가다 확 빨라진다 · linear: 종류가 정한 그대로 */
|
|
@@ -64,7 +68,7 @@ export interface Transition3dOpts extends TimingOpts {
|
|
|
64
68
|
export type Transition3dDraw = ((t: number) => void) & { timing: Timing; kind: Transition3dName; dispose(): void };
|
|
65
69
|
|
|
66
70
|
type Build = (k: Kit, o: Transition3dOpts & { arrivals: number[] }) => KindRun & { visible?(t: number): boolean[] };
|
|
67
|
-
const KINDS: Record<(typeof TRANSITIONS_3D)[number], Build> = { dive, portal, cards, shatter, zoomOut, fold, diveChain: chain, whip };
|
|
71
|
+
const KINDS: Record<(typeof TRANSITIONS_3D)[number], Build> = { dive, portal, cards, shatter, zoomOut, fold, diveChain: chain, whip, rise, land };
|
|
68
72
|
|
|
69
73
|
function builderOf(kind: Transition3dName): Build | undefined {
|
|
70
74
|
if (kind.startsWith("gl:")) return glKind(kind.slice(3));
|
|
@@ -155,20 +159,23 @@ export function transition3d(root: HTMLElement, ctxOrOpts?: SceneCtx | Transitio
|
|
|
155
159
|
fx.dolly = land && inside ? 0.07 * FIT * Math.sin(Math.PI * s) ** 2 * (1 - s) : 0;
|
|
156
160
|
let sx = 0;
|
|
157
161
|
let sy = 0;
|
|
162
|
+
let amp = 0;
|
|
158
163
|
let punch = 0;
|
|
159
164
|
for (const [i, h] of hits.entries()) {
|
|
160
165
|
const dt = t - h;
|
|
161
166
|
if (!inside || dt < 0 || dt > 0.5) continue;
|
|
162
167
|
const env = Math.exp(-dt / 0.11);
|
|
168
|
+
amp += 0.01 * env;
|
|
163
169
|
sx += 0.01 * env * Math.sin(2 * Math.PI * 17 * dt + rand(seed, 40 + i) * 6.283);
|
|
164
170
|
sy += 0.01 * env * Math.sin(2 * Math.PI * 13 * dt + rand(seed, 50 + i) * 6.283);
|
|
165
171
|
punch -= 7 * Math.exp(-dt / 0.16) * (1 - Math.exp(-dt / 0.02));
|
|
166
172
|
}
|
|
167
173
|
fx.shake = [sx, sy];
|
|
168
174
|
fx.fov = punch;
|
|
169
|
-
// 화면 높이 1 = 꽉 찬 화면이라 흔들림 0.01 은 높이의 1% — 검사가 px 로
|
|
175
|
+
// 화면 높이 1 = 꽉 찬 화면이라 흔들림 0.01 은 높이의 1% — 검사가 px 로 센다. 흔들림이 0 을 지나는 프레임에도 남겨야
|
|
176
|
+
// 한 번 친 흔들림이 끊긴 여러 번으로 세이지 않는다 — 잦아드는 폭(amp)이 0.5px 넘는 동안 이어서 남긴다
|
|
170
177
|
const px = Math.hypot(sx, sy) * h;
|
|
171
|
-
if (
|
|
178
|
+
if (amp * h > 0.5 && o.emit !== false) emit({ kind: "shake", source: `${source}:hit`, scene, value: Math.round(px * 10) / 10 });
|
|
172
179
|
const smp = frameSample();
|
|
173
180
|
if (aperture > 0 && inside && smp.count > 1) {
|
|
174
181
|
// 황금각 나선 — 하위 프레임들이 조리개 원판을 고르게 덮는다
|