@kaydash9999/banker-plugins 0.10.0 → 0.12.0

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 (30) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +39 -0
  4. package/README.md +73 -11
  5. package/codex/manifest.json +8 -1
  6. package/commands/setup.md +2 -0
  7. package/package.json +6 -1
  8. package/skills/3d-intro-build/SKILL.md +214 -0
  9. package/skills/3d-intro-build/references/LICENSE +21 -0
  10. package/skills/3d-intro-build/references/NOTICE.md +32 -0
  11. package/skills/3d-intro-build/references/assemble.mjs +252 -0
  12. package/skills/3d-intro-build/references/azure-adapter.mjs +495 -0
  13. package/skills/3d-intro-build/references/curate.mjs +301 -0
  14. package/skills/3d-intro-build/references/index-template.html +73 -0
  15. package/skills/3d-intro-build/references/prompts.md +171 -0
  16. package/skills/3d-intro-build/references/scrub-engine.js +448 -0
  17. package/skills/3d-intro-build/references/serve.mjs +123 -0
  18. package/skills/3d-intro-setup/SKILL.md +196 -0
  19. package/skills/3d-intro-setup/references/azure-adapter.mjs +495 -0
  20. package/skills/lineage/SKILL.md +126 -134
  21. package/skills/lineage/lineage.py +1041 -354
  22. package/skills/motion-graphic-make/SKILL.md +57 -0
  23. package/skills/motion-graphic-setup/SKILL.md +141 -0
  24. package/skills/ralph-qa/SKILL.md +59 -32
  25. package/skills/refresh-readme/SKILL.md +3 -3
  26. package/skills/vertical-pptx/SKILL.md +409 -0
  27. package/skills/vertical-pptx/build_a4p.js +923 -0
  28. package/skills/vertical-pptx/build_a4p.py +916 -0
  29. package/skills/vertical-pptx/verify_a4p.mjs +2168 -0
  30. package/skills/vertical-pptx-setup/SKILL.md +223 -0
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * First-party still-curation server for the 3d-intro-build skill.
4
+ *
5
+ * Sits BETWEEN still generation (cheap paid) and video generation (the big paid step): the user
6
+ * picks/approves one still per scene in a real browser BEFORE any Sora-2 call is spent. The project
7
+ * convention is a self-contained local page on an unused port, never a claude.ai artifact.
8
+ *
9
+ * Contract (both files live in the project dir):
10
+ * IN curate-input.json {"scenes":[{"id","label","variants":[{"file","prompt?"}]}]}
11
+ * file = png path relative to projectDir (served with a path-traversal guard).
12
+ * OUT selection.json {"scenes":[{"id","chosen":<file>,"regenerate?":<note>}]}
13
+ * chosen = the file the user picked; regenerate = a tweaked-prompt note (present only when
14
+ * the user wants that scene re-generated). Written on POST /select.
15
+ *
16
+ * Routes: GET / -> curation page (rebuilt from curate-input.json each load, so a
17
+ * regenerate-loop re-read shows fresh stills without a restart)
18
+ * GET /<img> -> a still image (image/png/…; traversal-guarded; Cache-Control no-store)
19
+ * POST /select -> writes selection.json, returns {ok:true}
20
+ *
21
+ * Runtime: Node >=18 builtins ONLY (node:http / node:fs / node:path). No external deps, no shell —
22
+ * cross-platform (path.join / path.sep everywhere). Loopback by default; PREVIEW_HOST=0.0.0.0 for LAN.
23
+ *
24
+ * Usage:
25
+ * node curate.mjs [projectDir] [--port N]
26
+ * projectDir dir holding curate-input.json + the stills it references (default: cwd)
27
+ * --port preferred port; falls back through a candidate list if busy/omitted
28
+ * On listen it writes <projectDir>/curate-port.txt and prints CURATE http://localhost:<port>/
29
+ */
30
+ import http from 'node:http';
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+
34
+ // ---- args ----
35
+ const argv = process.argv.slice(2);
36
+ let dirArg = null;
37
+ let portArg = null;
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const a = argv[i];
40
+ if (a === '--port') portArg = Number(argv[++i]);
41
+ else if (a.startsWith('--port=')) portArg = Number(a.slice('--port='.length));
42
+ else if (!a.startsWith('--')) dirArg = a;
43
+ }
44
+
45
+ const DIR = path.resolve(dirArg || process.cwd());
46
+ if (!fs.existsSync(DIR) || !fs.statSync(DIR).isDirectory()) {
47
+ console.error(`curate: not a directory: ${DIR}`);
48
+ process.exit(1);
49
+ }
50
+ const INPUT = path.join(DIR, 'curate-input.json');
51
+ const OUTPUT = path.join(DIR, 'selection.json');
52
+
53
+ const MIME = {
54
+ '.html': 'text/html;charset=utf-8',
55
+ '.png': 'image/png',
56
+ '.jpg': 'image/jpeg',
57
+ '.jpeg': 'image/jpeg',
58
+ '.webp': 'image/webp',
59
+ '.gif': 'image/gif',
60
+ '.svg': 'image/svg+xml',
61
+ };
62
+
63
+ // ---- html helpers ----
64
+ const escHtml = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
65
+ const escAttr = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
66
+ // posix URL for a project-relative file, each segment encoded (server decodes with decodeURIComponent)
67
+ const fileUrl = (file) => '/' + String(file).replace(/^[.][/\\]/, '').split(/[\\/]/).map(encodeURIComponent).join('/');
68
+
69
+ function renderCard(v, sceneId, idx, selected) {
70
+ const cap = v.prompt ? `<span class="cap">${escHtml(v.prompt)}</span>` : '';
71
+ return `<button type="button" class="card${selected ? ' selected' : ''}" data-file="${escAttr(v.file)}">`
72
+ + `<img src="${escAttr(fileUrl(v.file))}" alt="${escAttr(sceneId)} 변형 ${idx + 1}" loading="lazy">${cap}</button>`;
73
+ }
74
+
75
+ function renderScene(s) {
76
+ const variants = Array.isArray(s.variants) ? s.variants : [];
77
+ const cards = variants.map((v, i) => renderCard(v, s.id, i, i === 0)).join('\n ');
78
+ const body = cards || '<p class="empty">이 씬에 스틸 변형이 없습니다.</p>';
79
+ return `<section class="scene" data-scene-id="${escAttr(s.id)}">
80
+ <div class="scene-head">
81
+ <h2>${escHtml(s.label || s.id)}</h2>
82
+ <span class="chip keep" data-chip>이 스틸로 승인</span>
83
+ </div>
84
+ <div class="cards">
85
+ ${body}
86
+ </div>
87
+ <label class="regen">
88
+ <span>재생성 메모 (선택 · 비우면 위에서 고른 스틸로 승인)</span>
89
+ <textarea data-regen rows="2" placeholder="예: 배경을 더 어둡게, 캐릭터 2명 추가"></textarea>
90
+ </label>
91
+ </section>`;
92
+ }
93
+
94
+ function renderPage(manifest) {
95
+ const scenes = Array.isArray(manifest.scenes) ? manifest.scenes : [];
96
+ const rows = scenes.length ? scenes.map(renderScene).join('\n ') : '<p class="empty">curate-input.json 에 scenes 가 없습니다.</p>';
97
+ return `<!doctype html>
98
+ <html lang="ko">
99
+ <head>
100
+ <meta charset="utf-8">
101
+ <meta name="viewport" content="width=device-width, initial-scale=1">
102
+ <title>스틸 큐레이션${manifest.title ? ' — ' + escHtml(manifest.title) : ''}</title>
103
+ <style>
104
+ :root {
105
+ --bg: #f5f3ef; --panel: #ffffff; --ink: #23202a; --muted: #6c6675;
106
+ --line: #e2ddd4; --accent: #7c5cbf; --accent-ink: #ffffff;
107
+ --keep: #2f7d4f; --keep-bg: #e6f3ea; --regen: #b26a00; --regen-bg: #fbefd8;
108
+ }
109
+ @media (prefers-color-scheme: dark) {
110
+ :root {
111
+ --bg: #17151b; --panel: #201d26; --ink: #ece8f1; --muted: #a49db1;
112
+ --line: #322d3c; --accent: #a488e6; --accent-ink: #17151b;
113
+ --keep: #7fd3a0; --keep-bg: #1d3327; --regen: #e6b063; --regen-bg: #3a2c14;
114
+ }
115
+ }
116
+ * { box-sizing: border-box; }
117
+ body { margin: 0; background: var(--bg); color: var(--ink);
118
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
119
+ .bar { position: sticky; top: 0; z-index: 5; background: var(--panel);
120
+ border-bottom: 1px solid var(--line); }
121
+ .bar-in { max-width: 1100px; margin: 0 auto; padding: 12px 20px;
122
+ display: flex; align-items: center; gap: 16px; justify-content: space-between; }
123
+ .muted { color: var(--muted); font-size: 13px; }
124
+ .btn { border: 0; border-radius: 8px; background: var(--accent); color: var(--accent-ink);
125
+ font-size: 14px; font-weight: 600; padding: 10px 18px; cursor: pointer; }
126
+ .btn:disabled { opacity: .5; cursor: default; }
127
+ main { max-width: 1100px; margin: 0 auto; padding: 20px; }
128
+ .scene { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
129
+ padding: 16px; margin-bottom: 18px; }
130
+ .scene-head { display: flex; align-items: center; gap: 12px; margin: 0 0 12px; }
131
+ .scene-head h2 { font-size: 17px; margin: 0; }
132
+ .chip { font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 999px; }
133
+ .chip.keep { color: var(--keep); background: var(--keep-bg); }
134
+ .chip.regen-on { color: var(--regen); background: var(--regen-bg); }
135
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
136
+ .card { padding: 0; border: 2px solid var(--line); border-radius: 10px; background: var(--panel);
137
+ cursor: pointer; overflow: hidden; text-align: left; display: flex; flex-direction: column; }
138
+ .card img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 4; object-fit: cover; }
139
+ .card .cap { padding: 8px 10px; font-size: 12px; color: var(--muted);
140
+ display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
141
+ .card.selected { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); }
142
+ .regen { display: block; margin-top: 12px; }
143
+ .regen span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }
144
+ .regen textarea { width: 100%; resize: vertical; border: 1px solid var(--line); border-radius: 8px;
145
+ padding: 8px 10px; font: inherit; background: var(--bg); color: var(--ink); }
146
+ .empty { color: var(--muted); }
147
+ [hidden] { display: none !important; }
148
+ .done { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
149
+ background: color-mix(in srgb, var(--bg) 82%, transparent); z-index: 10; }
150
+ .done-card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
151
+ padding: 28px 36px; font-size: 18px; font-weight: 600; }
152
+ </style>
153
+ </head>
154
+ <body>
155
+ <header class="bar">
156
+ <div class="bar-in">
157
+ <div><strong>스틸 큐레이션</strong> <span class="muted">씬별로 스틸을 고르고, 다시 만들 씬엔 메모를 남기세요 (영상 생성은 이 확정 이후에만)</span></div>
158
+ <button id="confirm" class="btn">선택 확정</button>
159
+ </div>
160
+ </header>
161
+ <main id="app">
162
+ ${rows}
163
+ </main>
164
+ <div id="done" class="done" hidden><div class="done-card">선택 저장됨 — 에이전트로 돌아가세요</div></div>
165
+ <script>
166
+ const app = document.getElementById('app');
167
+ app.addEventListener('click', (e) => {
168
+ const card = e.target.closest('.card');
169
+ if (!card) return;
170
+ const scene = card.closest('.scene');
171
+ scene.querySelectorAll('.card').forEach((c) => c.classList.remove('selected'));
172
+ card.classList.add('selected');
173
+ });
174
+ app.addEventListener('input', (e) => {
175
+ const ta = e.target.closest('[data-regen]');
176
+ if (!ta) return;
177
+ const chip = ta.closest('.scene').querySelector('[data-chip]');
178
+ const has = ta.value.trim() !== '';
179
+ chip.textContent = has ? '재생성 요청됨' : '이 스틸로 승인';
180
+ chip.classList.toggle('regen-on', has);
181
+ chip.classList.toggle('keep', !has);
182
+ });
183
+ document.getElementById('confirm').addEventListener('click', async () => {
184
+ const scenes = [], missing = [];
185
+ document.querySelectorAll('.scene').forEach((scene) => {
186
+ const id = scene.getAttribute('data-scene-id');
187
+ const sel = scene.querySelector('.card.selected');
188
+ const regen = (scene.querySelector('[data-regen]') || {}).value?.trim() || '';
189
+ if (!sel) { missing.push(id); return; }
190
+ const s = { id, chosen: sel.getAttribute('data-file') };
191
+ if (regen) s.regenerate = regen;
192
+ scenes.push(s);
193
+ });
194
+ if (missing.length) { alert('스틸을 선택하지 않은 씬: ' + missing.join(', ')); return; }
195
+ const btn = document.getElementById('confirm');
196
+ btn.disabled = true;
197
+ try {
198
+ const r = await fetch('/select', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scenes }) });
199
+ if (!r.ok) throw new Error('HTTP ' + r.status);
200
+ document.getElementById('done').hidden = false;
201
+ } catch (err) {
202
+ btn.disabled = false;
203
+ alert('저장 실패: ' + (err && err.message ? err.message : err));
204
+ }
205
+ });
206
+ </script>
207
+ </body>
208
+ </html>
209
+ `;
210
+ }
211
+
212
+ // ---- request handling ----
213
+ function servePage(res) {
214
+ let manifest;
215
+ try { manifest = JSON.parse(fs.readFileSync(INPUT, 'utf8')); }
216
+ catch (e) {
217
+ res.writeHead(500, { 'Content-Type': 'text/plain;charset=utf-8', 'Cache-Control': 'no-store' });
218
+ res.end(`curate-input.json 을 읽지 못했습니다 (${INPUT}): ${e.message}`);
219
+ return;
220
+ }
221
+ const html = renderPage(manifest);
222
+ res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8', 'Cache-Control': 'no-store' });
223
+ res.end(html);
224
+ }
225
+
226
+ function serveFile(urlPath, res) {
227
+ // Resolve inside DIR and reject any path that escapes it (traversal guard) — matches serve.mjs.
228
+ const fp = path.normalize(path.join(DIR, urlPath));
229
+ if (fp !== DIR && !fp.startsWith(DIR + path.sep)) { res.writeHead(403); res.end('403'); return; }
230
+ if (!fs.existsSync(fp) || fs.statSync(fp).isDirectory()) { res.writeHead(404); res.end('404'); return; }
231
+ const type = MIME[path.extname(fp).toLowerCase()] || 'application/octet-stream';
232
+ res.writeHead(200, {
233
+ 'Content-Type': type,
234
+ 'Content-Length': fs.statSync(fp).size,
235
+ 'Cache-Control': 'no-store',
236
+ });
237
+ fs.createReadStream(fp).pipe(res);
238
+ }
239
+
240
+ function handleSelect(req, res) {
241
+ let body = '';
242
+ let tooBig = false;
243
+ req.on('data', (c) => {
244
+ body += c;
245
+ if (body.length > 1_000_000) { tooBig = true; req.destroy(); }
246
+ });
247
+ req.on('end', () => {
248
+ if (tooBig) { res.writeHead(413); res.end('413'); return; }
249
+ let payload;
250
+ try { payload = JSON.parse(body); }
251
+ catch { res.writeHead(400, { 'Content-Type': 'application/json;charset=utf-8' }); res.end('{"ok":false,"error":"invalid json"}'); return; }
252
+ const scenes = (Array.isArray(payload.scenes) ? payload.scenes : []).map((s) => {
253
+ const out = { id: String(s.id), chosen: String(s.chosen) };
254
+ const note = s.regenerate == null ? '' : String(s.regenerate).trim();
255
+ if (note) out.regenerate = note;
256
+ return out;
257
+ });
258
+ try { fs.writeFileSync(OUTPUT, JSON.stringify({ scenes }, null, 2) + '\n'); }
259
+ catch (e) {
260
+ res.writeHead(500, { 'Content-Type': 'application/json;charset=utf-8' });
261
+ res.end(JSON.stringify({ ok: false, error: String(e.message || e) }));
262
+ return;
263
+ }
264
+ res.writeHead(200, { 'Content-Type': 'application/json;charset=utf-8', 'Cache-Control': 'no-store' });
265
+ res.end(JSON.stringify({ ok: true, written: 'selection.json', scenes: scenes.length }));
266
+ console.log(`selection.json written -> ${OUTPUT} (${scenes.length} scene(s))`);
267
+ });
268
+ }
269
+
270
+ const server = http.createServer((req, res) => {
271
+ const method = req.method || 'GET';
272
+ const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
273
+ if (method === 'POST' && urlPath === '/select') { handleSelect(req, res); return; }
274
+ if (method !== 'GET' && method !== 'HEAD') { res.writeHead(405); res.end('405'); return; }
275
+ if (urlPath === '/' || urlPath === '/index.html') { servePage(res); return; }
276
+ serveFile(urlPath, res);
277
+ });
278
+
279
+ // ---- pick a free port (preferred first, then a candidate list) ----
280
+ // Distinct from serve.mjs's list so a preview server and this curation server can coexist during dev;
281
+ // EADDRINUSE still falls through if any are taken.
282
+ const candidates = [8941, 8942, 8943, 8850, 8860, 8870, 8223, 8421];
283
+ const ports = portArg ? [portArg, ...candidates.filter((p) => p !== portArg)] : candidates;
284
+ // Default to loopback (matches the printed localhost URL). Set PREVIEW_HOST=0.0.0.0 for LAN / on-device mobile preview.
285
+ const HOST = process.env.PREVIEW_HOST || '127.0.0.1';
286
+
287
+ (function tryPort(i) {
288
+ if (i >= ports.length) { console.error('curate: no free port available'); process.exit(1); }
289
+ server.once('error', (e) => {
290
+ if (e.code === 'EADDRINUSE') tryPort(i + 1);
291
+ else { console.error(e); process.exit(1); }
292
+ });
293
+ server.listen(ports[i], HOST, () => {
294
+ try { fs.writeFileSync(path.join(DIR, 'curate-port.txt'), String(ports[i])); } catch { /* best effort */ }
295
+ console.log(`CURATE http://${HOST === '127.0.0.1' ? 'localhost' : HOST}:${ports[i]}/`);
296
+ console.log(`curating ${DIR} (in: curate-input.json out: selection.json)`);
297
+ });
298
+ })(0);
299
+
300
+ process.on('SIGINT', () => { server.close(); process.exit(0); });
301
+ process.on('SIGTERM', () => { server.close(); process.exit(0); });
@@ -0,0 +1,73 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>BRAND — the world of SUBJECT</title>
7
+ <meta name="description" content="Scroll to fly through the world of BRAND." />
8
+ <style>
9
+ /* Theme the engine here. These match your generated scenes' background + palette. */
10
+ :root, .sw-root {
11
+ --sw-bg: #F5EDE0; /* == the scene background colour */
12
+ --sw-ink: #241d2b;
13
+ --sw-ink-soft: #6a6072;
14
+ --sw-accent: #9B7EBD; /* per-section accents override this at runtime */
15
+ }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <div id="top"></div>
20
+ <div id="world"></div>
21
+
22
+ <script src="scrub-engine.js"></script>
23
+ <script>
24
+ mountScrollWorld(document.getElementById('world'), {
25
+ brand: { name: 'BRAND', href: '#top' },
26
+ cta: { label: 'Order now', href: '#finale' },
27
+ hint: 'scroll to fly in',
28
+ diveScroll: 1.3,
29
+ connScroll: 0.9,
30
+ sections: [
31
+ // One object per scene, in order. `accent` colours the copy/route for that scene.
32
+ // First section is the hero (its copy greets on landing); last carries the CTA.
33
+ {
34
+ id: 'sceneA', label: 'Scene A',
35
+ still: 'assets/sceneA.webp',
36
+ clip: 'assets/vid/sceneA.mp4',
37
+ clipMobile: 'assets/vid/sceneA-m.mp4', // mobile opt-in only: lighter 720p encode served on phones
38
+ accent: '#8FB98A',
39
+ // scroll: 1.6, linger: 0.45, // optional pacing: longer dwell; camera settles mid-scene
40
+ eyebrow: 'Value prop label',
41
+ title: 'The hero line.',
42
+ body: 'One plain-spoken sentence about this stage.',
43
+ tags: ['Proof', 'Proof', 'Proof'],
44
+ },
45
+ // …middle scenes…
46
+ {
47
+ id: 'finale', label: 'The Product',
48
+ still: 'assets/product.webp',
49
+ clip: 'assets/vid/finale.mp4',
50
+ clipMobile: 'assets/vid/finale-m.mp4',
51
+ accent: '#9B7EBD',
52
+ eyebrow: 'And it all pours into',
53
+ title: 'One perfect thing.',
54
+ body: 'The payoff sentence.',
55
+ tags: [],
56
+ cta: { primary: { label: 'Get started', href: '#' },
57
+ secondary: { label: 'See more', href: '#' } },
58
+ },
59
+ ],
60
+ // length === sections.length - 1, in order. Generated per SKILL.md Step 5.
61
+ connectors: [
62
+ 'assets/vid/conn1.mp4',
63
+ // 'assets/vid/conn2.mp4', …
64
+ ],
65
+ // Mobile opt-in only: lighter connectors for phones — same length/order as `connectors`.
66
+ connectorsMobile: [
67
+ 'assets/vid/conn1-m.mp4',
68
+ // 'assets/vid/conn2-m.mp4', …
69
+ ],
70
+ });
71
+ </script>
72
+ </body>
73
+ </html>
@@ -0,0 +1,171 @@
1
+ # 프롬프트 템플릿 & 인테이크 (Azure 파이프라인)
2
+
3
+ 이 문서는 **채워 넣는 슬롯** 모음이다.
4
+ scroll-world 의 씬/아트디렉션 **구조**를 따르되, 문구는 이 스킬의 Azure 파이프라인(`gpt-image-2` 스틸 + `Sora-2` fly-through)에 맞게 새로 썼다.
5
+ Higgsfield·Monid 같은 외부 CLI 플래그는 쓰지 않는다 — 크기·길이·품질은 어댑터 인자(`size`, `seconds`, `quality`)로만 넘긴다.
6
+
7
+ 모델에 실제로 보내는 프롬프트 문자열은 **영어**로 둔다(이미지·영상 모델이 영어에 맞춰져 있고, 스모크로 검증한 문구도 영어다).
8
+ 설명·가이드는 한글이다.
9
+
10
+ **핵심 원칙 — 코히전은 반복에서 온다.**
11
+ 아래 **스타일 서문(style preamble)** 을 모든 씬 스틸 프롬프트에 **글자 그대로 동일하게** 넣어라.
12
+ 그 동일한 텍스트가 여러 씬을 "한 세계"로 묶는다.
13
+
14
+ ---
15
+
16
+ ## 1. 인테이크 체크리스트 (SKILL Step 1)
17
+
18
+ 진행 전 아래를 받아 적는다. 굵은 다섯 항목은 **반드시** 묻는다.
19
+
20
+ - **`SUBJECT`** — 대상 비즈니스/제품 + 한 줄 소개.
21
+ - **`BRAND`** — 화면에 표시할 브랜드명.
22
+ - **`SCENES[]` (순서 있는 씬 목록)** — 각 씬마다 `id`, `label`, `subject`(디오라마에 뭐가 있는지), `eyebrow`, `title`, `body`(1문장), `tags[]`(0~3). 마지막 씬 = 히어로 제품 + CTA.
23
+ - **`ORIENTATION`** — `720x1280`(세로) 또는 `1280x720`(가로). 스틸과 영상 크기가 이 값으로 통일된다. 세로는 모바일/스토리, 가로는 데스크톱/랜딩에 맞는다.
24
+ - **`BUDGET`** — 상한 USD. 씬 수 × (이미지 1장 + 영상 클립)로 추정하며, 어떤 유료 호출보다 먼저 승인 게이트에서 총액을 제시한다.
25
+
26
+ 부수 항목(없으면 기본값 사용):
27
+
28
+ - `PALETTE` — 4~6개 명명 hex. 예: `taro #9B7EBD, cream #F5EDE0, caramel #C88A5A, matcha #8FB98A, plum #3A2E48`. 하나를 씬 **배경색**(보통 가장 밝은 색), 하나를 기본 **accent** 로 고른다. 이 배경색을 페이지 테마(`--sw-bg`)에도 그대로 쓴다.
29
+ - `TONE` — 한두 단어(cozy/premium, playful, industrial…).
30
+ - `STYLE` — 아트디렉션(기본은 아래 clay diorama).
31
+ - `CAMERA_FEEL` — fly-through(디오라마를 내려다보다 안으로 급강하) | walkthrough(한 번에 쭉 전진) | locked-iso(고정 아이소메트릭 활공). 느낌으로 제시하고 트레이드오프 한 줄씩 곁들인다.
32
+ - `MOBILE` — yes/no. yes 면 세로 9:16 렌더를 별도로 만들어 `clipMobile`/`stillMobile` 로 배선한다(대략 2배 비용, 승인 게이트에 반영).
33
+ - `STILLS_SOURCE` — 기본 `gpt-image-2`(`generateImage`). 씬 간 코히전을 더 강하게 원하면 `FLUX.2-pro`(`generateImageFlux`) 옵트인.
34
+
35
+ ---
36
+
37
+ ## 2. 스타일 서문 (기본: clay diorama)
38
+
39
+ 모든 씬 프롬프트에 **글자 그대로 재사용**한다. 대괄호만 브랜드 팔레트/배경으로 치환한다.
40
+
41
+ ```
42
+ Isometric low-poly 3D diorama floating as a small rounded island on a plain solid
43
+ [BG_HEX] background with a soft contact shadow beneath it. Soft matte clay 3D render,
44
+ rounded toy-model shapes, gentle warm studio lighting, soft long shadows, tilt-shift
45
+ miniature look. Cohesive color palette of [PALETTE]. Highly detailed, centered
46
+ composition, absolutely no text, no letters, no numbers, no logos.
47
+ ```
48
+
49
+ 대체 방향(첫 두 문장만 바꾸고 팔레트/no-text 꼬리는 유지):
50
+
51
+ - **Flat papercraft:** "Isometric layered paper-craft diorama, matte cardstock, clean die-cut edges, subtle drop shadows between layers."
52
+ - **Glossy toy:** "Isometric glossy vinyl-toy diorama, smooth plastic shading, soft rim light, collectible figurine look."
53
+ - **Claymation:** "Isometric stop-motion clay set, visible thumbprints, handmade plasticine texture, soft studio softbox light."
54
+ - **Neon night:** "Isometric miniature at night, warm interior glow and neon signage, moody rim light, wet reflective ground."
55
+ - **Photoreal architectural**(부동산·호스피탈리티·프리미엄): "Ultra-photorealistic architectural photography of a single cohesive [subject], cinematic wide-angle, warm golden-hour light, natural materials, restrained designer furnishings, editorial magazine quality, shallow depth of field, no people." 포토리얼은 floating-island 프레이밍을 버리고 full-bleed 로 가며(어두운 페이지 배경이 프리미엄하게 읽힌다), dive 는 지붕을 여는 대신 문/유리를 통과한다.
56
+
57
+ ---
58
+
59
+ ## 3. 씬 스틸 프롬프트 (stills, `gpt-image-2`)
60
+
61
+ 각 씬마다: **[스타일 서문]** 다음에 그 씬의 subject 한 단락을 붙인다.
62
+
63
+ ```
64
+ [STYLE PREAMBLE]
65
+ Subject: [SCENE.subject — 미니어처 장면을 묘사: 건물/공간, 일하는 캐릭터 몇, 이 단계의
66
+ 비즈니스를 드러내는 소품들].
67
+ ```
68
+
69
+ 팁:
70
+
71
+ - 구체적인 소품을 이름으로 지정한다(장면을 고정한다): tanks, cauldrons, conveyor, crates, awning, string lights, benches, scooters, map pins.
72
+ - 마지막 "히어로 제품" 씬은 디오라마-섬 프레이밍을 버리고, 같은 배경 위에 떠 있는 **하나의 큼직한 제품**을 중심에 두고 작은 소품 몇 개만 궤도처럼 두른다.
73
+ - **중앙으로 구성한다.** 페이지는 모든 클립을 `object-fit:cover` 로 그린다. 초점 대상을 가로 중앙에 약간의 헤드룸과 함께 두고, 가장자리에 중요한 것을 두지 않는다.
74
+
75
+ 어댑터 인자(플래그 아님):
76
+
77
+ - `size` = `ORIENTATION` (`720x1280` 세로 / `1280x720` 가로). Sora 크기와 반드시 일치시킨다.
78
+ - `quality` = `low`(미리보기·저렴) 또는 `high`(최종).
79
+ - **N3 주의:** v1 경로가 실패해 classic 폴백이 뜨면 반환 크기가 `1024x1536`(2:3)라 `720x1280` 이 아니다. `createVideo` 에 넣기 전에 Sora 크기로 리사이즈(ffmpeg `scale`)하거나 재생성한다. v1 경로(`720x1280`)가 검증된 happy path 다.
80
+
81
+ ### FLUX 코히전 옵트인 (`generateImageFlux` / FLUX.2-pro)
82
+
83
+ 씬 사이 톤·질감 일관성을 더 강하게 원할 때만 쓴다.
84
+ 동일한 스타일 서문을 넣되 어댑터에는 `width`/`height` 를 `ORIENTATION` 에 맞춰 나눠 넘긴다(`720x1280` → `width:720, height:1280`).
85
+ 크리덴셜이 없으면 조용히 실패하지 말고 `gpt-image-2` 로 정직하게 폴백한다("FLUX 미수집 → gpt-image-2 로 진행").
86
+
87
+ ---
88
+
89
+ ## 4. Fly-through — forward-chaining leg 프롬프트 (`Sora-2`, `createVideo`)
90
+
91
+ Azure `/videos` 는 `input_reference` 를 **딱 하나만** 받는다(두-이미지는 400).
92
+ 그래서 연속성은 **forward-chaining** 으로 만든다: 이전 클립의 **실제 마지막 프레임**을 다음 클립의 `input_reference` 로 넣는다.
93
+ 아래 굵은 절이 **모션 핸드오프 계약**이다 — 글자 그대로 유지하고, 씬별 표현은 가운데 MOVE 슬롯에 넣는다.
94
+
95
+ **Leg 0 (씨앗) — 씬 1 스틸에서 dive-in:**
96
+ `input_reference = 씬 1 스틸`.
97
+
98
+ ```
99
+ Single continuous cinematic camera move, no cuts. Begin high and far, looking down at the
100
+ whole [SCENE 1 subject] from outside like a tiny model. The camera slowly glides forward and
101
+ descends toward it, sweeping in toward [FOCAL POINT], as if flying inside. As the camera pushes
102
+ in, the roof and upper structure gently lift and open away to reveal the warm interior.
103
+ [STYLE tail + PALETTE]. Smooth, graceful, slow motion, subtle parallax. No text, no captions.
104
+ ```
105
+
106
+ 여닫을 건물이 없는 씬(들판·광장·도로)이면 지붕 절을 "the camera flies low across [the scene] toward [focal point]." 로 바꾼다.
107
+
108
+ **Leg i (i ≥ 1) — 체이닝된 프레임에서 다음 씬으로:**
109
+ `input_reference = colorMatch(extractLastFrame(clip[i-1]))` (직전 클립의 마지막 프레임을 톤 정합 1차 처리한 것).
110
+
111
+ ```
112
+ Single continuous cinematic camera move, no cuts. Continue the same continuous camera flight
113
+ forward into the next area of the world, smooth dolly, one continuous shot. [MID-LEG MOVE —
114
+ 아래 라이브러리에서 선택, 없으면 생략.] The camera moves into [SCENE i] toward [FOCAL POINT].
115
+ In the final second, settle back into a slow, steady forward glide toward [the doorway / opening
116
+ / direction of the next scene]. [STYLE tail + PALETTE]. Smooth, graceful, slow motion, subtle
117
+ parallax. No text, no captions.
118
+ ```
119
+
120
+ ### Mid-leg MOVE 라이브러리 (컨셉으로 고른다; 밋밋한 활공이면 생략)
121
+
122
+ leg **안쪽**에서는 되감기·역방향 카메라가 안전하다(한 번에 렌더되는 연속 샷). 절대 안 되는 건 **씨앗(seam) 프레임의 역방향**뿐이다.
123
+
124
+ - **Half-orbit**(제품·럭셔리): "sweeping in a slow half-orbit around [the hero object], keeping it centered, then continuing past it"
125
+ - **Crane-up reveal**(스케일·아트리움): "rising smoothly as the full scale of [the space] reveals below"
126
+ - **Low lateral track**(생산 라인·카운터·선반): "tracking low and level alongside [the line], foreground objects sliding past in parallax"
127
+ - **Push-in + ease back**(디테일·공예): "pushing in close to [the craft moment] until it nearly fills the frame, then easing gently back out"
128
+ - **Rise-and-swoop**(여행·야외): "climbing in a gentle arc over [the terrain], then swooping down toward [the next focal point]"
129
+
130
+ **locked-iso 절**(`CAMERA_FEEL = locked-iso`): 위 라이브러리를 통째로 건너뛰고 아래를 모든 leg 의 MOVE 슬롯에 글자 그대로 넣는다.
131
+
132
+ ```
133
+ The camera keeps exactly the same high isometric angle throughout — no rotation, no orbit, no
134
+ tilt. It only travels straight and level, the world sliding past beneath the same view.
135
+ ```
136
+
137
+ **각 leg 렌더 직후, 다음 leg 를 만들기 전에 마지막 프레임을 확인한다.**
138
+ 잔잔한 전진 활공의 한 컷으로 읽혀야 한다(옆으로 흐르는 모션블러·완결 안 된 오빗 금지).
139
+ 아니면 그 leg 를 재생성한다 — 나쁜 핸드오프 프레임 하나가 뒤의 모든 leg 를 오염시킨다.
140
+
141
+ 어댑터 인자: `model: 'sora-2'`, `size: ORIENTATION`, `seconds`(기본 4; 예산·길이에 따라 조정).
142
+
143
+ ---
144
+
145
+ ## 5. 두-이미지 커넥터 프롬프트 (옵트인 · **UNVALIDATED**)
146
+
147
+ `detectTwoImageSupport()` 가 참일 때만, 그리고 사용자가 명시적으로 선택할 때만 쓴다.
148
+ 두 씬 사이에 **진짜 커넥터**(첫 프레임 → 마지막 프레임 보간)를 만들어 aerial hop 을 넣는다.
149
+ `createVideoTwoImage({ firstPng: dive_i 마지막 프레임, lastPng: dive_{i+1} 첫 프레임 })`.
150
+ 스모크로 검증되지 않은 경로다 — **어떤 실패에도 forward-chaining 으로 폴백**한다.
151
+
152
+ ```
153
+ Single continuous cinematic camera move, no cuts. The camera smoothly pulls up and back out of
154
+ [SCENE i], rising into the sky, then glides forward across the connected miniature world and
155
+ arrives above [SCENE i+1], beginning to descend toward it. One connected miniature clay world,
156
+ seamless flowing aerial transition. [STYLE tail + PALETTE]. Smooth graceful slow motion. No text,
157
+ no captions.
158
+ ```
159
+
160
+ 히어로-제품 피날레로 들어가는 마지막 커넥터: "…glides forward and the world dissolves toward a single giant [PRODUCT] floating in soft [BG] space, arriving in front of it."
161
+
162
+ ---
163
+
164
+ ## 6. 씬별 카피 (엔진 config 용)
165
+
166
+ - `eyebrow` — 2~4단어, 대문자 느낌(가치 제안 라벨).
167
+ - `title` — 3~6단어, 그 비트의 헤드라인. 첫 씬 = 사이트 히어로 라인, 마지막 = 페이오프 + CTA 를 얹는다.
168
+ - `body` — 한 문장, 방문자 입장에서 평이하게.
169
+ - `tags` — 0~3개 짧은 증거 칩(예: "Fresh-cooked", "30-min delivery").
170
+
171
+ 이 값들은 `assemble.mjs` 가 읽는 `intro.json` 의 각 섹션에 그대로 들어가고, `mountScrollWorld` 가 화면 카피로 렌더한다.