@unlimiting/unlimitgen 0.1.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/.github/workflows/publish.yml +48 -0
- package/README.md +129 -0
- package/dist/cli.js +123 -0
- package/dist/core/catalog.js +128 -0
- package/dist/core/generate.js +28 -0
- package/dist/core/types.js +1 -0
- package/dist/providers/gemini.js +124 -0
- package/dist/providers/openaiLike.js +98 -0
- package/dist/utils/auth.js +73 -0
- package/dist/utils/io.js +40 -0
- package/dist/utils/options.js +39 -0
- package/dist/utils/parts.js +33 -0
- package/eslint.config.js +21 -0
- package/package.json +43 -0
- package/skills/ugen/SKILL.md +163 -0
- package/src/cli.ts +146 -0
- package/src/core/catalog.ts +141 -0
- package/src/core/generate.ts +31 -0
- package/src/core/types.ts +31 -0
- package/src/providers/gemini.ts +142 -0
- package/src/providers/openaiLike.ts +122 -0
- package/src/utils/auth.ts +87 -0
- package/src/utils/io.ts +40 -0
- package/src/utils/options.ts +35 -0
- package/src/utils/parts.ts +42 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
const ENV_KEYS = {
|
|
6
|
+
gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
|
|
7
|
+
openai: ['OPENAI_API_KEY'],
|
|
8
|
+
grok: ['XAI_API_KEY']
|
|
9
|
+
};
|
|
10
|
+
const TOKEN_DIR = path.join(os.homedir(), '.config', 'unlimitgen');
|
|
11
|
+
const TOKEN_FILE = path.join(TOKEN_DIR, 'tokens.json');
|
|
12
|
+
export async function resolveApiKey(provider) {
|
|
13
|
+
const fromEnv = resolveFromEnv(provider);
|
|
14
|
+
if (fromEnv)
|
|
15
|
+
return fromEnv;
|
|
16
|
+
const stored = await getStoredToken(provider);
|
|
17
|
+
if (stored)
|
|
18
|
+
return stored;
|
|
19
|
+
return promptForToken(provider);
|
|
20
|
+
}
|
|
21
|
+
export async function authAndStoreToken(provider) {
|
|
22
|
+
const token = await promptForToken(provider);
|
|
23
|
+
await setStoredToken(provider, token);
|
|
24
|
+
}
|
|
25
|
+
export async function getStoredToken(provider) {
|
|
26
|
+
const store = await readTokenStore();
|
|
27
|
+
const token = store[provider];
|
|
28
|
+
return token && token.trim().length > 0 ? token : undefined;
|
|
29
|
+
}
|
|
30
|
+
export async function setStoredToken(provider, token) {
|
|
31
|
+
await fs.mkdir(TOKEN_DIR, { recursive: true });
|
|
32
|
+
const current = await readTokenStore();
|
|
33
|
+
current[provider] = token.trim();
|
|
34
|
+
await fs.writeFile(TOKEN_FILE, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
|
|
35
|
+
await fs.chmod(TOKEN_FILE, 0o600);
|
|
36
|
+
}
|
|
37
|
+
export function getTokenFilePath() {
|
|
38
|
+
return TOKEN_FILE;
|
|
39
|
+
}
|
|
40
|
+
function resolveFromEnv(provider) {
|
|
41
|
+
const keys = ENV_KEYS[provider];
|
|
42
|
+
for (const key of keys) {
|
|
43
|
+
const value = process.env[key];
|
|
44
|
+
if (value)
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
async function readTokenStore() {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await fs.readFile(TOKEN_FILE, 'utf8');
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function promptForToken(provider) {
|
|
63
|
+
const answer = await inquirer.prompt([
|
|
64
|
+
{
|
|
65
|
+
type: 'password',
|
|
66
|
+
name: 'token',
|
|
67
|
+
message: `${provider.toUpperCase()} API 토큰을 입력하세요 (입력 내용 숨김):`,
|
|
68
|
+
mask: '*',
|
|
69
|
+
validate: (input) => (input.trim().length > 0 ? true : '토큰이 비어 있습니다.')
|
|
70
|
+
}
|
|
71
|
+
]);
|
|
72
|
+
return answer.token.trim();
|
|
73
|
+
}
|
package/dist/utils/io.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export async function ensureDir(dirPath) {
|
|
4
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
5
|
+
}
|
|
6
|
+
export async function fileExists(filePath) {
|
|
7
|
+
try {
|
|
8
|
+
await fs.access(filePath);
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export async function writeBase64File(base64, outDir, prefix, ext) {
|
|
16
|
+
await ensureDir(outDir);
|
|
17
|
+
const fileName = `${prefix}-${Date.now()}.${ext}`;
|
|
18
|
+
const fullPath = path.resolve(outDir, fileName);
|
|
19
|
+
await fs.writeFile(fullPath, Buffer.from(base64, 'base64'));
|
|
20
|
+
return fullPath;
|
|
21
|
+
}
|
|
22
|
+
export async function writeBufferFile(buf, outDir, prefix, ext) {
|
|
23
|
+
await ensureDir(outDir);
|
|
24
|
+
const fileName = `${prefix}-${Date.now()}.${ext}`;
|
|
25
|
+
const fullPath = path.resolve(outDir, fileName);
|
|
26
|
+
await fs.writeFile(fullPath, buf);
|
|
27
|
+
return fullPath;
|
|
28
|
+
}
|
|
29
|
+
export async function detectMimeTypeFromPath(filePath) {
|
|
30
|
+
const lower = filePath.toLowerCase();
|
|
31
|
+
if (lower.endsWith('.png'))
|
|
32
|
+
return 'image/png';
|
|
33
|
+
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg'))
|
|
34
|
+
return 'image/jpeg';
|
|
35
|
+
if (lower.endsWith('.webp'))
|
|
36
|
+
return 'image/webp';
|
|
37
|
+
if (lower.endsWith('.mp4'))
|
|
38
|
+
return 'video/mp4';
|
|
39
|
+
throw new Error(`지원하지 않는 파일 확장자입니다: ${filePath}`);
|
|
40
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export function parseOptionPairs(optionPairs = []) {
|
|
2
|
+
const out = {};
|
|
3
|
+
for (const pair of optionPairs) {
|
|
4
|
+
const idx = pair.indexOf('=');
|
|
5
|
+
if (idx <= 0) {
|
|
6
|
+
throw new Error(`잘못된 --option 형식입니다: ${pair}. 예: quality=high`);
|
|
7
|
+
}
|
|
8
|
+
const key = pair.slice(0, idx).trim();
|
|
9
|
+
const raw = pair.slice(idx + 1).trim();
|
|
10
|
+
out[key] = inferValue(raw);
|
|
11
|
+
}
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
function inferValue(value) {
|
|
15
|
+
if (value === 'true')
|
|
16
|
+
return true;
|
|
17
|
+
if (value === 'false')
|
|
18
|
+
return false;
|
|
19
|
+
if (value === 'null')
|
|
20
|
+
return null;
|
|
21
|
+
if (/^-?\d+(\.\d+)?$/.test(value))
|
|
22
|
+
return Number(value);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function mergeOptions(base, jsonString) {
|
|
26
|
+
if (!jsonString)
|
|
27
|
+
return base;
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = JSON.parse(jsonString);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw new Error('--options-json 파싱 실패: 유효한 JSON 문자열을 입력해 주세요.');
|
|
34
|
+
}
|
|
35
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
36
|
+
throw new Error('--options-json은 JSON object 여야 합니다.');
|
|
37
|
+
}
|
|
38
|
+
return { ...base, ...parsed };
|
|
39
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function parseParts(rawParts) {
|
|
2
|
+
const parsed = [];
|
|
3
|
+
for (const raw of rawParts) {
|
|
4
|
+
const idx = raw.indexOf(':');
|
|
5
|
+
if (idx <= 0) {
|
|
6
|
+
throw new Error(`잘못된 --part 형식입니다: ${raw}. 예: text:고양이, image:/tmp/cat.png`);
|
|
7
|
+
}
|
|
8
|
+
const type = raw.slice(0, idx).trim();
|
|
9
|
+
const value = raw.slice(idx + 1).trim();
|
|
10
|
+
if (!value) {
|
|
11
|
+
throw new Error(`빈 값은 허용되지 않습니다: ${raw}`);
|
|
12
|
+
}
|
|
13
|
+
if (type === 'text') {
|
|
14
|
+
parsed.push({ type: 'text', value });
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (type === 'image') {
|
|
18
|
+
parsed.push({ type: 'image', value });
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
throw new Error(`지원하지 않는 part 타입입니다: ${type}. text/image만 지원합니다.`);
|
|
22
|
+
}
|
|
23
|
+
if (parsed.length === 0) {
|
|
24
|
+
throw new Error('최소 1개의 --part가 필요합니다.');
|
|
25
|
+
}
|
|
26
|
+
return parsed;
|
|
27
|
+
}
|
|
28
|
+
export function joinTextParts(parts) {
|
|
29
|
+
return parts.filter((p) => p.type === 'text').map((p) => p.value).join('\n');
|
|
30
|
+
}
|
|
31
|
+
export function imageParts(parts) {
|
|
32
|
+
return parts.filter((p) => p.type === 'image').map((p) => p.value);
|
|
33
|
+
}
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import tseslint from '@typescript-eslint/eslint-plugin';
|
|
2
|
+
import parser from '@typescript-eslint/parser';
|
|
3
|
+
|
|
4
|
+
export default [
|
|
5
|
+
{
|
|
6
|
+
files: ['src/**/*.ts'],
|
|
7
|
+
languageOptions: {
|
|
8
|
+
parser,
|
|
9
|
+
parserOptions: {
|
|
10
|
+
ecmaVersion: 'latest',
|
|
11
|
+
sourceType: 'module'
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
plugins: {
|
|
15
|
+
'@typescript-eslint': tseslint
|
|
16
|
+
},
|
|
17
|
+
rules: {
|
|
18
|
+
'@typescript-eslint/no-explicit-any': 'off'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unlimiting/unlimitgen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Unified CLI for image/video generation via Gemini/OpenAI/Grok",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
8
|
+
"build": "tsc -p tsconfig.json",
|
|
9
|
+
"start": "node dist/cli.js",
|
|
10
|
+
"dev": "tsx src/cli.ts",
|
|
11
|
+
"lint": "eslint . --ext .ts"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/unlimiting-studio/unlimitgen.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "ISC",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/unlimiting-studio/unlimitgen/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/unlimiting-studio/unlimitgen#readme",
|
|
25
|
+
"bin": {
|
|
26
|
+
"ugen": "dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@google/genai": "^1.42.0",
|
|
30
|
+
"commander": "^14.0.3",
|
|
31
|
+
"inquirer": "^13.2.5",
|
|
32
|
+
"openai": "^6.22.0",
|
|
33
|
+
"zod": "^4.3.6"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^25.3.0",
|
|
37
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
38
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
39
|
+
"eslint": "^10.0.1",
|
|
40
|
+
"tsx": "^4.21.0",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ugen
|
|
3
|
+
description: "Unified CLI workflow for generating images and videos with Gemini, OpenAI, and Grok(xAI) via `ugen`. Use for tasks that require model discovery (`ugen models`), ordered multi-input composition (`--part text:...` and `--part image:...`), provider-specific option tuning (`--option`, `--options-json`), secure token handling (env or password prompt), and troubleshooting generation failures/timeouts."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`ugen` 작업을 수행할 때 아래 순서를 그대로 따른다.
|
|
7
|
+
|
|
8
|
+
## 목표
|
|
9
|
+
|
|
10
|
+
- 실행 가능한 생성 명령을 빠르게 만든다.
|
|
11
|
+
- 입력 순서를 보존한 멀티 파트 프롬프트를 구성한다.
|
|
12
|
+
- 실패 시 원인을 분리하고 즉시 재시도 가능한 수정안을 만든다.
|
|
13
|
+
|
|
14
|
+
## 표준 절차
|
|
15
|
+
|
|
16
|
+
1. 모델과 모달리티를 확인한다.
|
|
17
|
+
2. 토큰 제공 방식을 확정한다.
|
|
18
|
+
3. 최소 입력으로 1회 성공시킨다.
|
|
19
|
+
4. 옵션을 추가하며 품질을 튜닝한다.
|
|
20
|
+
5. 실패 케이스를 점검하고 재현 가능한 해결 명령을 남긴다.
|
|
21
|
+
|
|
22
|
+
## 1) 모델 확인
|
|
23
|
+
|
|
24
|
+
먼저 모델 범위를 고정한다.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
ugen models
|
|
28
|
+
ugen models --provider gemini --modality image
|
|
29
|
+
ugen models --provider openai --modality video
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
모델 ID는 출력된 값을 그대로 사용한다.
|
|
33
|
+
|
|
34
|
+
## 2) 인증 처리
|
|
35
|
+
|
|
36
|
+
환경변수가 있으면 자동 사용한다. 없으면 `password` 프롬프트로 입력한다.
|
|
37
|
+
|
|
38
|
+
- Gemini: `GEMINI_API_KEY` 또는 `GOOGLE_API_KEY`
|
|
39
|
+
- OpenAI: `OPENAI_API_KEY`
|
|
40
|
+
- Grok(xAI): `XAI_API_KEY`
|
|
41
|
+
|
|
42
|
+
예시:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export OPENAI_API_KEY="***"
|
|
46
|
+
ugen models --provider openai
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 3) 입력 구성 규칙
|
|
50
|
+
|
|
51
|
+
`--part`는 반복 가능하며 순서가 그대로 전달된다.
|
|
52
|
+
|
|
53
|
+
- `text:...`
|
|
54
|
+
- `image:/path/to/file.png`
|
|
55
|
+
|
|
56
|
+
규칙:
|
|
57
|
+
|
|
58
|
+
- 텍스트 지시는 짧은 단위로 분해한다.
|
|
59
|
+
- 이미지 경로는 실행 전에 존재 여부를 확인한다.
|
|
60
|
+
- 여러 입력이 필요한 경우 `text/image`를 의도한 순서대로 배치한다.
|
|
61
|
+
|
|
62
|
+
예시:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
--part text:"구도 유지" image:./ref1.png --part text:"색감만 반영" image:./ref2.jpg
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 4) 이미지 생성 기본 템플릿
|
|
69
|
+
|
|
70
|
+
텍스트 기반:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
ugen generate image \
|
|
74
|
+
--provider openai \
|
|
75
|
+
--model gpt-image-1.5 \
|
|
76
|
+
--part text:"눈 오는 밤 네온 거리의 고양이" \
|
|
77
|
+
--option size=1024x1024 quality=high
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
텍스트+이미지 기반:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
ugen generate image \
|
|
84
|
+
--provider gemini \
|
|
85
|
+
--model gemini-2.5-flash-image-preview \
|
|
86
|
+
--part text:"구도를 유지" image:./ref.png text:"색감을 따뜻하게"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## 5) 비디오 생성 기본 템플릿
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
ugen generate video \
|
|
93
|
+
--provider openai \
|
|
94
|
+
--model sora-2 \
|
|
95
|
+
--part text:"비 오는 도시를 달리는 고양이" image:./first-frame.png \
|
|
96
|
+
--option seconds=8 size=1280x720
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
긴 작업은 타임아웃/폴링을 조정한다.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
ugen generate video ... --timeout-ms 1800000 --poll-interval-ms 7000
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 6) 옵션 전달 규칙
|
|
106
|
+
|
|
107
|
+
단순 키-값은 `--option`, 복잡한 구조는 `--options-json`을 사용한다.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
ugen generate video \
|
|
111
|
+
--provider gemini \
|
|
112
|
+
--model veo-3.1-generate-preview \
|
|
113
|
+
--part text:"해변 일출 타임랩스" \
|
|
114
|
+
--option durationSeconds=8 aspectRatio=16:9 \
|
|
115
|
+
--options-json '{"numberOfVideos":1,"generateAudio":false}'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
해석 규칙:
|
|
119
|
+
|
|
120
|
+
- `true/false/null` 자동 타입 변환
|
|
121
|
+
- 숫자 문자열 자동 숫자 변환
|
|
122
|
+
- `--options-json` 값이 최종 병합값으로 적용
|
|
123
|
+
|
|
124
|
+
## 7) 트러블슈팅
|
|
125
|
+
|
|
126
|
+
### `지원하지 않는 provider`
|
|
127
|
+
|
|
128
|
+
- `gemini|openai|grok` 중 하나로 수정한다.
|
|
129
|
+
|
|
130
|
+
### `이미지 파일을 찾을 수 없습니다`
|
|
131
|
+
|
|
132
|
+
- 파일 경로 오타를 수정한다.
|
|
133
|
+
- 상대경로 대신 절대경로로 재시도한다.
|
|
134
|
+
|
|
135
|
+
### 인증 실패 (`401`, `permission denied`)
|
|
136
|
+
|
|
137
|
+
- provider와 토큰 종류를 다시 맞춘다.
|
|
138
|
+
- 오래된 환경변수를 제거하고 다시 입력한다.
|
|
139
|
+
|
|
140
|
+
### 비디오 생성 지연/타임아웃
|
|
141
|
+
|
|
142
|
+
- `--timeout-ms`를 늘린다.
|
|
143
|
+
- `seconds`, `size`, `resolution`을 낮춘다.
|
|
144
|
+
|
|
145
|
+
### 모델이 입력 타입 거부
|
|
146
|
+
|
|
147
|
+
- `ugen models --provider ... --modality ...`로 지원 입력을 재확인한다.
|
|
148
|
+
- text-only 모델에는 이미지 파트를 제거한다.
|
|
149
|
+
|
|
150
|
+
## 8) 결과 확인 체크리스트
|
|
151
|
+
|
|
152
|
+
- 명령이 `완료: provider/modality/model`을 출력하는지 확인한다.
|
|
153
|
+
- 출력 파일 경로가 `outputs/` 아래 생성됐는지 확인한다.
|
|
154
|
+
- 실패 시 오류 메시지와 함께 재실행 가능한 명령을 남긴다.
|
|
155
|
+
|
|
156
|
+
## 9) 빠른 치트시트
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
ugen models
|
|
160
|
+
ugen generate image --provider openai --model gpt-image-1.5 --part text:"..."
|
|
161
|
+
ugen generate video --provider openai --model sora-2 --part text:"..." image:./first.png
|
|
162
|
+
ugen generate image --provider gemini --model imagen-4.0-generate-001 --part text:"..." --options-json '{"numberOfImages":2}'
|
|
163
|
+
```
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { MODEL_CATALOG } from './core/catalog.js';
|
|
4
|
+
import { generateImage, generateVideo } from './core/generate.js';
|
|
5
|
+
import { Modality, Provider } from './core/types.js';
|
|
6
|
+
import { authAndStoreToken, getTokenFilePath, resolveApiKey } from './utils/auth.js';
|
|
7
|
+
import { mergeOptions, parseOptionPairs } from './utils/options.js';
|
|
8
|
+
import { parseParts } from './utils/parts.js';
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('ugen')
|
|
14
|
+
.description('Gemini/OpenAI/Grok 이미지/비디오 생성 CLI')
|
|
15
|
+
.version('0.1.0');
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.command('models')
|
|
19
|
+
.description('지원 모델과 옵션 확인')
|
|
20
|
+
.option('-p, --provider <provider>', 'provider 필터 (gemini|openai|grok)')
|
|
21
|
+
.option('-m, --modality <modality>', 'modality 필터 (image|video)')
|
|
22
|
+
.action((opts: { provider?: Provider; modality?: Modality }) => {
|
|
23
|
+
const items = MODEL_CATALOG.filter((item) => (!opts.provider || item.provider === opts.provider) && (!opts.modality || item.modality === opts.modality));
|
|
24
|
+
|
|
25
|
+
if (items.length === 0) {
|
|
26
|
+
console.log('조건에 맞는 모델이 없습니다.');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const item of items) {
|
|
31
|
+
console.log(`- [${item.provider}/${item.modality}] ${item.model}`);
|
|
32
|
+
console.log(` 입력지원: text=${item.supportsText}, image=${item.supportsImageInput}`);
|
|
33
|
+
console.log(` 옵션: ${item.options.join(', ')}`);
|
|
34
|
+
if (item.notes) {
|
|
35
|
+
console.log(` 비고: ${item.notes}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const generate = program.command('generate').description('콘텐츠 생성');
|
|
41
|
+
|
|
42
|
+
program
|
|
43
|
+
.command('auth')
|
|
44
|
+
.description('provider 토큰을 비밀번호 입력으로 저장')
|
|
45
|
+
.requiredOption('-p, --provider <provider>', 'gemini|openai|grok')
|
|
46
|
+
.action(async (opts: { provider: Provider }) => {
|
|
47
|
+
const provider = assertProvider(opts.provider);
|
|
48
|
+
await authAndStoreToken(provider);
|
|
49
|
+
console.log(`저장 완료: ${provider} 토큰`);
|
|
50
|
+
console.log(`저장 위치: ${getTokenFilePath()}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
generate
|
|
54
|
+
.command('image')
|
|
55
|
+
.description('이미지 생성')
|
|
56
|
+
.requiredOption('-p, --provider <provider>', 'gemini|openai|grok')
|
|
57
|
+
.requiredOption('-m, --model <model>', '모델 ID')
|
|
58
|
+
.requiredOption('--part <part...>', '순서 보장 입력. 예: text:고양이 image:./cat.png text:배경은 숲')
|
|
59
|
+
.option('-o, --out <dir>', '출력 폴더', './outputs')
|
|
60
|
+
.option('--option <pair...>', '모델 옵션 key=value. 예: quality=high size=1024x1024')
|
|
61
|
+
.option('--options-json <json>', '모델 옵션 JSON 문자열')
|
|
62
|
+
.action(async (opts: { provider: Provider; model: string; part: string[]; out: string; option?: string[]; optionsJson?: string }) => {
|
|
63
|
+
const provider = assertProvider(opts.provider);
|
|
64
|
+
const apiKey = await resolveApiKey(provider);
|
|
65
|
+
const parts = parseParts(opts.part);
|
|
66
|
+
const options = mergeOptions(parseOptionPairs(opts.option), opts.optionsJson);
|
|
67
|
+
|
|
68
|
+
const result = await generateImage(
|
|
69
|
+
{
|
|
70
|
+
provider,
|
|
71
|
+
model: normalizeModel(opts.model),
|
|
72
|
+
parts,
|
|
73
|
+
outDir: opts.out,
|
|
74
|
+
options
|
|
75
|
+
},
|
|
76
|
+
apiKey
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
printResult(result);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
generate
|
|
83
|
+
.command('video')
|
|
84
|
+
.description('동영상 생성')
|
|
85
|
+
.requiredOption('-p, --provider <provider>', 'gemini|openai|grok')
|
|
86
|
+
.requiredOption('-m, --model <model>', '모델 ID')
|
|
87
|
+
.requiredOption('--part <part...>', '순서 보장 입력. 예: text:광고 영상 image:./first-frame.png')
|
|
88
|
+
.option('-o, --out <dir>', '출력 폴더', './outputs')
|
|
89
|
+
.option('--option <pair...>', '모델 옵션 key=value. 예: seconds=8 size=1280x720')
|
|
90
|
+
.option('--options-json <json>', '모델 옵션 JSON 문자열')
|
|
91
|
+
.option('--poll-interval-ms <ms>', '상태 폴링 주기(ms)', '5000')
|
|
92
|
+
.option('--timeout-ms <ms>', '타임아웃(ms)', '900000')
|
|
93
|
+
.action(async (opts: { provider: Provider; model: string; part: string[]; out: string; option?: string[]; optionsJson?: string; pollIntervalMs: string; timeoutMs: string }) => {
|
|
94
|
+
const provider = assertProvider(opts.provider);
|
|
95
|
+
const apiKey = await resolveApiKey(provider);
|
|
96
|
+
const parts = parseParts(opts.part);
|
|
97
|
+
const options = mergeOptions(parseOptionPairs(opts.option), opts.optionsJson);
|
|
98
|
+
|
|
99
|
+
const result = await generateVideo(
|
|
100
|
+
{
|
|
101
|
+
provider,
|
|
102
|
+
model: normalizeModel(opts.model),
|
|
103
|
+
parts,
|
|
104
|
+
outDir: opts.out,
|
|
105
|
+
options,
|
|
106
|
+
pollIntervalMs: Number(opts.pollIntervalMs),
|
|
107
|
+
timeoutMs: Number(opts.timeoutMs)
|
|
108
|
+
},
|
|
109
|
+
apiKey
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
printResult(result);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
program.parseAsync(process.argv).catch((error: unknown) => {
|
|
116
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
117
|
+
console.error(`오류: ${message}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function assertProvider(input: string): Provider {
|
|
122
|
+
if (input === 'gemini' || input === 'openai' || input === 'grok') {
|
|
123
|
+
return input;
|
|
124
|
+
}
|
|
125
|
+
throw new Error(`지원하지 않는 provider: ${input}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function printResult(result: { provider: string; model: string; modality: string; outputs: string[]; meta?: Record<string, unknown> }): void {
|
|
129
|
+
console.log(`완료: ${result.provider}/${result.modality}/${result.model}`);
|
|
130
|
+
if (result.outputs.length === 0) {
|
|
131
|
+
console.log('출력 파일이 없습니다. (모델 응답에 바이너리 결과 미포함)');
|
|
132
|
+
} else {
|
|
133
|
+
for (const output of result.outputs) {
|
|
134
|
+
console.log(`- ${output}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (result.meta) {
|
|
138
|
+
console.log(`meta: ${JSON.stringify(result.meta)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function normalizeModel(model: string): string {
|
|
143
|
+
const lowered = model.toLowerCase();
|
|
144
|
+
const fromCatalog = MODEL_CATALOG.find((item) => item.model.toLowerCase() === lowered);
|
|
145
|
+
return fromCatalog?.model ?? model;
|
|
146
|
+
}
|