aigen-sdk
Version:
Create hyper-personalized AI images and infinite variations with secure pre-signed URLs. Easy to use via Node.js or CLI, globally cached for speed.
640 lines (567 loc) • 22.7 kB
JavaScript
const https = require('https')
const crypto = require('crypto')
const pako = require('pako')
const fs = require('fs')
const path = require('path')
const jwt = require('jsonwebtoken')
class AigenSDK {
constructor(secret) {
this.secret = secret
this.baseUrl = 'https://aigen.run'
}
async createSignature(path, timestamp, queryParams) {
try {
// Get the current user ID
const keyId = this.secret.split('/')[0]
if (!keyId) {
throw new Error('Invalid secret format')
}
//use the first secret from the secrets array
const secretValue = this.secret.split('/')[2]
if (!secretValue) {
throw new Error('Invalid secret format')
}
let version = null
if (queryParams && Object.keys(queryParams).length > 0) {
//order them alphabetically to create a version hash
const sortedQueryParams = Object.keys(queryParams)
.sort()
.reduce((obj, key) => {
obj[key] = encodeURI(queryParams[key])
return obj
}, {})
const queryParamsString = JSON.stringify(sortedQueryParams)
const encoder = new TextEncoder();
const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(queryParamsString));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
version = hashHex.slice(0, 8)
queryParams = sortedQueryParams
}
// Create HMAC signature
const encoder = new TextEncoder()
const data = encoder.encode(path + timestamp + (version ? '_' + version : ''))
const cryptoKey = await crypto.subtle.importKey(
'raw',
encoder.encode(secretValue),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const signature = await crypto.subtle.sign('HMAC', cryptoKey, data)
const base64Signature = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
// Format as keyId:signature
return `${keyId}:${base64Signature}:${timestamp}`
} catch (error) {
console.error('Signature creation failed:', error)
throw error
}
}
/**
* Generate an image from a prompt
* @param {string} prompt - The prompt to generate an image from
* @param {Object} options - Optional parameters
* @param {number} options.width - Image width (default: 768)
* @param {number} options.height - Image height (default: 768)
* @param {string} options.model - Model to use (default: stability-ai/sdxl)
* Available models:
* flux-dev-lora@black-forest-labs
* flux-1.1-pro-ultra@black-forest-labs
* flux-1.1-pro@black-forest-labs
* recraft-v3@recraft-ai
* flux-schnell@black-forest-labs
* sdxl-lightning-4step:6f7a773af6fc3e8de9d5a3c00be77c17308914bf67772726aff83496ba1e3bbe@bytedance
* imagen-3.0-generate-002@google
* imagen-3.0-capability-001@google
* gemini-2.0-flash-exp-image-generation@google
* imagen-3@google
* imagen-3-fast@google
* sana:c6b5d2b7459910fec94432e9e1203c3cdce92d6db20f714f1355747990b52fa6@nvidia
* stable-diffusion-3.5-medium@stability-ai
* stable-diffusion-3.5-large-turbo@stability-ai
* stable-diffusion-3.5-large@stability-ai
* sdxl:7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc@stability-ai
* gpt-image-1@openai
* @param {string} options.original - Original image to use as a base for the generation
* @param {string} options.profile - Profile image to use as a base for the generation in the formats:
* - facebook:username
* - instagram:username
* - linkedin:username
* (more social networks comming soon)
* @returns {Promise<string>} - URL to the generated image
*/
async generateImage(prompt, options = {}) {
if (!prompt || prompt.length === 0) {
throw new Error('Prompt with instructions is required')
}
const width = options.width || 768
const height = options.height || 768
const model = options.model || null
const original = options.original || null
const profile = options.profile || null
const duration = options.duration || 24 //in hours
const userId = this.secret.split('/')[1]
let encoded = null
let typePrefix = 'img'
let sizePrefix = `${width}x${height}`
if (model && model.includes('/')) {
let modelParts = model.split('/')
sizePrefix += `/${modelParts[1] + '@' + modelParts[0]}`
} else if(model && model.includes('@')) {
sizePrefix += `/${model}`
} else {
sizePrefix += `/flux-schnell@black-forest-labs`
}
if (profile) {
let profileParts = profile.split(':')
let socialNetwork = profileParts[0]
//only facebook, instagram and linkedin are supported for now
if (socialNetwork != 'facebook' && socialNetwork != 'instagram' && socialNetwork != 'linkedin') {
throw new Error('Invalid profile format, use one of the following: facebook:username, instagram:username, linkedin:username')
}
}
if (prompt.length > 2000) {
//TODO: compress instructions
encoded = encodeURIComponent(prompt)
} else {
encoded = encodeURIComponent(prompt)
}
let queryParams = null
if (original) {
queryParams = { original: original }
}
if (profile) {
queryParams = { ...queryParams, profile: profile }
}
//create signature
const path = `${typePrefix}/${userId}/${sizePrefix}/${encoded}.png`
const timestamp = new Date().getTime()
//add 24 hours to the timestamp
const timestamp24Hours = timestamp + 1000 * 60 * 60 * duration
const signature = await this.createSignature(path, timestamp24Hours, queryParams)
//add query params to the url
if (queryParams && Object.keys(queryParams).length > 0) {
const queryParamsString = Object.entries(queryParams)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&')
return `${this.baseUrl}/${path}?sig=${signature}&${queryParamsString}`
} else {
return `${this.baseUrl}/${path}?sig=${signature}`
}
}
/**
* Create an image from a template
* @param {Object} options - Options for image creation
* @param {string} options.template - Template ID in format 'templateId/version'
* @param {Object} options.data - Key-value pairs for template variables
* @param {number} options.duration - Signature duration in hours (default: 24)
* @returns {Promise<string>} - URL to the generated image
*/
async createImageFromTemplate(options = {}) {
if (!options.template) {
throw new Error('Template ID is required')
}
const templateParts = options.template.split('/')
let templateId, version
if (templateParts.length === 2) {
// Format: templateId/version
templateId = templateParts[0]
version = templateParts[1]
} else {
// Format: templateId (use default version)
templateId = options.template
version = '1' // Default version
}
const data = options.data || {}
const duration = options.duration || 24 // in hours
const userId = this.secret.split('/')[1]
// Build the path
const path = `canvas/${userId}/${version}/${templateId}.png`
// Create timestamp
const timestamp = new Date().getTime()
const expirationTime = timestamp + 1000 * 60 * 60 * duration
// Generate signature with the data as query params
const signature = await this.createSignature(path, expirationTime, data)
// Build query string from data
let queryString = ''
if (data && Object.keys(data).length > 0) {
queryString = Object.entries(data)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
}
// Construct final URL
return `${this.baseUrl}/${path}?${queryString}${queryString ? '&' : ''}sig=${signature}`
}
/**
* Generate an image from a prompt
* @param {string|Object} promptOrOptions - The prompt to generate an image from or an options object for template-based generation
* @param {Object} [options] - Optional parameters (when first parameter is a prompt string)
* @returns {Promise<string>} - URL to the generated image
*/
async createImage(promptOrOptions, options = {}) {
// Handle template-based image creation
if (typeof promptOrOptions === 'object' && promptOrOptions !== null) {
return this.createImageFromTemplate(promptOrOptions)
}
// Otherwise, treat as prompt-based image generation (original generateImage behavior)
return this.generateImage(promptOrOptions, options)
}
/**
* Delete an image or unpublish a page
* @param {string} publicUrl - The public URL of the image or page
* @param {boolean} unpublish - Whether to unpublish the page (default: false)
* @returns {Promise<string>} - URL to the delete or unpublish command
*/
async removeContent(publicUrl, unpublish = false) {
// Extract the encoded part from the URL
const url = new URL(publicUrl)
const pathParts = url.pathname.split('/')
const key = pathParts[pathParts.length - 1]
//the secret is divided into userId, keyId and secret
const userId = this.secret.split(':')[0]
const keyId = this.secret.split(':')[1]
const secret = this.secret.split(':')[2]
// Create JWT signature with user ID and key
const payload = {
userId: userId,
keyId: keyId,
expiresAt: Math.floor(Date.now() / 1000) + 60 * 5 // 5 minutes from now
}
const token = jwt.sign(payload, secret)
// Construct the delete URL with signature
return `${this.baseUrl}/${unpublish ? 'unpublish' : 'delete'}/${key}?sig=${token}`
}
}
// CLI functionality
if (require.main === module) {
const args = process.argv.slice(2)
const command = args[0]
//aigen publish "<prompt>" - publish a page to the web
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
console.log(`
AigenSDK - Generate AI images and pages via URL with worldwide CDN cache and hosting
Usage:
aigen generate "<prompt>" [options] - generate an AI image
aigen template "<templateId>" [--data=<key>=<value>...] - create an image from a template
aigen save-secret - save the secret in the current directory for future use
aigen delete "<public_url>" - delete an image from the server
Options for generate:
--width=<width> Image width (default: 768)
--height=<height> Image height (default: 768)
--model=<model> Model to use (default: flux-schnell@black-forest-labs)
--secret=<secret> Your secret key for signing URLs (required)
--original=<url> Original image to use as a base for the generation
--save-secret Save the secret to .aigen-secret file in the current directory
Options for template:
--data.<key>=<value> Set template variable (can be used multiple times)
--secret=<secret> Your secret key for signing URLs (required)
--duration=<hours> Signature duration in hours (default: 24)
--save-secret Save the secret to .aigen-secret file in the current directory
Examples:
aigen generate "a beautiful sunset over the ocean" --width=1024 --height=768 --secret=your_secret_key
aigen template "abc123/2" --data.name="John" --data.company="Acme Inc" --secret=your_secret_key
📖 Go to https://aigen.run/#/developers for more information
`)
process.exit(0)
}
// Handle save-secret command
if (command === 'save-secret') {
const secretArg = args.find(arg => arg.startsWith('--secret='))
let secret = secretArg ? secretArg.split('=')[1] : process.env.AIGEN_SECRET
if (!secret) {
console.error(
'Error: Secret key is required. Provide it with --secret=<secretKey> or set AIGEN_SECRET environment variable.'
)
process.exit(1)
}
// Check if secret has 3 parts
const secretParts = secret.split('/')
if (secretParts.length !== 3) {
console.error('Error: Invalid secret format.')
process.exit(1)
}
// Save secret to file
const secretFilePath = path.join(process.cwd(), '.aigen-secret')
try {
fs.writeFileSync(secretFilePath, secret, 'utf8')
console.log(`Secret saved to ${secretFilePath}`)
// Add to .gitignore if it exists and doesn't already contain the entry
const gitignorePath = path.join(process.cwd(), '.gitignore')
if (fs.existsSync(gitignorePath)) {
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8')
if (!gitignoreContent.split('\n').some(line => line.trim() === '.aigen-secret')) {
fs.appendFileSync(gitignorePath, '\n# AigenSDK secret\n.aigen-secret\n')
console.log('Added .aigen-secret to .gitignore')
}
} else {
fs.writeFileSync(gitignorePath, '# AigenSDK secret\n.aigen-secret\n')
console.log('Created .gitignore with .aigen-secret entry')
}
process.exit(0)
} catch (error) {
console.error('Error: Could not save secret to file:', error.message)
process.exit(1)
}
}
// Handle delete command
if (command === 'delete') {
const publicUrl = args[1]
if (!publicUrl) {
console.error('Error: Public URL is required.')
process.exit(1)
}
const aigen = new AigenSDK(secret)
aigen
.removeContent(publicUrl, false)
.then(deleteUrl => {
//call the delete url to actually delete the image
https.get(deleteUrl, res => {
if (res.statusCode === 200) {
console.log('Image deleted successfully.')
} else {
console.error('Error:', res.statusCode)
}
process.exit(0)
})
})
.catch(error => {
console.error('Error:', error.message)
process.exit(1)
})
}
// Handle unpublish command
if (command === 'unpublish') {
const publicUrl = args[1]
if (!publicUrl) {
console.error('Error: Public URL is required.')
process.exit(1)
}
const aigen = new AigenSDK(secret)
aigen
.removeContent(publicUrl, true)
.then(unpublishUrl => {
//call the unpublish url to actually delete the page
https.get(unpublishUrl, res => {
if (res.statusCode === 200) {
console.log('Page unpublished successfully.')
} else {
console.error('Error:', res.statusCode)
}
process.exit(0)
})
})
.catch(error => {
console.error('Error:', error.message)
process.exit(1)
})
}
// Handle template command
if (command === 'template') {
const templateId = args[1]
if (!templateId) {
console.error('Error: Template ID is required.')
process.exit(1)
}
// Parse template variables from --data.<key>=<value> arguments
const data = {}
args.slice(2).forEach(arg => {
if (arg.startsWith('--data.')) {
const parts = arg.substring(7).split('=')
if (parts.length === 2) {
const key = parts[0]
const value = parts[1]
data[key] = value
}
}
})
const options = {
template: templateId,
data: data
}
// Get duration if specified
const durationArg = args.find(arg => arg.startsWith('--duration='))
if (durationArg) {
options.duration = parseInt(durationArg.split('=')[1])
}
// Try to load secret from .aigen-secret file if it exists
const secretFilePath = path.join(process.cwd(), '.aigen-secret')
let secret
if (fs.existsSync(secretFilePath)) {
try {
secret = fs.readFileSync(secretFilePath, 'utf8').trim()
} catch (error) {
console.warn('Warning: Could not read secret from .aigen-secret file:', error.message)
}
}
// Check for secret in arguments or environment
const secretArg = args.find(arg => arg.startsWith('--secret='))
secret = secretArg ? secretArg.split('=')[1] : secret || process.env.AIGEN_SECRET
if (!secret) {
console.error(
'Error: Secret key is required. Provide it with --secret=<secretKey>, set AIGEN_SECRET environment variable, or use --save-secret to save it for future use.'
)
process.exit(1)
}
// Save secret if requested
const saveSecret = args.includes('--save-secret')
if (saveSecret) {
try {
fs.writeFileSync(secretFilePath, secret, 'utf8')
console.log(`Secret saved to ${secretFilePath}`)
// Add to .gitignore
const gitignorePath = path.join(process.cwd(), '.gitignore')
if (fs.existsSync(gitignorePath)) {
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8')
if (!gitignoreContent.split('\n').some(line => line.trim() === '.aigen-secret')) {
fs.appendFileSync(gitignorePath, '\n# AigenSDK secret\n.aigen-secret\n')
console.log('Added .aigen-secret to .gitignore')
}
} else {
fs.writeFileSync(gitignorePath, '# AigenSDK secret\n.aigen-secret\n')
console.log('Created .gitignore with .aigen-secret entry')
}
} catch (error) {
console.warn('Warning: Could not save secret to file:', error.message)
}
}
const aigen = new AigenSDK(secret)
aigen
.createImageFromTemplate(options)
.then(url => {
console.log(`Template image URL: ${url}`)
})
.catch(error => {
console.error('Error:', error.message)
process.exit(1)
})
return
}
// Handle generate and publish commands
let prompt
if (command === 'generate' || command === 'publish') {
// Find the index of the first argument that starts with '--'
const optionIndex = args.slice(1).findIndex(arg => arg.startsWith('--'));
// If found, take everything before it, otherwise take all arguments after command
prompt = optionIndex !== -1
? args.slice(1, optionIndex + 1).join(' ')
: args.slice(1).join(' ');
} else {
// If no command is specified, assume the first arg is the prompt for generate
const optionIndex = args.findIndex(arg => arg.startsWith('--'));
prompt = optionIndex !== -1
? args.slice(0, optionIndex).join(' ')
: args.join(' ');
command = 'generate';
}
if (!prompt) {
console.error('Error: Prompt with instructions is required.')
process.exit(1)
}
const options = {}
let secret
let saveSecret = false
// Parse options
args.slice(command === 'generate' || command === 'publish' ? 2 : 1).forEach(arg => {
if (arg.startsWith('--width=')) {
options.width = parseInt(arg.split('=')[1])
} else if (arg.startsWith('--height=')) {
options.height = parseInt(arg.split('=')[1])
} else if (arg.startsWith('--model=')) {
options.model = arg.split('=')[1]
} else if (arg.startsWith('--original=')) {
options.original = arg.split('=')[1]
} else if (arg.startsWith('--secret=')) {
secret = arg.split('=')[1]
} else if (arg === '--save-secret') {
saveSecret = true
} else if (arg.startsWith('--duration=')) {
options.duration = parseInt(arg.split('=')[1])
} else if (arg.startsWith('--profile=')) {
options.profile = arg.split('=')[1]
}
})
// Try to load secret from .aigen-secret file if it exists
const secretFilePath = path.join(process.cwd(), '.aigen-secret')
if (!secret && fs.existsSync(secretFilePath)) {
try {
secret = fs.readFileSync(secretFilePath, 'utf8').trim()
} catch (error) {
console.warn('Warning: Could not read secret from .aigen-secret file:', error.message)
}
}
// Check for secret in environment or arguments
secret = secret || process.env.AIGEN_SECRET
if (!secret) {
console.error(
'Error: Secret key is required. Provide it with --secret=<secretKey>, set AIGEN_SECRET environment variable, or use --save-secret to save it for future use.'
)
process.exit(1)
}
// Check if secret has 3 parts (should be in format userId:keyId:secretValue)
const secretParts = secret.split('/')
if (secretParts.length !== 3) {
console.error('Error: Invalid secret format.')
process.exit(1)
}
// Save secret to file if requested
if (saveSecret && secret) {
try {
fs.writeFileSync(secretFilePath, secret, 'utf8')
console.log(`Secret saved to ${secretFilePath}`)
// Add to .gitignore if it exists and doesn't already contain the entry
const gitignorePath = path.join(process.cwd(), '.gitignore')
if (fs.existsSync(gitignorePath)) {
const gitignoreContent = fs.readFileSync(gitignorePath, 'utf8')
if (!gitignoreContent.split('\n').some(line => line.trim() === '.aigen-secret')) {
fs.appendFileSync(gitignorePath, '\n# AigenSDK secret\n.aigen-secret\n')
console.log('Added .aigen-secret to .gitignore')
}
} else {
fs.writeFileSync(gitignorePath, '# AigenSDK secret\n.aigen-secret\n')
console.log('Created .gitignore with .aigen-secret entry')
}
} catch (error) {
console.warn('Warning: Could not save secret to file:', error.message)
}
}
const aigen = new AigenSDK(secret)
if (command === 'generate') {
aigen
.generateImage(prompt, options)
.then(url => {
console.log(`Pre-signed generative URL: ${url}`)
})
.catch(error => {
console.error('Error:', error.message)
process.exit(1)
})
} else if (command === 'publish') {
// Encode the prompt to generate a URL for web pages
const compressedPrompt = pako.deflate(prompt)
const base64 = Buffer.from(compressedPrompt)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
// Create JWT signature with user ID and key
const userId = secret.split(':')[0]
const keyId = secret.split(':')[1]
const secretKey = secret.split(':')[2]
const payload = {
userId: userId,
keyId: keyId,
expiresAt: Math.floor(Date.now() / 1000) + 60 * 5 // 5 minutes from now
}
const token = jwt.sign(payload, secretKey)
// Construct the web page URL with signature
const url = `${aigen.baseUrl}/w/${base64}?sig=${token}`
console.log(`Page published: ${url}`)
}
} else {
// Export for use as a module
module.exports = AigenSDK
}