diff --git a/.gitignore b/.gitignore index 363fe9593..0f2363be2 100644 --- a/.gitignore +++ b/.gitignore @@ -157,6 +157,7 @@ supabase/ # Output directories (generated artifacts) outputs/ +output/ # Test fixtures (generated during tests) tests/*/__fixtures__/ diff --git a/eslint.config.js b/eslint.config.js index 4099eeceb..d1b844b5a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -144,6 +144,18 @@ module.exports = [ }, }, + // content-ai package - ESM (Node 18+) + { + files: ['packages/content-ai/**/*.js'], + languageOptions: { + sourceType: 'module', + globals: { + FormData: 'readonly', + crypto: 'readonly', + }, + }, + }, + // TypeScript files configuration { files: ['**/*.ts', '**/*.tsx'], diff --git a/jest.config.js b/jest.config.js index 7340f27cf..3fa3d4f8c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -57,6 +57,8 @@ module.exports = { 'tests/integration/install-transaction.test.js', // License tests require network/crypto resources unavailable in CI (pre-existing) 'tests/license/', + // content-ai package uses ESM + node:test runner — run via npm test in the package + 'packages/content-ai/', ], // Coverage collection (Story TD-3: Updated paths) diff --git a/packages/content-ai/bin/generate.js b/packages/content-ai/bin/generate.js new file mode 100644 index 000000000..5c9f37e93 --- /dev/null +++ b/packages/content-ai/bin/generate.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { generateImage, ConfigurationError, StabilityApiError, RateLimitError, TimeoutError } from '../stability.js'; + +const args = process.argv.slice(2); +let prompt = ''; + +for (let i = 0; i < args.length; i++) { + if (args[i] === '--prompt' && args[i + 1]) { + prompt = args[i + 1]; + break; + } +} + +if (!prompt) { + process.stderr.write('Error: --prompt is required\nUsage: node packages/content-ai/bin/generate.js --prompt "your prompt here"\n'); + process.exit(1); +} + +try { + const result = await generateImage(prompt); + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); +} catch (err) { + if (err instanceof ConfigurationError) { + process.stderr.write(`Configuration error: ${err.message}\n`); + } else if (err instanceof RateLimitError) { + process.stderr.write(`Rate limit exceeded: ${err.message}\n`); + } else if (err instanceof StabilityApiError) { + process.stderr.write(`API error (${err.statusCode}): ${err.message}\n`); + } else if (err instanceof TimeoutError) { + process.stderr.write(`Timeout: ${err.message}\n`); + } else { + process.stderr.write(`Unexpected error: ${err.message}\n`); + } + process.exit(1); +} diff --git a/packages/content-ai/index.js b/packages/content-ai/index.js new file mode 100644 index 000000000..261ce94ef --- /dev/null +++ b/packages/content-ai/index.js @@ -0,0 +1,2 @@ +export { generateImage } from './stability.js'; +export { StabilityApiError, RateLimitError, TimeoutError, ConfigurationError } from './stability.js'; diff --git a/packages/content-ai/package.json b/packages/content-ai/package.json new file mode 100644 index 000000000..a30ac9212 --- /dev/null +++ b/packages/content-ai/package.json @@ -0,0 +1,24 @@ +{ + "name": "@aiox/content-ai", + "version": "0.1.0", + "description": "Content AI module — image generation via Stability AI API", + "type": "module", + "main": "index.js", + "bin": { + "generate-image": "bin/generate.js" + }, + "scripts": { + "test": "node --test --test-concurrency=1", + "lint": "eslint ." + }, + "engines": { + "node": ">=18" + }, + "author": "SynkraAI", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/SynkraAI/aiox-core.git", + "directory": "packages/content-ai" + } +} diff --git a/packages/content-ai/stability.js b/packages/content-ai/stability.js new file mode 100644 index 000000000..f0c1768a1 --- /dev/null +++ b/packages/content-ai/stability.js @@ -0,0 +1,217 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const STABILITY_API_URL = 'https://api.stability.ai/v2beta/stable-image/generate/sd3'; +const MODEL = 'sd3.5-large'; +const TIMEOUT_MS = 30_000; +const RETRY_AFTER_DEFAULT_MS = 10_000; + +export class StabilityApiError extends Error { + constructor(message, statusCode) { + super(message); + this.name = 'StabilityApiError'; + this.statusCode = statusCode; + } +} + +export class RateLimitError extends Error { + constructor(message) { + super(message); + this.name = 'RateLimitError'; + } +} + +export class TimeoutError extends Error { + constructor(message) { + super(message); + this.name = 'TimeoutError'; + } +} + +export class ConfigurationError extends Error { + constructor(message) { + super(message); + this.name = 'ConfigurationError'; + } +} + +function _validateConfig() { + if (!process.env.STABILITY_API_KEY) { + throw new ConfigurationError( + 'STABILITY_API_KEY environment variable is required but not set', + ); + } + return process.env.STABILITY_API_KEY; +} + +function _buildRequestBody(prompt) { + const body = new FormData(); + body.append('prompt', prompt); + body.append('model', MODEL); + body.append('aspect_ratio', '1:1'); + body.append('output_format', 'png'); + return body; +} + +async function _callApi(requestBody, apiKey) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const response = await fetch(STABILITY_API_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: 'image/*', + }, + body: requestBody, + signal: controller.signal, + }); + return response; + } catch (err) { + if (err.name === 'AbortError') { + throw new TimeoutError('Stability AI API did not respond within 30 seconds'); + } + throw err; + } finally { + clearTimeout(timeoutId); + } +} + +async function _handleApiError(response) { + let message = `Stability AI API error: HTTP ${response.status}`; + try { + const text = await response.text(); + if (text) { + message += ` — ${text.slice(0, 200)}`; + } + } catch { + // ignore read errors on error response body + } + throw new StabilityApiError(message, response.status); +} + +async function _callWithRetry(prompt, apiKey) { + const requestBody = _buildRequestBody(prompt); + const response = await _callApi(requestBody, apiKey); + + if (response.status === 429) { + const retryAfterHeader = response.headers.get('Retry-After'); + const waitMs = retryAfterHeader + ? parseInt(retryAfterHeader, 10) * 1000 + : RETRY_AFTER_DEFAULT_MS; + + _log({ event: 'stability.rate_limit.retry', waitMs, ts: new Date().toISOString() }); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + + const retryBody = _buildRequestBody(prompt); + const retryResponse = await _callApi(retryBody, apiKey); + if (!retryResponse.ok) { + if (retryResponse.status === 429) { + throw new RateLimitError('Stability AI rate limit exceeded after 1 retry'); + } + await _handleApiError(retryResponse); + } + return retryResponse; + } + + if (!response.ok) { + await _handleApiError(response); + } + + return response; +} + +async function _ensureOutputDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +function _generateFilename() { + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + const datePart = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const timePart = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + // crypto.randomUUID() is available globally in Node 18+ (Web Crypto API) + const shortId = crypto.randomUUID().replace(/-/g, '').slice(0, 8); + return `image-${datePart}-${timePart}-${shortId}.png`; +} + +function _parsePngDimensions(buffer) { + // PNG IHDR: bytes 16-19 = width, 20-23 = height (big-endian uint32) + if (buffer.length < 24) { + return { width: 0, height: 0 }; + } + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; +} + +function _log(data) { + console.log(JSON.stringify(data)); +} + +export async function generateImage(prompt, options = {}) { + const apiKey = _validateConfig(); + + if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { + throw new ConfigurationError('prompt must be a non-empty string'); + } + + const startedAt = Date.now(); + + _log({ + event: 'stability.generate.start', + prompt: prompt.slice(0, 200), + model: MODEL, + aspect_ratio: '1:1', + ts: new Date().toISOString(), + }); + + let response; + try { + response = await _callWithRetry(prompt, apiKey); + } catch (err) { + _log({ + event: 'stability.generate.error', + errorType: err.name, + message: err.message, + statusCode: err.statusCode, + ts: new Date().toISOString(), + }); + throw err; + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const { width, height } = _parsePngDimensions(buffer); + + const outputDir = options.outputDir + ? path.resolve(options.outputDir) + : path.resolve(process.cwd(), 'output', 'images'); + await _ensureOutputDir(outputDir); + + const filename = _generateFilename(); + const filePath = path.join(outputDir, filename); + await fs.writeFile(filePath, buffer); + + const durationMs = Date.now() - startedAt; + const generatedAt = new Date().toISOString(); + + _log({ + event: 'stability.generate.success', + path: filePath, + filename, + durationMs, + ts: generatedAt, + }); + + return { + path: filePath, + filename, + prompt, + model: MODEL, + width, + height, + generatedAt, + durationMs, + }; +} diff --git a/packages/content-ai/tests/stability.test.js b/packages/content-ai/tests/stability.test.js new file mode 100644 index 000000000..623c0bfa7 --- /dev/null +++ b/packages/content-ai/tests/stability.test.js @@ -0,0 +1,208 @@ +import { test, describe, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { generateImage, StabilityApiError, RateLimitError, TimeoutError, ConfigurationError } from '../stability.js'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +// Minimal PNG buffer with IHDR chunk for dimension parsing +function makePngBuffer(width = 1024, height = 1024) { + const buf = Buffer.alloc(100); + // PNG signature (8 bytes) + buf[0] = 0x89; buf[1] = 0x50; buf[2] = 0x4e; buf[3] = 0x47; + buf[4] = 0x0d; buf[5] = 0x0a; buf[6] = 0x1a; buf[7] = 0x0a; + // IHDR chunk: length=13, type='IHDR', width (BE), height (BE) + buf.writeUInt32BE(13, 8); + buf[12] = 0x49; buf[13] = 0x48; buf[14] = 0x44; buf[15] = 0x52; + buf.writeUInt32BE(width, 16); + buf.writeUInt32BE(height, 20); + return buf; +} + +function makeSuccessResponse(width = 1024, height = 1024) { + const png = makePngBuffer(width, height); + return { + ok: true, + status: 200, + headers: { get: () => null }, + arrayBuffer: async () => { + const ab = new ArrayBuffer(png.length); + new Uint8Array(ab).set(png); + return ab; + }, + }; +} + +function makeErrorResponse(status, body = '') { + return { + ok: false, + status, + headers: { get: () => null }, + text: async () => body, + }; +} + +function make429Response(retryAfterSeconds = null) { + return { + ok: false, + status: 429, + headers: { + get: (name) => + name.toLowerCase() === 'retry-after' + ? (retryAfterSeconds !== null ? String(retryAfterSeconds) : null) + : null, + }, + text: async () => 'Rate limit exceeded', + }; +} + +let savedFetch; +let savedApiKey; +let tmpDir; + +describe('generateImage', () => { + beforeEach(async () => { + savedFetch = globalThis.fetch; + savedApiKey = process.env.STABILITY_API_KEY; + process.env.STABILITY_API_KEY = 'sk-test-key-123'; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stability-test-')); + }); + + afterEach(async () => { + globalThis.fetch = savedFetch; + if (savedApiKey === undefined) { + delete process.env.STABILITY_API_KEY; + } else { + process.env.STABILITY_API_KEY = savedApiKey; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + test('success: retorna metadados corretos e salva arquivo em disco', async () => { + globalThis.fetch = async () => makeSuccessResponse(1024, 1024); + + const result = await generateImage('pôr do sol na praia', { outputDir: tmpDir }); + + assert.equal(result.prompt, 'pôr do sol na praia'); + assert.equal(result.model, 'sd3.5-large'); + assert.equal(result.width, 1024); + assert.equal(result.height, 1024); + assert.ok(result.path.startsWith(tmpDir)); + assert.ok(/^image-\d{4}-\d{2}-\d{2}-\d{6}-[a-f0-9]{8}\.png$/.test(result.filename)); + assert.ok(typeof result.generatedAt === 'string'); + assert.ok(result.durationMs >= 0); + + const fileExists = await fs.access(result.path).then(() => true).catch(() => false); + assert.ok(fileExists, 'arquivo PNG deve existir em disco'); + }); + + test('ConfigurationError quando STABILITY_API_KEY está ausente', async () => { + delete process.env.STABILITY_API_KEY; + + await assert.rejects( + () => generateImage('test', { outputDir: tmpDir }), + (err) => { + assert.ok( + err instanceof ConfigurationError, + `esperado ConfigurationError, recebido ${err.constructor.name}`, + ); + return true; + }, + ); + }); + + test('ConfigurationError quando prompt é vazio', async () => { + await assert.rejects( + () => generateImage('', { outputDir: tmpDir }), + (err) => { + assert.ok( + err instanceof ConfigurationError, + `esperado ConfigurationError, recebido ${err.constructor.name}`, + ); + return true; + }, + ); + }); + + test('429 → aguarda Retry-After → sucesso no segundo attempt', async () => { + let callCount = 0; + globalThis.fetch = async () => { + callCount++; + if (callCount === 1) return make429Response(0); + return makeSuccessResponse(); + }; + + const result = await generateImage('test prompt', { outputDir: tmpDir }); + assert.equal(callCount, 2, 'deve realizar exatamente 2 chamadas (inicial + retry)'); + assert.ok(result.path, 'deve retornar path do arquivo gerado'); + }); + + test('429 duas vezes → lança RateLimitError', async () => { + globalThis.fetch = async () => make429Response(0); + + await assert.rejects( + () => generateImage('test prompt', { outputDir: tmpDir }), + (err) => { + assert.ok( + err instanceof RateLimitError, + `esperado RateLimitError, recebido ${err.constructor.name}`, + ); + return true; + }, + ); + }); + + test('500 → lança StabilityApiError sem retry', async () => { + let callCount = 0; + globalThis.fetch = async () => { + callCount++; + return makeErrorResponse(500, 'Internal Server Error'); + }; + + await assert.rejects( + () => generateImage('test prompt', { outputDir: tmpDir }), + (err) => { + assert.ok( + err instanceof StabilityApiError, + `esperado StabilityApiError, recebido ${err.constructor.name}`, + ); + assert.equal(err.statusCode, 500); + return true; + }, + ); + assert.equal(callCount, 1, 'não deve fazer retry em erro 500'); + }); + + test('timeout → lança TimeoutError', async () => { + globalThis.fetch = () => + new Promise((_, reject) => { + setTimeout(() => { + const err = new Error('The operation was aborted'); + err.name = 'AbortError'; + reject(err); + }, 50); + }); + + await assert.rejects( + () => generateImage('test prompt', { outputDir: tmpDir }), + (err) => { + assert.ok( + err instanceof TimeoutError, + `esperado TimeoutError, recebido ${err.constructor.name}`, + ); + return true; + }, + ); + }); + + test('diretório de output criado automaticamente se não existir', async () => { + const deepDir = path.join(tmpDir, 'nested', 'subdir', 'images'); + globalThis.fetch = async () => makeSuccessResponse(); + + const result = await generateImage('test', { outputDir: deepDir }); + + const dirExists = await fs.access(deepDir).then(() => true).catch(() => false); + assert.ok(dirExists, 'diretório aninhado deve ser criado automaticamente'); + assert.ok(result.path.startsWith(deepDir)); + }); +});