sns-auto-builder 1.0.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.
- package/.claude/agents/carousel-html-writer.md +71 -0
- package/.claude/agents/carousel-prompt-writer.md +57 -0
- package/.claude/agents/carousel-researcher.md +49 -0
- package/.claude/agents/carousel-reviewer.md +74 -0
- package/.claude/commands/carousel-new.md +111 -0
- package/.claude/commands/carousel-quality.md +31 -0
- package/CLAUDE.md +180 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/README.upstream.md +145 -0
- package/bin/sns-auto-builder.mjs +116 -0
- package/index.html +1350 -0
- package/knowledge/banned-words.json +33 -0
- package/knowledge/brand.schema.json +62 -0
- package/knowledge/patterns/carousel-output-format.md +47 -0
- package/knowledge/patterns/carousel-structure.md +83 -0
- package/knowledge/patterns/thread-structure.md +81 -0
- package/knowledge/reference/.keep +0 -0
- package/knowledge/tone/.keep +0 -0
- package/package.json +50 -0
- package/scripts/chatgpt-image-gen.js +400 -0
- package/scripts/hook-post-write.js +60 -0
- package/scripts/html-carousel-gen.js +125 -0
- package/scripts/nanobanana-gen.py +137 -0
- package/scripts/openai-image-gen.js +230 -0
- package/scripts/quality-check-text.js +95 -0
- package/scripts/quality-check.js +206 -0
- package/server.mjs +877 -0
- package/templates/backgrounds.example.json +22 -0
- package/templates/slide-layouts/.keep +0 -0
- package/templates/slides.example.json +51 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sns-auto-builder — ChatGPT 웹 UI 이미지 생성기
|
|
4
|
+
*
|
|
5
|
+
* `openai-image-gen.js` 의 형제. 다른 점은 **API 를 안 쓴다**는 것뿐이다.
|
|
6
|
+
* 로그인된 Chrome 프로필(.chrome-profile)을 puppeteer 로 몰아 chatgpt.com 에
|
|
7
|
+
* 프롬프트를 한 장씩 넣고 나온 이미지를 받아온다. → API 키 불필요, Plus 구독으로 처리.
|
|
8
|
+
*
|
|
9
|
+
* CLI/JSON 스키마는 openai-image-gen.js 와 동일하다 (templates/slides.*.json).
|
|
10
|
+
* 크롭도 그 스크립트의 cropTo45 를 그대로 재사용한다 → 두 트랙의 배율이 항상 같다.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 이 트랙도 한글이 이미지 안에 들어간다. 매번 달라지므로 9장 전부 눈으로 확인해라.
|
|
13
|
+
*
|
|
14
|
+
* 사용법:
|
|
15
|
+
* node scripts/chatgpt-image-gen.js --topic my-topic --slides templates/slides.my-topic.json
|
|
16
|
+
* node scripts/chatgpt-image-gen.js --topic my-topic --slides ... --ref output/_refs/123/ref-01.png
|
|
17
|
+
* node scripts/chatgpt-image-gen.js --topic my-topic --slides ... --only 3
|
|
18
|
+
* node scripts/chatgpt-image-gen.js --topic my-topic --slides ... --dry-run
|
|
19
|
+
*
|
|
20
|
+
* 이어서 돌리기:
|
|
21
|
+
* 이미 있는 PNG 는 자동으로 건너뛴다. 호출자 타임아웃(예: Bash 툴 2분)에 잘려도
|
|
22
|
+
* 같은 명령을 다시 부르면 남은 장부터 이어서 돈다. 전부 다시 뽑으려면 --force.
|
|
23
|
+
*
|
|
24
|
+
* 브라우저:
|
|
25
|
+
* 기본 — .chrome-profile 로 Chrome 을 직접 띄운다. 그 프로필로 이미 Chrome 이
|
|
26
|
+
* 떠 있으면(예: chrome-devtools MCP) 프로필이 잠겨 실패한다. 그때는
|
|
27
|
+
* --browser-url http://127.0.0.1:9222 로 떠 있는 Chrome 에 붙어라.
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
30
|
+
import { join, resolve, dirname } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { cropTo45 } from './openai-image-gen.js';
|
|
33
|
+
|
|
34
|
+
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
35
|
+
const BASE_W = 1080, BASE_H = 1350, SCALE = 2; // openai-image-gen.js / html-carousel-gen.js 와 동일
|
|
36
|
+
const CHATGPT = 'https://chatgpt.com/';
|
|
37
|
+
const PROFILE = join(REPO_ROOT, '.chrome-profile');
|
|
38
|
+
const DEBUG_URL = 'http://127.0.0.1:9222'; // .mcp.json 이 MCP Chrome 에 열어두는 포트
|
|
39
|
+
|
|
40
|
+
// 이미지 안에 한글이 들어가므로 정확도 가드를 붙인다 (openai-image-gen.js 의 KOREAN 과 같은 역할).
|
|
41
|
+
const KOREAN = '한글은 오타 없이 정확히 그대로 렌더링할 것. 지정하지 않은 문구는 넣지 말 것.';
|
|
42
|
+
// bg 모드는 글자가 들어가면 트랙의 존재 이유가 무너진다 (한글은 HTML 이 위에 얹는다).
|
|
43
|
+
const NO_TEXT = '글자·숫자·로고·워터마크를 이미지 안에 절대 넣지 마라. 순수한 배경 이미지만. '
|
|
44
|
+
+ '나중에 이 위에 헤드라인을 얹을 수 있게 여백을 넉넉히 두고 차분하게.';
|
|
45
|
+
// 레퍼런스는 **순서대로 1:1 대응이 아니라** 여러 레이아웃 타입의 예시 모음이다.
|
|
46
|
+
// (커버형·본문형·마무리형 등) 슬라이드 성격에 맞는 타입을 고르게 해야 한다.
|
|
47
|
+
const REF_NOTE = '첨부한 이미지들은 디자인 레퍼런스다. '
|
|
48
|
+
+ '배경색·액센트 컬러·타이포그래피·질감 등 전체 톤을 이 이미지들에서 그대로 가져온다. '
|
|
49
|
+
+ '레이아웃은 첨부 순서와 1:1로 대응시키지 마라 — 여러 타입의 예시일 뿐이니, '
|
|
50
|
+
+ '아래에 설명한 장의 성격에 가장 어울리는 타입을 골라 적용해라. '
|
|
51
|
+
+ '문구는 아래에 지정한 것만 쓴다.';
|
|
52
|
+
|
|
53
|
+
// ChatGPT 는 "9장짜리 캐러셀"이라고 알려주면 한 턴에 여러 장을 알아서 만들어버린다.
|
|
54
|
+
// (실측: 한 턴에 15장) 그러면 다음 프롬프트의 결과로 오인돼 수거가 한 칸씩 밀린다.
|
|
55
|
+
// 그래서 매 메시지마다 "딱 1장" 을 못 박고, 전체 장수는 아예 언급하지 않는다.
|
|
56
|
+
const ONE_ONLY = '이 메시지에 대해 이미지는 **정확히 1장만** 생성해라. '
|
|
57
|
+
+ '여러 장을 한 번에 만들거나 다음 장을 미리 만들지 마라. 다음 장은 내가 따로 요청한다.';
|
|
58
|
+
|
|
59
|
+
// 비율을 매 메시지에 못 박는다. common_style 에 맡기면 그걸 안 쓴 프롬프트 파일에서
|
|
60
|
+
// 2:3(1003×1568)으로 나오고, cropTo45 의 object-fit:cover 가 위아래 20% 를 잘라
|
|
61
|
+
// 라벨·페이지번호·로고가 날아간다. 잘린 뒤에는 복구가 안 된다.
|
|
62
|
+
const RATIO = '이미지는 반드시 세로 4:5 비율(1080×1350 px)로 만들어라. '
|
|
63
|
+
+ '정사각형이나 2:3 으로 만들지 마라. 중요한 요소는 가장자리에 붙이지 말고 안쪽 여백을 확보해라.';
|
|
64
|
+
|
|
65
|
+
function parseArgs(argv) {
|
|
66
|
+
const args = {};
|
|
67
|
+
for (let i = 2; i < argv.length; i++) {
|
|
68
|
+
if (!argv[i].startsWith('--')) continue;
|
|
69
|
+
const key = argv[i].slice(2);
|
|
70
|
+
args[key] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
|
71
|
+
}
|
|
72
|
+
return args;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// src 의 file id 만 뽑는다. ts/sig 쿼리는 매 렌더마다 바뀌어서 통째로 비교하면 오탐이 난다.
|
|
76
|
+
// **중복 제거 필수** — ChatGPT 는 이미지 1장을 <img> 3개(본문·라이트박스·숨김)로 그린다.
|
|
77
|
+
// 안 지우면 1장을 3장으로 세서 "여러 장 생성됨" 오탐이 난다.
|
|
78
|
+
const IMG_SEL = 'main img[src*="/backend-api/"]';
|
|
79
|
+
const fileIds = (page) => page.evaluate((sel) =>
|
|
80
|
+
[...new Set([...document.querySelectorAll(sel)]
|
|
81
|
+
.map((el) => (/[?&]id=([^&]+)/.exec(el.src) || [])[1])
|
|
82
|
+
.filter(Boolean))], IMG_SEL);
|
|
83
|
+
|
|
84
|
+
// **설치된** Chrome 을 찾는다. puppeteer.executablePath() 는 번들 Chrome(구버전, 예: 131)을
|
|
85
|
+
// 돌려주는데, .chrome-profile 은 설치된 Chrome(151)이 만든 것이라 버전이 안 맞으면
|
|
86
|
+
// Chrome 이 포트만 잠깐 열고 바로 죽는다 (connect 시 ECONNRESET).
|
|
87
|
+
function findInstalledChrome() {
|
|
88
|
+
const cands = [
|
|
89
|
+
process.env.PROGRAMFILES && join(process.env.PROGRAMFILES, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
90
|
+
process.env['PROGRAMFILES(X86)'] && join(process.env['PROGRAMFILES(X86)'], 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
91
|
+
process.env.LOCALAPPDATA && join(process.env.LOCALAPPDATA, 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
92
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
93
|
+
'/usr/bin/google-chrome',
|
|
94
|
+
].filter(Boolean);
|
|
95
|
+
return cands.find((p) => existsSync(p)) || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Chrome 을 **부모와 분리해서** 띄운다.
|
|
99
|
+
// puppeteer.launch 로 띄우면 이 스크립트가 끝나는 순간 puppeteer 의 process.on('exit')
|
|
100
|
+
// 훅이 브라우저를 같이 죽인다 (handleSIGTERM 등으로는 못 막는다). 그러면 Bash 툴 타임아웃에
|
|
101
|
+
// 잘릴 때마다 9장을 다 못 만든 채 창이 닫히고, 다음 실행이 이어받을 창도 없다.
|
|
102
|
+
// detached + unref 로 띄우고 CDP 로 붙으면, 이 프로세스가 죽어도 창은 남는다.
|
|
103
|
+
async function spawnDetachedChrome(exe) {
|
|
104
|
+
const { spawn } = await import('node:child_process');
|
|
105
|
+
const port = new URL(DEBUG_URL).port;
|
|
106
|
+
spawn(exe, [
|
|
107
|
+
`--remote-debugging-port=${port}`,
|
|
108
|
+
`--user-data-dir=${PROFILE}`,
|
|
109
|
+
'--no-first-run',
|
|
110
|
+
'--no-default-browser-check',
|
|
111
|
+
], { detached: true, stdio: 'ignore' }).unref();
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < 60; i++) { // 최대 30초 대기
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${DEBUG_URL}/json/version`);
|
|
116
|
+
if (res.ok) return;
|
|
117
|
+
} catch { /* 아직 안 떴다 */ }
|
|
118
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Chrome 이 30초 안에 ${DEBUG_URL} 를 열지 못했습니다`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function waitForComposer(page) {
|
|
124
|
+
await page.waitForSelector('#prompt-textarea', { timeout: 60_000 });
|
|
125
|
+
// 로그인이 풀렸으면 여기서 프롬프트 창이 안 뜬다 — 위 waitForSelector 가 던진다.
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function attachRef(page, refPaths) {
|
|
129
|
+
const input = await page.waitForSelector('input[type="file"]', { timeout: 30_000 });
|
|
130
|
+
await input.uploadFile(...refPaths); // 여러 장을 한 번에 올린다
|
|
131
|
+
// 업로드가 끝나기 전에 Enter 를 치면 첨부 없이 전송된다. 썸네일이 붙을 때까지 기다린다.
|
|
132
|
+
await page.waitForFunction(
|
|
133
|
+
() => !!document.querySelector('form img, form [data-testid*="attachment"]'),
|
|
134
|
+
{ timeout: 120_000 },
|
|
135
|
+
).catch(() => console.warn(' (경고) 첨부 썸네일을 확인하지 못했습니다. 그대로 진행합니다.'));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function sendPrompt(page, text) {
|
|
139
|
+
await page.click('#prompt-textarea');
|
|
140
|
+
// 프롬프트는 항상 한 줄로 만든다 — 개행이 들어가면 Enter 가 도중에 전송해버린다.
|
|
141
|
+
await page.type('#prompt-textarea', text.replace(/\s*\n\s*/g, ' '));
|
|
142
|
+
await page.keyboard.press('Enter');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 새 이미지를 기다렸다가 **이 프롬프트에 해당하는 1장**을 골라 돌려준다.
|
|
146
|
+
//
|
|
147
|
+
// 첫 장이 보이자마자 집고 넘어가면 안 된다. ChatGPT 가 한 턴에 여러 장을 만들면
|
|
148
|
+
// 나머지가 다음 프롬프트의 결과로 오인돼 수거가 한 칸씩 밀린다 (실제로 그렇게 밀렸다).
|
|
149
|
+
// 새 이미지가 보인 뒤에도 개수가 멈출 때까지 기다렸다가, **가장 먼저 나온 것**을 쓴다.
|
|
150
|
+
// 여러 장이 나왔으면 경고를 남긴다 — 프롬프트가 다장 생성을 유도했다는 신호다.
|
|
151
|
+
const SETTLE_MS = 12_000; // 개수가 이만큼 안 늘면 그 턴은 끝난 것으로 본다
|
|
152
|
+
|
|
153
|
+
async function waitForNewImage(page, before, timeoutMs) {
|
|
154
|
+
const deadline = Date.now() + timeoutMs;
|
|
155
|
+
let fresh = [], lastChange = 0;
|
|
156
|
+
while (Date.now() < deadline) {
|
|
157
|
+
const now = (await fileIds(page)).filter((id) => !before.includes(id));
|
|
158
|
+
if (now.length !== fresh.length) { fresh = now; lastChange = Date.now(); }
|
|
159
|
+
else if (fresh.length && Date.now() - lastChange >= SETTLE_MS) {
|
|
160
|
+
if (fresh.length > 1) {
|
|
161
|
+
console.warn(` (경고) 한 프롬프트에 ${fresh.length}장이 생성됨 — 첫 장만 사용합니다.`);
|
|
162
|
+
}
|
|
163
|
+
return fresh[0];
|
|
164
|
+
}
|
|
165
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
166
|
+
}
|
|
167
|
+
if (fresh.length) return fresh[0]; // 타임아웃이어도 받은 게 있으면 쓴다
|
|
168
|
+
throw new Error(`이미지가 ${Math.round(timeoutMs / 1000)}초 안에 안 나옴`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// PNG 헤더(IHDR)에서 크기만 읽는다. 디코딩 라이브러리 없이 바이트만 본다.
|
|
172
|
+
const pngSize = (buf) => ({ w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) });
|
|
173
|
+
|
|
174
|
+
// cropTo45 는 object-fit:cover 라 원본이 4:5 가 아니면 **위아래(또는 좌우)를 잘라낸다.**
|
|
175
|
+
// ChatGPT 가 2:3(1024×1536)으로 주면 17% 가량이 잘려 라벨·로고가 날아간다.
|
|
176
|
+
// 잘리는지 여부는 눈으로만 알 수 있으니 여기서 수치로 남긴다.
|
|
177
|
+
const TARGET_RATIO = BASE_W / BASE_H; // 0.8
|
|
178
|
+
function checkRatio(buf) {
|
|
179
|
+
const { w, h } = pngSize(buf);
|
|
180
|
+
const r = w / h;
|
|
181
|
+
const off = Math.abs(r - TARGET_RATIO) / TARGET_RATIO;
|
|
182
|
+
return { w, h, r, warn: off > 0.02 ? Math.round(off * 100) : 0 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 페이지 안에서 fetch 한다 — 인증 쿠키가 이미 붙어 있어 별도 토큰이 필요 없다.
|
|
186
|
+
// Chrome 의 다운로드 정책(연속 다운로드 차단)도 피한다.
|
|
187
|
+
async function downloadImage(page, fileId) {
|
|
188
|
+
const b64 = await page.evaluate(async (sel, id) => {
|
|
189
|
+
const el = [...document.querySelectorAll(sel)].find((e) => e.src.includes(`id=${id}`));
|
|
190
|
+
if (!el) return null;
|
|
191
|
+
const buf = new Uint8Array(await (await fetch(el.src)).arrayBuffer());
|
|
192
|
+
let s = '';
|
|
193
|
+
for (let i = 0; i < buf.length; i += 8192) s += String.fromCharCode(...buf.subarray(i, i + 8192));
|
|
194
|
+
return btoa(s);
|
|
195
|
+
}, IMG_SEL, fileId);
|
|
196
|
+
if (!b64) throw new Error('이미지를 받아오지 못함');
|
|
197
|
+
return Buffer.from(b64, 'base64');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function main() {
|
|
201
|
+
const args = parseArgs(process.argv);
|
|
202
|
+
const topic = args.topic || 'default';
|
|
203
|
+
const mode = args.mode === 'bg' ? 'bg' : 'slide';
|
|
204
|
+
const only = args.only ? Number(args.only) : null;
|
|
205
|
+
const dryRun = !!args['dry-run'];
|
|
206
|
+
const timeoutMs = (Number(args.timeout) || 300) * 1000;
|
|
207
|
+
// --ref 는 콤마로 여러 장을 받는다. 전부 첫 장에 첨부해 "타입 예시 모음"으로 준다.
|
|
208
|
+
const refPaths = args.ref
|
|
209
|
+
? String(args.ref).split(',').map((p) => resolve(REPO_ROOT, p.trim())).filter(Boolean)
|
|
210
|
+
: [];
|
|
211
|
+
|
|
212
|
+
// 로고는 모델이 그리게 하지 않는다 — 매번 뭉개진다. 자리만 비우게 하고 크롭할 때 원본을 얹는다.
|
|
213
|
+
const logoPos = args['logo-pos'] ? String(args['logo-pos']) : '';
|
|
214
|
+
let logo = null;
|
|
215
|
+
if (args.logo && logoPos && logoPos !== '넣지 않음') {
|
|
216
|
+
const lp = resolve(REPO_ROOT, String(args.logo));
|
|
217
|
+
if (!existsSync(lp)) { console.error(`[ERROR] 로고 파일 없음: ${lp}`); process.exit(1); }
|
|
218
|
+
logo = { svg: readFileSync(lp), pos: logoPos };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const slidesPath = args.slides
|
|
222
|
+
? resolve(args.slides)
|
|
223
|
+
: join(REPO_ROOT, 'templates', mode === 'slide' ? 'slides.example.json' : 'backgrounds.example.json');
|
|
224
|
+
if (!existsSync(slidesPath)) {
|
|
225
|
+
console.error(`[ERROR] 프롬프트 파일 없음: ${slidesPath}`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
for (const p of refPaths) {
|
|
229
|
+
if (!existsSync(p)) { console.error(`[ERROR] 레퍼런스 이미지 없음: ${p}`); process.exit(1); }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const data = JSON.parse(readFileSync(slidesPath, 'utf8'));
|
|
233
|
+
const commonStyle = data.common_style || '';
|
|
234
|
+
let slides = data.slides || [];
|
|
235
|
+
if (!slides.length) { console.error('[ERROR] slides 배열이 비어 있습니다.'); process.exit(1); }
|
|
236
|
+
if (only != null) {
|
|
237
|
+
slides = slides.filter((s) => s.n === only);
|
|
238
|
+
if (!slides.length) { console.error(`[ERROR] n=${only} 슬라이드 없음`); process.exit(1); }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 첫 장만 스타일 전체를 싣는다. 이후는 같은 대화 안이라 "같은 스타일로" 로 이어받는다
|
|
242
|
+
// — 매번 스타일을 반복하면 오히려 해석이 흔들려 9장 톤이 갈린다.
|
|
243
|
+
// 로고는 크롭할 때 원본 SVG 로 합성한다. 모델에게는 그 자리를 비우라고 해야 겹치지 않는다.
|
|
244
|
+
const logoNote = logo
|
|
245
|
+
? `이미지 안에 로고·워드마크·브랜드명을 절대 그리지 마라. ${logo.pos} 영역은 비워두고 여백으로 남겨라 (나중에 실제 로고를 얹는다).`
|
|
246
|
+
: '';
|
|
247
|
+
const guard = mode === 'slide' ? KOREAN : NO_TEXT;
|
|
248
|
+
// first = 이 실행에서 처음 보내는 장. 인덱스가 아니라 실제 전송 여부로 판단해야 한다 —
|
|
249
|
+
// 스킵/--only 로 1번이 빠지면 새 대화에 스타일이 하나도 안 실린 채 시작된다.
|
|
250
|
+
const build = (s, first) => (first
|
|
251
|
+
? [refPaths.length ? REF_NOTE : '', commonStyle, s.prompt, guard, logoNote, RATIO, ONE_ONLY]
|
|
252
|
+
: ['같은 스타일로 다음 장.', s.prompt, guard, logoNote, RATIO, ONE_ONLY]
|
|
253
|
+
).filter(Boolean).join(' ');
|
|
254
|
+
|
|
255
|
+
const outDir = mode === 'slide'
|
|
256
|
+
? join(REPO_ROOT, 'output', topic)
|
|
257
|
+
: join(REPO_ROOT, 'output', topic, 'bg');
|
|
258
|
+
const name = (n) => `${mode === 'slide' ? 'slide' : 'bg'}-${String(n).padStart(2, '0')}.png`;
|
|
259
|
+
|
|
260
|
+
if (dryRun) {
|
|
261
|
+
console.log(`[DRY RUN] mode=${mode} · ${slides.length}장 · 출력: ${outDir}`);
|
|
262
|
+
console.log(` 레퍼런스: ${refPaths.join(", ") || '(없음)'}`);
|
|
263
|
+
if (mode === 'slide') console.log(` 생성 후 ${BASE_W}×${BASE_H} 의 ${SCALE}배로 크롭\n`);
|
|
264
|
+
else console.log(' 배경은 원본 비율 그대로 저장 (HTML 이 cover 로 채운다)\n');
|
|
265
|
+
slides.forEach((s, i) => {
|
|
266
|
+
console.log(`--- ${name(s.n)} (${s.role || '?'}) ---`);
|
|
267
|
+
console.log(build(s, i === 0) + '\n');
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
mkdirSync(outDir, { recursive: true });
|
|
273
|
+
const { default: puppeteer } = await import('puppeteer');
|
|
274
|
+
|
|
275
|
+
// 크롭 전용 헤드리스 브라우저. ChatGPT 창과 섞으면 그 페이지를 오염시킨다.
|
|
276
|
+
// bg 모드는 원본 비율 그대로 두므로 띄우지 않는다.
|
|
277
|
+
let cropper = null, cropPage = null;
|
|
278
|
+
if (mode === 'slide') {
|
|
279
|
+
cropper = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] });
|
|
280
|
+
cropPage = await cropper.newPage();
|
|
281
|
+
await cropPage.setViewport({ width: BASE_W, height: BASE_H, deviceScaleFactor: SCALE });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 프로필은 Chrome 한 개만 잠글 수 있다. chrome-devtools MCP 가 이미 그 프로필로 떠 있는 게
|
|
285
|
+
// 흔한 상황이라, 먼저 붙어보고(attach) 안 되면 그때 직접 띄운다. 순서를 바꾸면 늘 잠김 에러가 난다.
|
|
286
|
+
const url = String(args['browser-url'] || DEBUG_URL);
|
|
287
|
+
let browser = null;
|
|
288
|
+
try {
|
|
289
|
+
browser = await puppeteer.connect({ browserURL: url, defaultViewport: null, protocolTimeout: 600_000 });
|
|
290
|
+
console.log(`(떠 있는 Chrome 에 붙었습니다: ${url})`);
|
|
291
|
+
} catch {
|
|
292
|
+
if (args['browser-url']) {
|
|
293
|
+
await cropper?.close();
|
|
294
|
+
console.error(`[ERROR] ${url} 에 Chrome 이 없습니다.`);
|
|
295
|
+
process.exit(1);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (!browser) {
|
|
299
|
+
const exe = args['chrome-path'] ? String(args['chrome-path']) : findInstalledChrome();
|
|
300
|
+
if (!exe) {
|
|
301
|
+
await cropper?.close();
|
|
302
|
+
console.error('[ERROR] 설치된 Chrome 을 찾지 못했습니다. --chrome-path 로 지정하세요.');
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
await spawnDetachedChrome(exe);
|
|
307
|
+
browser = await puppeteer.connect({ browserURL: DEBUG_URL, defaultViewport: null, protocolTimeout: 600_000 });
|
|
308
|
+
console.log(`(Chrome 을 새로 띄웠습니다: ${DEBUG_URL})`);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
await cropper?.close();
|
|
311
|
+
console.error(`[ERROR] Chrome 을 열지 못했습니다: ${e.message}`);
|
|
312
|
+
console.error(` 같은 프로필(${PROFILE})로 포트 없이 뜬 Chrome 이 있으면 그 창을 닫으세요.`);
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log(`\n${slides.length}장 생성 (ChatGPT 웹 · ${refPaths.length ? refPaths.length + '장 첨부' : '레퍼런스 없음'})`);
|
|
318
|
+
console.log('⚠️ 한글이 이미지 안에 들어갑니다. 생성 후 전부 눈으로 확인하세요.\n');
|
|
319
|
+
|
|
320
|
+
const summary = [];
|
|
321
|
+
let primed = false; // 이 실행에서 스타일/레퍼런스를 실은 첫 장을 이미 보냈는가
|
|
322
|
+
const page = await browser.newPage();
|
|
323
|
+
try {
|
|
324
|
+
await page.goto(CHATGPT, { waitUntil: 'domcontentloaded', timeout: 120_000 });
|
|
325
|
+
await waitForComposer(page);
|
|
326
|
+
|
|
327
|
+
for (let i = 0; i < slides.length; i++) {
|
|
328
|
+
const s = slides[i];
|
|
329
|
+
const outPathEarly = join(outDir, name(s.n));
|
|
330
|
+
// 이미 있는 장은 건너뛴다. 호출자의 타임아웃에 잘려도 다시 부르면 이어서 돈다.
|
|
331
|
+
// (--force 로 무시하고 전부 다시 뽑을 수 있다.)
|
|
332
|
+
if (!args.force && existsSync(outPathEarly)) {
|
|
333
|
+
console.log(`[${s.n}/${slides.length}] ${s.role || '?'} ... SKIP (이미 있음)`);
|
|
334
|
+
summary.push([name(s.n), 'SKIP', '-', '기존 파일 유지']);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
process.stdout.write(`[${s.n}/${slides.length}] ${s.role || '?'} ... `);
|
|
338
|
+
const t0 = Date.now();
|
|
339
|
+
const first = !primed;
|
|
340
|
+
try {
|
|
341
|
+
if (first && refPaths.length) await attachRef(page, refPaths);
|
|
342
|
+
// 보낸 뒤에 스냅샷을 뜨면 이미 생성된 장을 새 것으로 착각한다. 반드시 전송 전에.
|
|
343
|
+
let before = await fileIds(page);
|
|
344
|
+
await sendPrompt(page, build(s, first));
|
|
345
|
+
primed = true;
|
|
346
|
+
// 첨부한 레퍼런스는 **전송된 뒤에야** main 안에 /backend-api/ 이미지로 나타난다.
|
|
347
|
+
// 위 스냅샷에는 안 잡히므로 그대로 두면 생성 결과로 오인돼 1장이 밀리고 이후 전부
|
|
348
|
+
// 한 칸씩 어긋난다. 썸네일이 다 뜰 때까지 기다렸다가 before 를 다시 뜬다
|
|
349
|
+
// (이미지 생성은 25초 이상 걸리므로 그 사이에 결과가 끼어들 일은 없다).
|
|
350
|
+
if (first && refPaths.length) {
|
|
351
|
+
await page.waitForFunction(
|
|
352
|
+
(sel, n) => document.querySelectorAll(sel).length >= n,
|
|
353
|
+
{ timeout: 60_000 }, IMG_SEL, before.length + refPaths.length,
|
|
354
|
+
).catch(() => console.warn('\n (경고) 레퍼런스 썸네일을 확인하지 못했습니다.'));
|
|
355
|
+
before = await fileIds(page);
|
|
356
|
+
}
|
|
357
|
+
const fileId = await waitForNewImage(page, before, timeoutMs);
|
|
358
|
+
let buf = await downloadImage(page, fileId);
|
|
359
|
+
const src = checkRatio(buf);
|
|
360
|
+
if (mode === 'slide') buf = await cropTo45(buf, cropPage, logo);
|
|
361
|
+
const outPath = join(outDir, name(s.n));
|
|
362
|
+
writeFileSync(outPath, buf);
|
|
363
|
+
const kb = Math.round(statSync(outPath).size / 1024);
|
|
364
|
+
const dt = ((Date.now() - t0) / 1000).toFixed(1);
|
|
365
|
+
const ratio = `원본 ${src.w}×${src.h}`
|
|
366
|
+
+ (src.warn ? ` ⚠️ 4:5 에서 ${src.warn}% 벗어남 → cover 로 잘림` : ' (4:5)');
|
|
367
|
+
console.log(`OK (${dt}s, ${kb}KB, ${ratio}) -> ${name(s.n)}`);
|
|
368
|
+
summary.push([name(s.n), 'OK', `${dt}s`, `${kb}KB · ${src.w}×${src.h}${src.warn ? ' ⚠️' : ''}`]);
|
|
369
|
+
} catch (e) {
|
|
370
|
+
const dt = ((Date.now() - t0) / 1000).toFixed(1);
|
|
371
|
+
console.log(`ERR (${dt}s): ${e.message}`);
|
|
372
|
+
summary.push([name(s.n), 'ERR', `${dt}s`, e.message.slice(0, 60)]);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
} finally {
|
|
376
|
+
await cropper?.close(); // 크롭용 헤드리스는 이 실행 전용이라 닫아도 된다
|
|
377
|
+
// ChatGPT 창은 **닫지 않는다.** 붙어서 쓴 거면 남의 창이고, 직접 띄운 거라도
|
|
378
|
+
// 9장을 다 못 채웠으면 다음 실행이 이어서 써야 한다. 떼기만 한다.
|
|
379
|
+
browser.disconnect();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
console.log('\n=== SUMMARY ===');
|
|
383
|
+
summary.forEach(([f, st, dt, info]) => console.log(` ${f.padEnd(12)} ${st.padEnd(5)} ${dt.padStart(7)} ${info}`));
|
|
384
|
+
const missing = summary.filter(([, st]) => st === 'ERR').length;
|
|
385
|
+
console.log(`\n출력: ${outDir}`);
|
|
386
|
+
console.log(missing
|
|
387
|
+
? `Chrome 창은 열어둡니다 (${missing}장 남음). 같은 명령을 다시 부르면 그 창에 붙어 이어서 뽑습니다.`
|
|
388
|
+
: 'Chrome 창은 열어둡니다. 다음 실행이 같은 창에 붙습니다.');
|
|
389
|
+
if (mode === 'slide') {
|
|
390
|
+
console.log(`검사: node scripts/quality-check.js --dir output/${topic}`);
|
|
391
|
+
console.log('※ 한글 오타는 스크립트가 못 잡습니다. 반드시 눈으로 확인하세요.');
|
|
392
|
+
} else {
|
|
393
|
+
console.log('다음: carousel-html-writer 가 이 배경을 참조해 슬라이드 HTML 을 작성 →');
|
|
394
|
+
console.log(` node scripts/html-carousel-gen.js --topic ${topic}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (summary.some(([, st]) => st === 'ERR')) process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) main().catch((e) => { console.error('ERROR:', e); process.exit(1); });
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* PostToolUse 훅 — Write/Edit 후 자동 품질 검사.
|
|
4
|
+
*
|
|
5
|
+
* 설정: .claude/settings.json 의 hooks.PostToolUse 에 등록.
|
|
6
|
+
*
|
|
7
|
+
* 동작:
|
|
8
|
+
* - templates/slides.*.json 저장 시 → quality-check.js --prompt <파일> 자동 실행
|
|
9
|
+
* - output/*/slide-*.png 저장 시 → 같은 폴더에 대해 quality-check.js --dir 자동 실행
|
|
10
|
+
*
|
|
11
|
+
* stdin 으로 PostToolUse payload 받음 (Claude Code 훅 규약).
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { dirname, basename, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
function readStdin() {
|
|
17
|
+
try {
|
|
18
|
+
return require('node:fs').readFileSync(0, 'utf-8');
|
|
19
|
+
} catch {
|
|
20
|
+
return '';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function run(script, args) {
|
|
25
|
+
const r = spawnSync('node', [script, ...args], { encoding: 'utf-8' });
|
|
26
|
+
process.stdout.write(r.stdout || '');
|
|
27
|
+
process.stderr.write(r.stderr || '');
|
|
28
|
+
return r.status;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
const raw = readStdin();
|
|
33
|
+
let payload = {};
|
|
34
|
+
try {
|
|
35
|
+
payload = JSON.parse(raw);
|
|
36
|
+
} catch {}
|
|
37
|
+
|
|
38
|
+
const toolInput = payload.tool_input || {};
|
|
39
|
+
const filePath = toolInput.file_path || '';
|
|
40
|
+
if (!filePath) return;
|
|
41
|
+
|
|
42
|
+
const fname = basename(filePath);
|
|
43
|
+
const dir = dirname(filePath);
|
|
44
|
+
|
|
45
|
+
// 1) 프롬프트 JSON 저장 시
|
|
46
|
+
if (/^slides\..+\.json$/.test(fname) || fname === 'slides.example.json') {
|
|
47
|
+
console.log(`\n[hook] 프롬프트 JSON 감지 → 스키마 검증`);
|
|
48
|
+
run(join('scripts', 'quality-check.js'), ['--prompt', filePath]);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2) 슬라이드 PNG 저장 시 — 마지막 장(slide-09)일 때만 폴더 전체 검증
|
|
53
|
+
if (/^slide-09\.png$/.test(fname)) {
|
|
54
|
+
console.log(`\n[hook] slide-09 감지 → 폴더 전체 검증`);
|
|
55
|
+
run(join('scripts', 'quality-check.js'), ['--dir', dir]);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
main();
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* insta-carousel-builder — HTML/Puppeteer 캐러셀 생성기
|
|
4
|
+
*
|
|
5
|
+
* slides 폴더의 *.html 파일을 1080×1350 (2x 레티나) PNG로 일괄 캡처.
|
|
6
|
+
* 한글 100% 정확, 비용 0원, 결정적 결과.
|
|
7
|
+
*
|
|
8
|
+
* 사용법:
|
|
9
|
+
* # 1. templates/html-templates/ 또는 docs/sample-html/ 의 HTML 9장을 복사
|
|
10
|
+
* # output/{topic}/slides/ 에 두기 (또는 직접 작성)
|
|
11
|
+
* # 2. 실행:
|
|
12
|
+
* node scripts/html-carousel-gen.js --topic my-topic
|
|
13
|
+
*
|
|
14
|
+
* # 단일 슬라이드만 캡처:
|
|
15
|
+
* node scripts/html-carousel-gen.js --topic my-topic --only 3
|
|
16
|
+
*
|
|
17
|
+
* 의존성:
|
|
18
|
+
* npm install puppeteer
|
|
19
|
+
*
|
|
20
|
+
* 폰트:
|
|
21
|
+
* Pretendard 시스템 설치 권장 (없으면 CDN으로 폴백되지만 첫 로드 느림)
|
|
22
|
+
* Linux: https://github.com/orioncactus/pretendard 릴리즈 참조
|
|
23
|
+
*/
|
|
24
|
+
import puppeteer from 'puppeteer';
|
|
25
|
+
import { readdirSync, existsSync, mkdirSync } from 'node:fs';
|
|
26
|
+
import { join, resolve, dirname } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
30
|
+
const __dirname = dirname(__filename);
|
|
31
|
+
const REPO_ROOT = resolve(__dirname, '..');
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const args = {};
|
|
35
|
+
for (let i = 2; i < argv.length; i++) {
|
|
36
|
+
if (argv[i].startsWith('--') && argv[i + 1]) {
|
|
37
|
+
args[argv[i].slice(2)] = argv[++i];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONFIG = {
|
|
44
|
+
width: 1080,
|
|
45
|
+
height: 1350,
|
|
46
|
+
deviceScaleFactor: 2,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
async function captureSlides({ slidesDir, outputDir, only }) {
|
|
50
|
+
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
let files = readdirSync(slidesDir)
|
|
53
|
+
.filter((f) => f.endsWith('.html'))
|
|
54
|
+
.sort();
|
|
55
|
+
|
|
56
|
+
if (only) {
|
|
57
|
+
const target = `slide-${String(only).padStart(2, '0')}.html`;
|
|
58
|
+
files = files.filter((f) => f === target);
|
|
59
|
+
if (files.length === 0) {
|
|
60
|
+
console.error(`❌ ${target} 없음 in ${slidesDir}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (files.length === 0) {
|
|
66
|
+
console.error(`❌ ${slidesDir} 에 HTML 파일이 없습니다.`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`\n${files.length}개 슬라이드 캡처 시작 (${slidesDir})\n`);
|
|
71
|
+
|
|
72
|
+
const browser = await puppeteer.launch({
|
|
73
|
+
headless: 'new',
|
|
74
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--font-render-hinting=none'],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const page = await browser.newPage();
|
|
78
|
+
await page.setViewport({
|
|
79
|
+
width: CONFIG.width,
|
|
80
|
+
height: CONFIG.height,
|
|
81
|
+
deviceScaleFactor: CONFIG.deviceScaleFactor,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
const filePath = resolve(slidesDir, file);
|
|
86
|
+
const fileUrl = 'file:///' + filePath.replace(/\\/g, '/');
|
|
87
|
+
const outName = file.replace('.html', '.png');
|
|
88
|
+
const outPath = join(outputDir, outName);
|
|
89
|
+
|
|
90
|
+
await page.goto(fileUrl, { waitUntil: 'networkidle0', timeout: 15000 });
|
|
91
|
+
await page.evaluate(() => document.fonts.ready);
|
|
92
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
93
|
+
|
|
94
|
+
await page.screenshot({
|
|
95
|
+
path: outPath,
|
|
96
|
+
type: 'png',
|
|
97
|
+
clip: { x: 0, y: 0, width: CONFIG.width, height: CONFIG.height },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
console.log(` ✓ ${outName}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
await browser.close();
|
|
104
|
+
console.log(`\n완료! ${outputDir}\n`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function main() {
|
|
108
|
+
const args = parseArgs(process.argv);
|
|
109
|
+
const topic = args.topic || 'default';
|
|
110
|
+
const slidesDir = args['slides-dir'] || join(REPO_ROOT, 'output', topic, 'slides');
|
|
111
|
+
const outputDir = join(REPO_ROOT, 'output', topic);
|
|
112
|
+
|
|
113
|
+
if (!existsSync(slidesDir)) {
|
|
114
|
+
console.error(`❌ slides 폴더 없음: ${slidesDir}`);
|
|
115
|
+
console.error(' docs/sample-html/ 의 9장을 복사하거나, 직접 HTML 작성 후 실행하세요.');
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await captureSlides({ slidesDir, outputDir, only: args.only });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
main().catch((e) => {
|
|
123
|
+
console.error('ERROR:', e);
|
|
124
|
+
process.exit(1);
|
|
125
|
+
});
|