@kaydash9999/banker-plugins 0.15.2 → 0.16.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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +65 -0
  4. package/README.md +31 -9
  5. package/codex/manifest.json +6 -3
  6. package/codex/transform-matrix.md +2 -0
  7. package/commands/progress.md +10 -0
  8. package/commands/setup.md +1 -1
  9. package/hooks/graceful-pause.mjs +31 -71
  10. package/hooks/hooks.json +11 -1
  11. package/hooks/progress-fallback.mjs +10 -0
  12. package/hooks/progress.mjs +427 -0
  13. package/hooks/register.mjs +82 -5
  14. package/package.json +1 -1
  15. package/skills/3d-intro-build/SKILL.md +238 -122
  16. package/skills/3d-intro-build/references/NOTICE.md +2 -1
  17. package/skills/3d-intro-build/references/assemble.mjs +164 -108
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +204 -44
  19. package/skills/3d-intro-build/references/curate.mjs +573 -267
  20. package/skills/3d-intro-build/references/intro-fixes.css +61 -0
  21. package/skills/3d-intro-build/references/panel-glass.css +55 -0
  22. package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
  23. package/skills/3d-intro-build/references/prompts.md +32 -9
  24. package/skills/3d-intro-build/references/serve.mjs +111 -103
  25. package/skills/3d-intro-build/references/step-nav.css +59 -0
  26. package/skills/3d-intro-build/references/step-nav.js +503 -0
  27. package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
  28. package/skills/3d-intro-build/references/verify.md +45 -0
  29. package/skills/3d-intro-build/references/video-pool.mjs +817 -0
  30. package/skills/3d-intro-setup/SKILL.md +120 -47
  31. package/skills/3d-intro-setup/references/azure-adapter.mjs +204 -44
  32. package/skills/3d-intro-setup/references/video-pool.mjs +817 -0
  33. package/skills/motion-graphic-make/SKILL.md +73 -28
  34. package/skills/motion-graphic-make/references/curate.mjs +607 -0
  35. package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
  36. package/skills/motion-graphic-make/references/serve.mjs +131 -0
  37. package/skills/omc-patch/SKILL.md +143 -0
  38. package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
  39. package/skills/remains/SKILL.md +125 -0
  40. package/skills/remains/scripts/boxes.mjs +448 -0
  41. package/skills/trouble-shooting/SKILL.md +50 -0
@@ -0,0 +1,61 @@
1
+ /* Fixes for the vendored scroll-world engine (scrub-engine.js), applied by the page so the engine
2
+ file stays as shipped. The engine's own rules sit in `@layer sw`, so these unlayered rules win.
3
+ assemble.mjs links this file on every page. */
4
+
5
+ /* The engine writes an inline transform on each copy block every frame (a small parallax), which
6
+ replaces the stylesheet's translateY(-50%), so the block hangs below the middle. The separate
7
+ `translate` property composes with that transform and keeps the block centred. At 860px and
8
+ below the engine anchors the block to the bottom instead, so the fix stops there. */
9
+ @media (min-width: 861px) {
10
+ .sw-copy { translate: 0 -50%; }
11
+ }
12
+
13
+ /* At 860px and below the engine anchors the copy to the bottom across the screen, under its own
14
+ route dots (6px from the right edge, up to 40px wide on a touch screen), so the dots sit on the
15
+ text. A 52px right margin keeps the copy clear of them.
16
+ The engine's bottom margin (12dvh, 80px on a 667px phone) leaves the copy on the hint and the
17
+ main button, which reach 88px up from the bottom. The copy keeps 96px clear, plus the 2vh the
18
+ parallax moves it down; that is above the engine's margin at every height. */
19
+ @media (max-width: 860px) {
20
+ .sw-copy { right: 52px; bottom: calc(96px + 2vh + env(safe-area-inset-bottom)); }
21
+ }
22
+
23
+ /* A short landscape screen (a phone on its side, a short window) has no room for the copy in one
24
+ column: it ran under the header and, with a CTA, off the top. The copy sits above the hint and
25
+ the main button (up to 88px) in two columns, the label, title and tags on the left, the
26
+ body and the CTA on the right, with smaller type and without the scene number (the route dots
27
+ show it). The child selector outweighs the glass panel's width. The insets keep the copy off a
28
+ phone's notch (the page uses viewport-fit=cover). */
29
+ @media (max-height: 500px) and (orientation: landscape) {
30
+ .sw-copylayer > .sw-copy {
31
+ top: auto; bottom: calc(90px + 2vh + env(safe-area-inset-bottom)); translate: none;
32
+ left: max(clamp(18px, 5vw, 64px), calc(env(safe-area-inset-left) + 8px));
33
+ right: max(clamp(52px, 8vw, 120px), calc(env(safe-area-inset-right) + 44px)); width: auto; max-width: 760px;
34
+ display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr); grid-template-rows: auto 1fr auto;
35
+ grid-template-areas: "eyebrow body" "title body" "tags cta"; column-gap: clamp(16px, 3vw, 32px); align-items: start;
36
+ }
37
+ .sw-copy__num { display: none; }
38
+ .sw-copy__eyebrow { grid-area: eyebrow; margin-top: 0; }
39
+ .sw-copy__title { grid-area: title; margin-top: 6px; font-size: clamp(1.2rem, 6vh, 2rem); }
40
+ .sw-copy__body { grid-area: body; margin-top: 0; max-width: none; font-size: clamp(.84rem, 4vh, 1rem); line-height: 1.45; }
41
+ .sw-copy__tags { grid-area: tags; margin-top: 10px; }
42
+ .sw-copy__cta { grid-area: cta; margin-top: 10px; }
43
+ /* The shade rises from the bottom and reaches the top of the copy, which sits higher than in the
44
+ engine's phone layout. No room for the dot labels. */
45
+ .sw-copylayer::before {
46
+ width: 100%; height: 85%; top: auto; bottom: 0;
47
+ background: linear-gradient(0deg, var(--sw-bg) 0%, color-mix(in srgb, var(--sw-bg) 88%, transparent) 40%,
48
+ color-mix(in srgb, var(--sw-bg) 55%, transparent) 70%, transparent 100%);
49
+ }
50
+ .sw-route__label { display: none; }
51
+ }
52
+
53
+ /* The engine draws its primary buttons in white on the theme's ink colour, which a dark theme
54
+ (light ink) turns into white on a light pill. The theme's background colour reads on the ink in
55
+ any theme, as the body text reads on the background. */
56
+ .sw-btn--primary, .sw-topcta { color: var(--sw-bg); }
57
+
58
+ /* The engine sets overflow-x:hidden on html and body, which makes them scroll containers and breaks
59
+ position:sticky headers of a site the intro sits on. `clip` hides the overflow without that.
60
+ A browser without `clip` drops this line and keeps the engine's `hidden`. */
61
+ html, body { overflow-x: clip; }
@@ -0,0 +1,55 @@
1
+ /* Frosted-glass copy panel for the scroll-world intro (opt-in: intro.json "panel": "glass").
2
+ Dark glass under light text, readable over bright and busy scenes.
3
+
4
+ Contrast rule: the body lines keep a contrast of at least 4.5 against the brightest real pixel
5
+ behind them (verify-intro.mjs measures it with the text hidden). When a bright scene falls below,
6
+ raise --intro-panel-alpha or deepen the shade; a lighter panel fails first on the body lines.
7
+ The colours below are a starting point: take --intro-panel-rgb from the brand's darkest colour. */
8
+
9
+ .sw-root {
10
+ --intro-panel-rgb: 11, 18, 32;
11
+ --intro-panel-alpha: .36;
12
+ --intro-title: #fff;
13
+ --intro-body: #F1F5F9;
14
+ --intro-num: #E2E8F0;
15
+ --intro-text-shadow: 0 1px 2px rgba(var(--intro-panel-rgb), .9), 0 0 6px rgba(var(--intro-panel-rgb), .75),
16
+ 0 0 16px rgba(var(--intro-panel-rgb), .6);
17
+ }
18
+
19
+ .sw-copy {
20
+ box-sizing: border-box;
21
+ width: min(44vw, 500px);
22
+ padding: clamp(18px, 2.2vw, 28px);
23
+ background: rgba(var(--intro-panel-rgb), var(--intro-panel-alpha));
24
+ -webkit-backdrop-filter: blur(10px) saturate(1.15);
25
+ backdrop-filter: blur(10px) saturate(1.15);
26
+ border: 1px solid rgba(255, 255, 255, .18);
27
+ border-radius: 18px;
28
+ box-shadow: 0 18px 48px rgba(0, 0, 0, .24);
29
+ }
30
+
31
+ .sw-copy__num { color: var(--intro-num); text-shadow: var(--intro-text-shadow); }
32
+ .sw-copy__eyebrow { color: color-mix(in srgb, var(--sw-accent) 30%, #fff); text-shadow: var(--intro-text-shadow); }
33
+ .sw-copy__title { color: var(--intro-title); text-shadow: var(--intro-text-shadow); }
34
+ /* One item per line: write the body with line breaks, each short enough for one line at 375px
35
+ (about 36 Latin characters). */
36
+ .sw-copy__body { color: var(--intro-body); text-shadow: var(--intro-text-shadow); white-space: pre-line; max-width: none; }
37
+
38
+ /* A secondary (ghost) button has no fill, so its label sits on the dark glass: it takes the panel's
39
+ light text, whatever the theme's ink. */
40
+ .sw-copy .sw-btn--ghost { color: var(--intro-title); border-color: rgba(255, 255, 255, .45); }
41
+
42
+ /* A light shade behind the panel, from the left on wide screens. */
43
+ .sw-copylayer::before {
44
+ background: linear-gradient(90deg, rgba(var(--intro-panel-rgb), .14) 0%, rgba(var(--intro-panel-rgb), .1) 40%,
45
+ rgba(var(--intro-panel-rgb), .04) 68%, transparent 100%);
46
+ }
47
+
48
+ /* At 860px and below the engine puts the panel at the bottom with side margins, and intro-fixes.css
49
+ does the same on a short landscape screen; the shade rises from the bottom instead. */
50
+ @media (max-width: 860px), (max-height: 500px) and (orientation: landscape) {
51
+ .sw-copy { width: auto; }
52
+ .sw-copylayer::before {
53
+ background: linear-gradient(0deg, rgba(var(--intro-panel-rgb), .28) 0%, rgba(var(--intro-panel-rgb), .14) 45%, transparent 75%);
54
+ }
55
+ }
@@ -0,0 +1,256 @@
1
+ /*
2
+ * Shared by serve.mjs and curate.mjs: the local servers of the 3d-intro-build skill (mirrored
3
+ * byte-identical into motion-graphic-make by scripts/sync-adapter.js).
4
+ *
5
+ * - A port the system picks (a preferred one first when asked), bound to loopback by default.
6
+ * - A state file recording { pid, port, requestedPort, host, url, dir }: serve.mjs keeps it beside
7
+ * the folder it serves, curate.mjs in the project folder, which it does not serve (it serves
8
+ * listed takes only).
9
+ * - Requests whose Host is a name other than localhost are refused, so a DNS name rebound to this
10
+ * machine cannot read what is served.
11
+ * - A stop that ends only the process answering on that port with the recorded PID (a whole number
12
+ * above 1), then waits for the port to close. Nothing is matched by command line (no pkill -f).
13
+ * - Files open before any header goes out, so one that cannot be read gets 403 and the server
14
+ * stays up, and a download the browser drops closes its file.
15
+ * - An optional Content-Security-Policy header, so a page is checked under the policy it ships with.
16
+ *
17
+ * Runtime: Node >=18 builtins only (node:fs / node:http / node:net / node:path). No shell.
18
+ */
19
+ import fs from 'node:fs';
20
+ import http from 'node:http';
21
+ import net from 'node:net';
22
+ import path from 'node:path';
23
+ import { pipeline } from 'node:stream';
24
+
25
+ export const LOOPBACK = '127.0.0.1';
26
+ // Answers { pid } so a stop can tell this server from another process on the same port.
27
+ export const PID_ROUTE = '/__preview/pid';
28
+ // As strict as a production site: no inline script or style. The scrub engine fetches its clips
29
+ // from its own origin and plays them from blob: URLs.
30
+ export const STRICT_CSP = [
31
+ "default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data: blob:",
32
+ "media-src 'self' blob:", "connect-src 'self'", "font-src 'self'", "object-src 'none'",
33
+ "base-uri 'self'", "frame-ancestors 'none'",
34
+ ].join('; ');
35
+
36
+ export const MIME = {
37
+ '.html': 'text/html;charset=utf-8', '.css': 'text/css;charset=utf-8', '.js': 'text/javascript;charset=utf-8',
38
+ '.mjs': 'text/javascript;charset=utf-8', '.json': 'application/json;charset=utf-8', '.png': 'image/png',
39
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
40
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.woff2': 'font/woff2',
41
+ };
42
+
43
+ // `strict` is the policy above; any other text is a policy as written; nothing means no header.
44
+ export const cspFrom = (value) => (value ? (value === 'strict' ? STRICT_CSP : String(value)) : null);
45
+
46
+ export function hostWarning(host) {
47
+ if (!host || [LOOPBACK, 'localhost', '::1'].includes(host)) return null;
48
+ return `주의: ${host} 에 바인드합니다. 같은 네트워크의 기기가 아직 공개하지 않은 산출물을 볼 수 있습니다. 끝나면 --stop 으로 닫으세요.`;
49
+ }
50
+
51
+ // True for a Host header naming this machine as localhost, by an IP address, or by a name the server
52
+ // was bound to (`names`); any other DNS name fails, which is what a rebinding attack needs.
53
+ export function hostAllowed(hostHeader, names = []) {
54
+ const h = String(hostHeader || '').trim().toLowerCase();
55
+ if (!h) return false;
56
+ if (h.startsWith('[')) return /^\[[0-9a-f:.]+\](:\d+)?$/.test(h);
57
+ const name = h.split(':')[0];
58
+ return name === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(name) || names.map((n) => String(n).toLowerCase()).includes(name);
59
+ }
60
+
61
+ // The request path without its query, or null when its escapes are broken.
62
+ export function decodePath(rawUrl) {
63
+ try { return decodeURIComponent(String(rawUrl || '/').split('?')[0]); } catch { return null; }
64
+ }
65
+
66
+ // The file a request path names inside `dir`, or null when the path leaves it.
67
+ export function resolveInside(dir, urlPath) {
68
+ const fp = path.normalize(path.join(dir, urlPath));
69
+ return fp === dir || fp.startsWith(dir + path.sep) ? fp : null;
70
+ }
71
+
72
+ export function send(res, status, body, headers = {}) {
73
+ res.writeHead(status, { ...headers, 'Content-Type': 'text/plain;charset=utf-8', 'Cache-Control': 'no-store' });
74
+ res.end(String(body));
75
+ }
76
+
77
+ // Read flags that never wait: a path that has turned into a FIFO opens at once and is then refused.
78
+ export const READ_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0);
79
+
80
+ // An open regular file: { fd, size }, or { status } when there is none (404) or it cannot be read (403).
81
+ function openRegular(fp) {
82
+ let fd;
83
+ try { fd = fs.openSync(fp, READ_FLAGS); } catch (e) { return { status: ['ENOENT', 'ENOTDIR', 'EISDIR'].includes(e.code) ? 404 : 403 }; }
84
+ let st = null;
85
+ try { st = fs.fstatSync(fd); } catch { /* a status the system cannot give (EIO on a network drive) */ }
86
+ if (st && st.isFile()) return { fd, size: st.size };
87
+ fs.closeSync(fd);
88
+ return { status: st ? 404 : 403 };
89
+ }
90
+
91
+ // Sends the bytes `start`..`end` of an open file (what the headers declared, even if the file grows
92
+ // meanwhile) and closes it, also when the browser drops the download midway (a pipe would keep the
93
+ // file open). Nothing to send closes the file and ends the response.
94
+ function stream(res, fp, { fd, start, end }) {
95
+ if (end < start) {
96
+ fs.closeSync(fd);
97
+ return void res.end();
98
+ }
99
+ pipeline(fs.createReadStream(fp, { fd, start, end }), res, () => {});
100
+ }
101
+
102
+ // A file, with byte ranges for video (smoother scrubbing). Never cached: a rebuilt page is fresh.
103
+ // The file is opened before any header goes out, so one that cannot be read gets 403 instead of
104
+ // ending the server.
105
+ export function sendFile(req, res, fp, headers = {}) {
106
+ const f = openRegular(fp);
107
+ if (f.status) return send(res, f.status, String(f.status), headers);
108
+ const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
109
+ const base = { ...headers, 'Content-Type': type, 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' };
110
+ const range = type.startsWith('video/') ? /bytes=(\d+)-(\d*)/.exec(req.headers.range || '') : null;
111
+ if (range) return sendRange(res, fp, f, range, base, headers);
112
+ res.writeHead(200, { ...base, 'Content-Length': f.size });
113
+ stream(res, fp, { fd: f.fd, start: 0, end: f.size - 1 });
114
+ }
115
+
116
+ function sendRange(res, fp, f, range, base, headers) {
117
+ const start = Number(range[1]);
118
+ const end = Math.min(range[2] ? Number(range[2]) : f.size - 1, f.size - 1);
119
+ if (start >= f.size || start > end) {
120
+ fs.closeSync(f.fd);
121
+ res.writeHead(416, { ...headers, 'Content-Range': `bytes */${f.size}` });
122
+ return void res.end();
123
+ }
124
+ res.writeHead(206, { ...base, 'Content-Range': `bytes ${start}-${end}/${f.size}`, 'Content-Length': end - start + 1 });
125
+ stream(res, fp, { fd: f.fd, start, end });
126
+ }
127
+
128
+ // True when the request was the PID question, which this answers.
129
+ export function answersPid(urlPath, res) {
130
+ if (urlPath !== PID_ROUTE) return false;
131
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
132
+ res.end(JSON.stringify({ pid: process.pid }));
133
+ return true;
134
+ }
135
+
136
+ // Listens on `port` when given and free, else on a port the system picks. Resolves to the port.
137
+ export function listen(server, { host = LOOPBACK, port } = {}) {
138
+ const attempt = (p) => new Promise((resolve, reject) => {
139
+ const onError = (e) => { server.off('listening', onListening); reject(e); };
140
+ const onListening = () => { server.off('error', onError); resolve(server.address().port); };
141
+ server.once('error', onError);
142
+ server.once('listening', onListening);
143
+ server.listen(p, host);
144
+ });
145
+ if (!port) return attempt(0);
146
+ return attempt(port).catch((e) => (e.code === 'EADDRINUSE' ? attempt(0) : Promise.reject(e)));
147
+ }
148
+
149
+ // The address a person opens: localhost for loopback and the wildcard, IPv6 in brackets.
150
+ export function urlFor(host, port) {
151
+ if (!host || host === LOOPBACK || host === '0.0.0.0') return `http://localhost:${port}/`;
152
+ if (host === '::' || host === '::1') return `http://[::1]:${port}/`;
153
+ return host.includes(':') ? `http://[${host}]:${port}/` : `http://${host}:${port}/`;
154
+ }
155
+
156
+ // Where a stop asks a server for its PID: the address it was bound to, a wildcard at loopback.
157
+ export function askHost(host) {
158
+ if (!host || host === '0.0.0.0') return LOOPBACK;
159
+ return host === '::' ? '::1' : host;
160
+ }
161
+ /**
162
+ * Whether the server a state file records answers a start that asks for `port`: one that asks no
163
+ * port, the port it listens on, or the port its own start asked for (busy then, so it got another).
164
+ */
165
+ export const portMatches = (st, port) => !port || st.port === port || st.requestedPort === port;
166
+
167
+ // The state file of a server for `dir`: beside the folder, so a deployed folder never carries it.
168
+ export const siblingState = (dir) => path.join(path.dirname(dir), `${path.basename(dir)}.server.json`);
169
+
170
+ export function writeState(file, info) {
171
+ fs.writeFileSync(file, JSON.stringify(info, null, 2) + '\n');
172
+ }
173
+
174
+ export function readState(file) {
175
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
176
+ }
177
+
178
+ export const removeState = (file) => fs.rmSync(file, { force: true });
179
+
180
+ // Removes a state file if it can. One that cannot be removed (a folder made read-only) stays; the
181
+ // next start finds it stale. Stopping and closing go on either way.
182
+ function dropState(file) {
183
+ try { removeState(file); } catch { /* left behind */ }
184
+ }
185
+
186
+ // Removes the state file when the process recorded there is this one.
187
+ export function forgetSelf(file) {
188
+ if (readState(file)?.pid === process.pid) dropState(file);
189
+ }
190
+
191
+ const NONE = Symbol('none');
192
+ // The PID the server on `port` says it has; NONE when nothing listens; null for another program.
193
+ function askPid(port, timeoutMs, host) {
194
+ return new Promise((resolve) => {
195
+ const req = http.get({ host: askHost(host), port, path: PID_ROUTE, timeout: timeoutMs, headers: { Host: 'localhost' } }, (res) => {
196
+ let body = '';
197
+ res.on('data', (c) => { body += c; });
198
+ res.on('end', () => { try { resolve(JSON.parse(body).pid ?? null); } catch { resolve(null); } });
199
+ });
200
+ req.on('timeout', () => { req.destroy(); resolve(null); });
201
+ req.on('error', (e) => resolve(e.code === 'ECONNREFUSED' ? NONE : null));
202
+ });
203
+ }
204
+
205
+ const portOpen = (port, host) => new Promise((resolve) => {
206
+ const s = net.connect({ host: askHost(host), port }, () => { s.destroy(); resolve(true); });
207
+ s.on('error', () => resolve(false));
208
+ });
209
+
210
+ async function waitClosed(port, timeoutMs, host) {
211
+ for (const until = Date.now() + timeoutMs; Date.now() < until;) {
212
+ if (!(await portOpen(port, host))) return true;
213
+ await new Promise((r) => setTimeout(r, 100));
214
+ }
215
+ return !(await portOpen(port, host));
216
+ }
217
+
218
+ // A PID a state file may name: a whole number above 1 (never a process group, never init).
219
+ const validPid = (pid) => Number.isInteger(pid) && pid > 1;
220
+
221
+ /** True when the server a state file records still answers on its port with its PID. */
222
+ export async function isRunning(stateFile) {
223
+ const st = readState(stateFile);
224
+ return Boolean(st && st.port) && validPid(st.pid) && (await askPid(st.port, 1500, st.host)) === st.pid;
225
+ }
226
+
227
+ function signal(pid, sig) {
228
+ try { process.kill(pid, sig); } catch { /* already gone */ }
229
+ }
230
+
231
+ // Stops the server a state file records. Result reasons: stopped, no-state, invalid-state (a PID
232
+ // that is no process id; the file is left for a person to look at), not-running (the stale record
233
+ // is removed), other-process (left alone), still-open.
234
+ export async function stopServer(stateFile, { timeoutMs = 3000 } = {}) {
235
+ const st = readState(stateFile);
236
+ if (!st || !st.port) return { stopped: false, reason: 'no-state' };
237
+ if (!validPid(st.pid)) return { stopped: false, reason: 'invalid-state' };
238
+ const who = await askPid(st.port, Math.min(timeoutMs, 1500), st.host);
239
+ if (who === NONE) {
240
+ dropState(stateFile);
241
+ return { stopped: false, reason: 'not-running' };
242
+ }
243
+ if (who !== st.pid) return { stopped: false, reason: 'other-process' };
244
+ if (!(await terminate(st, timeoutMs))) return { stopped: false, reason: 'still-open' };
245
+ dropState(stateFile);
246
+ return { stopped: true, reason: 'stopped' };
247
+ }
248
+
249
+ // Ends a server that answered with its PID: SIGTERM, then SIGKILL while its port stays open. True
250
+ // once the port is closed.
251
+ async function terminate(st, timeoutMs) {
252
+ signal(st.pid, 'SIGTERM');
253
+ if (await waitClosed(st.port, timeoutMs, st.host)) return true;
254
+ signal(st.pid, 'SIGKILL');
255
+ return waitClosed(st.port, timeoutMs, st.host);
256
+ }
@@ -19,7 +19,7 @@ Higgsfield·Monid 같은 외부 CLI 플래그는 쓰지 않는다 — 크기·
19
19
 
20
20
  - **`SUBJECT`** — 대상 비즈니스/제품 + 한 줄 소개.
21
21
  - **`BRAND`** — 화면에 표시할 브랜드명.
22
- - **`SCENES[]` (순서 있는 씬 목록)** — 각 씬마다 `id`, `label`, `subject`(디오라마에 뭐가 있는지), `eyebrow`, `title`, `body`(1문장), `tags[]`(0~3). 마지막 씬 = 히어로 제품 + CTA.
22
+ - **`SCENES[]` (순서 있는 씬 목록)** — 권장 5~7개. 이야기 순서는 브랜드에 맞게 정한다(예: 문제 제기, 해법, 결론, 또는 도착, 경험, 초대). 장면 하나에 뜻 하나다(7절). 각 씬마다 `id`, `label`, `subject`(장면에 뭐가 있는지), `eyebrow`, `title`, `body`, `tags[]`(0~3). 마지막 씬 = 결론(히어로 제품) + CTA. CTA 는 이 장면에만 둔다.
23
23
  - **`ORIENTATION`** — `720x1280`(세로) 또는 `1280x720`(가로). 스틸과 영상 크기가 이 값으로 통일된다. 세로는 모바일/스토리, 가로는 데스크톱/랜딩에 맞는다.
24
24
  - **`BUDGET`** — 상한 USD. 씬 수 × (이미지 1장 + 영상 클립)로 추정하며, 어떤 유료 호출보다 먼저 승인 게이트에서 총액을 제시한다.
25
25
 
@@ -31,6 +31,10 @@ Higgsfield·Monid 같은 외부 CLI 플래그는 쓰지 않는다 — 크기·
31
31
  - `CAMERA_FEEL` — fly-through(디오라마를 내려다보다 안으로 급강하) | walkthrough(한 번에 쭉 전진) | locked-iso(고정 아이소메트릭 활공). 느낌으로 제시하고 트레이드오프 한 줄씩 곁들인다.
32
32
  - `MOBILE` — yes/no. yes 면 세로 9:16 렌더를 별도로 만들어 `clipMobile`/`stillMobile` 로 배선한다(대략 2배 비용, 승인 게이트에 반영).
33
33
  - `STILLS_SOURCE` — 기본 `gpt-image-2`(`generateImage`). 씬 간 코히전을 더 강하게 원하면 `FLUX.2-pro`(`generateImageFlux`) 옵트인.
34
+ - `CLIP_MODE` — `chain`(기본, 검증된 경로) 또는 `holdFlight`(장면마다 정지 클립, 장면 사이 첫과 끝 프레임을 지정한 비행 클립. WAN 필요, 이 스킬에서 미검증).
35
+ - `SITE` — 단독 인트로 페이지 또는 기존 사이트 위에 붙임. 붙이면 그 사이트의 CSP, 본문 시작 선택자, 고정 헤더 선택자, 지원 언어를 받는다.
36
+ - `STEP_NAV` — yes 면 휠 1칸, 키, 스와이프가 정확히 한 장면을 이동한다. 스크롤 동작을 가로채므로 사이트 성격을 보고 고른다.
37
+ - `PANEL` — `glass` 면 흐린 유리 문구 패널. 기본값은 어두운 장면용이다. 밝은 장면이 많으면 대비 기준(4.5)을 지키도록 패널을 덜 투명하게(`--intro-panel-alpha` .6 이상) 한다.
34
38
 
35
39
  ---
36
40
 
@@ -106,7 +110,7 @@ in, the roof and upper structure gently lift and open away to reveal the warm in
106
110
  여닫을 건물이 없는 씬(들판·광장·도로)이면 지붕 절을 "the camera flies low across [the scene] toward [focal point]." 로 바꾼다.
107
111
 
108
112
  **Leg i (i ≥ 1) — 체이닝된 프레임에서 다음 씬으로:**
109
- `input_reference = colorMatch(extractLastFrame(clip[i-1]))` (직전 클립의 마지막 프레임을 톤 정합 1차 처리한 것).
113
+ `firstFramePng = extractLastFrame(clip[i-1])` (직전 클립의 **raw** 마지막 프레임. 톤 정합을 걸면 연속성이 나빠진다: 실측 raw SSIM ~0.59, histeq 0.46).
110
114
 
111
115
  ```
112
116
  Single continuous cinematic camera move, no cuts. Continue the same continuous camera flight
@@ -142,12 +146,15 @@ tilt. It only travels straight and level, the world sliding past beneath the sam
142
146
 
143
147
  ---
144
148
 
145
- ## 5. 두-이미지 커넥터 프롬프트 (옵트인 · **UNVALIDATED**)
149
+ ## 5. 비행 클립과 두-이미지 커넥터 프롬프트 (선택, **이 스킬에서 미검증**)
146
150
 
147
- `detectTwoImageSupport()` 가 참일 때만, 그리고 사용자가 명시적으로 선택할 때만 쓴다.
148
- 두 씬 사이에 **진짜 커넥터**(첫 프레임 → 마지막 프레임 보간)를 만들어 aerial hop 을 넣는다.
149
- `createVideoTwoImage({ firstPng: dive_i 마지막 프레임, lastPng: dive_{i+1} 첫 프레임 })`.
150
- 스모크로 검증되지 않은 경로다 — **어떤 실패에도 forward-chaining 으로 폴백**한다.
151
+ 장면 사이를 첫 프레임과 끝 프레임을 모두 지정한 클립으로 잇는다. 두 경로가 있다.
152
+
153
+ - **holdFlight 의 비행 클립(WAN)** — `generateClip({ firstFramePng: 장면 i 스틸, lastFramePng: 장면 i+1 스틸, seconds: 5, order: ['wan'] })`.
154
+ 장면마다 정지 클립(`stillToClip`, 무료)을 두므로 비행 클립의 첫과 끝 프레임이 앞뒤 장면과 맞는다. Sora 는 끝 프레임을 받지 못하므로 쓰지 않는다(`order: ['wan']`).
155
+ - **Azure 두-이미지 커넥터** — `detectTwoImageSupport()` 가 참일 때만 `createVideoTwoImage({ firstPng: dive_i 마지막 프레임, lastPng: dive_{i+1} 첫 프레임 })`. 실패하면 forward-chaining 으로 폴백한다.
156
+
157
+ 두 경로 모두 유료 스모크로 확인하지 않았다. 첫 클립을 검토 페이지에서 먼저 확인받고 나머지를 만든다.
151
158
 
152
159
  ```
153
160
  Single continuous cinematic camera move, no cuts. The camera smoothly pulls up and back out of
@@ -157,7 +164,8 @@ seamless flowing aerial transition. [STYLE tail + PALETTE]. Smooth graceful slow
157
164
  no captions.
158
165
  ```
159
166
 
160
- 히어로-제품 피날레로 들어가는 마지막 커넥터: "…glides forward and the world dissolves toward a single giant [PRODUCT] floating in soft [BG] space, arriving in front of it."
167
+ 히어로-제품 피날레로 들어가는 마지막 커넥터: "...glides forward and the world dissolves toward a single giant [PRODUCT] floating in soft [BG] space, arriving in front of it."
168
+ 포토리얼이나 건물 장면이면 "pulls up and back out" 을 장면에 맞는 이동(문, 유리, 외벽을 따라가는 이동)으로 바꾼다.
161
169
 
162
170
  ---
163
171
 
@@ -165,7 +173,22 @@ no captions.
165
173
 
166
174
  - `eyebrow` — 2~4단어, 대문자 느낌(가치 제안 라벨).
167
175
  - `title` — 3~6단어, 그 비트의 헤드라인. 첫 씬 = 사이트 히어로 라인, 마지막 = 페이오프 + CTA 를 얹는다.
168
- - `body` — 한 문장, 방문자 입장에서 평이하게.
176
+ - `body` — 방문자 입장에서 평이하게. 유리 패널(`panel: "glass"`)이면 한 줄에 항목 하나로(권장 세 줄) 줄바꿈(`\n`)으로 나누고, 줄마다 375px 화면에서 한 줄에 들어가는 길이(영문 약 36자, 한글 약 18자)로 쓴다.
169
177
  - `tags` — 0~3개 짧은 증거 칩(예: "Fresh-cooked", "30-min delivery").
170
178
 
171
179
  이 값들은 `assemble.mjs` 가 읽는 `intro.json` 의 각 섹션에 그대로 들어가고, `mountScrollWorld` 가 화면 카피로 렌더한다.
180
+
181
+ 패널 안 순서는 번호(`01 / 07`), 라벨(`eyebrow`), 제목, 본문, 태그(2개 권장)다. 결론 장면만 CTA 버튼 2개를 더한다.
182
+
183
+ ---
184
+
185
+ ## 7. 장면 표현과 흔한 지적
186
+
187
+ 검토에서 자주 나오는 지적이다. 스틸 프롬프트를 쓸 때 미리 피하고, 지적이 나오면 그 장면이나 클립만 다시 만든다.
188
+
189
+ - 장면 하나에 뜻 하나. 한 장면에 두 메시지를 넣지 않는다.
190
+ - 행위 주체가 화면에서 드러나야 한다. 무엇이 문제를 일으키고 무엇이 막는지, 누가 쓰는지 보이게 한다.
191
+ - 의도하지 않은 만화식 은유(예: 보호를 뜻하는 반투명 돔)는 유치해 보인다. 브랜드 톤이 장난스럽지 않으면 실제 사물과 공간으로 표현한다.
192
+ - 일이 일어나는 자리가 맞아야 한다. 예: 막는 장면은 이미 뚫린 안쪽이 아니라 바깥 경계에서 막는 모습으로.
193
+ - 화면 속 글자는 깨지기 쉽다. 프롬프트 끝의 "No text, no captions." 를 지키고, 글자가 깨진 결과는 재생성한다.
194
+ - 사물이 엉뚱한 위치에 뜨거나, 강조색이 유리나 다른 물체로 번지면 그 결과만 재생성한다.