kasaframe 0.1.6 → 0.1.7
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/package.json +1 -1
- package/packages/cli/src/commands/direct.ts +24 -4
- package/packages/director/src/assemble.ts +8 -2
- package/packages/director/src/fallback.ts +14 -2
- package/packages/director/src/patterns/fill/cells.ts +5 -2
- package/packages/director/src/scene.ts +6 -3
- package/packages/patterns/src/end/slam.ts +9 -5
- package/packages/patterns/src/kit.ts +94 -12
- package/packages/patterns/src/limit/gauge.ts +5 -4
- package/packages/patterns/src/steps/path.ts +15 -7
- package/packages/patterns/src/timeline/axis.ts +79 -25
- package/packages/patterns/src/title/mask.ts +8 -4
- package/themes/og-measure/THEME.md +3 -1
- package/themes/og-measure/style.json +3 -0
- package/themes/og-measure/theme.json +6 -0
- package/themes/og-measure/variants/snow-blue.json +3 -0
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import { parseDuration } from "@kasaframe/plan";
|
|
|
8
8
|
import { type Command, num, parseFlags } from "../command.ts";
|
|
9
9
|
|
|
10
10
|
const kfBin = fileURLToPath(new URL("../kf.ts", import.meta.url));
|
|
11
|
+
const FALLBACK = "any.words";
|
|
11
12
|
// 검사가 떨어뜨려도 패턴 탓이 아닌 규칙 — 장면 경계(시간표)·생성 자산·자막 판
|
|
12
13
|
const NOT_PATTERN = /^(beat\.|asset\.|caption\.)/;
|
|
13
14
|
// 생성 능력 질문의 답 — kf make 로 그대로 넘긴다
|
|
@@ -130,6 +131,7 @@ const cmd: Command = {
|
|
|
130
131
|
let code = 0;
|
|
131
132
|
if (!opts["no-check"]) {
|
|
132
133
|
const retries = num(opts.retries, 2);
|
|
134
|
+
let forced = false;
|
|
133
135
|
for (let round = 0; ; round++) {
|
|
134
136
|
// 옛 결과를 치우고 돌린다 — 검사가 돌지 못하고 끝나면 옛 check.json 을 이번 결과로 읽게 된다
|
|
135
137
|
const report_ = join(dir, "out", "check.json");
|
|
@@ -154,14 +156,32 @@ const cmd: Command = {
|
|
|
154
156
|
}
|
|
155
157
|
const blame = blameScenes(dir, report);
|
|
156
158
|
console.error(`kf check 오류 — ${blame.lines.join(" · ")}`);
|
|
157
|
-
if (
|
|
159
|
+
if (!Object.keys(blame.failed).length && !Object.keys(blame.seamFailed).length) break;
|
|
160
|
+
// 다시 짓기를 다 써도 떨어지는 장면은 감독 기본(any.words)으로 한 번 더 — 막혀도 대체로는 끝까지 간다. 후보가 많은 type 은
|
|
161
|
+
// 다시 짓기 두 번 안에 기본까지 내려오지 못했다. 기본도 떨어지면 멈춘다
|
|
162
|
+
const left = Object.keys(blame.failed).filter((id) => report.scenes.find((s) => s.id === id)?.pattern !== FALLBACK);
|
|
163
|
+
const fallback = (): Record<string, string> | null => {
|
|
164
|
+
if (forced || !left.length) return null;
|
|
165
|
+
forced = true;
|
|
166
|
+
return Object.fromEntries(left.map((id) => [id, FALLBACK]));
|
|
167
|
+
};
|
|
168
|
+
let force = round >= retries ? fallback() : undefined;
|
|
169
|
+
if (force === null) break;
|
|
158
170
|
try {
|
|
159
|
-
report = await build(dir, content, { seed, failed: blame.failed, seamFailed: blame.seamFailed, log });
|
|
171
|
+
report = await build(dir, content, { seed, failed: blame.failed, seamFailed: blame.seamFailed, force, log });
|
|
160
172
|
} catch (e) {
|
|
173
|
+
// 남은 후보가 없어 고르기가 멈췄으면 그 장면들을 감독 기본으로
|
|
161
174
|
console.error(`kf direct: ${e instanceof Error ? e.message : e}`);
|
|
162
|
-
|
|
175
|
+
force = fallback();
|
|
176
|
+
if (!force) break;
|
|
177
|
+
try {
|
|
178
|
+
report = await build(dir, content, { seed, failed: blame.failed, seamFailed: blame.seamFailed, force, log });
|
|
179
|
+
} catch (e2) {
|
|
180
|
+
console.error(`kf direct: ${e2 instanceof Error ? e2.message : e2}`);
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
163
183
|
}
|
|
164
|
-
console.error(`다시 지음(${round + 1}/${retries}) — ${[...Object.entries(blame.failed), ...Object.entries(blame.seamFailed)].map(([id]) => `${id} → ${report.scenes.find((s) => s.id === id)?.pattern ?? report.scenes.find((s) => s.id === id)?.transition}`).join(" · ")}`);
|
|
184
|
+
console.error(`${force ? "감독 기본으로" : `다시 지음(${round + 1}/${retries})`} — ${[...Object.entries(blame.failed), ...Object.entries(blame.seamFailed)].map(([id]) => `${id} → ${report.scenes.find((s) => s.id === id)?.pattern ?? report.scenes.find((s) => s.id === id)?.transition}`).join(" · ")}`);
|
|
165
185
|
}
|
|
166
186
|
if (code !== 0) console.error(`kf check 가 아직 떨어진다 — ${dir}/out/check.json 을 보고 content.json 을 고치거나 --seed 를 바꿔 다시`);
|
|
167
187
|
}
|
|
@@ -133,6 +133,8 @@ export interface BuildOpts {
|
|
|
133
133
|
only?: string[];
|
|
134
134
|
/** 사람이 고른 것 — 장면 id → 패턴 id·장면 씨앗. director.json 의 pinned 에 남아 다음 kf direct 가 덮지 않는다 */
|
|
135
135
|
pins?: Record<string, Pin>;
|
|
136
|
+
/** 이번 짓기에만 — 장면 id → 패턴 id. pins 와 달리 director.json 에 안 남는다(다시 짓기를 다 쓰고 감독 기본으로 박을 때) */
|
|
137
|
+
force?: Record<string, string>;
|
|
136
138
|
/** 장면마다 진행 줄(「장면 <id>: 쓰는 중|씀|실패 <까닭>」 — 굽기·검사까지 끝난 「끝」은 작업실·render 몫) */
|
|
137
139
|
log?: (line: string) => void;
|
|
138
140
|
}
|
|
@@ -258,10 +260,14 @@ export async function build(dir: string, content: Content, o: BuildOpts = {}): P
|
|
|
258
260
|
if (!r) throw new Error(`장면 ${s.id}: 남은 패턴 후보가 없다 — 떨어진 것 ${(failed[s.id] ?? []).join(", ")}`);
|
|
259
261
|
picks[i] = r;
|
|
260
262
|
}
|
|
261
|
-
} else
|
|
263
|
+
} else {
|
|
264
|
+
// 박을 장면은 떨어진 기록 없이 고른다 — 후보가 다 떨어졌으면 고르기가 「고를 패턴이 없다」로 멈춰 박기까지 못 간다
|
|
265
|
+
const left = o.force ? Object.fromEntries(Object.entries(failed).filter(([id]) => !(id in o.force!))) : failed;
|
|
266
|
+
picks = selectAll(slots, entries, ctx, left);
|
|
267
|
+
}
|
|
262
268
|
// 사람이 고른 패턴이 이긴다 — 그 type 의 패턴이어야 한다
|
|
263
269
|
for (const [i, s] of slots.entries()) {
|
|
264
|
-
const want = pinned[s.id]?.pattern;
|
|
270
|
+
const want = o.force?.[s.id] ?? pinned[s.id]?.pattern;
|
|
265
271
|
if (!want || picks[i].pattern === want) continue;
|
|
266
272
|
const e = entryOf.get(want);
|
|
267
273
|
if (!e || (!e.fallback && e.meta.type !== s.type)) throw new Error(`--pattern ${s.id}=${want}: ${e ? `${s.type} 장면에 ${e.meta.type} 패턴` : "없는 패턴"} — 후보는 director.json 의 order`);
|
|
@@ -5,11 +5,23 @@ import { definePattern } from "./pattern.ts";
|
|
|
5
5
|
|
|
6
6
|
const MAX_WORD = 14;
|
|
7
7
|
|
|
8
|
+
function chunks(s: string): string[] {
|
|
9
|
+
if ([...s].length <= MAX_WORD) return [s];
|
|
10
|
+
const out: string[] = [];
|
|
11
|
+
for (const w of s.split(/\s+/)) {
|
|
12
|
+
const last = out[out.length - 1];
|
|
13
|
+
if (last !== undefined && [...`${last} ${w}`].length <= MAX_WORD) out[out.length - 1] = `${last} ${w}`;
|
|
14
|
+
else out.push(w);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
function words(content: Record<string, unknown>): string[] {
|
|
9
20
|
const out: string[] = [];
|
|
21
|
+
// 긴 글은 버리지 않고 띄어쓰기에서 MAX_WORD 안으로 나눈다 — 통째로 버리면 긴 제목 하나뿐인 장면에 띄울 낱말이 없어 대체 후보가 빠졌다
|
|
10
22
|
const add = (v: unknown, unit?: unknown) => {
|
|
11
23
|
if (typeof v === "number") out.push(`${v}${typeof unit === "string" ? unit : ""}`);
|
|
12
|
-
else if (typeof v === "string" && v.trim()) out.push(v.trim());
|
|
24
|
+
else if (typeof v === "string" && v.trim()) out.push(...chunks(v.trim()));
|
|
13
25
|
};
|
|
14
26
|
const each = (list: unknown, key: string) => Array.isArray(list) && list.forEach((x) => (typeof x === "string" ? add(x) : x && typeof x === "object" && add((x as Record<string, unknown>)[key])));
|
|
15
27
|
const side = (x: unknown) => {
|
|
@@ -20,7 +32,7 @@ function words(content: Record<string, unknown>): string[] {
|
|
|
20
32
|
};
|
|
21
33
|
add(content.prefix !== undefined && typeof content.value === "number" ? `${content.prefix}${content.value}${content.unit ?? ""}` : undefined);
|
|
22
34
|
if (content.prefix === undefined) add(content.value, content.unit);
|
|
23
|
-
for (const k of ["title", "term", "full", "ko", "label", "knob", "ratio", "change", "cost", "text", "who", "line", "url"]) add(content[k]);
|
|
35
|
+
for (const k of ["title", "term", "full", "ko", "label", "knob", "ratio", "change", "cost", "text", "who", "line", "url", "kicker", "sub"]) add(content[k]);
|
|
24
36
|
side(content.a);
|
|
25
37
|
side(content.b);
|
|
26
38
|
side(content.before);
|
|
@@ -57,7 +57,8 @@ export default definePattern<Fill>({
|
|
|
57
57
|
const x = (i % cols) * cell + gap / 2;
|
|
58
58
|
const y = Math.floor(i / cols) * cell + gap / 2;
|
|
59
59
|
const box = el("div", `position:absolute;left:${x}px;top:${y}px;width:${side}px;height:${side}px;border-radius:${Math.round(side * 0.18)}px;box-sizing:border-box;border:${Math.max(2, Math.round(side * 0.06))}px solid color-mix(in srgb, var(--kf-ink) 28%, transparent)`);
|
|
60
|
-
|
|
60
|
+
// 칠 토큰(fill·fillLine)을 둔 테마는 그 결로 — 없으면 강조색 꽉 찬 칸 그대로
|
|
61
|
+
const fillEl = el("div", `position:absolute;inset:-1px;border-radius:inherit;background:var(--kf-fill, var(--kf-accent));box-shadow:inset 0 0 0 ${Math.max(2, Math.round(side * 0.06))}px var(--kf-fill-line, transparent);transform-origin:50% 50%`);
|
|
61
62
|
box.append(fillEl);
|
|
62
63
|
grid.append(box);
|
|
63
64
|
return { fillEl };
|
|
@@ -84,7 +85,9 @@ export default definePattern<Fill>({
|
|
|
84
85
|
stops.push({ t: ctx.dur + 0.4, idx: recap[after.length % recap.length] });
|
|
85
86
|
const stopAt = stops.map((x) => x.t);
|
|
86
87
|
const zoomAt = (at[k - 1] ?? 0) + 0.25;
|
|
87
|
-
|
|
88
|
+
// 머무는 짚개 테마는 이웃 장면이 첫 프레임부터 카메라를 잇는다 — 다가감을 장면 끝까지 늘여 그 움직임과 한 번으로 잇는다(사이가 뜨면 한 프레이즈에 두 번으로 센다)
|
|
89
|
+
const dwell = !!kit.slots.mark?.(document.createElement("div"), { ctx, seed: `${kit.seed}:mark` }).dwell;
|
|
90
|
+
if (ctx.dur - zoomAt > 1.4) kit.stage.camera.pushIn(zoomAt, { scale: 1.07, dur: dwell ? ctx.dur + 0.1 - zoomAt : 0.9 });
|
|
88
91
|
|
|
89
92
|
const pos = (idx: number) => ({ x: (idx % cols) * cell - gap / 2, y: Math.floor(idx / cols) * cell - gap / 2 });
|
|
90
93
|
return (t: number) => {
|
|
@@ -41,9 +41,12 @@ function linesOf(glob: Record<string, unknown> | undefined, ctx: SceneCtx): Line
|
|
|
41
41
|
|
|
42
42
|
const wordsOf = (lines: Line[], ctx: SceneCtx): SpokenWord[] => lines.flatMap((l) => (l.words ?? []).map((w) => ({ text: w.text, from: w.from - ctx.from, to: w.to - ctx.from })));
|
|
43
43
|
|
|
44
|
-
// 낱말 시각 없이
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// 낱말 시각 없이 줄만 있는 자막(목소리 없이 자막만 단 프로젝트) — 시각이 없는 줄은 화면 자막(toCues)처럼 장면 안 양끝을 조금 띄운 구간에 편다.
|
|
45
|
+
// 빼면 패턴이 「자막이 말하는 순간」을 못 읽어 항목이 처음에 다 서고 그 뒤가 멈춘다
|
|
46
|
+
const timedLines = (lines: Line[], ctx: SceneCtx): SpokenWord[] => {
|
|
47
|
+
const pad = Math.min(0.25, ctx.dur * 0.05);
|
|
48
|
+
return lines.map((l) => (l.from !== undefined && l.to !== undefined ? { text: l.text, from: l.from - ctx.from, to: l.to - ctx.from } : { text: l.text, from: pad, to: ctx.dur - pad }));
|
|
49
|
+
};
|
|
47
50
|
|
|
48
51
|
export function directed(pattern: Pattern<any>, d: Directed, deps: { makeStage: MakeStage; captions?: Record<string, unknown> }) {
|
|
49
52
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { area, beatStops, cameraOk, dwells, fitOnce, glider, moving, node, readOver, reread, sceneDrop, slam, slotsOf } from "../kit.ts";
|
|
3
|
+
import { area, beatStops, cameraOk, dwells, fitOnce, followSpoken, glider, moving, node, readOver, reread, sayTimes, sceneDrop, slam, slotsOf } from "../kit.ts";
|
|
4
4
|
import type { EndContent } from "./lockup.ts";
|
|
5
5
|
import type { Pattern } from "../types.ts";
|
|
6
6
|
|
|
@@ -60,14 +60,18 @@ const pattern: Pattern<EndContent> = {
|
|
|
60
60
|
const at = di !== null ? ctx.beats.drop(di) : (hits.find((b) => b >= 0.1) ?? 0.12);
|
|
61
61
|
const dropPaint = di !== null && slots.drop ? slots.drop(burst, { ctx, drop: di }) : null;
|
|
62
62
|
if (di === null && kit.stage && cameraOk(ctx)) kit.stage.camera.impact(at, { amount: 0.04 });
|
|
63
|
-
|
|
64
|
-
const
|
|
63
|
+
// 머무는 짚개 테마는 한 줄·주소를 자막·목소리가 말하는 순간에 붙이고, 카메라가 이름에서 그쪽으로 서지 않고 다가간다 — 쾅 뒤 다 선 끝 카드가 3초 넘게 섰다
|
|
64
|
+
const dwell = dwells(kit, ctx);
|
|
65
|
+
const [lineAt, urlAt] = dwell
|
|
66
|
+
? sayTimes(kit, [c.line, c.url], at + 0.2, Math.max(at + 0.2, ctx.dur - 1))
|
|
67
|
+
: [at + 0.2, at + 0.2 + (c.line ? 0.35 : 0)];
|
|
68
|
+
if (dwell) followSpoken(kit, ctx, [{ at, el: stage }, ...(lineEl ? [{ at: lineAt, el: lineEl }] : []), ...(urlEl ? [{ at: urlAt, el: urlEl }] : [])], { reach: 0.25, zoom: 1.05 });
|
|
65
69
|
const writeLine = lineEl ? slots.write(lineEl, { ctx, text: c.line!, at: lineAt, dur: 0.35, seed: `${kit.seed}:line` }) : null;
|
|
66
70
|
const writeUrl = urlEl ? slots.write(urlEl, { ctx, text: c.url!, at: urlAt, dur: 0.3, seed: `${kit.seed}:url` }) : null;
|
|
67
71
|
const landed = slots.land(name, { ctx, at, seed: `${kit.seed}:land` });
|
|
68
72
|
// 앉으면 짚개가 이름·한 줄·주소를 짚는다(짚기 방식은 테마 짚음). 한 줄·주소가 없고 훑는 테마면 이름 글을 박마다 다시 짚는다
|
|
69
73
|
const point = glider(host, [{ el: stage, text: c.title }, ...(lineEl ? [{ el: lineEl, text: c.line }] : []), ...(urlEl ? [{ el: urlEl, text: c.url }] : [])], { kit, ctx, from: at + 0.2 });
|
|
70
|
-
const again = line ||
|
|
74
|
+
const again = line || dwell ? null : readOver(name, c.title, ctx);
|
|
71
75
|
const againStops = again ? beatStops(kit.hits, at + 0.2, ctx.dur) : [];
|
|
72
76
|
|
|
73
77
|
return (t: number) => {
|
|
@@ -81,7 +85,7 @@ const pattern: Pattern<EndContent> = {
|
|
|
81
85
|
ghost.style.clipPath = t < at ? `inset(0 ${((1 - clamp(t / Math.max(0.1, at - 0.16))) * 100).toFixed(2)}% 0 0)` : "";
|
|
82
86
|
point(t);
|
|
83
87
|
again?.(reread(t, againStops));
|
|
84
|
-
if (line) line.style.visibility = t >= lineAt ? "visible" : "hidden";
|
|
88
|
+
if (line) line.style.visibility = t >= Math.min(lineAt, urlAt) ? "visible" : "hidden";
|
|
85
89
|
writeLine?.(t);
|
|
86
90
|
writeUrl?.(t);
|
|
87
91
|
if (urlEl) applyStyle(urlEl, t >= urlAt ? { scale: spring(t - urlAt, { from: 0.5, to: 1, stiffness: 320, damping: 14 }) } : { opacity: 0 });
|
|
@@ -4,7 +4,7 @@ import { typeDuration, typeText } from "@kasaframe/motion";
|
|
|
4
4
|
import { partial } from "@kasaframe/themes/slotkit";
|
|
5
5
|
import type { MarkBox, MarkPaint } from "@kasaframe/themes/slots";
|
|
6
6
|
import { keyCue, rollCues, slamCue, stopCues, withSfx } from "./sfx.ts";
|
|
7
|
-
import type { Kit, ThemeSlots } from "./types.ts";
|
|
7
|
+
import type { Kit, SpokenWord, ThemeSlots } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
// 패턴들이 같이 쓰는 몫. 모듈 맨 위에서 DOM 을 만지지 않는다 — 감독이 Node 에서 meta·fits 를 읽는다.
|
|
10
10
|
|
|
@@ -26,6 +26,17 @@ export const timesSign = (unit: string): boolean => /^[×✕✖xX]$/.test(unit.t
|
|
|
26
26
|
/** 강조색 글 — 테마의 accentText(바탕 위에서 읽히는 강조색). 면은 accent, 글은 이것 */
|
|
27
27
|
export const ACCENT_TEXT = "var(--kf-accent-text, var(--kf-accent))";
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 칠한 칸 — 진행·점유·지금처럼 「찼다」를 보이는 면(FILL)과 그 위 글(ON_FILL)·테두리(FILL_LINE·fillEdge). 테마가 칠 토큰(style.json colors
|
|
31
|
+
* fill·onFill·fillLine)을 주면 그 결(og-measure: 파랑/10 바탕·/45 테두리·accent 글 — 칠한 배지가 없는 세계), 없으면 강조색 꽉 찬 면과 onAccent 글
|
|
32
|
+
*/
|
|
33
|
+
export const FILL = "var(--kf-fill, var(--kf-accent))";
|
|
34
|
+
export const ON_FILL = "var(--kf-on-fill, var(--kf-on-accent))";
|
|
35
|
+
/** 칠한 칸 둘레 선 색 — 토큰이 없으면 강조색(원래 강조색으로 칠하던 테두리) */
|
|
36
|
+
export const FILL_LINE = "var(--kf-fill-line, var(--kf-accent))";
|
|
37
|
+
/** 칠한 면 안쪽 테두리 — 토큰이 없는 테마엔 안 보인다(면만 칠하던 그대로) */
|
|
38
|
+
export const fillEdge = (px: number): string => `box-shadow:inset 0 0 0 ${px}px var(--kf-fill-line, transparent)`;
|
|
39
|
+
|
|
29
40
|
/** 스타일·틀이 카메라를 끈 영상(cameraMove: off)이면 패턴은 카메라 대신 화면 안 강조로 */
|
|
30
41
|
export const cameraOk = (ctx: SceneCtx): boolean => ctx.style.motion?.cameraMove !== "off";
|
|
31
42
|
|
|
@@ -405,6 +416,20 @@ export function keyPoints(say: readonly { text: string; from: number }[], from:
|
|
|
405
416
|
return out;
|
|
406
417
|
}
|
|
407
418
|
|
|
419
|
+
/** 들리는 낱말 — 목소리 낱말 시각이 없으면(자막만) 자막 줄을 낱말로 나눠 줄 안에서 놓인 자리만큼의 시각으로 */
|
|
420
|
+
export function spokenWords(kit: Kit): readonly SpokenWord[] {
|
|
421
|
+
if (kit.say.length) return kit.say;
|
|
422
|
+
return kit.lines.flatMap((l) => {
|
|
423
|
+
const n = Math.max(1, l.text.length);
|
|
424
|
+
let pos = 0;
|
|
425
|
+
return l.text.split(/\s+/).filter(Boolean).map((text) => {
|
|
426
|
+
const i = l.text.indexOf(text, pos);
|
|
427
|
+
pos = i + text.length;
|
|
428
|
+
return { text, from: l.from + ((l.to - l.from) * i) / n, to: l.from + ((l.to - l.from) * pos) / n };
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
408
433
|
/** 글 text 가 [from, to) 안에서 말해지는 시각 — 목소리 낱말이 있으면 그 글을 말하는 첫 낱말, 없으면 자막 줄 안에서 그 글이 놓인 자리만큼. 못 찾으면 null */
|
|
409
434
|
export function heardAt(kit: Kit, text: string, from: number, to: number): number | null {
|
|
410
435
|
if (kit.say.length) return kit.say.find((w) => w.from >= from - 0.05 && w.from < to && said(w.text, text))?.from ?? null;
|
|
@@ -449,24 +474,81 @@ export function sayTimes(kit: Kit, texts: readonly (string | undefined)[], from:
|
|
|
449
474
|
});
|
|
450
475
|
}
|
|
451
476
|
|
|
477
|
+
// 카메라 움직임 하나는 천천히 떠나 천천히 선다 — 긴 구간에 한 번 걸면 앞뒤가 멈춘 화면이 된다(제목 하나뿐인 13초 장면이 2초 넘게 섰다).
|
|
478
|
+
// STEP 초마다 두 배 길이의 다가감을 겹쳐 걸고 목표를 그 다가감이 끝날 시각의 경로 자리로 두면, 서는 데 없이 고른 빠르기로 간다
|
|
479
|
+
const STEP = 0.8;
|
|
480
|
+
|
|
452
481
|
/**
|
|
453
|
-
* 지금 말하는 항목 쪽으로 다가가는 카메라 — 장면 첫 프레임부터 첫 항목 쪽으로, 항목이 말해질 때마다 그쪽으로
|
|
454
|
-
*
|
|
455
|
-
*
|
|
482
|
+
* 지금 말하는 항목 쪽으로 다가가는 카메라 — 장면 첫 프레임부터 첫 항목(주인공) 쪽으로, 항목이 말해질 때마다 그 구간 동안 그쪽으로 고른 빠르기로 옮겨 가고,
|
|
483
|
+
* 배율은 장면 내내 고르게 가까워진다. 장면 끝까지 서지 않는 한 번의 움직임 — 내용이 다 선 뒤 쉬는 구간을 뜻 있는 움직임으로 메운다(다가감의 이유가 그 순간 말하는 것).
|
|
484
|
+
* 머무는 짚개(dwell) 테마만 — 훑는 테마는 짚개가 쉬지 않는 몫을 진다. 카메라를 끈 영상엔 안 건다.
|
|
485
|
+
* zoom 은 장면 끝 배율(기본 항목마다 1.02, 1.08 까지). reach < 1 이면 항목 가운데까지 가지 않고 화면 가운데에서 그 몫만큼만 — 내용이 안전 영역 위아래 끝까지 찬
|
|
486
|
+
* 장면(단계·시간축)은 항목에 끝까지 가거나 크게 다가가면 반대쪽 끝 글이 안전 영역 밖으로 밀린다
|
|
456
487
|
*/
|
|
457
|
-
export function followSpoken(kit: Kit, ctx: SceneCtx, shots: readonly Focus[]): void {
|
|
488
|
+
export function followSpoken(kit: Kit, ctx: SceneCtx, shots: readonly Focus[], o: { reach?: number; zoom?: number } = {}): void {
|
|
458
489
|
if (!cameraOk(ctx) || !kit.stage || !dwells(kit, ctx)) return;
|
|
490
|
+
const stage = kit.stage;
|
|
491
|
+
const cam = stage.cam;
|
|
492
|
+
const reach = o.reach ?? 1;
|
|
459
493
|
const sorted = [...shots].filter((s) => s.at < ctx.dur - 0.2).sort((a, b) => a.at - b.at);
|
|
460
494
|
if (!sorted.length) return;
|
|
461
495
|
// 첫 프레임부터 주인공 쪽으로 — 앞 장면의 다가감과 끊김 없이 이어져 한 번으로 센다(틀이 카메라를 프레이즈당 한 번 허락한다). 0 에서 시작하면
|
|
462
496
|
// 첫 프레임엔 아직 안 움직여 사이가 뜬다. 아직 안 찍힌(빈) 항목 쪽으로 먼저 가면 화면이 비켜 내용이 안전 영역 밖으로 밀린다
|
|
463
|
-
const hero =
|
|
497
|
+
const hero = cam.querySelector<HTMLElement>("[data-kf-hero]") ?? sorted[0].el;
|
|
464
498
|
const list = [{ at: -0.05, el: hero }, ...sorted.filter((s) => s.at > 0.05)];
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
499
|
+
const zoom = o.zoom ?? Math.min(1.08, 1 + 0.02 * list.length);
|
|
500
|
+
const mid = (): [number, number] => [cam.offsetWidth / 2, cam.offsetHeight / 2];
|
|
501
|
+
// 카메라가 재는 것과 같은 층 안 좌표(층 배율을 걷어 낸)로 잰 항목 가운데를 층 가운데 쪽으로 reach 만큼 당긴다. 크기도 넘겨 카메라가
|
|
502
|
+
// 항목이 화면 86% 안에 들게 배율을 누르게 한다 — 빼면 넓은 숫자판 끝 단위가 화면 밖으로 밀렸다
|
|
503
|
+
const spot = (el: HTMLElement): { x: number; y: number; w?: number; h?: number } => {
|
|
504
|
+
const r = el.getBoundingClientRect();
|
|
505
|
+
const [cx, cy] = mid();
|
|
506
|
+
if (r.width < 1 || r.height < 1) return { x: cx, y: cy };
|
|
507
|
+
const lr = cam.getBoundingClientRect();
|
|
508
|
+
const k = lr.width / (cam.offsetWidth || 1) || 1;
|
|
509
|
+
return { x: cx + ((r.left + r.width / 2 - lr.left) / k - cx) * reach, y: cy + ((r.top + r.height / 2 - lr.top) / k - cy) * reach, w: r.width / k, h: r.height / k };
|
|
510
|
+
};
|
|
511
|
+
// 장면 글 전체 상자(층 안 좌표) — 주인공만 가운데로 데려오면 옆 글(숫자판 밖 단위 따위)이 화면 밖으로 밀린다. 한 프레임에 한 번만 잰다
|
|
512
|
+
let boxAt = NaN;
|
|
513
|
+
let box: { l: number; t: number; r: number; b: number } | null = null;
|
|
514
|
+
const textBox = (t: number) => {
|
|
515
|
+
if (t === boxAt) return box;
|
|
516
|
+
boxAt = t;
|
|
517
|
+
box = null;
|
|
518
|
+
const lr = cam.getBoundingClientRect();
|
|
519
|
+
const k = lr.width / (cam.offsetWidth || 1) || 1;
|
|
520
|
+
for (const el of cam.querySelectorAll<HTMLElement>("*")) {
|
|
521
|
+
if (![...el.childNodes].some((n) => n.nodeType === 3 && n.textContent!.trim())) continue;
|
|
522
|
+
const r = el.getBoundingClientRect();
|
|
523
|
+
if (r.width < 1 || r.height < 1) continue;
|
|
524
|
+
const [l, top] = [(r.left - lr.left) / k, (r.top - lr.top) / k];
|
|
525
|
+
box = box ? { l: Math.min(box.l, l), t: Math.min(box.t, top), r: Math.max(box.r, l + r.width / k), b: Math.max(box.b, top + r.height / k) } : { l, t: top, r: l + r.width / k, b: top + r.height / k };
|
|
526
|
+
}
|
|
527
|
+
return box;
|
|
528
|
+
};
|
|
529
|
+
// 그 배율에서 글 상자가 가장자리 4% 띠 안으로 들게 초점을 묶는다(띠는 검사 layout.edge 와 같은 몫)
|
|
530
|
+
const keepIn = (f: number, lo: number, hi: number, size: number, sc: number): number => {
|
|
531
|
+
const [min, max] = [hi - (size * 0.96 - size / 2) / sc, lo + (size / 2 - size * 0.04) / sc];
|
|
532
|
+
return min <= max ? clamp(f, min, max) : (min + max) / 2;
|
|
533
|
+
};
|
|
534
|
+
// 경로 — 항목 차례마다 그 구간 동안 앞 항목 자리에서 그 항목 자리로
|
|
535
|
+
const path = (t: number, when: number, sc: number): { x: number; y: number; w?: number; h?: number } => {
|
|
536
|
+
let k = 0;
|
|
537
|
+
while (k + 1 < list.length && list[k + 1].at <= when) k++;
|
|
538
|
+
const u = clamp((when - list[k].at) / Math.max(0.1, (list[k + 1]?.at ?? ctx.dur) - list[k].at));
|
|
539
|
+
const [cx, cy] = mid();
|
|
540
|
+
const a = k ? spot(list[k - 1].el) : { x: cx, y: cy };
|
|
541
|
+
const b = spot(list[k].el);
|
|
542
|
+
const [x, y] = [a.x + (b.x - a.x) * u, a.y + (b.y - a.y) * u];
|
|
543
|
+
const tb = textBox(t);
|
|
544
|
+
if (!tb) return { x, y, w: b.w, h: b.h };
|
|
545
|
+
return { x: keepIn(x, tb.l, tb.r, cam.offsetWidth, sc), y: keepIn(y, tb.t, tb.b, cam.offsetHeight, sc), w: b.w, h: b.h };
|
|
546
|
+
};
|
|
547
|
+
for (let at = -0.05; at < ctx.dur; at += STEP) {
|
|
548
|
+
const when = at + STEP * 2;
|
|
549
|
+
const sc = 1 + (zoom - 1) * clamp((when + 0.05) / (ctx.dur + 0.05));
|
|
550
|
+
stage.camera.pushIn(at, { to: (t: number) => path(t, when, sc), scale: sc, dur: STEP * 2 });
|
|
551
|
+
}
|
|
470
552
|
}
|
|
471
553
|
|
|
472
554
|
// 요점 사이 옮김 — 스타일가이드 자리 옮김 300ms. 다음 요점이 그보다 가까우면 닿을 틈을 남기고 더 빨리
|
|
@@ -497,7 +579,7 @@ export function glider(host: HTMLElement, points: readonly Point[], o: { kit: Ki
|
|
|
497
579
|
host.style.isolation = "isolate";
|
|
498
580
|
const block = node(host, MARK_CSS);
|
|
499
581
|
const shape = markOf(o.kit, o.ctx)?.(block) ?? plainMark(block);
|
|
500
|
-
const focus = shape.dwell ? (hub ? keyPoints(o.kit
|
|
582
|
+
const focus = shape.dwell ? (hub ? keyPoints(spokenWords(o.kit), o.from, points) : []) : null;
|
|
501
583
|
if (focus && !focus.length) {
|
|
502
584
|
block.remove();
|
|
503
585
|
return () => {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyStyle, clamp, spring } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { area, decimalsOf, dwells, factRoll, fmt, followSpoken, glider, moving, node, sayTimes, slotsOf } from "../kit.ts";
|
|
3
|
+
import { area, decimalsOf, dwells, factRoll, FILL, fillEdge, fmt, followSpoken, glider, moving, node, ON_FILL, sayTimes, slotsOf } from "../kit.ts";
|
|
4
4
|
import type { Pattern } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export interface LimitContent {
|
|
@@ -58,7 +58,7 @@ const pattern: Pattern<LimitContent> = {
|
|
|
58
58
|
const row = vertical ? node(col, "position:relative;display:flex;justify-content:center;width:100%") : col;
|
|
59
59
|
const line = Math.max(2, Math.round(3 * u));
|
|
60
60
|
const gauge = node(row, `position:relative;width:${gw}px;height:${gh}px;background:var(--kf-raised);border:${line}px solid var(--kf-line);box-sizing:border-box`);
|
|
61
|
-
const fill = node(gauge, vertical ? "
|
|
61
|
+
const fill = node(gauge, `position:absolute;${vertical ? "left:0;right:0;bottom:0;height:100%;transform-origin:50% 100%;transform:scaleY(0)" : "left:0;top:0;bottom:0;width:100%;transform-origin:0 50%;transform:scaleX(0)"};background:${FILL};${fillEdge(line)}`);
|
|
62
62
|
const danger = node(gauge, vertical ? `position:absolute;left:0;right:0;top:0;height:${(1 - wall) * 100}%;background:var(--kf-danger, var(--kf-accent));opacity:.28` : `position:absolute;left:${wall * 100}%;right:0;top:0;bottom:0;background:var(--kf-danger, var(--kf-accent));opacity:.28`);
|
|
63
63
|
const bar = Math.max(4, Math.round(6 * u));
|
|
64
64
|
const wallEl = node(
|
|
@@ -68,7 +68,7 @@ const pattern: Pattern<LimitContent> = {
|
|
|
68
68
|
: `position:absolute;left:${wall * 100}%;top:-${Math.round(gh * 0.3)}px;bottom:-${Math.round(gh * 0.3)}px;width:${bar}px;margin-left:-${bar / 2}px;background:var(--kf-danger, var(--kf-ink))`,
|
|
69
69
|
);
|
|
70
70
|
const val = valText
|
|
71
|
-
? node(gauge, vertical ? `position:absolute;left:0;right:0;display:flex;justify-content:center;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color
|
|
71
|
+
? node(gauge, vertical ? `position:absolute;left:0;right:0;display:flex;justify-content:center;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:${ON_FILL};white-space:nowrap;font-variant-numeric:tabular-nums;line-height:1.2` : `position:absolute;top:0;bottom:0;display:flex;align-items:center;padding:0 ${Math.round(gh * 0.25)}px;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:${ON_FILL};white-space:nowrap;font-variant-numeric:tabular-nums`)
|
|
72
72
|
: null;
|
|
73
73
|
const costCss = `font-family:var(--kf-font-sans);font-size:${costPx}px;font-weight:800;color:var(--kf-ink);border:${Math.max(3, Math.round(4 * u))}px solid var(--kf-danger, var(--kf-ink));padding:0.08em 0.45em;line-height:1.2;white-space:nowrap`;
|
|
74
74
|
// 세로면 치르는 것은 계기 위 바깥, 위험 칸 바로 위 오른쪽에 — 계기 안에 두면 짚개 띠가 불투명한 계기 판 뒤로만 오가 화면이 안 바뀐다
|
|
@@ -87,7 +87,8 @@ const pattern: Pattern<LimitContent> = {
|
|
|
87
87
|
const hitAt = heard ? heardLabel : hits.reduce((best, b) => (Math.abs(b - hitWant) < Math.abs(best - hitWant) && b > fillAt + 0.6 ? b : best), hitWant);
|
|
88
88
|
const costAt = Math.max(hitAt + 0.1, heardCost);
|
|
89
89
|
const markFrom = hitAt + 0.2;
|
|
90
|
-
|
|
90
|
+
// 한계 문장·대가는 계기 위아래 끝이라 끝까지 다가가면 반대쪽 글(값·문장)이 화면 밖으로 밀린다
|
|
91
|
+
followSpoken(kit, ctx, [{ at: hitAt, el: labelBox }, ...(cost ? [{ at: costAt, el: cost }] : [])], { reach: 0.4 });
|
|
91
92
|
|
|
92
93
|
// 한계 문장은 목소리가 말하는 순간 찍힌다(그 밖엔 처음부터)
|
|
93
94
|
const writeLabel = slots.write(label, { ctx, text: c.label, at: heard ? Math.max(0, hitAt - labelDur) : 0, dur: labelDur, seed: `${kit.seed}:l` });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { area, moving, node, slotsOf, spaced } from "../kit.ts";
|
|
3
|
+
import { area, FILL, FILL_LINE, fillEdge, followSpoken, moving, node, ON_FILL, slotsOf, spaced } from "../kit.ts";
|
|
4
4
|
import type { Pattern } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export interface StepsContent {
|
|
@@ -76,16 +76,19 @@ const pattern: Pattern<StepsContent> = {
|
|
|
76
76
|
const [x, y] = cardPos(i);
|
|
77
77
|
const card = node(host, `position:absolute;left:${x}px;top:${y}px;width:${cardW}px;height:${cardH}px;transform-origin:${vertical ? "0 50%" : "50% 0"}`);
|
|
78
78
|
const panel = slots.panel(card, { ctx, at: enters[i], seed: `${kit.seed}:c${i}` });
|
|
79
|
-
const
|
|
79
|
+
const edge = Math.max(2, Math.round(3 * u));
|
|
80
|
+
const fill = node(card, `position:absolute;inset:0;background:${FILL};${fillEdge(edge)};border-radius:var(--kf-radius-md)`);
|
|
81
|
+
// 차오르는 수위 — 칠 테두리 색 줄(토큰이 없는 테마엔 안 보인다). 옅은 칠은 차오름만으로는 화면이 거의 안 바뀐다
|
|
82
|
+
const level = node(card, `position:absolute;left:0;right:0;top:0;height:${edge}px;background:var(--kf-fill-line, transparent);visibility:hidden`);
|
|
80
83
|
const face = (lit: boolean) => {
|
|
81
|
-
const f = node(card, `position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:${vertical ? "flex-start" : "center"};gap:${Math.round(detailPx * 0.4)}px;padding:0 ${Math.round(labelPx * 0.4)}px;box-sizing:border-box;color:${lit ?
|
|
84
|
+
const f = node(card, `position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:${vertical ? "flex-start" : "center"};gap:${Math.round(detailPx * 0.4)}px;padding:0 ${Math.round(labelPx * 0.4)}px;box-sizing:border-box;color:${lit ? ON_FILL : "var(--kf-ink)"}`);
|
|
82
85
|
const label = node(f, `font-family:var(--kf-font-display);font-size:${labelPx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.08;word-break:keep-all;text-align:${vertical ? "left" : "center"};white-space:${vertical ? "nowrap" : "normal"}`);
|
|
83
86
|
const detail = showDetail && s.detail ? node(f, `font-family:var(--kf-font-sans);font-size:${detailPx}px;line-height:1.3;opacity:.85;text-align:${vertical ? "left" : "center"};word-break:keep-all`) : null;
|
|
84
87
|
return { f, label, detail };
|
|
85
88
|
};
|
|
86
89
|
const base = face(false);
|
|
87
90
|
const lit = face(true);
|
|
88
|
-
return { card, panel, fill, base, lit, s };
|
|
91
|
+
return { card, panel, fill, level, base, lit, s };
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
const walkFrom = enters[n - 1] + 0.3;
|
|
@@ -100,6 +103,8 @@ const pattern: Pattern<StepsContent> = {
|
|
|
100
103
|
]),
|
|
101
104
|
);
|
|
102
105
|
const lands = dots.map((d, i) => slots.land(d, { ctx, at: enters[i], seed: `${kit.seed}:n${i}` }));
|
|
106
|
+
// 머무는 짚개 테마는 지금 단계 차례마다 카메라가 그 카드 쪽으로 다가간다
|
|
107
|
+
followSpoken(kit, ctx, cards.map((cd, i) => ({ at: turns[i], el: cd.card })), { reach: 0.15, zoom: 1.03 });
|
|
103
108
|
|
|
104
109
|
return (t: number) => {
|
|
105
110
|
moving("steps.path", ctx);
|
|
@@ -113,14 +118,17 @@ const pattern: Pattern<StepsContent> = {
|
|
|
113
118
|
cards[i].panel(t);
|
|
114
119
|
applyStyle(cards[i].card, { scale: spring(t - enters[i], { from: 0.82, to: 1, stiffness: 300, damping: 16 }) });
|
|
115
120
|
const now = t >= turns[i];
|
|
116
|
-
|
|
117
|
-
dots[i].style.
|
|
118
|
-
dots[i].style.
|
|
121
|
+
// 칠이 옅은 테마도 길이 마디 뒤로 비치지 않게 바탕 위에 칠한다
|
|
122
|
+
dots[i].style.background = now ? `linear-gradient(${FILL}, ${FILL}), var(--kf-bg)` : "var(--kf-bg)";
|
|
123
|
+
dots[i].style.color = now ? ON_FILL : "var(--kf-ink)";
|
|
124
|
+
dots[i].style.borderColor = now ? FILL_LINE : "var(--kf-ink)";
|
|
119
125
|
// 그 단계 차례 내내 카드가 아래부터 차오른다 — 차례 끝자락엔 길이 다음 마디로 같이 이어진다
|
|
120
126
|
const k = clamp((t - turns[i]) / Math.max(0.05, ends[i] - turns[i]));
|
|
121
127
|
const clip = `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
|
|
122
128
|
cards[i].fill.style.clipPath = clip;
|
|
123
129
|
cards[i].lit.f.style.clipPath = clip;
|
|
130
|
+
cards[i].level.style.visibility = k > 0 && k < 1 ? "visible" : "hidden";
|
|
131
|
+
cards[i].level.style.top = `${((1 - k) * 100).toFixed(2)}%`;
|
|
124
132
|
});
|
|
125
133
|
rails.forEach((r, i) => {
|
|
126
134
|
// 회색 길은 뒤 마디가 서기 직전에 그어지고, 강조색 길은 앞 단계 차례 끝자락에 다음 마디까지 이어진다
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { applyStyle, clamp
|
|
1
|
+
import { applyStyle, clamp } from "@kasaframe/core";
|
|
2
2
|
import { fitSize, textEm } from "@kasaframe/motion/moves";
|
|
3
|
-
import { ACCENT_TEXT, area, decimalsOf, fmt, moving, node, slotsOf, spaced } from "../kit.ts";
|
|
3
|
+
import { ACCENT_TEXT, area, decimalsOf, FILL, fillEdge, fmt, followSpoken, moving, node, ON_FILL, slotsOf, spaced } from "../kit.ts";
|
|
4
4
|
import type { Pattern } from "../types.ts";
|
|
5
5
|
|
|
6
6
|
export interface TimelineContent {
|
|
@@ -64,6 +64,8 @@ const pattern: Pattern<TimelineContent> = {
|
|
|
64
64
|
? `left:${axisX - trackT / 2}px;top:${a0}px;width:${trackT}px;height:${len}px`
|
|
65
65
|
: `left:${a0}px;top:${axisY - trackT / 2}px;width:${len}px;height:${trackT}px`;
|
|
66
66
|
const track = node(host, `position:absolute;${trackCss};background:var(--kf-raised);border:${Math.max(2, Math.round(2 * u))}px solid var(--kf-line);box-sizing:border-box;transform-origin:${vertical ? "50% 0" : "0 50%"}`);
|
|
67
|
+
const edge = Math.max(2, Math.round(3 * u));
|
|
68
|
+
const winPx = Math.round(chipPx * 0.95);
|
|
67
69
|
const win = c.window ? (() => {
|
|
68
70
|
const p0 = pos(c.window.from);
|
|
69
71
|
const p1 = pos(c.window.to);
|
|
@@ -74,29 +76,72 @@ const pattern: Pattern<TimelineContent> = {
|
|
|
74
76
|
// 세로 화면은 그 구간을 화면 폭 가득 옅게 칠한다 — 「이 시간대」가 축 옆 점선만으로는 작게 읽혔다
|
|
75
77
|
if (vertical) host.prepend(node(host, `position:absolute;left:0;right:0;top:${a0 + len * p0}px;height:${len * (p1 - p0)}px;background:color-mix(in srgb, var(--kf-accent) 14%, transparent)`));
|
|
76
78
|
const [lx, ly] = at((p0 + p1) / 2);
|
|
79
|
+
// 구간 이름은 눕히지 않는다 — 세로 화면은 오른쪽 딱지 줄에 두고 자리는 아래 줄 맞추기가 정한다
|
|
77
80
|
const label = node(host, vertical
|
|
78
|
-
? `position:absolute;left
|
|
81
|
+
? `position:absolute;left:0;top:0;font-family:var(--kf-font-display);font-size:${winPx}px;font-weight:800;line-height:1.2;color:${ACCENT_TEXT};white-space:nowrap`
|
|
79
82
|
: `position:absolute;left:${lx}px;top:${ly - trackT * 1.35 - chipPx * 1.75}px;transform:translate(-50%,-100%);font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:${ACCENT_TEXT};white-space:nowrap`);
|
|
80
|
-
return { p0, p1, outline, label };
|
|
83
|
+
return { p0, p1, outline, label, y: ly };
|
|
81
84
|
})() : null;
|
|
82
|
-
const done = node(track, `position:absolute;${vertical ? "left:0;right:0;top:0;height:100%" : "top:0;bottom:0;left:0;width:100%"};background
|
|
85
|
+
const done = node(track, `position:absolute;${vertical ? "left:0;right:0;top:0;height:100%" : "top:0;bottom:0;left:0;width:100%"};background:${FILL};${fillEdge(edge)};transform-origin:${vertical ? "50% 0" : "0 50%"};transform:scale(0)`);
|
|
86
|
+
// 축 끝 값 — 세로 화면은 왼쪽(지금 읽음값과 같은 줄, 오른쪽은 딱지 줄)
|
|
83
87
|
const ends = [span.from, span.to].map((v, i) => {
|
|
84
88
|
const [x, y] = at(i);
|
|
85
89
|
return node(host, vertical
|
|
86
|
-
? `position:absolute;left:${x
|
|
90
|
+
? `position:absolute;left:${x - trackT / 2 - 10}px;top:${y}px;transform:translate(-100%, ${i ? "-100%" : "0"});font-family:var(--kf-font-mono);font-size:${Math.round(Math.max(44 * u, readPx * 0.8))}px;color:var(--kf-dim, var(--kf-ink));white-space:nowrap`
|
|
87
91
|
: `position:absolute;left:${x}px;top:${y + trackT * 0.7}px;transform:translateX(${i ? "-100%" : "0"});font-family:var(--kf-font-mono);font-size:${Math.round(readPx * 0.8)}px;color:var(--kf-dim, var(--kf-ink));white-space:nowrap`, `${fmt(v, dec)}${unit ? ` ${unit}` : ""}`);
|
|
88
92
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
|
|
94
|
+
// 표시 — 축을 가로지르는 눈금 하나와 딱지를 잇는 선. 큰 동그라미는 뜻 없이 이웃 표시·딱지를 덮었다
|
|
95
|
+
const chipCss = `padding:0.12em 0.4em;font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:var(--kf-ink);background:var(--kf-raised);white-space:nowrap;line-height:1.2`;
|
|
96
|
+
// 자리는 바깥 칸이 잡고 내려앉음(land)은 안쪽 딱지에 — land 가 transform 을 통째로 다시 쓴다
|
|
97
|
+
const pins = marks.map((m) => {
|
|
98
|
+
const anchor = node(host, "position:absolute;left:0;top:0");
|
|
99
|
+
return { m, p: pos(m.at), anchor, chip: node(anchor, chipCss) };
|
|
100
|
+
});
|
|
101
|
+
// 세로 화면 딱지 줄 — 바라는 자리(표시 높이·구간 가운데)에서 서로 안 겹치게 밀고, 아래로 넘치면 거꾸로 올린다
|
|
102
|
+
const chipX = vertical ? axisX + trackT / 2 + trackT * 0.55 : 0;
|
|
103
|
+
const rowGap = chipPx * 0.22;
|
|
104
|
+
const slots2 = [...pins.map((p) => ({ want: at(p.p)[1], h: chipPx * 1.44, el: p.anchor })), ...(vertical && win ? [{ want: win.y, h: winPx * 1.2, el: win.label }] : [])].sort((x, y) => x.want - y.want);
|
|
105
|
+
const ys = slots2.map((x) => x.want);
|
|
106
|
+
if (vertical) {
|
|
107
|
+
for (let i = 0; i < slots2.length; i++) ys[i] = Math.max(slots2[i].want, i ? ys[i - 1] + (slots2[i - 1].h + slots2[i].h) / 2 + rowGap : a0 + slots2[i].h / 2);
|
|
108
|
+
const bottom = a0 + len + chipPx;
|
|
109
|
+
for (let i = slots2.length - 1; i >= 0; i--) ys[i] = Math.min(ys[i], i < slots2.length - 1 ? ys[i + 1] - (slots2[i].h + slots2[i + 1].h) / 2 - rowGap : bottom - slots2[i].h / 2);
|
|
110
|
+
}
|
|
111
|
+
const yOf = new Map(slots2.map((x, i) => [x.el, ys[i]]));
|
|
112
|
+
const SVG = "http://www.w3.org/2000/svg";
|
|
113
|
+
const svg = host.ownerDocument.createElementNS(SVG, "svg");
|
|
114
|
+
svg.setAttribute("style", "position:absolute;left:0;top:0;width:100%;height:100%;overflow:visible;pointer-events:none");
|
|
115
|
+
host.append(svg);
|
|
116
|
+
const ticks = pins.map((p, i) => {
|
|
117
|
+
const [x, y] = at(p.p);
|
|
118
|
+
const line = host.ownerDocument.createElementNS(SVG, "polyline");
|
|
119
|
+
line.setAttribute("fill", "none");
|
|
120
|
+
line.setAttribute("stroke-width", String(Math.max(3, Math.round(4 * u))));
|
|
121
|
+
line.setAttribute("stroke-linejoin", "round");
|
|
122
|
+
if (vertical) {
|
|
123
|
+
const cy = yOf.get(p.anchor)!;
|
|
124
|
+
const elbow = chipX - trackT * 0.25;
|
|
125
|
+
line.setAttribute("points", `${x - trackT / 2},${y} ${elbow},${y} ${elbow},${cy} ${chipX},${cy}`);
|
|
126
|
+
p.anchor.style.transform = `translate(${chipX}px, ${cy.toFixed(1)}px) translateY(-50%)`;
|
|
127
|
+
} else {
|
|
128
|
+
// 가로 화면은 딱지가 위아래 번갈아 — 눈금이 축을 가로질러 딱지까지 곧게
|
|
129
|
+
const side = i % 2 ? 1 : -1;
|
|
130
|
+
const cy = y + side * trackT * 1.25;
|
|
131
|
+
line.setAttribute("points", `${x},${y - side * trackT / 2} ${x},${cy}`);
|
|
132
|
+
p.anchor.style.transform = `translate(${x}px, ${cy}px) translate(-50%, ${side < 0 ? "-100%" : "0"})`;
|
|
133
|
+
}
|
|
134
|
+
svg.append(line);
|
|
135
|
+
return line;
|
|
98
136
|
});
|
|
99
|
-
|
|
137
|
+
if (vertical && win) win.label.style.transform = `translate(${chipX}px, ${yOf.get(win.label)!.toFixed(1)}px) translateY(-50%)`;
|
|
138
|
+
// 지금 줄 — 읽음값에서 축을 가로질러 딱지 줄 앞까지. 옅게 칠하는 테마는 지난 길이 차오르는 것만으로는 화면이 거의 안 바뀐다
|
|
139
|
+
const nowT = Math.max(2, Math.round(3 * u));
|
|
140
|
+
const nowLine = node(host, vertical
|
|
141
|
+
? `position:absolute;left:${w * 0.03}px;top:0;width:${chipX - trackT * 0.25 - w * 0.03}px;height:${nowT}px;margin-top:-${nowT / 2}px;background:var(--kf-accent);visibility:hidden`
|
|
142
|
+
: `position:absolute;left:0;top:${axisY - trackT * 0.65}px;width:${nowT}px;height:${trackT * 1.3}px;margin-left:-${nowT / 2}px;background:var(--kf-accent);visibility:hidden`);
|
|
143
|
+
// 읽음값 칩은 지금 줄 위에 앉는다 — 옅은 칠도 줄이 비치지 않게 바탕 위에 겹쳐 칠한다
|
|
144
|
+
const head = node(host, `position:absolute;left:0;top:0;padding:0.1em 0.35em;font-family:var(--kf-font-mono);font-size:${readPx}px;font-weight:700;color:${ON_FILL};background:linear-gradient(${FILL}, ${FILL}), var(--kf-bg);${fillEdge(edge)};white-space:nowrap;font-variant-numeric:tabular-nums;visibility:hidden`);
|
|
100
145
|
|
|
101
146
|
const hits = spaced(kit.hits.length ? kit.hits : [0], 0.3);
|
|
102
147
|
const drawAt = 0;
|
|
@@ -107,35 +152,44 @@ const pattern: Pattern<TimelineContent> = {
|
|
|
107
152
|
|
|
108
153
|
const writes = pins.map((p, i) => slots.write(p.chip, { ctx, text: p.m.label, at: pinAt[i], dur: 0.25, seed: `${kit.seed}:m${i}` }));
|
|
109
154
|
const writeWin = win ? slots.write(win.label, { ctx, text: c.window!.label, at: walkFrom - 0.3, dur: 0.3, seed: `${kit.seed}:w` }) : null;
|
|
110
|
-
const lands = pins.map((p, i) => slots.land(p.
|
|
155
|
+
const lands = pins.map((p, i) => slots.land(p.chip, { ctx, at: pinAt[i], seed: `${kit.seed}:p${i}` }));
|
|
156
|
+
// 머무는 짚개 테마는 지금이 표시에 닿을 때마다 카메라가 그 딱지 쪽으로 다가간다
|
|
157
|
+
followSpoken(kit, ctx, pins.map((p) => ({ at: walkFrom + p.p * (walkEnd - walkFrom), el: p.anchor })), { reach: 0.15, zoom: 1.03 });
|
|
111
158
|
|
|
112
159
|
return (t: number) => {
|
|
113
160
|
moving("timeline.axis", ctx);
|
|
114
161
|
const g = clamp((t - drawAt) / 0.3);
|
|
115
162
|
track.style.transform = vertical ? `scaleY(${g.toFixed(4)})` : `scaleX(${g.toFixed(4)})`;
|
|
116
|
-
for (const e of ends) e.style.visibility = g >= 1 ? "visible" : "hidden";
|
|
117
163
|
// 지금 — 고른 빠르기로 축 처음에서 끝까지
|
|
118
164
|
const now = clamp((t - walkFrom) / (walkEnd - walkFrom));
|
|
119
165
|
done.style.transform = vertical ? `scaleY(${now.toFixed(4)})` : `scaleX(${now.toFixed(4)})`;
|
|
120
166
|
const [hx, hy] = at(now);
|
|
121
167
|
head.style.visibility = t >= walkFrom ? "visible" : "hidden";
|
|
168
|
+
nowLine.style.visibility = head.style.visibility;
|
|
169
|
+
nowLine.style.transform = vertical ? `translateY(${hy.toFixed(1)}px)` : `translateX(${hx.toFixed(1)}px)`;
|
|
122
170
|
head.textContent = `${fmt(span.from + (span.to - span.from) * now, dec)}${unit ? ` ${unit}` : ""}`;
|
|
123
171
|
head.style.transform = vertical ? `translate(${(hx - trackT * 0.5 - 8).toFixed(1)}px, ${hy.toFixed(1)}px) translate(-100%, -50%)` : `translate(${hx.toFixed(1)}px, ${(hy - trackT * 0.5 - 8).toFixed(1)}px) translate(-50%, -100%)`;
|
|
172
|
+
// 세로 화면은 끝 값과 지금 읽음값이 같은 줄 — 지금이 그 끝 가까이 오면 끝 값을 비킨다(같은 값을 두 번 쓴다)
|
|
173
|
+
ends.forEach((e, i) => {
|
|
174
|
+
const near = vertical && Math.abs(hy - at(i)[1]) < readPx * 1.6 && t >= walkFrom;
|
|
175
|
+
e.style.visibility = g >= 1 && !near ? "visible" : "hidden";
|
|
176
|
+
});
|
|
124
177
|
if (win) win.label.style.visibility = t >= walkFrom - 0.3 ? "visible" : "hidden";
|
|
125
178
|
writeWin?.(t);
|
|
126
179
|
pins.forEach((p, i) => {
|
|
127
180
|
const on = t >= pinAt[i];
|
|
128
|
-
p.pin.style.visibility = on ? "visible" : "hidden";
|
|
129
181
|
p.chip.style.visibility = on ? "visible" : "hidden";
|
|
182
|
+
ticks[i].style.visibility = on ? "visible" : "hidden";
|
|
130
183
|
if (!on) return;
|
|
131
184
|
writes[i](t);
|
|
132
|
-
|
|
133
|
-
// 지금이 지나간 표시는 켜진다 — 강조 표시(hot)는 반전 칩으로
|
|
185
|
+
// 지금이 지나간 표시는 눈금이 켜진다 — 강조 표시(hot)는 딱지도 칠한 칸으로
|
|
134
186
|
const passed = now >= p.p - 1e-6 && t >= walkFrom;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
p.chip.style.background =
|
|
138
|
-
p.chip.style.color =
|
|
187
|
+
ticks[i].setAttribute("stroke", passed ? "var(--kf-accent)" : "var(--kf-dim, var(--kf-ink))");
|
|
188
|
+
const hot = passed && p.m.hot;
|
|
189
|
+
p.chip.style.background = hot ? FILL : "var(--kf-raised)";
|
|
190
|
+
p.chip.style.color = hot ? ON_FILL : "var(--kf-ink)";
|
|
191
|
+
p.chip.style.boxShadow = hot ? `inset 0 0 0 ${edge}px var(--kf-fill-line, transparent)` : "";
|
|
192
|
+
applyStyle(p.chip, lands[i](t));
|
|
139
193
|
});
|
|
140
194
|
};
|
|
141
195
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { applyStyle, clamp } from "@kasaframe/core";
|
|
2
2
|
import { sweep } from "@kasaframe/motion";
|
|
3
3
|
import { fitSize } from "@kasaframe/motion/moves";
|
|
4
|
-
import { ACCENT_TEXT, area, beatStops, cameraOk, dwells, glider, moving, node, reread, riseName, slotsOf } from "../kit.ts";
|
|
4
|
+
import { ACCENT_TEXT, area, beatStops, cameraOk, dwells, followSpoken, glider, moving, node, reread, riseName, sayTimes, slotsOf } from "../kit.ts";
|
|
5
5
|
import type { Pattern } from "../types.ts";
|
|
6
6
|
|
|
7
7
|
export interface TitleContent {
|
|
@@ -58,13 +58,17 @@ const pattern: Pattern<TitleContent> = {
|
|
|
58
58
|
const nameEnd = nameAt + each * (count - 1) + 0.3;
|
|
59
59
|
const settle = hits.find((b) => b >= nameEnd - 0.05) ?? nameEnd;
|
|
60
60
|
const sweepAt = settle;
|
|
61
|
-
|
|
61
|
+
// 머무는 짚개 테마는 부제를 자막·목소리가 말하는 순간에 세우고, 카메라가 이름에서 부제 쪽으로 서지 않고 다가간다 — 한 번에 길게 다가가면
|
|
62
|
+
// 앞뒤 느린 끝이 멈춘 화면이라 이름 하나뿐인 용어 장면이 2초 넘게 섰다. 이름 낱말을 말하는 순간에 나눠 세우면 짧은 장면은 낱말 사이가 도리어 멈춘다
|
|
63
|
+
const dwell = dwells(kit, ctx);
|
|
64
|
+
const subAt = dwell && sub ? sayTimes(kit, [c.sub], sweepAt + 0.3, Math.max(sweepAt + 0.3, ctx.dur - 1))[0] : sweepAt + 0.3;
|
|
62
65
|
const subDur = Math.max(0.3, ctx.dur - subAt - 0.08);
|
|
63
|
-
if (
|
|
66
|
+
if (dwell) followSpoken(kit, ctx, [{ at: settle, el: name }, ...(sub ? [{ at: subAt, el: sub }] : [])], { reach: 0.5, zoom: 1.1 });
|
|
67
|
+
else if (cameraOk(ctx)) kit.stage?.camera.pushIn(settle, { to: name, scale: 1.07, dur: Math.max(0.6, ctx.dur - settle) });
|
|
64
68
|
// 이름이 서면 짚개가 이름과 위 작은 글·부제를 짚는다(짚기 방식은 테마 짚음). 작은 글이 없고 훑는 테마면 이름 글자를 박마다 다시 짚는다
|
|
65
69
|
const markFrom = Math.min(settle, nameAt + 0.35);
|
|
66
70
|
const point = glider(host, [{ el: name, text: c.title }, ...(kicker ? [{ el: kicker, text: c.kicker }] : []), ...(sub ? [{ el: sub, text: c.sub }] : [])], { kit, ctx, from: markFrom });
|
|
67
|
-
const again = !kicker && !sub && !
|
|
71
|
+
const again = !kicker && !sub && !dwell ? beatStops(kit.hits, markFrom, ctx.dur) : null;
|
|
68
72
|
const writeKicker = kicker ? slots.write(kicker, { ctx, text: c.kicker!, at: 0, dur: Math.min(0.35, nameEnd * 0.6), seed: `${kit.seed}:kicker` }) : null;
|
|
69
73
|
const writeSub = sub ? slots.write(sub, { ctx, text: c.sub!, at: subAt, dur: subDur, seed: `${kit.seed}:sub` }) : null;
|
|
70
74
|
const landed = slots.land(name, { ctx, at: nameEnd, seed: `${kit.seed}:land` });
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
## 변주 — 브랜드를 지키는 축만
|
|
30
30
|
|
|
31
|
-
- 강조 파랑은 돌지 않는다(`vary.keep` — accent·accentText·accentSoft·onAccent·tint·의미색). `hue`·`chroma`·`tint` 축이 없다.
|
|
31
|
+
- 강조 파랑은 돌지 않는다(`vary.keep` — accent·accentText·accentSoft·onAccent·tint·칠 토큰 fill·onFill·fillLine·의미색). `hue`·`chroma`·`tint` 축이 없다.
|
|
32
32
|
- 밝기 둘: 다크(세계 기본)·라이트(`vary.palettes.light` — 라이트 판은 다크를 뒤집어 옮기지 않고 정본 라이트 한 벌에서 시작한다. 두 벌이 거울상이 아니라서 옮기면 정본 값이 안 나온다).
|
|
33
33
|
- 바탕은 정본 면 색 사이만(`vary.bg` — 다크 `--background`~`--card`, 라이트 `--card`~`--background`). 그 위의 면·선·흐린 글은 바탕을 따라 자리를 지킨다.
|
|
34
34
|
- 글꼴·모서리·빠르기·질감·선 굵기는 정본이 하나뿐이라 돌지 않는다(스타일가이드의 한글 글꼴 다섯 후보는 「비교용, 고르기 전엔 전역 글꼴을 안 바꾼다」).
|
|
@@ -78,5 +78,7 @@ export default {
|
|
|
78
78
|
감독(`kf direct`)의 장면 문법 패턴은 효과 대신 슬롯(`slots.ts`)으로 이 세계를 입는다 — 바탕 모눈(`surface`), 위 구분선 판(`panel`), 6px 앉기(`land`), 계기판 글 찍기(`write`), 3px 추이 선(`trace`), 파란 구분선 드롭(`drop`), 그리고 짚는 칸(`mark`).
|
|
79
79
|
|
|
80
80
|
- **짚는 칸은 고른 상태 알약** — 파랑/10 칠에 파랑/45 테두리, 글 뒤에 깔리고 칸 위 글은 `--on-primary` 파랑. 꽉 칠한 강조색 네모는 서지 않는다(4절). 장면의 요점(주인공 값·그 순간 목소리가 말하는 값)에만 내려앉아 머물고, 다음 요점이 생기면 읽는 칸(`pickFly`)과 같은 길로 아래 테두리 한 줄로 내려앉아 미끄러진 뒤 그 칸만큼 차오른다. 짚을 것이 하나뿐이면 칸이 없다.
|
|
81
|
+
- **칠한 칸도 고른 상태 결** — 진행·점유·지금(차오르는 단계 카드·한계 계기·GPU 칸·시간축 지난 길과 읽음값)은 칠 토큰(`fill`·`onFill`·`fillLine`)으로 파랑/10 면에 파랑/45 안쪽 테두리, 그 위 글은 `--on-primary` 파랑. 「찼다」는 뜻은 남고 꽉 찬 강조색 면은 없다.
|
|
82
|
+
- **시간축 표시는 눈금선** — 축을 가로지르는 가는 눈금이 딱지까지 이어지고(지나면 파랑), 딱지·구간 이름은 오른쪽 한 줄에 겹치지 않게 쌓인다. 글은 눕히지 않는다.
|
|
81
83
|
- **멈춤 오류 기준 1초**(`theme.json` `motion.maxFreezeSec`) — 화면을 살려 두려고 칸이 박마다 항목을 훑지 않는다. 0.1초 넘는 멈춤은 kf check 경고로 보이고(틀 기준), 오류·감독 다시 고르기는 1초부터. 쉬는 구간은 내용이 메운다 — 항목은 목소리가 그것을 말하는 순간에 서고(숫자·계기는 그때까지 구르고 차오른다), 그 사이 카메라가 방금 말한 항목 쪽으로 천천히 다가간다(바탕은 흐르지 않는다).
|
|
82
84
|
- **장면 사이는 컷과 짧은 페이드만**(`theme.json` `transitions`: `none`·`fade`) — 육각 조리개·화면을 쓰는 큰 면·흐린 원 같은 면 전환과 3D 이음매는 쓰지 않는다. 페이드는 바탕색으로 경계 양쪽 0.15초씩.
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
"accentText": "#3B99F3",
|
|
16
16
|
"onAccent": "#FFFFFF",
|
|
17
17
|
"tint": "rgba(0, 132, 255, 0.1)",
|
|
18
|
+
"fill": "rgba(0, 132, 255, 0.1)",
|
|
19
|
+
"onFill": "#3B99F3",
|
|
20
|
+
"fillLine": "rgba(0, 132, 255, 0.45)",
|
|
18
21
|
"success": "#10B981",
|
|
19
22
|
"danger": "#EF4343",
|
|
20
23
|
"warning": "#F59E0B",
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"accentText": "#01509D",
|
|
41
41
|
"onAccent": "#FFFFFF",
|
|
42
42
|
"tint": "rgba(0, 111, 214, 0.1)",
|
|
43
|
+
"fill": "rgba(0, 111, 214, 0.1)",
|
|
44
|
+
"onFill": "#01509D",
|
|
45
|
+
"fillLine": "rgba(0, 111, 214, 0.45)",
|
|
43
46
|
"success": "#10B981",
|
|
44
47
|
"danger": "#EF4444",
|
|
45
48
|
"warning": "#F59E0B",
|
|
@@ -62,6 +65,9 @@
|
|
|
62
65
|
"accentText",
|
|
63
66
|
"onAccent",
|
|
64
67
|
"tint",
|
|
68
|
+
"fill",
|
|
69
|
+
"onFill",
|
|
70
|
+
"fillLine",
|
|
65
71
|
"success",
|
|
66
72
|
"danger",
|
|
67
73
|
"warning"
|
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
"accentText": "#01509D",
|
|
39
39
|
"onAccent": "#FFFFFF",
|
|
40
40
|
"tint": "rgba(0, 111, 214, 0.1)",
|
|
41
|
+
"fill": "rgba(0, 111, 214, 0.1)",
|
|
42
|
+
"onFill": "#01509D",
|
|
43
|
+
"fillLine": "rgba(0, 111, 214, 0.45)",
|
|
41
44
|
"success": "#10B981",
|
|
42
45
|
"danger": "#EF4444",
|
|
43
46
|
"warning": "#F59E0B",
|