@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,141 @@
1
+ import { Modality, Provider } from './types.js';
2
+
3
+ export interface ModelCatalogItem {
4
+ provider: Provider;
5
+ modality: Modality;
6
+ model: string;
7
+ supportsText: boolean;
8
+ supportsImageInput: boolean;
9
+ options: string[];
10
+ notes?: string;
11
+ }
12
+
13
+ export const MODEL_CATALOG: ModelCatalogItem[] = [
14
+ {
15
+ provider: 'gemini',
16
+ modality: 'image',
17
+ model: 'gemini-3-pro-image-preview',
18
+ supportsText: true,
19
+ supportsImageInput: true,
20
+ options: ['responseModalities', 'temperature', 'topP', 'topK', 'seed']
21
+ },
22
+ {
23
+ provider: 'gemini',
24
+ modality: 'image',
25
+ model: 'gemini-2.5-flash-image-preview',
26
+ supportsText: true,
27
+ supportsImageInput: true,
28
+ options: ['responseModalities', 'temperature', 'topP', 'topK', 'seed']
29
+ },
30
+ {
31
+ provider: 'gemini',
32
+ modality: 'image',
33
+ model: 'imagen-4.0-generate-001',
34
+ supportsText: true,
35
+ supportsImageInput: false,
36
+ options: ['negativePrompt', 'numberOfImages', 'aspectRatio', 'guidanceScale', 'seed', 'outputMimeType', 'imageSize', 'enhancePrompt']
37
+ },
38
+ {
39
+ provider: 'gemini',
40
+ modality: 'image',
41
+ model: 'imagen-4.0-ultra-generate-001',
42
+ supportsText: true,
43
+ supportsImageInput: false,
44
+ options: ['negativePrompt', 'numberOfImages', 'aspectRatio', 'guidanceScale', 'seed', 'outputMimeType', 'imageSize', 'enhancePrompt']
45
+ },
46
+ {
47
+ provider: 'gemini',
48
+ modality: 'image',
49
+ model: 'imagen-4.0-fast-generate-001',
50
+ supportsText: true,
51
+ supportsImageInput: false,
52
+ options: ['negativePrompt', 'numberOfImages', 'aspectRatio', 'guidanceScale', 'seed', 'outputMimeType', 'imageSize', 'enhancePrompt']
53
+ },
54
+ {
55
+ provider: 'openai',
56
+ modality: 'image',
57
+ model: 'gpt-image-1.5',
58
+ supportsText: true,
59
+ supportsImageInput: true,
60
+ options: ['background', 'moderation', 'n', 'output_format', 'output_compression', 'quality', 'size', 'stream']
61
+ },
62
+ {
63
+ provider: 'openai',
64
+ modality: 'image',
65
+ model: 'gpt-image-1',
66
+ supportsText: true,
67
+ supportsImageInput: true,
68
+ options: ['background', 'moderation', 'n', 'output_format', 'output_compression', 'quality', 'size', 'stream', 'input_fidelity']
69
+ },
70
+ {
71
+ provider: 'openai',
72
+ modality: 'image',
73
+ model: 'gpt-image-1-mini',
74
+ supportsText: true,
75
+ supportsImageInput: true,
76
+ options: ['background', 'moderation', 'n', 'output_format', 'output_compression', 'quality', 'size', 'stream']
77
+ },
78
+ {
79
+ provider: 'grok',
80
+ modality: 'image',
81
+ model: 'grok-imagine-image',
82
+ supportsText: true,
83
+ supportsImageInput: true,
84
+ options: ['n', 'output_format', 'quality', 'size'],
85
+ notes: 'xAI는 OpenAI 호환 SDK 경로를 사용'
86
+ },
87
+ {
88
+ provider: 'grok',
89
+ modality: 'image',
90
+ model: 'grok-imagine-image-pro',
91
+ supportsText: true,
92
+ supportsImageInput: true,
93
+ options: ['n', 'output_format', 'quality', 'size'],
94
+ notes: 'xAI는 OpenAI 호환 SDK 경로를 사용'
95
+ },
96
+ {
97
+ provider: 'gemini',
98
+ modality: 'video',
99
+ model: 'veo-3.1-generate-preview',
100
+ supportsText: true,
101
+ supportsImageInput: true,
102
+ options: ['numberOfVideos', 'fps', 'durationSeconds', 'seed', 'aspectRatio', 'resolution', 'negativePrompt', 'enhancePrompt', 'generateAudio']
103
+ },
104
+ {
105
+ provider: 'gemini',
106
+ modality: 'video',
107
+ model: 'veo-3.1-fast-generate-preview',
108
+ supportsText: true,
109
+ supportsImageInput: true,
110
+ options: ['numberOfVideos', 'fps', 'durationSeconds', 'seed', 'aspectRatio', 'resolution', 'negativePrompt', 'enhancePrompt', 'generateAudio']
111
+ },
112
+ {
113
+ provider: 'openai',
114
+ modality: 'video',
115
+ model: 'sora-2',
116
+ supportsText: true,
117
+ supportsImageInput: true,
118
+ options: ['seconds', 'size']
119
+ },
120
+ {
121
+ provider: 'openai',
122
+ modality: 'video',
123
+ model: 'sora-2-pro',
124
+ supportsText: true,
125
+ supportsImageInput: true,
126
+ options: ['seconds', 'size']
127
+ },
128
+ {
129
+ provider: 'grok',
130
+ modality: 'video',
131
+ model: 'grok-imagine-video',
132
+ supportsText: true,
133
+ supportsImageInput: true,
134
+ options: ['seconds', 'size'],
135
+ notes: 'xAI는 OpenAI 호환 SDK 경로를 사용'
136
+ }
137
+ ];
138
+
139
+ export function getModelItem(provider: Provider, modality: Modality, model: string): ModelCatalogItem | undefined {
140
+ return MODEL_CATALOG.find((item) => item.provider === provider && item.modality === modality && item.model === model);
141
+ }
@@ -0,0 +1,31 @@
1
+ import { generateImageWithGemini, generateVideoWithGemini } from '../providers/gemini.js';
2
+ import { generateImageWithOpenAILike, generateVideoWithOpenAILike } from '../providers/openaiLike.js';
3
+ import { GenerateImageRequest, GenerateResult, GenerateVideoRequest } from './types.js';
4
+
5
+ export async function generateImage(req: GenerateImageRequest, apiKey: string): Promise<GenerateResult> {
6
+ if (req.provider === 'gemini') {
7
+ return generateImageWithGemini(req, apiKey);
8
+ }
9
+ if (req.provider === 'openai') {
10
+ return generateImageWithOpenAILike(req, { provider: 'openai', apiKey });
11
+ }
12
+ return generateImageWithOpenAILike(req, {
13
+ provider: 'grok',
14
+ apiKey,
15
+ baseURL: 'https://api.x.ai/v1'
16
+ });
17
+ }
18
+
19
+ export async function generateVideo(req: GenerateVideoRequest, apiKey: string): Promise<GenerateResult> {
20
+ if (req.provider === 'gemini') {
21
+ return generateVideoWithGemini(req, apiKey);
22
+ }
23
+ if (req.provider === 'openai') {
24
+ return generateVideoWithOpenAILike(req, { provider: 'openai', apiKey });
25
+ }
26
+ return generateVideoWithOpenAILike(req, {
27
+ provider: 'grok',
28
+ apiKey,
29
+ baseURL: 'https://api.x.ai/v1'
30
+ });
31
+ }
@@ -0,0 +1,31 @@
1
+ export type Provider = 'gemini' | 'openai' | 'grok';
2
+ export type Modality = 'image' | 'video';
3
+ export type InputPart =
4
+ | { type: 'text'; value: string }
5
+ | { type: 'image'; value: string };
6
+
7
+ export interface GenerateImageRequest {
8
+ provider: Provider;
9
+ model: string;
10
+ parts: InputPart[];
11
+ outDir: string;
12
+ options: Record<string, unknown>;
13
+ }
14
+
15
+ export interface GenerateVideoRequest {
16
+ provider: Provider;
17
+ model: string;
18
+ parts: InputPart[];
19
+ outDir: string;
20
+ options: Record<string, unknown>;
21
+ pollIntervalMs: number;
22
+ timeoutMs: number;
23
+ }
24
+
25
+ export interface GenerateResult {
26
+ provider: Provider;
27
+ model: string;
28
+ modality: Modality;
29
+ outputs: string[];
30
+ meta?: Record<string, unknown>;
31
+ }
@@ -0,0 +1,142 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { GoogleGenAI, createPartFromUri } from '@google/genai';
4
+ import { GenerateImageRequest, GenerateResult, GenerateVideoRequest } from '../core/types.js';
5
+ import { detectMimeTypeFromPath, fileExists, writeBase64File } from '../utils/io.js';
6
+ import { imageParts, joinTextParts } from '../utils/parts.js';
7
+
8
+ export async function generateImageWithGemini(req: GenerateImageRequest, apiKey: string): Promise<GenerateResult> {
9
+ const ai = new GoogleGenAI({ apiKey });
10
+ const text = joinTextParts(req.parts);
11
+ const images = imageParts(req.parts);
12
+
13
+ if (req.model.startsWith('imagen-')) {
14
+ if (!text) {
15
+ throw new Error('Imagen 계열은 text 파트가 필요합니다.');
16
+ }
17
+ if (images.length > 0) {
18
+ throw new Error(`${req.model}은 이 CLI에서 text-to-image만 지원합니다. (image 입력 불가)`);
19
+ }
20
+
21
+ const response = await ai.models.generateImages({
22
+ model: req.model,
23
+ prompt: text,
24
+ config: req.options as any
25
+ });
26
+
27
+ const outputs: string[] = [];
28
+ for (const image of response.generatedImages ?? []) {
29
+ const b64 = image.image?.imageBytes;
30
+ if (!b64) continue;
31
+ const mime = (req.options.outputMimeType as string | undefined) ?? 'image/png';
32
+ const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/webp' ? 'webp' : 'png';
33
+ outputs.push(await writeBase64File(b64, req.outDir, 'gemini-image', ext));
34
+ }
35
+
36
+ return { provider: 'gemini', model: req.model, modality: 'image', outputs };
37
+ }
38
+
39
+ const contentParts: any[] = [];
40
+ for (const part of req.parts) {
41
+ if (part.type === 'text') {
42
+ contentParts.push({ text: part.value });
43
+ continue;
44
+ }
45
+
46
+ if (!(await fileExists(part.value))) {
47
+ throw new Error(`이미지 파일을 찾을 수 없습니다: ${part.value}`);
48
+ }
49
+ const uploaded = await ai.files.upload({ file: part.value });
50
+ if (!uploaded.uri || !uploaded.mimeType) {
51
+ throw new Error(`Gemini 파일 업로드 결과에 uri/mimeType이 없습니다: ${part.value}`);
52
+ }
53
+ contentParts.push(createPartFromUri(uploaded.uri, uploaded.mimeType));
54
+ }
55
+
56
+ const response = await ai.models.generateContent({
57
+ model: req.model,
58
+ contents: [{ role: 'user', parts: contentParts }],
59
+ config: {
60
+ responseModalities: ['IMAGE', 'TEXT'],
61
+ ...(req.options as Record<string, unknown>)
62
+ }
63
+ });
64
+
65
+ const outputs: string[] = [];
66
+ const candidates = (response as any).candidates ?? [];
67
+ for (const candidate of candidates) {
68
+ const parts = candidate?.content?.parts ?? [];
69
+ for (const part of parts) {
70
+ const b64 = part?.inlineData?.data;
71
+ const mime = part?.inlineData?.mimeType as string | undefined;
72
+ if (!b64) continue;
73
+ const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/webp' ? 'webp' : 'png';
74
+ outputs.push(await writeBase64File(b64, req.outDir, 'gemini-image', ext));
75
+ }
76
+ }
77
+
78
+ return { provider: 'gemini', model: req.model, modality: 'image', outputs };
79
+ }
80
+
81
+ export async function generateVideoWithGemini(req: GenerateVideoRequest, apiKey: string): Promise<GenerateResult> {
82
+ const ai = new GoogleGenAI({ apiKey });
83
+ const text = joinTextParts(req.parts);
84
+ const images = imageParts(req.parts);
85
+
86
+ if (!text && images.length === 0) {
87
+ throw new Error('Gemini 비디오는 text 또는 image 입력이 필요합니다.');
88
+ }
89
+
90
+ if (images.length > 1) {
91
+ throw new Error('Gemini 비디오는 현재 이미지 입력 1개만 지원합니다.');
92
+ }
93
+
94
+ const source: Record<string, unknown> = {};
95
+ if (text) source.prompt = text;
96
+
97
+ if (images[0]) {
98
+ if (!(await fileExists(images[0]))) {
99
+ throw new Error(`이미지 파일을 찾을 수 없습니다: ${images[0]}`);
100
+ }
101
+ const bytes = await fs.readFile(images[0]);
102
+ source.image = {
103
+ imageBytes: bytes.toString('base64'),
104
+ mimeType: await detectMimeTypeFromPath(images[0])
105
+ };
106
+ }
107
+
108
+ let operation = await ai.models.generateVideos({
109
+ model: req.model,
110
+ source,
111
+ config: req.options as any
112
+ });
113
+
114
+ const started = Date.now();
115
+ while (!operation.done) {
116
+ if (Date.now() - started > req.timeoutMs) {
117
+ throw new Error('Gemini 비디오 생성 대기 시간이 초과되었습니다. --timeout-ms를 늘려주세요.');
118
+ }
119
+ await new Promise((resolve) => setTimeout(resolve, req.pollIntervalMs));
120
+ operation = await ai.operations.getVideosOperation({ operation });
121
+ }
122
+
123
+ const generated = operation.response?.generatedVideos ?? [];
124
+ if (generated.length === 0) {
125
+ throw new Error('Gemini 비디오 생성 결과가 비어 있습니다.');
126
+ }
127
+
128
+ const outputs: string[] = [];
129
+ for (let i = 0; i < generated.length; i += 1) {
130
+ const downloadPath = path.resolve(req.outDir, `gemini-video-${Date.now()}-${i + 1}.mp4`);
131
+ await ai.files.download({ file: generated[i], downloadPath });
132
+ outputs.push(downloadPath);
133
+ }
134
+
135
+ return {
136
+ provider: 'gemini',
137
+ model: req.model,
138
+ modality: 'video',
139
+ outputs,
140
+ meta: { operationName: operation.name }
141
+ };
142
+ }
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs';
2
+ import OpenAI from 'openai';
3
+ import { GenerateImageRequest, GenerateResult, GenerateVideoRequest, Provider } from '../core/types.js';
4
+ import { imageParts, joinTextParts } from '../utils/parts.js';
5
+ import { fileExists, writeBase64File, writeBufferFile } from '../utils/io.js';
6
+
7
+ interface OpenAILikeConfig {
8
+ provider: Provider;
9
+ apiKey: string;
10
+ baseURL?: string;
11
+ }
12
+
13
+ function makeClient(config: OpenAILikeConfig): OpenAI {
14
+ return new OpenAI({
15
+ apiKey: config.apiKey,
16
+ baseURL: config.baseURL
17
+ });
18
+ }
19
+
20
+ export async function generateImageWithOpenAILike(req: GenerateImageRequest, config: OpenAILikeConfig): Promise<GenerateResult> {
21
+ const client = makeClient(config);
22
+ const prompt = joinTextParts(req.parts);
23
+ const images = imageParts(req.parts);
24
+
25
+ if (!prompt) {
26
+ throw new Error('이미지 생성에는 최소 1개의 text 파트가 필요합니다.');
27
+ }
28
+
29
+ if (images.length === 0) {
30
+ const result = await client.images.generate({
31
+ model: req.model,
32
+ prompt,
33
+ ...(req.options as Record<string, never>)
34
+ });
35
+
36
+ const outputs: string[] = [];
37
+ for (const image of result.data ?? []) {
38
+ if (!image.b64_json) continue;
39
+ const ext = ((req.options.output_format as string | undefined) ?? 'png').replace('jpeg', 'jpg');
40
+ outputs.push(await writeBase64File(image.b64_json, req.outDir, `${config.provider}-image`, ext));
41
+ }
42
+
43
+ return { provider: config.provider, model: req.model, modality: 'image', outputs };
44
+ }
45
+
46
+ const uploadables: fs.ReadStream[] = [];
47
+ for (const imagePath of images) {
48
+ if (!(await fileExists(imagePath))) {
49
+ throw new Error(`이미지 파일을 찾을 수 없습니다: ${imagePath}`);
50
+ }
51
+ uploadables.push(fs.createReadStream(imagePath));
52
+ }
53
+
54
+ const edited = await client.images.edit({
55
+ model: req.model,
56
+ prompt,
57
+ image: uploadables as unknown as fs.ReadStream,
58
+ ...(req.options as Record<string, never>)
59
+ } as any);
60
+
61
+ const outputs: string[] = [];
62
+ for (const image of edited.data ?? []) {
63
+ if (!image.b64_json) continue;
64
+ const ext = ((req.options.output_format as string | undefined) ?? 'png').replace('jpeg', 'jpg');
65
+ outputs.push(await writeBase64File(image.b64_json, req.outDir, `${config.provider}-image`, ext));
66
+ }
67
+
68
+ return { provider: config.provider, model: req.model, modality: 'image', outputs };
69
+ }
70
+
71
+ export async function generateVideoWithOpenAILike(req: GenerateVideoRequest, config: OpenAILikeConfig): Promise<GenerateResult> {
72
+ const client = makeClient(config);
73
+ const prompt = joinTextParts(req.parts);
74
+ const images = imageParts(req.parts);
75
+
76
+ if (!prompt) {
77
+ throw new Error('동영상 생성에는 최소 1개의 text 파트가 필요합니다.');
78
+ }
79
+
80
+ if (images.length > 1) {
81
+ throw new Error('OpenAI/xAI 비디오는 현재 이미지 참조 1개만 지원합니다.');
82
+ }
83
+
84
+ const createReq: Record<string, unknown> = {
85
+ model: req.model,
86
+ prompt,
87
+ ...(req.options as Record<string, unknown>)
88
+ };
89
+
90
+ if (images[0]) {
91
+ if (!(await fileExists(images[0]))) {
92
+ throw new Error(`이미지 파일을 찾을 수 없습니다: ${images[0]}`);
93
+ }
94
+ createReq.input_reference = fs.createReadStream(images[0]);
95
+ }
96
+
97
+ let job = await client.videos.create(createReq as any);
98
+
99
+ const started = Date.now();
100
+ while (job.status !== 'completed') {
101
+ if (job.status === 'failed') {
102
+ throw new Error(`비디오 생성 실패: ${job.error?.message ?? '알 수 없는 오류'}`);
103
+ }
104
+ if (Date.now() - started > req.timeoutMs) {
105
+ throw new Error('비디오 생성 대기 시간이 초과되었습니다. --timeout-ms를 늘려주세요.');
106
+ }
107
+ await new Promise((resolve) => setTimeout(resolve, req.pollIntervalMs));
108
+ job = await client.videos.retrieve(job.id);
109
+ }
110
+
111
+ const response = await client.videos.downloadContent(job.id, { variant: 'video' });
112
+ const arrayBuffer = await response.arrayBuffer();
113
+ const outputPath = await writeBufferFile(Buffer.from(arrayBuffer), req.outDir, `${config.provider}-video`, 'mp4');
114
+
115
+ return {
116
+ provider: config.provider,
117
+ model: req.model,
118
+ modality: 'video',
119
+ outputs: [outputPath],
120
+ meta: { jobId: job.id }
121
+ };
122
+ }
@@ -0,0 +1,87 @@
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
+ import { Provider } from '../core/types.js';
6
+
7
+ const ENV_KEYS: Record<Provider, string[]> = {
8
+ gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'],
9
+ openai: ['OPENAI_API_KEY'],
10
+ grok: ['XAI_API_KEY']
11
+ };
12
+
13
+ const TOKEN_DIR = path.join(os.homedir(), '.config', 'unlimitgen');
14
+ const TOKEN_FILE = path.join(TOKEN_DIR, 'tokens.json');
15
+
16
+ type TokenStore = Partial<Record<Provider, string>>;
17
+
18
+ export async function resolveApiKey(provider: Provider): Promise<string> {
19
+ const fromEnv = resolveFromEnv(provider);
20
+ if (fromEnv) return fromEnv;
21
+
22
+ const stored = await getStoredToken(provider);
23
+ if (stored) return stored;
24
+
25
+ return promptForToken(provider);
26
+ }
27
+
28
+ export async function authAndStoreToken(provider: Provider): Promise<void> {
29
+ const token = await promptForToken(provider);
30
+ await setStoredToken(provider, token);
31
+ }
32
+
33
+ export async function getStoredToken(provider: Provider): Promise<string | undefined> {
34
+ const store = await readTokenStore();
35
+ const token = store[provider];
36
+ return token && token.trim().length > 0 ? token : undefined;
37
+ }
38
+
39
+ export async function setStoredToken(provider: Provider, token: string): Promise<void> {
40
+ await fs.mkdir(TOKEN_DIR, { recursive: true });
41
+
42
+ const current = await readTokenStore();
43
+ current[provider] = token.trim();
44
+
45
+ await fs.writeFile(TOKEN_FILE, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
46
+ await fs.chmod(TOKEN_FILE, 0o600);
47
+ }
48
+
49
+ export function getTokenFilePath(): string {
50
+ return TOKEN_FILE;
51
+ }
52
+
53
+ function resolveFromEnv(provider: Provider): string | undefined {
54
+ const keys = ENV_KEYS[provider];
55
+ for (const key of keys) {
56
+ const value = process.env[key];
57
+ if (value) return value;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ async function readTokenStore(): Promise<TokenStore> {
63
+ try {
64
+ const raw = await fs.readFile(TOKEN_FILE, 'utf8');
65
+ const parsed = JSON.parse(raw) as unknown;
66
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
67
+ return {};
68
+ }
69
+ return parsed as TokenStore;
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+
75
+ async function promptForToken(provider: Provider): Promise<string> {
76
+ const answer = await inquirer.prompt<{ token: string }>([
77
+ {
78
+ type: 'password',
79
+ name: 'token',
80
+ message: `${provider.toUpperCase()} API 토큰을 입력하세요 (입력 내용 숨김):`,
81
+ mask: '*',
82
+ validate: (input: string) => (input.trim().length > 0 ? true : '토큰이 비어 있습니다.')
83
+ }
84
+ ]);
85
+
86
+ return answer.token.trim();
87
+ }
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export async function ensureDir(dirPath: string): Promise<void> {
5
+ await fs.mkdir(dirPath, { recursive: true });
6
+ }
7
+
8
+ export async function fileExists(filePath: string): Promise<boolean> {
9
+ try {
10
+ await fs.access(filePath);
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+
17
+ export async function writeBase64File(base64: string, outDir: string, prefix: string, ext: string): Promise<string> {
18
+ await ensureDir(outDir);
19
+ const fileName = `${prefix}-${Date.now()}.${ext}`;
20
+ const fullPath = path.resolve(outDir, fileName);
21
+ await fs.writeFile(fullPath, Buffer.from(base64, 'base64'));
22
+ return fullPath;
23
+ }
24
+
25
+ export async function writeBufferFile(buf: Buffer, outDir: string, prefix: string, ext: string): Promise<string> {
26
+ await ensureDir(outDir);
27
+ const fileName = `${prefix}-${Date.now()}.${ext}`;
28
+ const fullPath = path.resolve(outDir, fileName);
29
+ await fs.writeFile(fullPath, buf);
30
+ return fullPath;
31
+ }
32
+
33
+ export async function detectMimeTypeFromPath(filePath: string): Promise<string> {
34
+ const lower = filePath.toLowerCase();
35
+ if (lower.endsWith('.png')) return 'image/png';
36
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
37
+ if (lower.endsWith('.webp')) return 'image/webp';
38
+ if (lower.endsWith('.mp4')) return 'video/mp4';
39
+ throw new Error(`지원하지 않는 파일 확장자입니다: ${filePath}`);
40
+ }
@@ -0,0 +1,35 @@
1
+ export function parseOptionPairs(optionPairs: string[] = []): Record<string, unknown> {
2
+ const out: Record<string, unknown> = {};
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
+
15
+ function inferValue(value: string): unknown {
16
+ if (value === 'true') return true;
17
+ if (value === 'false') return false;
18
+ if (value === 'null') return null;
19
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
20
+ return value;
21
+ }
22
+
23
+ export function mergeOptions(base: Record<string, unknown>, jsonString?: string): Record<string, unknown> {
24
+ if (!jsonString) return base;
25
+ let parsed: unknown;
26
+ try {
27
+ parsed = JSON.parse(jsonString);
28
+ } catch {
29
+ throw new Error('--options-json 파싱 실패: 유효한 JSON 문자열을 입력해 주세요.');
30
+ }
31
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
32
+ throw new Error('--options-json은 JSON object 여야 합니다.');
33
+ }
34
+ return { ...base, ...(parsed as Record<string, unknown>) };
35
+ }
@@ -0,0 +1,42 @@
1
+ import { InputPart } from '../core/types.js';
2
+
3
+ export function parseParts(rawParts: string[]): InputPart[] {
4
+ const parsed: InputPart[] = [];
5
+ for (const raw of rawParts) {
6
+ const idx = raw.indexOf(':');
7
+ if (idx <= 0) {
8
+ throw new Error(`잘못된 --part 형식입니다: ${raw}. 예: text:고양이, image:/tmp/cat.png`);
9
+ }
10
+ const type = raw.slice(0, idx).trim();
11
+ const value = raw.slice(idx + 1).trim();
12
+
13
+ if (!value) {
14
+ throw new Error(`빈 값은 허용되지 않습니다: ${raw}`);
15
+ }
16
+
17
+ if (type === 'text') {
18
+ parsed.push({ type: 'text', value });
19
+ continue;
20
+ }
21
+ if (type === 'image') {
22
+ parsed.push({ type: 'image', value });
23
+ continue;
24
+ }
25
+
26
+ throw new Error(`지원하지 않는 part 타입입니다: ${type}. text/image만 지원합니다.`);
27
+ }
28
+
29
+ if (parsed.length === 0) {
30
+ throw new Error('최소 1개의 --part가 필요합니다.');
31
+ }
32
+
33
+ return parsed;
34
+ }
35
+
36
+ export function joinTextParts(parts: InputPart[]): string {
37
+ return parts.filter((p) => p.type === 'text').map((p) => p.value).join('\n');
38
+ }
39
+
40
+ export function imageParts(parts: InputPart[]): string[] {
41
+ return parts.filter((p) => p.type === 'image').map((p) => p.value);
42
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "types": ["node"]
13
+ },
14
+ "include": ["src/**/*.ts"],
15
+ "exclude": ["dist", "node_modules"]
16
+ }