@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }