ima2-gen 1.1.0 → 1.1.2
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/README.md +47 -7
- package/assets/card-news/templates/academy-lesson-square/base.png +0 -0
- package/assets/card-news/templates/academy-lesson-square/preview.png +0 -0
- package/assets/card-news/templates/academy-lesson-square/template.json +20 -0
- package/assets/card-news/templates/clean-report-square/base.png +0 -0
- package/assets/card-news/templates/clean-report-square/preview.png +0 -0
- package/assets/card-news/templates/clean-report-square/template.json +20 -0
- package/bin/commands/cancel.js +45 -0
- package/bin/commands/edit.js +33 -4
- package/bin/commands/gen.js +26 -3
- package/bin/commands/ps.js +48 -16
- package/bin/ima2.js +56 -12
- package/bin/lib/client.js +4 -1
- package/bin/lib/error-hints.js +23 -0
- package/bin/lib/output.js +10 -0
- package/config.js +19 -1
- package/docs/API.md +67 -0
- package/docs/FAQ.ko.md +248 -0
- package/docs/FAQ.md +256 -0
- package/docs/README.ja.md +4 -0
- package/docs/README.ko.md +14 -1
- package/docs/README.zh-CN.md +4 -0
- package/docs/RECOVER_OLD_IMAGES.md +2 -0
- package/lib/cardNewsGenerator.js +162 -0
- package/lib/cardNewsJobStore.js +107 -0
- package/lib/cardNewsManifestStore.js +112 -0
- package/lib/cardNewsPlanner.js +180 -0
- package/lib/cardNewsPlannerClient.js +112 -0
- package/lib/cardNewsPlannerPrompt.js +60 -0
- package/lib/cardNewsPlannerSchema.js +259 -0
- package/lib/cardNewsRoleTemplateStore.js +47 -0
- package/lib/cardNewsTemplateStore.js +210 -0
- package/lib/db.js +20 -3
- package/lib/errorClassify.js +2 -2
- package/lib/generationErrors.js +51 -0
- package/lib/historyList.js +82 -8
- package/lib/inflight.js +117 -34
- package/lib/logger.js +37 -3
- package/lib/oauthLauncher.js +52 -19
- package/lib/oauthProxy.js +81 -14
- package/lib/requestLogger.js +48 -0
- package/lib/runtimePorts.js +93 -0
- package/lib/sessionStore.js +48 -7
- package/package.json +3 -2
- package/routes/cardNews.js +183 -0
- package/routes/edit.js +1 -1
- package/routes/generate.js +10 -10
- package/routes/health.js +27 -3
- package/routes/index.js +2 -0
- package/routes/nodes.js +93 -26
- package/server.js +91 -18
- package/ui/dist/assets/index-BjX_nzuK.js +23 -0
- package/ui/dist/assets/index-BjX_nzuK.js.map +1 -0
- package/ui/dist/assets/index-DHyUax4_.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CqpVoXpZ.css +0 -1
- package/ui/dist/assets/index-IHSd1z1a.js +0 -22
- package/ui/dist/assets/index-IHSd1z1a.js.map +0 -1
package/docs/FAQ.md
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# ima2-gen FAQ
|
|
2
|
+
|
|
3
|
+
Last reviewed: 2026-04-25
|
|
4
|
+
|
|
5
|
+
This FAQ collects the questions that tend to come up after installing or updating `ima2-gen`. The README stays short; this page is the place for practical details and recovery steps.
|
|
6
|
+
|
|
7
|
+
For Korean, see [FAQ.ko.md](FAQ.ko.md).
|
|
8
|
+
|
|
9
|
+
## Quick fixes
|
|
10
|
+
|
|
11
|
+
| Symptom | Try first |
|
|
12
|
+
|---|---|
|
|
13
|
+
| The server is unreachable | Run `ima2 serve`, then `ima2 ping`. |
|
|
14
|
+
| OAuth login fails | Run `npx @openai/codex login`, then restart `ima2 serve`. |
|
|
15
|
+
| API key generation is disabled | Use OAuth for image generation. API keys are only used by auxiliary paths. |
|
|
16
|
+
| Old gallery images look missing | Run `ima2 doctor`, then see [Recover Old Generated Images](RECOVER_OLD_IMAGES.md). |
|
|
17
|
+
| `gpt-5.5` fails | Update Codex CLI first, then try `gpt-5.4` as the stable fallback. |
|
|
18
|
+
| Reference upload fails | Use JPEG/PNG, lower the resolution, and keep references to 5 images or fewer. |
|
|
19
|
+
| Windows reports OAuth/proxy failures around port `10531` | Run `ima2 doctor`; if needed start with `IMA2_OAUTH_PROXY_PORT=11531 ima2 serve`. |
|
|
20
|
+
|
|
21
|
+
## Install and update
|
|
22
|
+
|
|
23
|
+
### What version of Node do I need?
|
|
24
|
+
|
|
25
|
+
Use Node.js 20 or newer. The package declares Node `>=20`, and the README badge follows that requirement.
|
|
26
|
+
|
|
27
|
+
### Should I use `npx` or a global install?
|
|
28
|
+
|
|
29
|
+
Both are supported.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx ima2-gen serve
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install -g ima2-gen
|
|
39
|
+
ima2 serve
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
If an old global install behaves strangely, update first:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install -g ima2-gen@latest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then run:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
ima2 doctor
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Windows says `spawn EINVAL`. What should I do?
|
|
55
|
+
|
|
56
|
+
Update to the latest version. Older versions had trouble spawning npm/npx shims on Windows. Current builds route those commands through a Windows-safe path.
|
|
57
|
+
|
|
58
|
+
If Codex login itself is unreliable on native Windows, WSL can be the more predictable environment.
|
|
59
|
+
|
|
60
|
+
### Windows says `EBUSY` or `resource busy or locked` during update. What should I do?
|
|
61
|
+
|
|
62
|
+
This usually means npm cannot replace the global package because a running
|
|
63
|
+
`ima2 serve`, stale `node.exe`, terminal, Explorer window, antivirus, or indexer
|
|
64
|
+
still holds the package folder. Stop ima2, close related terminals, end stale
|
|
65
|
+
`node.exe` processes if needed, then retry:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install -g ima2-gen@latest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If the lock persists, reboot Windows and run the update before starting ima2
|
|
72
|
+
again.
|
|
73
|
+
|
|
74
|
+
## Authentication and providers
|
|
75
|
+
|
|
76
|
+
### Do I need an OpenAI API key?
|
|
77
|
+
|
|
78
|
+
No for image generation. The normal generation path uses your local Codex/ChatGPT OAuth session.
|
|
79
|
+
|
|
80
|
+
You may still see an API key detected in settings. That only means an API key exists in env/config. Image generation routes still reject `provider: "api"` with `APIKEY_DISABLED`.
|
|
81
|
+
|
|
82
|
+
### Why does the settings page say "Configured but disabled"?
|
|
83
|
+
|
|
84
|
+
It means `ima2-gen` found an API key, but API-key image generation is intentionally disabled in this build. Use OAuth for generation.
|
|
85
|
+
|
|
86
|
+
### If Codex CLI is already logged in, does ima2-gen reuse it?
|
|
87
|
+
|
|
88
|
+
Yes. `ima2-gen` checks for an existing Codex login and uses the local OAuth path. If detection fails or the token expires, run:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx @openai/codex login
|
|
92
|
+
ima2 doctor
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then restart `ima2 serve`.
|
|
96
|
+
|
|
97
|
+
### What if I see `Provided authentication token is expired`?
|
|
98
|
+
|
|
99
|
+
Your Codex/ChatGPT OAuth session needs to be refreshed.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx @openai/codex login
|
|
103
|
+
ima2 serve
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If this happens on a company network, a firewall, VPN, proxy, or captive portal may also be blocking the OAuth flow.
|
|
107
|
+
|
|
108
|
+
## Models and quota
|
|
109
|
+
|
|
110
|
+
### Which model should I use?
|
|
111
|
+
|
|
112
|
+
Start with `gpt-5.4` for the safest balanced workflow.
|
|
113
|
+
|
|
114
|
+
- `gpt-5.4`: recommended balanced choice.
|
|
115
|
+
- `gpt-5.4-mini`: current app default and faster draft model.
|
|
116
|
+
- `gpt-5.5`: strongest quality option when supported.
|
|
117
|
+
|
|
118
|
+
### Why does `gpt-5.5` fail when other models work?
|
|
119
|
+
|
|
120
|
+
`gpt-5.5` may require a newer Codex CLI, backend capability, or account/quota availability. Update Codex CLI first. If it still fails, use `gpt-5.4` as the stable fallback.
|
|
121
|
+
|
|
122
|
+
### How many images can Plus or Pro generate?
|
|
123
|
+
|
|
124
|
+
Do not treat any community number as a guarantee. OAuth generation can be limited by account, backend capability, traffic, and policy changes. `ima2-gen` does not publish a fixed Plus/Pro image count because that number is not stable enough to document as a promise.
|
|
125
|
+
|
|
126
|
+
## Gallery and generated files
|
|
127
|
+
|
|
128
|
+
### Where are generated images stored?
|
|
129
|
+
|
|
130
|
+
Current versions store generated images in your user data folder:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
macOS / Linux: ~/.ima2/generated
|
|
134
|
+
Windows: %USERPROFILE%\.ima2\generated
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
You can override that with `IMA2_GENERATED_DIR`.
|
|
138
|
+
|
|
139
|
+
### Why did old gallery images look missing after an update?
|
|
140
|
+
|
|
141
|
+
Older versions stored generated images inside the installed package folder. Recent versions moved the gallery to user data storage so package updates do not mix app code with runtime files.
|
|
142
|
+
|
|
143
|
+
Sorry for the scare. If the old global install folder was replaced during an update, the previous `generated/` folder may no longer be on disk. `ima2-gen` can recover old files only when that old folder still exists.
|
|
144
|
+
|
|
145
|
+
Run:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
ima2 doctor
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Then follow [Recover Old Generated Images](RECOVER_OLD_IMAGES.md).
|
|
152
|
+
|
|
153
|
+
### Does ima2-gen delete my old images during this migration?
|
|
154
|
+
|
|
155
|
+
No. The migration is copy-only. It does not delete or move legacy folders. If old files are not found, the likely issue is that the old global install folder is no longer present on disk.
|
|
156
|
+
|
|
157
|
+
### What does "Open folder" open?
|
|
158
|
+
|
|
159
|
+
The gallery's **Open folder** button opens the generated image folder on the machine running `ima2 serve`.
|
|
160
|
+
|
|
161
|
+
That is usually your own computer. If you are using a remote server, SSH session, VM, container, WSL, or another machine on your network, the folder opens or resolves on that server machine, not necessarily on the browser device.
|
|
162
|
+
|
|
163
|
+
### Is Card News part of the stable public release?
|
|
164
|
+
|
|
165
|
+
Not yet. Card News is still dev-only and experimental. The default published
|
|
166
|
+
runtime should keep it hidden unless it is explicitly enabled for development,
|
|
167
|
+
and public docs should not treat it as a stable feature.
|
|
168
|
+
|
|
169
|
+
## Reference images
|
|
170
|
+
|
|
171
|
+
### How many reference images can I attach?
|
|
172
|
+
|
|
173
|
+
Up to 5.
|
|
174
|
+
|
|
175
|
+
### What formats work best?
|
|
176
|
+
|
|
177
|
+
Use JPEG or PNG. The browser path does not support HEIC/HEIF directly, so convert those images before attaching them.
|
|
178
|
+
|
|
179
|
+
### What if a reference image is too large?
|
|
180
|
+
|
|
181
|
+
The app compresses large JPEG/PNG files before upload. If a file still fails, lower the resolution or convert it to JPEG/PNG and try again.
|
|
182
|
+
|
|
183
|
+
The API may report reference errors such as `REF_TOO_MANY`, `REF_TOO_LARGE`, `REF_NOT_BASE64`, or `REF_EMPTY`.
|
|
184
|
+
|
|
185
|
+
## Network and OAuth errors
|
|
186
|
+
|
|
187
|
+
### Why did the backend or OAuth proxy move to another port?
|
|
188
|
+
|
|
189
|
+
`ima2-gen` is a local app. If the preferred backend port `3333` or OAuth proxy port `10531` is already in use, the runtime can fall back to the next available port and records the actual URLs in:
|
|
190
|
+
|
|
191
|
+
```text
|
|
192
|
+
~/.ima2/server.json
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Use:
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
ima2 doctor
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
to see the configured and actual backend/OAuth URLs.
|
|
202
|
+
|
|
203
|
+
### Windows: what if `AnySign4PC.exe` owns port `10531`?
|
|
204
|
+
|
|
205
|
+
Some Windows security software can occupy the default OAuth proxy port. Current builds track the actual fallback port, but you can also force a quieter range:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
IMA2_OAUTH_PROXY_PORT=11531 ima2 serve
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
For split frontend development, point Vite at the actual backend:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
VITE_IMA2_API_TARGET=http://localhost:3334 npm run ui:dev
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### What does `failed to fetch` mean?
|
|
218
|
+
|
|
219
|
+
Usually one of these:
|
|
220
|
+
|
|
221
|
+
- the local OAuth proxy is not ready,
|
|
222
|
+
- the server was restarted,
|
|
223
|
+
- a VPN/proxy/firewall blocked the request,
|
|
224
|
+
- the network dropped while Codex/ChatGPT OAuth was being used.
|
|
225
|
+
|
|
226
|
+
Try:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
ima2 doctor
|
|
230
|
+
ima2 ping
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Then restart `ima2 serve` if needed.
|
|
234
|
+
|
|
235
|
+
### What should I check on a company computer?
|
|
236
|
+
|
|
237
|
+
OAuth may require access to OpenAI and ChatGPT/Codex-related hosts. A corporate firewall, TLS inspection, VPN, or proxy can break the flow. Try a different network if login and `failed to fetch` errors keep repeating.
|
|
238
|
+
|
|
239
|
+
## CLI troubleshooting checklist
|
|
240
|
+
|
|
241
|
+
Run these in order:
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
ima2 doctor
|
|
245
|
+
ima2 status
|
|
246
|
+
ima2 ping
|
|
247
|
+
ima2 ps
|
|
248
|
+
npx @openai/codex login
|
|
249
|
+
npm install -g ima2-gen@latest
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
If you run the server on a non-default port:
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
IMA2_SERVER=http://localhost:3333 ima2 ping
|
|
256
|
+
```
|
package/docs/README.ja.md
CHANGED
|
@@ -143,6 +143,8 @@ environment variables > ~/.ima2/config.json > built-in defaults
|
|
|
143
143
|
|
|
144
144
|
Endpoint 一覧は [API Reference](API.md) に分離しました。
|
|
145
145
|
|
|
146
|
+
詳しいFAQは [FAQ](FAQ.md) にまとめています。アップデート後に以前のギャラリー画像が見えない場合は、まず [Recover Old Generated Images](RECOVER_OLD_IMAGES.md) を確認してください。
|
|
147
|
+
|
|
146
148
|
## Troubleshooting
|
|
147
149
|
|
|
148
150
|
**`ima2 ping` が server unreachable になる**
|
|
@@ -166,6 +168,8 @@ JPEG/PNG は送信前に自動圧縮されます。それでも失敗する場
|
|
|
166
168
|
**Port が突然 `3457` になる**
|
|
167
169
|
別のローカルツールから `PORT=3457` が引き継がれている可能性があります。`unset PORT` するか、`IMA2_PORT=3333 ima2 serve` で起動してください。
|
|
168
170
|
|
|
171
|
+
より詳しい確認手順は [FAQ](FAQ.md) を参照してください。
|
|
172
|
+
|
|
169
173
|
## Development
|
|
170
174
|
|
|
171
175
|
```bash
|
package/docs/README.ko.md
CHANGED
|
@@ -98,7 +98,7 @@ Style sheet는 반복해서 쓰고 싶은 시각적 방향을 저장하는 기
|
|
|
98
98
|
|
|
99
99
|
| 명령어 | 설명 |
|
|
100
100
|
|---|---|
|
|
101
|
-
| `ima2 serve` | 로컬 웹 서버
|
|
101
|
+
| `ima2 serve [--dev]` | 로컬 웹 서버 시작. `--dev`는 서버 진단 로그를 자세히 표시 |
|
|
102
102
|
| `ima2 setup` | 인증 설정 다시 구성 |
|
|
103
103
|
| `ima2 status` | config와 OAuth 상태 확인 |
|
|
104
104
|
| `ima2 doctor` | Node, 패키지, config, auth 진단 |
|
|
@@ -136,13 +136,22 @@ environment variables > ~/.ima2/config.json > built-in defaults
|
|
|
136
136
|
| `IMA2_CONFIG_DIR` | `~/.ima2` | config와 SQLite 저장 위치 |
|
|
137
137
|
| `IMA2_GENERATED_DIR` | `~/.ima2/generated` | 생성 이미지 저장 위치 |
|
|
138
138
|
| `IMA2_NO_OAUTH_PROXY` | — | `1`이면 OAuth 프록시 자동 시작 비활성화 |
|
|
139
|
+
| `IMA2_LOG_LEVEL` | `warn` | 일반 `serve`는 `warn`, dev 모드는 `debug`. `debug`, `info`, `warn`, `error`, `silent` 지원 |
|
|
139
140
|
| `IMA2_INFLIGHT_TERMINAL_TTL_MS` | `30000` | 디버그용 최근 작업 보존 시간 |
|
|
140
141
|
| `OPENAI_API_KEY` | — | 보조 기능용 API 키. 이미지 생성용은 아님 |
|
|
141
142
|
|
|
143
|
+
### 로그 모드
|
|
144
|
+
|
|
145
|
+
`ima2 serve`는 일반 사용자 기준으로 터미널 출력을 조용하게 유지합니다. 시작 URL, 경고, 오류는 보이지만 요청/노드/OAuth structured log는 기본적으로 숨깁니다.
|
|
146
|
+
|
|
147
|
+
요청 ID, 노드 생성 단계, OAuth stream 진단, inflight 상태 전환을 봐야 하면 `ima2 serve --dev`, `npm run dev`, 또는 `IMA2_LOG_LEVEL=debug ima2 serve`를 사용하세요. 명시한 `IMA2_LOG_LEVEL`과 `~/.ima2/config.json` 값은 기본값보다 우선합니다.
|
|
148
|
+
|
|
142
149
|
## API 문서
|
|
143
150
|
|
|
144
151
|
엔드포인트 목록은 [API Reference](API.md)로 분리했습니다.
|
|
145
152
|
|
|
153
|
+
자주 묻는 질문은 [FAQ](FAQ.ko.md)에 정리했습니다. 업데이트 후 예전 이미지가 안 보이면 [예전 이미지 복구 안내](RECOVER_OLD_IMAGES.md)를 먼저 확인하세요.
|
|
154
|
+
|
|
146
155
|
## 문제 해결
|
|
147
156
|
|
|
148
157
|
**`ima2 ping`이 서버에 연결하지 못한다고 나와요**
|
|
@@ -166,6 +175,8 @@ JPEG/PNG는 업로드 전에 자동 압축됩니다. 그래도 실패하면 해
|
|
|
166
175
|
**포트가 갑자기 `3457`로 떠요**
|
|
167
176
|
다른 로컬 도구에서 `PORT=3457`이 상속됐을 수 있습니다. `unset PORT`를 실행하거나 `IMA2_PORT=3333 ima2 serve`로 시작하세요.
|
|
168
177
|
|
|
178
|
+
더 자세한 답변은 [FAQ](FAQ.ko.md)를 확인하세요.
|
|
179
|
+
|
|
169
180
|
## 개발
|
|
170
181
|
|
|
171
182
|
```bash
|
|
@@ -177,6 +188,8 @@ npm test
|
|
|
177
188
|
npm run build
|
|
178
189
|
```
|
|
179
190
|
|
|
191
|
+
`npm run dev`는 UI를 빌드한 뒤 `server.js`를 `--watch`로 실행하고, 서버 진단 로그를 자세히 표시합니다.
|
|
192
|
+
|
|
180
193
|
## 라이선스
|
|
181
194
|
|
|
182
195
|
MIT
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -143,6 +143,8 @@ environment variables > ~/.ima2/config.json > built-in defaults
|
|
|
143
143
|
|
|
144
144
|
接口列表已移到 [API Reference](API.md)。
|
|
145
145
|
|
|
146
|
+
更详细的常见问题整理在 [FAQ](FAQ.md)。如果更新后看不到旧图库图片,请先查看[旧图片恢复指南](RECOVER_OLD_IMAGES.md)。
|
|
147
|
+
|
|
146
148
|
## 常见问题
|
|
147
149
|
|
|
148
150
|
**`ima2 ping` 提示 server unreachable**
|
|
@@ -166,6 +168,8 @@ JPEG/PNG 会在上传前自动压缩。如果仍然失败,请转成更低分
|
|
|
166
168
|
**端口突然变成 `3457`**
|
|
167
169
|
shell 可能继承了其他本地工具的 `PORT=3457`。运行 `unset PORT`,或使用 `IMA2_PORT=3333 ima2 serve`。
|
|
168
170
|
|
|
171
|
+
更多面向新手的排查步骤请查看 [FAQ](FAQ.md)。
|
|
172
|
+
|
|
169
173
|
## Development
|
|
170
174
|
|
|
171
175
|
```bash
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
`ima2-gen` moved generated images to a safer user-data folder in `v1.0.8`.
|
|
4
4
|
|
|
5
|
+
For broader install and troubleshooting questions, see the [FAQ](FAQ.md) or [Korean FAQ](FAQ.ko.md).
|
|
6
|
+
|
|
5
7
|
## What changed
|
|
6
8
|
|
|
7
9
|
Versions up to `v1.0.7` stored generated images inside the installed package:
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ulid } from "ulid";
|
|
4
|
+
import { generateViaOAuth } from "./oauthProxy.js";
|
|
5
|
+
import { readTemplateBaseB64 } from "./cardNewsTemplateStore.js";
|
|
6
|
+
import { writeCardNewsManifest, writeCardSidecar } from "./cardNewsManifestStore.js";
|
|
7
|
+
|
|
8
|
+
function formatRenderedTextInstruction(textFields = []) {
|
|
9
|
+
const visible = (Array.isArray(textFields) ? textFields : [])
|
|
10
|
+
.filter((field) => field?.renderMode === "in-image" && field.text);
|
|
11
|
+
if (!visible.length) {
|
|
12
|
+
return [
|
|
13
|
+
"Do not render readable text unless explicitly listed.",
|
|
14
|
+
"Do not render role labels, schema keys, placeholder labels, or untranslated summaries.",
|
|
15
|
+
].join("\n");
|
|
16
|
+
}
|
|
17
|
+
return [
|
|
18
|
+
"Render only the following readable text items exactly as written:",
|
|
19
|
+
...visible.map((field) => {
|
|
20
|
+
const slot = field.slotId ? ` in slot ${field.slotId}` : "";
|
|
21
|
+
return `- ${field.kind} at ${field.placement}${slot}: "${field.text}"`;
|
|
22
|
+
}),
|
|
23
|
+
"Preserve the language and spelling of every listed text item.",
|
|
24
|
+
"Do not render role labels, schema keys, placeholder labels, or extra text.",
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assemblePrompt(template, card) {
|
|
29
|
+
return [
|
|
30
|
+
template.stylePrompt,
|
|
31
|
+
card.visualPrompt,
|
|
32
|
+
formatRenderedTextInstruction(card.textFields),
|
|
33
|
+
template.negativePrompt ? `Avoid: ${template.negativePrompt}` : "",
|
|
34
|
+
].filter(Boolean).join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function mapLimit(items, limit, fn) {
|
|
38
|
+
const out = new Array(items.length);
|
|
39
|
+
let next = 0;
|
|
40
|
+
async function worker() {
|
|
41
|
+
while (next < items.length) {
|
|
42
|
+
const i = next++;
|
|
43
|
+
out[i] = await fn(items[i], i);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function generateCardNewsSet(ctx, input, options = {}) {
|
|
51
|
+
const setId = input.setId || `cs_${ulid()}`;
|
|
52
|
+
const cards = Array.isArray(input.cards) ? input.cards : [];
|
|
53
|
+
const cardsToGenerate = cards.filter((card) => !card.locked);
|
|
54
|
+
if (cardsToGenerate.length === 0) {
|
|
55
|
+
const err = new Error("cards are required");
|
|
56
|
+
err.status = 400;
|
|
57
|
+
err.code = "CARD_NEWS_CARDS_REQUIRED";
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const imageTemplateId = input.imageTemplateId || "academy-lesson-square";
|
|
62
|
+
const { template, templateBase, b64: templateB64 } = await readTemplateBaseB64(ctx, imageTemplateId);
|
|
63
|
+
const dir = join(ctx.config.storage.generatedDir, "cardnews", setId);
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
const quality = input.quality || "medium";
|
|
67
|
+
const size = input.size || template.size || "2048x2048";
|
|
68
|
+
const moderation = input.moderation || "low";
|
|
69
|
+
const model = input.model || ctx.config.imageModels.default;
|
|
70
|
+
const generateFn = options.generateFn || generateViaOAuth;
|
|
71
|
+
|
|
72
|
+
const generatedCards = await mapLimit(cardsToGenerate, Number(input.concurrency) || 2, async (card, index) => {
|
|
73
|
+
const cardOrder = Number(card.cardOrder || card.order || index + 1);
|
|
74
|
+
const baseFilename = `card-${String(cardOrder).padStart(2, "0")}`;
|
|
75
|
+
const imageFilename = `${baseFilename}.png`;
|
|
76
|
+
const sidecarFilename = `${baseFilename}.json`;
|
|
77
|
+
const requestId = input.requestId || `${setId}_${baseFilename}`;
|
|
78
|
+
const prompt = assemblePrompt(template, card);
|
|
79
|
+
let result = null;
|
|
80
|
+
let error = null;
|
|
81
|
+
if (typeof options.onCardStart === "function") {
|
|
82
|
+
await options.onCardStart({ ...card, cardOrder, cardId: card.id || `card_${cardOrder}` });
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
result = await generateFn(
|
|
86
|
+
prompt,
|
|
87
|
+
quality,
|
|
88
|
+
size,
|
|
89
|
+
moderation,
|
|
90
|
+
[templateB64, ...(Array.isArray(card.references) ? card.references : [])],
|
|
91
|
+
requestId,
|
|
92
|
+
input.promptMode || "direct",
|
|
93
|
+
ctx,
|
|
94
|
+
{ model },
|
|
95
|
+
);
|
|
96
|
+
if (!result?.b64) {
|
|
97
|
+
error = { code: "CARD_NEWS_EMPTY_IMAGE", message: "No image data returned" };
|
|
98
|
+
} else {
|
|
99
|
+
await writeFile(join(dir, imageFilename), Buffer.from(result.b64, "base64"));
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
error = { code: err.code || "CARD_NEWS_CARD_FAILED", message: err.message || "Card generation failed" };
|
|
103
|
+
}
|
|
104
|
+
const sidecar = {
|
|
105
|
+
kind: "card-news-card",
|
|
106
|
+
setId,
|
|
107
|
+
sessionId: input.sessionId || null,
|
|
108
|
+
requestId,
|
|
109
|
+
cardId: card.id || `card_${cardOrder}`,
|
|
110
|
+
cardOrder,
|
|
111
|
+
title: input.title || "Untitled card news",
|
|
112
|
+
role: card.role || "card",
|
|
113
|
+
headline: card.headline || "",
|
|
114
|
+
body: card.body || "",
|
|
115
|
+
textFields: Array.isArray(card.textFields) ? card.textFields : [],
|
|
116
|
+
imageTemplateId,
|
|
117
|
+
generationStrategy: "parallel-template-i2i",
|
|
118
|
+
templateBase,
|
|
119
|
+
prompt,
|
|
120
|
+
visualPrompt: card.visualPrompt || "",
|
|
121
|
+
imageFilename: error ? null : imageFilename,
|
|
122
|
+
sidecarFilename,
|
|
123
|
+
locked: !!card.locked,
|
|
124
|
+
status: error ? "error" : "generated",
|
|
125
|
+
error,
|
|
126
|
+
createdAt: Date.now(),
|
|
127
|
+
generatedAt: error ? null : Date.now(),
|
|
128
|
+
revisedPrompt: result?.revisedPrompt || null,
|
|
129
|
+
};
|
|
130
|
+
await writeCardSidecar(dir, sidecarFilename, sidecar);
|
|
131
|
+
if (typeof options.onCardDone === "function") await options.onCardDone(sidecar);
|
|
132
|
+
return sidecar;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const manifest = {
|
|
136
|
+
kind: "card-news-set",
|
|
137
|
+
setId,
|
|
138
|
+
sessionId: input.sessionId || null,
|
|
139
|
+
requestId: input.requestId || null,
|
|
140
|
+
title: input.title || "Untitled card news",
|
|
141
|
+
imageTemplateId,
|
|
142
|
+
roleTemplateId: input.roleTemplateId || "mid-5",
|
|
143
|
+
generationStrategy: "parallel-template-i2i",
|
|
144
|
+
size,
|
|
145
|
+
cardCount: generatedCards.length,
|
|
146
|
+
createdAt: Date.now(),
|
|
147
|
+
cards: generatedCards,
|
|
148
|
+
};
|
|
149
|
+
await writeCardNewsManifest(ctx.config.storage.generatedDir, manifest);
|
|
150
|
+
return {
|
|
151
|
+
setId,
|
|
152
|
+
manifest,
|
|
153
|
+
cards: generatedCards.map((card) => ({
|
|
154
|
+
...card,
|
|
155
|
+
id: card.cardId,
|
|
156
|
+
order: card.cardOrder,
|
|
157
|
+
url: card.imageFilename
|
|
158
|
+
? `/generated/cardnews/${encodeURIComponent(setId)}/${encodeURIComponent(card.imageFilename)}`
|
|
159
|
+
: undefined,
|
|
160
|
+
})),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { ulid } from "ulid";
|
|
2
|
+
|
|
3
|
+
const jobs = new Map();
|
|
4
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
function summarize(job) {
|
|
7
|
+
const generated = job.cards.filter((card) => card.status === "generated").length;
|
|
8
|
+
const errors = job.cards.filter((card) => card.status === "error").length;
|
|
9
|
+
return {
|
|
10
|
+
jobId: job.jobId,
|
|
11
|
+
setId: job.setId,
|
|
12
|
+
status: job.status,
|
|
13
|
+
total: job.cards.length,
|
|
14
|
+
generated,
|
|
15
|
+
errors,
|
|
16
|
+
cards: job.cards,
|
|
17
|
+
updatedAt: job.updatedAt,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function statusFromCards(cards) {
|
|
22
|
+
const active = cards.some((card) => card.status === "queued" || card.status === "generating");
|
|
23
|
+
if (active) return "running";
|
|
24
|
+
const errors = cards.some((card) => card.status === "error");
|
|
25
|
+
const generated = cards.some((card) => card.status === "generated");
|
|
26
|
+
if (errors && generated) return "partial";
|
|
27
|
+
if (errors) return "error";
|
|
28
|
+
return "done";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createCardNewsJob(plan) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const job = {
|
|
34
|
+
jobId: `cj_${ulid()}`,
|
|
35
|
+
setId: plan.setId,
|
|
36
|
+
status: "queued",
|
|
37
|
+
plan,
|
|
38
|
+
cards: (plan.cards || []).map((card) => ({
|
|
39
|
+
id: card.id,
|
|
40
|
+
order: card.order,
|
|
41
|
+
status: card.locked ? "skipped" : "queued",
|
|
42
|
+
textFields: Array.isArray(card.textFields) ? card.textFields : [],
|
|
43
|
+
})),
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
};
|
|
47
|
+
jobs.set(job.jobId, job);
|
|
48
|
+
return summarize(job);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getCardNewsJob(jobId) {
|
|
52
|
+
const job = jobs.get(jobId);
|
|
53
|
+
if (!job) return null;
|
|
54
|
+
return summarize(job);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function updateCardNewsJob(jobId, patch) {
|
|
58
|
+
const job = jobs.get(jobId);
|
|
59
|
+
if (!job) return null;
|
|
60
|
+
Object.assign(job, patch, { updatedAt: Date.now() });
|
|
61
|
+
return summarize(job);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function updateCardNewsJobCard(jobId, cardId, patch) {
|
|
65
|
+
const job = jobs.get(jobId);
|
|
66
|
+
if (!job) return null;
|
|
67
|
+
job.cards = job.cards.map((card) => (
|
|
68
|
+
card.id === cardId ? {
|
|
69
|
+
...card,
|
|
70
|
+
...patch,
|
|
71
|
+
textFields: Array.isArray(patch.textFields) ? patch.textFields : card.textFields,
|
|
72
|
+
} : card
|
|
73
|
+
));
|
|
74
|
+
job.status = statusFromCards(job.cards);
|
|
75
|
+
job.updatedAt = Date.now();
|
|
76
|
+
return summarize(job);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function finishCardNewsJob(jobId) {
|
|
80
|
+
const job = jobs.get(jobId);
|
|
81
|
+
if (!job) return null;
|
|
82
|
+
job.status = statusFromCards(job.cards);
|
|
83
|
+
job.updatedAt = Date.now();
|
|
84
|
+
return summarize(job);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getCardNewsJobPlan(jobId) {
|
|
88
|
+
return jobs.get(jobId)?.plan || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function retryCardNewsJob(jobId, cardIds) {
|
|
92
|
+
const job = jobs.get(jobId);
|
|
93
|
+
if (!job) return null;
|
|
94
|
+
const wanted = new Set(cardIds || []);
|
|
95
|
+
job.cards = job.cards.map((card) => (
|
|
96
|
+
wanted.has(card.id) && card.status === "error" ? { ...card, status: "queued", error: undefined } : card
|
|
97
|
+
));
|
|
98
|
+
job.status = "queued";
|
|
99
|
+
job.updatedAt = Date.now();
|
|
100
|
+
return summarize(job);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function reapCardNewsJobs(now = Date.now()) {
|
|
104
|
+
for (const [jobId, job] of jobs.entries()) {
|
|
105
|
+
if (now - job.updatedAt > TTL_MS) jobs.delete(jobId);
|
|
106
|
+
}
|
|
107
|
+
}
|