+21
| MIT License | ||
| Copyright (c) 2024 sjhddh | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+658
-172
| const fs = require('fs').promises; | ||
| const path = require('path'); | ||
| const sharp = require('sharp'); | ||
| const crypto = require('crypto'); | ||
@@ -18,3 +19,18 @@ function envInt(name, fallback) { | ||
| const VLM_MAX_RETRIES = envInt('AGENTLUX_VLM_MAX_RETRIES', 2); | ||
| const MAX_BURST_SIZE = envInt('AGENTLUX_MAX_BURST_SIZE', 20); | ||
| const RECOVERY_HINTS = { | ||
| CONFIG_ERROR: 'Set at least one VLM API key: ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY. Or configure AGENTLUX_CUSTOM_BASE_URL + AGENTLUX_CUSTOM_API_KEY.', | ||
| INPUT_ERROR: 'Check input parameters. image_path must be an absolute path to an existing file. image_paths must be an array of absolute paths. Provide one or the other, not both.', | ||
| INPUT_TOO_LARGE: `Resize or compress the image to under ${MAX_IMAGE_BYTES} bytes before retrying.`, | ||
| IMAGE_METADATA_ERROR: 'The file may be corrupted or not a valid image. Try a different file.', | ||
| VLM_TIMEOUT: 'The VLM request timed out. Retry the same request — transient timeouts are normal.', | ||
| VLM_NETWORK_ERROR: 'Network error reaching VLM provider. Check connectivity and retry.', | ||
| VLM_HTTP_ERROR: 'The VLM API returned a client error (e.g. invalid key). Verify the API key and model name.', | ||
| VLM_HTTP_TRANSIENT: 'The VLM API is temporarily overloaded. Wait a few seconds and retry.', | ||
| VLM_PARSE_ERROR: 'The VLM returned malformed output. Retry the same request.', | ||
| VLM_SCHEMA_ERROR: 'The VLM returned unexpected output. Retry the same request.', | ||
| UNEXPECTED_ERROR: 'An unexpected internal error occurred. Retry once; if it persists, report the error message.' | ||
| }; | ||
| class AgentLuxError extends Error { | ||
@@ -37,139 +53,334 @@ constructor(code, message, details) { | ||
| function parseCropBox(raw) { | ||
| if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', 'VLM response is not a valid JSON object.'); | ||
| // ============================================================ | ||
| // Master Photographer Registry | ||
| // ============================================================ | ||
| const MASTER_REGISTRY = { | ||
| bresson: { | ||
| name: 'Henri Cartier-Bresson', | ||
| style: 'The Decisive Moment', | ||
| prompt: (w, h) => `You ARE Henri Cartier-Bresson, shooting with your Leica M3 and 50mm Summicron. You live for The Decisive Moment — that fraction of a second when geometry, emotion, and narrative converge into perfection. You compose through dynamic symmetry, the golden rectangle, and diagonal tensions. Every frame must feel inevitable. | ||
| Analyze this image (${w}x${h} pixels). Find the decisive geometry within it. Calculate the mathematically perfect crop that creates maximum tension between form and content. | ||
| Return ONLY a JSON object: | ||
| {"x": int, "y": int, "width": int, "height": int, "rule": "Your compositional explanation — speak as Bresson would, referencing the specific geometric relationships you've found"} | ||
| Constraints: x >= 0, y >= 0, x+width <= ${w}, y+height <= ${h}, width >= 1, height >= 1.` | ||
| }, | ||
| webb: { | ||
| name: 'Alex Webb', | ||
| style: 'Complex Color Layering', | ||
| prompt: (w, h) => `You ARE Alex Webb, Magnum photographer renowned for complex multi-layered color compositions. You see the world as overlapping planes of saturated color and shadow. Your frames are impossibly dense — foreground, midground, background all active, all essential. You find order in visual chaos through color relationships and geometric interlocking. | ||
| Analyze this image (${w}x${h} pixels). Find the crop that creates maximum visual density — layer upon layer of information, each plane adding meaning. Color tension between warm and cool zones is your signature. | ||
| Return ONLY a JSON object: | ||
| {"x": int, "y": int, "width": int, "height": int, "rule": "Your compositional explanation — speak as Webb would, describing the layers and color relationships"} | ||
| Constraints: x >= 0, y >= 0, x+width <= ${w}, y+height <= ${h}, width >= 1, height >= 1.` | ||
| }, | ||
| fan_ho: { | ||
| name: 'Fan Ho', | ||
| style: 'Light & Shadow Geometry', | ||
| prompt: (w, h) => `You ARE Fan Ho, the master of Hong Kong light and shadow. Your photographs are paintings in chiaroscuro — shafts of light cutting through darkness, lone figures as punctuation marks in vast geometric compositions. You compose in triangles and diagonals of light, finding the poetry where architecture meets humanity. | ||
| Analyze this image (${w}x${h} pixels). Find the crop that maximizes the dramatic interplay of light and shadow. Isolate the most powerful light geometry — let darkness frame the luminous moment. | ||
| Return ONLY a JSON object: | ||
| {"x": int, "y": int, "width": int, "height": int, "rule": "Your compositional explanation — speak as Fan Ho would, describing the light geometry and emotional resonance"} | ||
| Constraints: x >= 0, y >= 0, x+width <= ${w}, y+height <= ${h}, width >= 1, height >= 1.` | ||
| }, | ||
| koudelka: { | ||
| name: 'Josef Koudelka', | ||
| style: 'Panoramic High-Contrast Geometry', | ||
| prompt: (w, h) => `You ARE Josef Koudelka, the exiled Czech photographer of sweeping landscapes and fierce human drama. Your vision is panoramic and stark — walls, horizons, human figures as sculptural elements against vast spaces. You embrace extreme contrast and wide aspect ratios. Your compositions have the weight of mythology. | ||
| Analyze this image (${w}x${h} pixels). Find the crop that creates maximum spatial drama and geometric austerity. Favor wider aspect ratios if they serve the epic scale. Let the composition breathe with tension. | ||
| Return ONLY a JSON object: | ||
| {"x": int, "y": int, "width": int, "height": int, "rule": "Your compositional explanation — speak as Koudelka would, describing the spatial drama and geometric forces"} | ||
| Constraints: x >= 0, y >= 0, x+width <= ${w}, y+height <= ${h}, width >= 1, height >= 1.` | ||
| }, | ||
| salgado: { | ||
| name: 'Sebastiao Salgado', | ||
| style: 'Epic Human Documentary', | ||
| prompt: (w, h) => `You ARE Sebastiao Salgado, the Brazilian documentarian who elevates humanity to mythic status through a Leica. Your compositions are monumental — human figures gain dignity through dramatic light, low angles, and environmental context. You find the universal in the specific, the epic in the everyday. | ||
| Analyze this image (${w}x${h} pixels). Find the crop that elevates the human narrative to its most dignified and monumental form. Let the subject command the frame with the gravity they deserve. | ||
| Return ONLY a JSON object: | ||
| {"x": int, "y": int, "width": int, "height": int, "rule": "Your compositional explanation — speak as Salgado would, describing the human dignity and monumental quality"} | ||
| Constraints: x >= 0, y >= 0, x+width <= ${w}, y+height <= ${h}, width >= 1, height >= 1.` | ||
| }, | ||
| moriyama: { | ||
| name: 'Daido Moriyama', | ||
| style: 'Provoke-Era Raw Street', | ||
| prompt: (w, h) => `You ARE Daido Moriyama, the provocateur of Japanese street photography. You shoot raw — blur, grain, tilt, and high contrast are your vocabulary. Your compositions deliberately reject classical beauty: you fragment, you isolate, you find the visceral in the chaotic. The street does not pose for you, and you do not ask it to. | ||
| Analyze this image (${w}x${h} pixels). Find the most raw, visceral crop. Embrace imperfection — tilt, fragment, push into the subject. Classical composition rules are meaningless here; only emotional intensity matters. | ||
| Return ONLY a JSON object: | ||
| {"x": int, "y": int, "width": int, "height": int, "rule": "Your compositional explanation — speak as Moriyama would, raw and direct, about the visceral energy captured"} | ||
| Constraints: x >= 0, y >= 0, x+width <= ${w}, y+height <= ${h}, width >= 1, height >= 1.` | ||
| } | ||
| const requiredKeys = ['x', 'y', 'width', 'height']; | ||
| for (const key of requiredKeys) { | ||
| if (!(key in raw)) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', `VLM response missing "${key}" field.`); | ||
| }; | ||
| // ============================================================ | ||
| // Leica Color Profiles | ||
| // ============================================================ | ||
| const LEICA_PROFILES = { | ||
| m10: { | ||
| name: 'Leica M10 Digital', | ||
| recomb: [[1.1, -0.05, -0.05], [0.0, 0.9, 0.1], [0.0, 0.0, 1.05]], | ||
| saturation: 0.9, | ||
| brightness: 1.02, | ||
| contrastSlope: 1.15, | ||
| contrastOffset: -(0.05 * 255), | ||
| grayscale: false, | ||
| grain: null | ||
| }, | ||
| m9_ccd: { | ||
| name: 'Leica M9 CCD', | ||
| recomb: [[1.18, -0.10, -0.03], [0.03, 0.82, 0.15], [-0.03, 0.06, 1.15]], | ||
| saturation: 1.08, | ||
| brightness: 1.0, | ||
| contrastSlope: 1.22, | ||
| contrastOffset: -(0.08 * 255), | ||
| grayscale: false, | ||
| grain: null | ||
| }, | ||
| m_monochrom: { | ||
| name: 'Leica M Monochrom', | ||
| recomb: [[0.33, 0.50, 0.17], [0.33, 0.50, 0.17], [0.33, 0.50, 0.17]], | ||
| brightness: 1.0, | ||
| contrastSlope: 1.35, | ||
| contrastOffset: -(0.1 * 255), | ||
| grayscale: true, | ||
| grain: { intensity: 12, size: 1 } | ||
| }, | ||
| m6_trix400: { | ||
| name: 'Leica M6 + Kodak Tri-X 400', | ||
| recomb: [[0.30, 0.59, 0.11], [0.30, 0.59, 0.11], [0.30, 0.59, 0.11]], | ||
| brightness: 1.05, | ||
| contrastSlope: 1.45, | ||
| contrastOffset: -(0.15 * 255), | ||
| grayscale: true, | ||
| grain: { intensity: 32, size: 2 } | ||
| }, | ||
| m6_portra400: { | ||
| name: 'Leica M6 + Kodak Portra 400', | ||
| recomb: [[1.05, 0.03, -0.02], [0.0, 1.0, 0.05], [-0.02, -0.02, 1.08]], | ||
| saturation: 0.92, | ||
| brightness: 1.05, | ||
| contrastSlope: 1.08, | ||
| contrastOffset: 0.02 * 255, | ||
| grayscale: false, | ||
| grain: { intensity: 14, size: 1 } | ||
| } | ||
| }; | ||
| // ============================================================ | ||
| // Leica Lens Profiles | ||
| // ============================================================ | ||
| const LENS_PROFILES = { | ||
| summilux_35: { | ||
| name: 'Summilux-M 35mm f/1.4 ASPH', | ||
| vignetteRadius: 75, | ||
| vignetteStrength: 0.4, | ||
| vignetteFeather: 50, | ||
| sharpenSigma: 1.0, | ||
| sharpenFlat: 1.0, | ||
| sharpenJagged: 0.6 | ||
| }, | ||
| noctilux_50: { | ||
| name: 'Noctilux-M 50mm f/0.95 ASPH', | ||
| vignetteRadius: 60, | ||
| vignetteStrength: 0.55, | ||
| vignetteFeather: 35, | ||
| sharpenSigma: 0.7, | ||
| sharpenFlat: 0.5, | ||
| sharpenJagged: 0.3 | ||
| }, | ||
| summicron_35: { | ||
| name: 'Summicron-M 35mm f/2 ASPH', | ||
| vignetteRadius: 82, | ||
| vignetteStrength: 0.22, | ||
| vignetteFeather: 58, | ||
| sharpenSigma: 1.2, | ||
| sharpenFlat: 1.5, | ||
| sharpenJagged: 0.8 | ||
| }, | ||
| elmarit_28: { | ||
| name: 'Elmarit-M 28mm f/2.8 ASPH', | ||
| vignetteRadius: 68, | ||
| vignetteStrength: 0.35, | ||
| vignetteFeather: 42, | ||
| sharpenSigma: 0.9, | ||
| sharpenFlat: 1.2, | ||
| sharpenJagged: 0.5 | ||
| } | ||
| }; | ||
| // ============================================================ | ||
| // VLM Provider Abstraction | ||
| // ============================================================ | ||
| function resolveProvider(modelOverride) { | ||
| if (modelOverride) { | ||
| if (modelOverride.startsWith('claude-')) { | ||
| const key = process.env.ANTHROPIC_API_KEY; | ||
| if (!key) throw new AgentLuxError('CONFIG_ERROR', 'ANTHROPIC_API_KEY required for Claude models.'); | ||
| return { type: 'anthropic', model: modelOverride, apiKey: key }; | ||
| } | ||
| if (!Number.isFinite(raw[key])) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', `VLM "${key}" must be a finite number.`); | ||
| if (/^(gpt-|o[134]-|chatgpt-)/.test(modelOverride)) { | ||
| const key = process.env.OPENAI_API_KEY; | ||
| if (!key) throw new AgentLuxError('CONFIG_ERROR', 'OPENAI_API_KEY required for OpenAI models.'); | ||
| return { type: 'openai', model: modelOverride, apiKey: key }; | ||
| } | ||
| if (modelOverride.startsWith('gemini-')) { | ||
| const key = process.env.GOOGLE_API_KEY; | ||
| if (!key) throw new AgentLuxError('CONFIG_ERROR', 'GOOGLE_API_KEY required for Gemini models.'); | ||
| return { type: 'gemini', model: modelOverride, apiKey: key }; | ||
| } | ||
| const baseUrl = process.env.AGENTLUX_CUSTOM_BASE_URL; | ||
| const key = process.env.AGENTLUX_CUSTOM_API_KEY; | ||
| if (baseUrl && key) return { type: 'custom', model: modelOverride, apiKey: key, baseUrl }; | ||
| throw new AgentLuxError('CONFIG_ERROR', `Unknown model "${modelOverride}". Set AGENTLUX_CUSTOM_BASE_URL + AGENTLUX_CUSTOM_API_KEY for custom models.`); | ||
| } | ||
| if (process.env.AGENTLUX_CUSTOM_BASE_URL && process.env.AGENTLUX_CUSTOM_API_KEY) { | ||
| return { type: 'custom', model: 'default', apiKey: process.env.AGENTLUX_CUSTOM_API_KEY, baseUrl: process.env.AGENTLUX_CUSTOM_BASE_URL }; | ||
| } | ||
| if (process.env.ANTHROPIC_API_KEY) return { type: 'anthropic', model: 'claude-sonnet-4-20250514', apiKey: process.env.ANTHROPIC_API_KEY }; | ||
| if (process.env.OPENAI_API_KEY) return { type: 'openai', model: 'gpt-4o', apiKey: process.env.OPENAI_API_KEY }; | ||
| if (process.env.GOOGLE_API_KEY) return { type: 'gemini', model: 'gemini-1.5-pro', apiKey: process.env.GOOGLE_API_KEY }; | ||
| throw new AgentLuxError('CONFIG_ERROR', 'No VLM provider configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, or AGENTLUX_CUSTOM_BASE_URL + AGENTLUX_CUSTOM_API_KEY.'); | ||
| } | ||
| function buildOpenAIBody(model, prompt, imageBase64) { | ||
| return { | ||
| x: Math.floor(raw.x), | ||
| y: Math.floor(raw.y), | ||
| width: Math.floor(raw.width), | ||
| height: Math.floor(raw.height), | ||
| rule: typeof raw.rule === 'string' ? raw.rule : 'Composition optimized by AgentLux.' | ||
| model, | ||
| messages: [{ role: 'user', content: [ | ||
| { type: 'text', text: prompt }, | ||
| { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${imageBase64}` } } | ||
| ] }], | ||
| response_format: { type: 'json_object' } | ||
| }; | ||
| } | ||
| function sanitizeCropBox(cropBox, imageWidth, imageHeight) { | ||
| const x = Math.max(0, Math.min(cropBox.x, imageWidth - 1)); | ||
| const y = Math.max(0, Math.min(cropBox.y, imageHeight - 1)); | ||
| const width = Math.max(1, Math.min(cropBox.width, imageWidth - x)); | ||
| const height = Math.max(1, Math.min(cropBox.height, imageHeight - y)); | ||
| return { ...cropBox, x, y, width, height }; | ||
| function buildVLMRequest(provider, prompt, imageBase64) { | ||
| if (provider.type === 'anthropic') { | ||
| return { | ||
| url: 'https://api.anthropic.com/v1/messages', | ||
| headers: { 'Content-Type': 'application/json', 'x-api-key': provider.apiKey, 'anthropic-version': '2023-06-01' }, | ||
| body: { model: provider.model, max_tokens: 1024, messages: [{ role: 'user', content: [ | ||
| { type: 'text', text: prompt }, | ||
| { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: imageBase64 } } | ||
| ] }] }, | ||
| extractContent: (data) => data?.content?.find(b => b.type === 'text')?.text | ||
| }; | ||
| } | ||
| if (provider.type === 'gemini') { | ||
| return { | ||
| url: `https://generativelanguage.googleapis.com/v1beta/models/${provider.model}:generateContent?key=${provider.apiKey}`, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: { contents: [{ parts: [ | ||
| { text: prompt }, | ||
| { inlineData: { mimeType: 'image/jpeg', data: imageBase64 } } | ||
| ] }], generationConfig: { responseMimeType: 'application/json' } }, | ||
| extractContent: (data) => data?.candidates?.[0]?.content?.parts?.[0]?.text | ||
| }; | ||
| } | ||
| const url = provider.type === 'custom' | ||
| ? `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions` | ||
| : 'https://api.openai.com/v1/chat/completions'; | ||
| return { | ||
| url, | ||
| headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.apiKey}` }, | ||
| body: buildOpenAIBody(provider.model, prompt, imageBase64), | ||
| extractContent: (data) => data?.choices?.[0]?.message?.content | ||
| }; | ||
| } | ||
| function applyLeicaM10Color(sharpInstance, width, height) { | ||
| // 1. Leica M10 Color Science (Recomb Matrix) | ||
| // - Boost Reds, slightly desaturate Greens, warm up the Midtones | ||
| // [R, G, B] | ||
| const leicaMatrix = [ | ||
| [1.1, -0.05, -0.05], // R | ||
| [0.0, 0.9, 0.1], // G | ||
| [0.0, 0.0, 1.05] // B | ||
| ]; | ||
| // 2. Optical Vignetting (Simulating a 35mm Summilux f/1.4 wide open) | ||
| const vignetteSvg = `<svg width="${width}" height="${height}"> | ||
| <defs> | ||
| <radialGradient id="vignette" cx="50%" cy="50%" r="75%"> | ||
| <stop offset="50%" stop-color="black" stop-opacity="0" /> | ||
| <stop offset="100%" stop-color="black" stop-opacity="0.4" /> | ||
| </radialGradient> | ||
| </defs> | ||
| <rect x="0" y="0" width="${width}" height="${height}" fill="url(#vignette)" /> | ||
| </svg>`; | ||
| // 3. Contrast & Saturation (Micro-contrast punch, slightly muted saturation for filmic look) | ||
| return sharpInstance | ||
| .recomb(leicaMatrix) // Color shift | ||
| .modulate({ | ||
| saturation: 0.9, // Slightly desaturated | ||
| brightness: 1.02 // Slight bump to offset matrix darkening | ||
| }) | ||
| .linear(1.15, -(0.05 * 255)) // S-curve contrast boost (slope 1.15, intercept shift to crush blacks slightly) | ||
| .composite([{ | ||
| input: Buffer.from(vignetteSvg), | ||
| blend: 'multiply' | ||
| }]); | ||
| function buildMultiImageVLMRequest(provider, prompt, imagesBase64) { | ||
| if (provider.type === 'anthropic') { | ||
| const content = [{ type: 'text', text: prompt }]; | ||
| imagesBase64.forEach((img, i) => { | ||
| content.push({ type: 'text', text: `[Image ${i}]:` }); | ||
| content.push({ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: img } }); | ||
| }); | ||
| return { | ||
| url: 'https://api.anthropic.com/v1/messages', | ||
| headers: { 'Content-Type': 'application/json', 'x-api-key': provider.apiKey, 'anthropic-version': '2023-06-01' }, | ||
| body: { model: provider.model, max_tokens: 1024, messages: [{ role: 'user', content }] }, | ||
| extractContent: (data) => data?.content?.find(b => b.type === 'text')?.text | ||
| }; | ||
| } | ||
| if (provider.type === 'gemini') { | ||
| const parts = [{ text: prompt }]; | ||
| imagesBase64.forEach((img, i) => { | ||
| parts.push({ text: `[Image ${i}]:` }); | ||
| parts.push({ inlineData: { mimeType: 'image/jpeg', data: img } }); | ||
| }); | ||
| return { | ||
| url: `https://generativelanguage.googleapis.com/v1beta/models/${provider.model}:generateContent?key=${provider.apiKey}`, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: { contents: [{ parts }], generationConfig: { responseMimeType: 'application/json' } }, | ||
| extractContent: (data) => data?.candidates?.[0]?.content?.parts?.[0]?.text | ||
| }; | ||
| } | ||
| const url = provider.type === 'custom' | ||
| ? `${provider.baseUrl.replace(/\/+$/, '')}/chat/completions` | ||
| : 'https://api.openai.com/v1/chat/completions'; | ||
| const content = [{ type: 'text', text: prompt }]; | ||
| imagesBase64.forEach((img, i) => { | ||
| content.push({ type: 'text', text: `[Image ${i}]:` }); | ||
| content.push({ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${img}` } }); | ||
| }); | ||
| return { | ||
| url, | ||
| headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.apiKey}` }, | ||
| body: { model: provider.model, messages: [{ role: 'user', content }], response_format: { type: 'json_object' } }, | ||
| extractContent: (data) => data?.choices?.[0]?.message?.content | ||
| }; | ||
| } | ||
| async function analyzeComposition(imageBase64, width, height) { | ||
| const apiKey = process.env.OPENAI_API_KEY; | ||
| if (!apiKey) { | ||
| throw new AgentLuxError('CONFIG_ERROR', 'OPENAI_API_KEY required for vision analysis.'); | ||
| } | ||
| const prompt = `You are a master photographer in the tradition of Henri Cartier-Bresson, shooting with a 35mm Leica. You possess absolute mastery over dynamic symmetry, the golden ratio, leading lines, and 'The Decisive Moment'. | ||
| Analyze this image (original size: ${width}x${height}). | ||
| Determine the primary subject and calculate the absolute mathematically perfect photographic crop to elevate this image to a magnum opus. | ||
| Return ONLY a JSON object representing the optimal crop box. | ||
| Ensure x+width <= ${width} and y+height <= ${height}. | ||
| Format: {"x": int, "y": int, "width": int, "height": int, "rule": "string explaining the compositional choice, e.g. 'Golden Spiral alignment on the subject's gaze'"}`; | ||
| async function callVLM(request) { | ||
| const RETRYABLE_CODES = new Set(['VLM_TIMEOUT', 'VLM_NETWORK_ERROR']); | ||
| let lastError; | ||
| for (let attempt = 0; attempt <= VLM_MAX_RETRIES; attempt += 1) { | ||
| for (let attempt = 0; attempt <= VLM_MAX_RETRIES; attempt++) { | ||
| const controller = new AbortController(); | ||
| const timeoutHandle = setTimeout(() => controller.abort(), VLM_TIMEOUT_MS); | ||
| try { | ||
| const response = await fetch('https://api.openai.com/v1/chat/completions', { | ||
| const response = await fetch(request.url, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, | ||
| body: JSON.stringify({ | ||
| model: "gpt-4o", | ||
| messages: [{ | ||
| role: "user", | ||
| content: [ | ||
| { type: "text", text: prompt }, | ||
| { type: "image_url", image_url: { url: `data:image/jpeg;base64,${imageBase64}` } } | ||
| ] | ||
| }], | ||
| response_format: { type: "json_object" } | ||
| }), | ||
| headers: request.headers, | ||
| body: JSON.stringify(request.body), | ||
| signal: controller.signal | ||
| }); | ||
| const responseText = await response.text(); | ||
| if (!response.ok) { | ||
| const code = response.status >= 500 || response.status === 429 | ||
| ? 'VLM_HTTP_TRANSIENT' | ||
| : 'VLM_HTTP_ERROR'; | ||
| const code = response.status >= 500 || response.status === 429 ? 'VLM_HTTP_TRANSIENT' : 'VLM_HTTP_ERROR'; | ||
| if (code === 'VLM_HTTP_TRANSIENT') RETRYABLE_CODES.add(code); | ||
| throw new AgentLuxError( | ||
| code, | ||
| `VLM request failed with status ${response.status}.`, | ||
| { status: response.status, statusText: response.statusText, body: responseText.slice(0, 512) } | ||
| ); | ||
| throw new AgentLuxError(code, `VLM request failed with status ${response.status}.`, { | ||
| status: response.status, statusText: response.statusText, body: responseText.slice(0, 512) | ||
| }); | ||
| } | ||
| let data; | ||
| try { | ||
| data = JSON.parse(responseText); | ||
| } catch { | ||
| throw new AgentLuxError('VLM_PARSE_ERROR', 'Unable to parse VLM HTTP response as JSON.'); | ||
| } | ||
| const content = data?.choices?.[0]?.message?.content; | ||
| try { data = JSON.parse(responseText); } | ||
| catch { throw new AgentLuxError('VLM_PARSE_ERROR', 'Unable to parse VLM HTTP response as JSON.'); } | ||
| const content = request.extractContent(data); | ||
| if (typeof content !== 'string') { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', 'VLM response missing choices[0].message.content string.'); | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', 'VLM response missing expected content string.'); | ||
| } | ||
| let rawCrop; | ||
| try { | ||
| rawCrop = JSON.parse(content); | ||
| } catch { | ||
| throw new AgentLuxError('VLM_PARSE_ERROR', 'Unable to parse VLM composition JSON payload.'); | ||
| } | ||
| return parseCropBox(rawCrop); | ||
| let parsed; | ||
| try { parsed = JSON.parse(content); } | ||
| catch { throw new AgentLuxError('VLM_PARSE_ERROR', 'Unable to parse VLM JSON payload.'); } | ||
| return parsed; | ||
| } catch (err) { | ||
@@ -183,4 +394,3 @@ if (err.name === 'AbortError') { | ||
| } | ||
| const isRetryable = RETRYABLE_CODES.has(lastError.code); | ||
| if (!isRetryable || attempt === VLM_MAX_RETRIES) break; | ||
| if (!RETRYABLE_CODES.has(lastError.code) || attempt === VLM_MAX_RETRIES) break; | ||
| await sleep(200 * Math.pow(2, attempt)); | ||
@@ -194,3 +404,240 @@ } finally { | ||
| async function execute({ image_path, delete_after = true }) { | ||
| // ============================================================ | ||
| // Parsing & Validation | ||
| // ============================================================ | ||
| function parseCropBox(raw) { | ||
| if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', 'VLM response is not a valid JSON object.'); | ||
| } | ||
| for (const key of ['x', 'y', 'width', 'height']) { | ||
| if (!(key in raw)) throw new AgentLuxError('VLM_SCHEMA_ERROR', `VLM response missing "${key}" field.`); | ||
| if (!Number.isFinite(raw[key])) throw new AgentLuxError('VLM_SCHEMA_ERROR', `VLM "${key}" must be a finite number.`); | ||
| } | ||
| return { | ||
| x: Math.floor(raw.x), y: Math.floor(raw.y), | ||
| width: Math.floor(raw.width), height: Math.floor(raw.height), | ||
| rule: typeof raw.rule === 'string' ? raw.rule : 'Composition optimized by AgentLux.' | ||
| }; | ||
| } | ||
| function sanitizeCropBox(cropBox, imageWidth, imageHeight) { | ||
| const x = Math.max(0, Math.min(cropBox.x, imageWidth - 1)); | ||
| const y = Math.max(0, Math.min(cropBox.y, imageHeight - 1)); | ||
| const width = Math.max(1, Math.min(cropBox.width, imageWidth - x)); | ||
| const height = Math.max(1, Math.min(cropBox.height, imageHeight - y)); | ||
| return { ...cropBox, x, y, width, height }; | ||
| } | ||
| function parseCuratorResponse(raw) { | ||
| if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', 'Curator response is not a valid JSON object.'); | ||
| } | ||
| return { | ||
| master: typeof raw.master === 'string' && raw.master in MASTER_REGISTRY ? raw.master : 'bresson', | ||
| colorProfile: typeof raw.color_profile === 'string' && raw.color_profile in LEICA_PROFILES ? raw.color_profile : 'm10', | ||
| lens: typeof raw.lens === 'string' && raw.lens in LENS_PROFILES ? raw.lens : 'summilux_35', | ||
| masterRationale: typeof raw.master_rationale === 'string' ? raw.master_rationale : '', | ||
| colorRationale: typeof raw.color_rationale === 'string' ? raw.color_rationale : '', | ||
| lensRationale: typeof raw.lens_rationale === 'string' ? raw.lens_rationale : '' | ||
| }; | ||
| } | ||
| // ============================================================ | ||
| // Agent Pipelines | ||
| // ============================================================ | ||
| async function curateImage(imageBase64, width, height) { | ||
| const provider = resolveProvider(process.env.AGENTLUX_CURATOR_MODEL); | ||
| const masterList = Object.entries(MASTER_REGISTRY).map(([k, m]) => ` - "${k}": ${m.name} (${m.style})`).join('\n'); | ||
| const profileList = Object.entries(LEICA_PROFILES).map(([k, p]) => ` - "${k}": ${p.name}`).join('\n'); | ||
| const lensList = Object.entries(LENS_PROFILES).map(([k, l]) => ` - "${k}": ${l.name}`).join('\n'); | ||
| const prompt = `You are the Chief Curator of a world-class Leica photography exhibition. You have spent decades studying the masters who defined 35mm street and documentary photography. | ||
| Analyze this photograph (${width}x${height} pixels). Consider: | ||
| 1. The dominant light/shadow structure and tonal range | ||
| 2. Geometric patterns, leading lines, and spatial tensions | ||
| 3. The emotional weight, narrative content, and subject matter | ||
| 4. Color palette, contrast characteristics, and mood | ||
| Select the ONE master photographer whose compositional philosophy best matches this image: | ||
| ${masterList} | ||
| Select the optimal Leica color grade: | ||
| ${profileList} | ||
| Select the lens character: | ||
| ${lensList} | ||
| Return ONLY a JSON object: | ||
| {"master": "key", "master_rationale": "brief why", "color_profile": "key", "color_rationale": "brief why", "lens": "key", "lens_rationale": "brief why"}`; | ||
| const request = buildVLMRequest(provider, prompt, imageBase64); | ||
| return parseCuratorResponse(await callVLM(request)); | ||
| } | ||
| async function masterCompose(imageBase64, width, height, masterKey) { | ||
| const provider = resolveProvider(process.env.AGENTLUX_MASTER_MODEL); | ||
| const master = MASTER_REGISTRY[masterKey] || MASTER_REGISTRY.bresson; | ||
| const request = buildVLMRequest(provider, master.prompt(width, height), imageBase64); | ||
| return parseCropBox(await callVLM(request)); | ||
| } | ||
| async function selectDecisiveMoment(thumbnailsBase64) { | ||
| const provider = resolveProvider(process.env.AGENTLUX_SELECTOR_MODEL || process.env.AGENTLUX_CURATOR_MODEL); | ||
| const count = thumbnailsBase64.length; | ||
| const prompt = `You are selecting The Decisive Moment from a burst of ${count} consecutive frames (indexed 0 to ${count - 1}). | ||
| Evaluate each frame for: | ||
| 1. Peak action — the apex of gesture, expression, or movement | ||
| 2. Geometric perfection — the strongest compositional potential | ||
| 3. Light quality — the most dramatic or revealing illumination | ||
| 4. Emotional resonance — the frame that tells the most powerful story | ||
| There is only ONE decisive moment. Find it. | ||
| Return ONLY a JSON object: | ||
| {"selected_index": int, "rationale": "Why THIS frame captures the unrepeatable instant"}`; | ||
| const request = buildMultiImageVLMRequest(provider, prompt, thumbnailsBase64); | ||
| const raw = await callVLM(request); | ||
| if (!raw || typeof raw.selected_index !== 'number') { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', 'Burst selector response missing selected_index.'); | ||
| } | ||
| const idx = Math.floor(raw.selected_index); | ||
| if (idx < 0 || idx >= count) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', `Burst selector index ${idx} out of range [0, ${count - 1}].`); | ||
| } | ||
| return { selectedIndex: idx, rationale: typeof raw.rationale === 'string' ? raw.rationale : '' }; | ||
| } | ||
| // ============================================================ | ||
| // Image Processing Pipeline | ||
| // ============================================================ | ||
| function applyLeicaColor(sharpInstance, profile) { | ||
| let s = sharpInstance.recomb(profile.recomb); | ||
| if (profile.grayscale) { | ||
| s = s.modulate({ brightness: profile.brightness }); | ||
| } else { | ||
| s = s.modulate({ saturation: profile.saturation ?? 1.0, brightness: profile.brightness }); | ||
| } | ||
| return s.linear(profile.contrastSlope, profile.contrastOffset); | ||
| } | ||
| function buildVignetteSvg(width, height, lens) { | ||
| return `<svg width="${width}" height="${height}"> | ||
| <defs><radialGradient id="v" cx="50%" cy="50%" r="${lens.vignetteRadius}%"> | ||
| <stop offset="${lens.vignetteFeather}%" stop-color="black" stop-opacity="0"/> | ||
| <stop offset="100%" stop-color="black" stop-opacity="${lens.vignetteStrength}"/> | ||
| </radialGradient></defs> | ||
| <rect x="0" y="0" width="${width}" height="${height}" fill="url(#v)"/> | ||
| </svg>`; | ||
| } | ||
| async function generateFilmGrain(width, height, grainConfig) { | ||
| if (!grainConfig) return null; | ||
| const { intensity, size } = grainConfig; | ||
| const gw = Math.max(1, Math.ceil(width / size)); | ||
| const gh = Math.max(1, Math.ceil(height / size)); | ||
| const total = gw * gh; | ||
| const buf = Buffer.alloc(total * 3); | ||
| const rnd = crypto.randomBytes(total * 2); | ||
| for (let i = 0; i < total; i++) { | ||
| const u1 = (rnd[i * 2] + 1) / 257; | ||
| const u2 = (rnd[i * 2 + 1] + 1) / 257; | ||
| const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); | ||
| const v = Math.max(0, Math.min(255, 128 + Math.round(z * intensity))); | ||
| buf[i * 3] = v; | ||
| buf[i * 3 + 1] = v; | ||
| buf[i * 3 + 2] = v; | ||
| } | ||
| return sharp(buf, { raw: { width: gw, height: gh, channels: 3 } }) | ||
| .resize(width, height, { kernel: size > 1 ? 'nearest' : 'lanczos3' }) | ||
| .png() | ||
| .toBuffer(); | ||
| } | ||
| // ============================================================ | ||
| // Core Processing | ||
| // ============================================================ | ||
| async function processImage(buffer, metadata, context) { | ||
| const { width, height } = metadata; | ||
| const vlmJpeg = await sharp(buffer).jpeg({ quality: 90 }).toBuffer(); | ||
| const base64 = vlmJpeg.toString('base64'); | ||
| const curation = await curateImage(base64, width, height); | ||
| const cropBox = await masterCompose(base64, width, height, curation.master); | ||
| const safeCrop = sanitizeCropBox(cropBox, width, height); | ||
| const profile = LEICA_PROFILES[curation.colorProfile] || LEICA_PROFILES.m10; | ||
| const lens = LENS_PROFILES[curation.lens] || LENS_PROFILES.summilux_35; | ||
| const cw = safeCrop.width; | ||
| const ch = safeCrop.height; | ||
| const overlays = []; | ||
| overlays.push({ input: Buffer.from(buildVignetteSvg(cw, ch, lens)), blend: 'multiply' }); | ||
| if (profile.grain) { | ||
| const grainBuf = await generateFilmGrain(cw, ch, profile.grain); | ||
| if (grainBuf) overlays.push({ input: grainBuf, blend: 'soft-light' }); | ||
| } | ||
| let processed = sharp(buffer) | ||
| .extract({ left: safeCrop.x, top: safeCrop.y, width: cw, height: ch }); | ||
| processed = applyLeicaColor(processed, profile); | ||
| const outputBuffer = await processed | ||
| .sharpen({ sigma: lens.sharpenSigma, flat: lens.sharpenFlat, jagged: lens.sharpenJagged }) | ||
| .composite(overlays) | ||
| .withMetadata() | ||
| .jpeg({ quality: 92 }) | ||
| .toBuffer(); | ||
| const masterName = MASTER_REGISTRY[curation.master]?.name || curation.master; | ||
| const masterStyle = MASTER_REGISTRY[curation.master]?.style || ''; | ||
| const lensName = LENS_PROFILES[curation.lens]?.name || curation.lens; | ||
| const result = { | ||
| status: 'success', | ||
| master_photographer: masterName, | ||
| master_style: masterStyle, | ||
| master_rationale: curation.masterRationale, | ||
| composition_rule: safeCrop.rule, | ||
| coordinates: safeCrop, | ||
| color_profile: profile.name, | ||
| color_rationale: curation.colorRationale, | ||
| lens_profile: lensName, | ||
| lens_rationale: curation.lensRationale, | ||
| source_file_deletion: context.delete_after ? context.deletionStatus : 'disabled', | ||
| source_file_deletion_message: context.deletionMessage | ||
| }; | ||
| if (context.outputPath) { | ||
| await fs.writeFile(context.outputPath, outputBuffer); | ||
| result.output_path = context.outputPath; | ||
| } else { | ||
| result.image_data_uri = `data:image/jpeg;base64,${outputBuffer.toString('base64')}`; | ||
| } | ||
| if (context.burstResult) result.burst_selection = context.burstResult; | ||
| const narrativeParts = []; | ||
| if (context.burstResult) { | ||
| narrativeParts.push(`Selected frame ${context.burstResult.selected_index + 1} of ${context.burstResult.total_images}: ${context.burstResult.rationale}`); | ||
| } | ||
| narrativeParts.push(`Recomposed through the eye of ${masterName} (${masterStyle}).`); | ||
| if (safeCrop.rule) narrativeParts.push(safeCrop.rule); | ||
| narrativeParts.push(`Color grade: ${profile.name}.`); | ||
| narrativeParts.push(`Lens character: ${lensName}.`); | ||
| result.presentation = narrativeParts.join('\n'); | ||
| return result; | ||
| } | ||
| // ============================================================ | ||
| // Main Execute | ||
| // ============================================================ | ||
| async function execute({ image_path, image_paths, output_path, delete_after = true }) { | ||
| try { | ||
@@ -200,25 +647,77 @@ if (typeof delete_after !== 'boolean') { | ||
| } | ||
| if (typeof image_path !== 'string' || image_path.trim().length === 0) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'image_path must be a non-empty string.'); | ||
| if (output_path !== undefined) { | ||
| if (typeof output_path !== 'string' || output_path.trim().length === 0) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'output_path must be a non-empty string.'); | ||
| } | ||
| if (!path.isAbsolute(output_path)) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'output_path must be an absolute path.'); | ||
| } | ||
| const parentDir = path.dirname(output_path); | ||
| const parentStat = await fs.stat(parentDir).catch(() => null); | ||
| if (!parentStat || !parentStat.isDirectory()) { | ||
| throw new AgentLuxError('INPUT_ERROR', `output_path parent directory does not exist: ${parentDir}`); | ||
| } | ||
| } | ||
| if (!path.isAbsolute(image_path)) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'image_path must be an absolute path.'); | ||
| if (image_paths !== undefined) { | ||
| if (image_path !== undefined) throw new AgentLuxError('INPUT_ERROR', 'Provide either image_path or image_paths, not both.'); | ||
| if (!Array.isArray(image_paths) || image_paths.length === 0) throw new AgentLuxError('INPUT_ERROR', 'image_paths must be a non-empty array.'); | ||
| if (image_paths.length > MAX_BURST_SIZE) throw new AgentLuxError('INPUT_ERROR', `image_paths exceeds maximum burst size of ${MAX_BURST_SIZE}. Set AGENTLUX_MAX_BURST_SIZE to increase.`); | ||
| for (const p of image_paths) { | ||
| if (typeof p !== 'string' || !path.isAbsolute(p)) throw new AgentLuxError('INPUT_ERROR', `Each path must be absolute. Got: "${p}"`); | ||
| } | ||
| const loaded = []; | ||
| for (const p of image_paths) { | ||
| const stat = await fs.stat(p).catch(() => null); | ||
| if (!stat || !stat.isFile()) throw new AgentLuxError('INPUT_ERROR', `File not found: ${p}`); | ||
| if (stat.size > MAX_IMAGE_BYTES) throw new AgentLuxError('INPUT_TOO_LARGE', `Image exceeds max size: ${p}`); | ||
| loaded.push({ path: p, buffer: await fs.readFile(p) }); | ||
| } | ||
| let deletionStatus = 'disabled'; | ||
| let deletionMessage = null; | ||
| if (delete_after) { | ||
| let delOk = 0; | ||
| let delFail = 0; | ||
| const delErrors = []; | ||
| for (const { path: fp } of loaded) { | ||
| try { await fs.unlink(fp); delOk++; } | ||
| catch (e) { delFail++; delErrors.push(e.message); } | ||
| } | ||
| if (delFail === 0) { | ||
| deletionStatus = 'deleted'; | ||
| } else if (delOk === 0) { | ||
| deletionStatus = 'delete_failed'; | ||
| deletionMessage = delErrors[0]; | ||
| } else { | ||
| deletionStatus = 'partial'; | ||
| deletionMessage = `${delOk} deleted, ${delFail} failed: ${delErrors[0]}`; | ||
| } | ||
| } | ||
| const thumbnails = await Promise.all(loaded.map(({ buffer }) => | ||
| sharp(buffer).resize(512, 512, { fit: 'inside' }).jpeg({ quality: 70 }).toBuffer().then(b => b.toString('base64')) | ||
| )); | ||
| const { selectedIndex, rationale } = await selectDecisiveMoment(thumbnails); | ||
| const selected = loaded[selectedIndex]; | ||
| const meta = await sharp(selected.buffer).metadata(); | ||
| if (!isFinitePositiveInt(meta.width) || !isFinitePositiveInt(meta.height)) { | ||
| throw new AgentLuxError('IMAGE_METADATA_ERROR', 'Invalid image dimensions.'); | ||
| } | ||
| return await processImage(selected.buffer, meta, { | ||
| delete_after, deletionStatus, deletionMessage, outputPath: output_path, | ||
| burstResult: { selected_index: selectedIndex, total_images: image_paths.length, rationale } | ||
| }); | ||
| } | ||
| if (typeof image_path !== 'string' || image_path.trim().length === 0) throw new AgentLuxError('INPUT_ERROR', 'image_path must be a non-empty string.'); | ||
| if (!path.isAbsolute(image_path)) throw new AgentLuxError('INPUT_ERROR', 'image_path must be an absolute path.'); | ||
| const fileStat = await fs.stat(image_path).catch(() => null); | ||
| if (!fileStat || !fileStat.isFile()) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'image_path must point to an existing file.'); | ||
| } | ||
| if (!Number.isFinite(fileStat.size) || fileStat.size <= 0) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'Input image file is empty or invalid.'); | ||
| } | ||
| if (fileStat.size > MAX_IMAGE_BYTES) { | ||
| throw new AgentLuxError( | ||
| 'INPUT_TOO_LARGE', | ||
| `Input image exceeds max size ${MAX_IMAGE_BYTES} bytes.`, | ||
| { maxBytes: MAX_IMAGE_BYTES, actualBytes: fileStat.size } | ||
| ); | ||
| } | ||
| if (!fileStat || !fileStat.isFile()) throw new AgentLuxError('INPUT_ERROR', 'image_path must point to an existing file.'); | ||
| if (!Number.isFinite(fileStat.size) || fileStat.size <= 0) throw new AgentLuxError('INPUT_ERROR', 'Input image file is empty or invalid.'); | ||
| if (fileStat.size > MAX_IMAGE_BYTES) throw new AgentLuxError('INPUT_TOO_LARGE', `Input image exceeds max size ${MAX_IMAGE_BYTES} bytes.`, { maxBytes: MAX_IMAGE_BYTES, actualBytes: fileStat.size }); | ||
| // 1. Read to memory | ||
| const buffer = await fs.readFile(image_path); | ||
@@ -229,45 +728,26 @@ const metadata = await sharp(buffer).metadata(); | ||
| } | ||
| const base64 = buffer.toString('base64'); | ||
| // 2. Zero-Retention Memory Management: Purge original from disk immediately | ||
| let deletionStatus = 'skipped'; | ||
| let deletionStatus = 'disabled'; | ||
| let deletionMessage = null; | ||
| if (delete_after) { | ||
| try { | ||
| await fs.unlink(image_path); | ||
| deletionStatus = 'deleted'; | ||
| } catch (e) { | ||
| deletionStatus = 'delete_failed'; | ||
| deletionMessage = e.message; | ||
| console.warn("[AgentLux] Could not delete original file:", e.message); | ||
| } | ||
| try { await fs.unlink(image_path); deletionStatus = 'deleted'; } | ||
| catch (e) { deletionStatus = 'delete_failed'; deletionMessage = e.message; } | ||
| } | ||
| // 3. VLM Analysis | ||
| const cropBox = await analyzeComposition(base64, metadata.width, metadata.height); | ||
| // 4. Boundary Safety Fallback (Evaluator Requirement) | ||
| const safeCrop = sanitizeCropBox(cropBox, metadata.width, metadata.height); | ||
| // 5. Transformation Engine (Lossless crop + Leica Color Science) | ||
| let croppedSharp = sharp(buffer) | ||
| .extract({ left: safeCrop.x, top: safeCrop.y, width: safeCrop.width, height: safeCrop.height }); | ||
| croppedSharp = applyLeicaM10Color(croppedSharp, safeCrop.width, safeCrop.height); | ||
| const croppedBuffer = await croppedSharp.withMetadata().toBuffer(); | ||
| // 6. Return Data URI (No disk footprint for the output either) | ||
| return { | ||
| status: "success", | ||
| composition_rule: safeCrop.rule, | ||
| coordinates: safeCrop, | ||
| source_file_deletion: delete_after ? deletionStatus : 'disabled', | ||
| source_file_deletion_message: deletionMessage, | ||
| image_data_uri: `data:image/jpeg;base64,${croppedBuffer.toString('base64')}` | ||
| }; | ||
| return await processImage(buffer, metadata, { | ||
| delete_after, deletionStatus, deletionMessage, outputPath: output_path, burstResult: null | ||
| }); | ||
| } catch (err) { | ||
| if (err instanceof AgentLuxError) { | ||
| return { status: "error", error_code: err.code, message: err.message, details: err.details || null }; | ||
| return { | ||
| status: 'error', error_code: err.code, message: err.message, | ||
| details: err.details || null, | ||
| recovery_hint: RECOVERY_HINTS[err.code] || RECOVERY_HINTS.UNEXPECTED_ERROR | ||
| }; | ||
| } | ||
| return { status: "error", error_code: "UNEXPECTED_ERROR", message: err.message || "Unknown error." }; | ||
| return { | ||
| status: 'error', error_code: 'UNEXPECTED_ERROR', | ||
| message: err.message || 'Unknown error.', | ||
| recovery_hint: RECOVERY_HINTS.UNEXPECTED_ERROR | ||
| }; | ||
| } | ||
@@ -277,13 +757,19 @@ } | ||
| module.exports = { | ||
| name: "agentlux_compose", | ||
| description: "Re-compose an image to Leica/Bresson master-level standards using VLM and sharp. Implements zero-retention memory management.", | ||
| name: 'agentlux_compose', | ||
| description: 'Recompose and color-grade a photograph with Leica master-photographer aesthetics. Automatically selects the best master, color science, and lens. Returns the processed image and a presentation narrative. Use when a user uploads a photo and wants it improved.', | ||
| parameters: { | ||
| type: "object", | ||
| type: 'object', | ||
| oneOf: [ | ||
| { required: ['image_path'] }, | ||
| { required: ['image_paths'] } | ||
| ], | ||
| properties: { | ||
| image_path: { type: "string", description: "Absolute path to the input image." }, | ||
| delete_after: { type: "boolean", description: "If true, deletes the original image from disk immediately after loading into memory. Defaults to true." } | ||
| image_path: { type: 'string', description: 'Absolute path to a single input image.' }, | ||
| image_paths: { type: 'array', items: { type: 'string' }, description: 'Array of absolute paths for burst mode. Mutually exclusive with image_path.' }, | ||
| output_path: { type: 'string', description: 'Absolute path to write the output JPEG. If omitted, output is returned as image_data_uri (base64). Recommended for agent workflows.' }, | ||
| delete_after: { type: 'boolean', description: 'Delete original image(s) from disk after loading. Defaults to true (zero-retention).', default: true } | ||
| }, | ||
| required: ["image_path"] | ||
| additionalProperties: false | ||
| }, | ||
| execute | ||
| }; |
+11
-2
| { | ||
| "name": "agentlux", | ||
| "version": "1.0.5", | ||
| "description": "Zero-retention AgentSkill bringing the Leica 35mm aesthetic and Henri Cartier-Bresson's geometry to autonomous vision models.", | ||
| "version": "2.0.0", | ||
| "description": "Multi-master Leica composition engine with dynamic color science, lens simulation, film grain, and decisive moment selection for autonomous vision agents.", | ||
| "main": "index.js", | ||
@@ -25,2 +25,11 @@ "scripts": { | ||
| "license": "MIT", | ||
| "engines": { | ||
| "node": ">=18" | ||
| }, | ||
| "files": [ | ||
| "index.js", | ||
| "README.md", | ||
| "SKILL.md", | ||
| "LICENSE" | ||
| ], | ||
| "bugs": { | ||
@@ -27,0 +36,0 @@ "url": "https://github.com/sjhddh/AgentLux/issues" |
+158
-25
| # AgentLux | ||
| AgentLux is an AgentSkill for automatic image recomposition and Leica-style color grading. | ||
| It reads an input image, asks an OpenAI vision model for an optimal crop, applies a sharp | ||
| pipeline, and returns the output as a JPEG data URI. | ||
| **用 Agent 给你的照片注入徕卡德味。** | ||
| ## Runtime Contract | ||
| One skill. Only Leica. Six master photographers. Zero compromise. | ||
| - Provider: OpenAI Chat Completions (`gpt-4o`) via `OPENAI_API_KEY` | ||
| - Input: absolute `image_path` and optional `delete_after` | ||
| - Default behavior: `delete_after=true` (intentional product design) | ||
| - Output: | ||
| - success: cropped image data URI + crop coordinates | ||
| - error: structured `error_code` + `message` (+ optional `details`) | ||
| --- | ||
| ## Zero-Retention Behavior | ||
| > **Agents**: Your operational instructions are in **[SKILL.md](SKILL.md)**. Read that file for the complete step-by-step workflow, trigger conditions, error recovery guide, and code templates. This README is the reference manual. | ||
| The default mode is immediate deletion of the source file after it is read into memory. | ||
| This is deliberate and keeps disk retention minimal for plug-and-play agent workflows. | ||
| --- | ||
| If you need to keep the source file, call with `delete_after: false`. | ||
| ## Philosophy | ||
| ## Environment Variables | ||
| AgentLux does one thing and does it with absolute conviction: it transforms any photograph into a Leica-grade image through the eyes of the world's greatest 35mm photographers. It does not ask the user what they want. It *knows*. | ||
| - `OPENAI_API_KEY` (required) | ||
| - `AGENTLUX_MAX_IMAGE_BYTES` (optional, default `31457280`) | ||
| - `AGENTLUX_VLM_TIMEOUT_MS` (optional, default `15000`) | ||
| - `AGENTLUX_VLM_MAX_RETRIES` (optional, default `2`) | ||
| - **Only Leica.** Every color matrix, every vignette curve, every grain particle is calibrated to real Leica hardware: M10, M9 CCD, M Monochrom, M6 bodies; Summilux, Noctilux, Summicron, Elmarit glass. | ||
| - **Only Masters.** Bresson's decisive geometry. Alex Webb's layered color. Fan Ho's chiaroscuro. Koudelka's panoramic austerity. Salgado's monumental humanity. Moriyama's raw provocation. The system picks the right eye for your image. | ||
| - **Only Opinionated.** No sliders. No presets menu. No "which filter do you want?" The Curator Agent analyzes your image and makes every creative decision autonomously. | ||
| ## Example | ||
| ## Architecture | ||
| ``` | ||
| ┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐ | ||
| │ Input Image │────▶│ Curator Agent │────▶│ Master Agent │ | ||
| │ (or Burst) │ │ (Pass 1: VLM) │ │ (Pass 2: VLM) │ | ||
| └─────────────┘ │ │ │ │ | ||
| │ Selects: │ │ Computes: │ | ||
| │ · Master style │ │ · Crop box │ | ||
| │ · Color profile │ │ · Composition │ | ||
| │ · Lens character│ │ rule narrative │ | ||
| └──────────────────┘ └────────┬─────────┘ | ||
| │ | ||
| ▼ | ||
| ┌──────────────────────┐ | ||
| │ Image Pipeline │ | ||
| │ (sharp, no AI) │ | ||
| │ │ | ||
| │ · Precision crop │ | ||
| │ · Leica color │ | ||
| │ science (recomb) │ | ||
| │ · Lens vignette + │ | ||
| │ micro-contrast │ | ||
| │ · Silver halide │ | ||
| │ film grain │ | ||
| └──────────┬───────────┘ | ||
| │ | ||
| ▼ | ||
| ┌──────────────────────┐ | ||
| │ Output │ | ||
| │ · JPEG file or URI │ | ||
| │ · presentation text │ | ||
| │ · full metadata │ | ||
| └──────────────────────┘ | ||
| ``` | ||
| For burst input, a **Selector Agent** runs before Pass 1 to pick the single strongest frame. | ||
| ## Install | ||
| ```bash | ||
| npm install agentlux | ||
| ``` | ||
| Entry point: `require('agentlux')` → `agentlux.execute({ ... })` | ||
| Agent skill instructions: **[SKILL.md](SKILL.md)** | ||
| ## Quick Start | ||
| ```javascript | ||
@@ -36,12 +75,106 @@ const agentlux = require('agentlux'); | ||
| const result = await agentlux.execute({ | ||
| image_path: '/tmp/user_upload_123.jpg', | ||
| delete_after: true | ||
| image_path: '/tmp/photo.jpg', // absolute path to input | ||
| output_path: '/tmp/agentlux_out.jpg', // where to write the output JPEG | ||
| delete_after: true // zero-retention: delete input after loading | ||
| }); | ||
| if (result.status === 'success') { | ||
| console.log(result.composition_rule); | ||
| console.log(result.source_file_deletion); // deleted | delete_failed | disabled | ||
| // result.output_path → send this file to user | ||
| // result.presentation → show this narrative to user | ||
| } else { | ||
| console.error(result.error_code, result.message); | ||
| // result.recovery_hint → follow this to fix the error | ||
| } | ||
| ``` | ||
| ## Parameters | ||
| | Parameter | Type | Default | Description | | ||
| |---|---|---|---| | ||
| | `image_path` | `string` | — | Absolute path to a single input image. Required unless `image_paths` is used. | | ||
| | `image_paths` | `string[]` | — | Absolute paths for burst mode. Mutually exclusive with `image_path`. | | ||
| | `output_path` | `string` | — | Absolute path to write output JPEG. If omitted, output is returned as `image_data_uri` (base64). **Recommended for agent workflows.** | | ||
| | `delete_after` | `boolean` | `true` | Delete original file(s) after loading into memory. | | ||
| ## Success Response | ||
| | Field | Type | Description | | ||
| |---|---|---| | ||
| | `status` | `"success"` | | | ||
| | `presentation` | `string` | **Ready-to-show narrative** of all creative decisions. Show this to the user. | | ||
| | `output_path` | `string` | Path to output JPEG (only if `output_path` was provided) | | ||
| | `image_data_uri` | `string` | Base64 JPEG data URI (only if `output_path` was NOT provided) | | ||
| | `master_photographer` | `string` | e.g. `"Fan Ho"` | | ||
| | `master_style` | `string` | e.g. `"Light & Shadow Geometry"` | | ||
| | `master_rationale` | `string` | Why this master was chosen | | ||
| | `composition_rule` | `string` | The master's compositional explanation | | ||
| | `coordinates` | `object` | `{x, y, width, height, rule}` — the crop applied | | ||
| | `color_profile` | `string` | e.g. `"Leica M Monochrom"` | | ||
| | `color_rationale` | `string` | Why this color grade | | ||
| | `lens_profile` | `string` | e.g. `"Noctilux-M 50mm f/0.95 ASPH"` | | ||
| | `lens_rationale` | `string` | Why this lens character | | ||
| | `burst_selection` | `object` | (Burst only) `{selected_index, total_images, rationale}` | | ||
| | `source_file_deletion` | `string` | `"deleted"` / `"delete_failed"` / `"disabled"` | | ||
| ## Error Response | ||
| | Field | Type | Description | | ||
| |---|---|---| | ||
| | `status` | `"error"` | | | ||
| | `error_code` | `string` | Machine-readable error type | | ||
| | `message` | `string` | Human-readable description | | ||
| | `recovery_hint` | `string` | **What the agent should do next** to resolve the error | | ||
| | `details` | `object\|null` | Additional context (e.g. HTTP status) | | ||
| ## VLM Provider Configuration | ||
| AgentLux auto-detects the VLM provider from available API keys. Set at least one: | ||
| | Priority | Provider | API Key | Default Model | | ||
| |---|---|---|---| | ||
| | 1 (highest) | Custom | `AGENTLUX_CUSTOM_BASE_URL` + `AGENTLUX_CUSTOM_API_KEY` | configurable | | ||
| | 2 | Anthropic | `ANTHROPIC_API_KEY` | `claude-sonnet-4-20250514` | | ||
| | 3 | OpenAI | `OPENAI_API_KEY` | `gpt-4o` | | ||
| | 4 | Google | `GOOGLE_API_KEY` | `gemini-1.5-pro` | | ||
| Override the model for any agent role: | ||
| | Variable | Controls | Example | | ||
| |---|---|---| | ||
| | `AGENTLUX_CURATOR_MODEL` | Curator Agent (image analysis + style selection) | `claude-sonnet-4-20250514` | | ||
| | `AGENTLUX_MASTER_MODEL` | Master Agent (crop computation) | `gpt-4o` | | ||
| | `AGENTLUX_SELECTOR_MODEL` | Burst Selector (decisive moment) | defaults to curator model | | ||
| **Model names determine provider routing.** `claude-*` → Anthropic, `gpt-*` / `o1-*` / `o3-*` → OpenAI, `gemini-*` → Google, anything else → Custom API. | ||
| ## Other Environment Variables | ||
| | Variable | Default | Description | | ||
| |---|---|---| | ||
| | `AGENTLUX_MAX_IMAGE_BYTES` | `31457280` (30MB) | Maximum input image size | | ||
| | `AGENTLUX_VLM_TIMEOUT_MS` | `15000` | VLM request timeout | | ||
| | `AGENTLUX_VLM_MAX_RETRIES` | `2` | VLM retry count for transient errors | | ||
| ## The Six Masters | ||
| | Key | Photographer | Style | Best For | | ||
| |---|---|---|---| | ||
| | `bresson` | Henri Cartier-Bresson | The Decisive Moment | Geometric tension, street moments, golden ratio | | ||
| | `webb` | Alex Webb | Complex Color Layering | Dense multi-layer scenes, saturated color | | ||
| | `fan_ho` | Fan Ho | Light & Shadow Geometry | Dramatic chiaroscuro, lone figures, architecture | | ||
| | `koudelka` | Josef Koudelka | Panoramic High-Contrast | Sweeping landscapes, stark geometry, wide frames | | ||
| | `salgado` | Sebastiao Salgado | Epic Human Documentary | Human dignity, environmental portraits, monumental | | ||
| | `moriyama` | Daido Moriyama | Provoke-Era Raw Street | Raw energy, blur, fragments, anti-classical | | ||
| ## The Five Color Profiles | ||
| | Key | Profile | Character | | ||
| |---|---|---| | ||
| | `m10` | Leica M10 Digital | Warm, micro-contrast punch, slightly desaturated. The modern Leica digital look. | | ||
| | `m9_ccd` | Leica M9 CCD | Rich reds, warm shadows, filmic saturation. The legendary CCD 德味. | | ||
| | `m_monochrom` | Leica M Monochrom | High-contrast B&W with extreme tonal range. Fine sensor grain. | | ||
| | `m6_trix400` | M6 + Kodak Tri-X 400 | Gritty high-contrast B&W. Heavy film grain. Classic photojournalism. | | ||
| | `m6_portra400` | M6 + Kodak Portra 400 | Warm pastels, lifted shadows, beautiful skin tones. Fine grain. | | ||
| ## License | ||
| MIT |
+106
-48
| # AgentLux | ||
| **A zero-retention, VLM-powered master composition and Leica color-grading skill for OpenClaw Agents.** | ||
| **Leica master-photographer composition and color-grading skill.** | ||
| ## ⚠️ Mandatory Installation (NPM) | ||
| To ensure you are using the latest runtime and behavior contract, install AgentLux via NPM. | ||
| ## When To Activate | ||
| If the module is not present in your environment, install it first: | ||
| ```bash | ||
| npm install agentlux | ||
| Trigger this skill when the user does ANY of the following: | ||
| - Uploads a photo and asks to make it look better, professional, cinematic, or artistic | ||
| - Says "fix the composition," "crop this better," "edit this photo," "reframe this" | ||
| - Asks for a "Leica look," "film look," "德味" (German rendering), or "大师构图" (master composition) | ||
| - Provides multiple photos and wants the best one selected and improved | ||
| - Uploads a photo with no specific editing instructions (default to this skill) | ||
| **DO NOT ask the user for any preferences.** This skill is fully autonomous and opinionated. | ||
| ## Prerequisites Check | ||
| Before first use, verify the module is installed and an API key is available: | ||
| ```javascript | ||
| // Step 0: One-time installation check | ||
| const agentlux = require('agentlux'); // If this fails: npm install agentlux | ||
| // At least one of these env vars must be set: | ||
| // ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY | ||
| ``` | ||
| *(Or `npm install -g agentlux` for global access).* | ||
| ## Purpose & Triggers | ||
| Use this skill whenever a user uploads a photograph and asks you to "make it look better," "fix the composition," "edit this," or "make it look professional." | ||
| **DO NOT** ask the user for cropping coordinates or color filter preferences. The skill is entirely opinionated: it automatically applies Henri Cartier-Bresson geometry and a Leica M10 aesthetic. | ||
| ## Agent Workflow: Single Image | ||
| ## Features | ||
| 1. **Bresson-Level VLM Framing**: Evaluates subjects and computes bounding boxes using Dynamic Symmetry, Golden Ratio, and Leading Lines. | ||
| 2. **Leica Color Science**: High-performance `sharp` transformations apply micro-contrast, red-channel boosting, green desaturation, and Summilux f/1.4 optical vignetting. | ||
| 3. **Zero-Retention Privacy**: Original image files are deleted from disk by default (`delete_after: true`). The final output is streamed via memory as a Base64 Data URI. | ||
| ### Step 1 — Save the user's image to disk | ||
| If the user uploaded an image that is in memory or a temp location, ensure it is written to an absolute path on disk. Note the path. | ||
| ## Provider and Environment | ||
| - Current provider implementation: **OpenAI only** | ||
| - Required key: `OPENAI_API_KEY` | ||
| - Optional runtime controls: | ||
| - `AGENTLUX_MAX_IMAGE_BYTES` | ||
| - `AGENTLUX_VLM_TIMEOUT_MS` | ||
| - `AGENTLUX_VLM_MAX_RETRIES` | ||
| ### Step 2 — Execute the skill | ||
| Write and run a Node.js script. Use `output_path` to write the result directly to a file (recommended): | ||
| ## Tool Usage | ||
| Write and execute a temporary Node.js script using the official NPM package: | ||
| ```javascript | ||
| const agentlux = require('agentlux'); | ||
| const fs = require('fs'); | ||
| async function run() { | ||
| // 1. delete_after defaults to true (intentional zero-retention behavior) | ||
| const result = await agentlux.execute({ | ||
| image_path: "/absolute/path/to/image.jpg", | ||
| delete_after: true | ||
| const result = await agentlux.execute({ | ||
| image_path: "ABSOLUTE_PATH_TO_INPUT", | ||
| output_path: "/tmp/agentlux_output_" + Date.now() + ".jpg", | ||
| delete_after: true | ||
| }); | ||
| if (result.status === 'success') { | ||
| // 2. Convert Data URI back to a temporary file to send to the user | ||
| const base64Data = result.image_data_uri.replace(/^data:image\/\w+;base64,/, ""); | ||
| const outPath = '/tmp/agentlux_output_' + Date.now() + '.jpg'; | ||
| fs.writeFileSync(outPath, base64Data, 'base64'); | ||
| console.log(JSON.stringify({ | ||
| status: "success", | ||
| rule: result.composition_rule, | ||
| file: outPath | ||
| })); | ||
| } else { | ||
| console.error(result.error_code, result.message); | ||
| } | ||
| console.log(JSON.stringify(result)); | ||
| } | ||
| run(); | ||
| ``` | ||
| *(Ensure `OPENAI_API_KEY` is exported in the environment).* | ||
| ## Behavioral Instructions | ||
| - **Execute Silently**: Run the script immediately upon receiving an image manipulation request. | ||
| - **Present the Output**: Send the resulting image back to the user and briefly explain the `composition_rule` returned by the tool (e.g., "I've reframed the shot using the Golden Spiral and applied a Leica M-Series color profile."). | ||
| ### Step 3 — Parse the result from stdout | ||
| The output is a single JSON object. Check `result.status`: | ||
| **On `"success"`:** | ||
| - `result.output_path` — The absolute path to the processed JPEG. **Send this file to the user.** | ||
| - `result.presentation` — A ready-to-use narrative explaining the creative decisions. **Show this text to the user.** | ||
| - `result.master_photographer` — e.g. "Fan Ho" | ||
| - `result.master_style` — e.g. "Light & Shadow Geometry" | ||
| - `result.composition_rule` — e.g. "Diagonal shaft of light creates a natural leading line..." | ||
| - `result.color_profile` — e.g. "Leica M Monochrom" | ||
| - `result.lens_profile` — e.g. "Noctilux-M 50mm f/0.95 ASPH" | ||
| - `result.coordinates` — The crop box applied `{x, y, width, height}` | ||
| **On `"error"`:** | ||
| - `result.error_code` — Machine-readable error type | ||
| - `result.message` — Human-readable description | ||
| - `result.recovery_hint` — **What YOU (the agent) should do next.** Follow this hint. | ||
| ### Step 4 — Present to the user | ||
| 1. Send the output image file (`result.output_path`) | ||
| 2. Display `result.presentation` as the explanation | ||
| 3. Optionally add your own commentary about the artistic choices | ||
| ## Agent Workflow: Burst Mode | ||
| When the user provides multiple sequential images (e.g. burst photos, bracketed exposures): | ||
| ```javascript | ||
| const result = await agentlux.execute({ | ||
| image_paths: [ | ||
| "/absolute/path/to/frame_001.jpg", | ||
| "/absolute/path/to/frame_002.jpg", | ||
| "/absolute/path/to/frame_003.jpg" | ||
| ], | ||
| output_path: "/tmp/agentlux_burst_output_" + Date.now() + ".jpg", | ||
| delete_after: true | ||
| }); | ||
| ``` | ||
| The system will auto-select the strongest frame, then compose and color-grade it. `result.burst_selection` contains: | ||
| - `selected_index` — Which frame was chosen (0-based) | ||
| - `total_images` — How many frames were evaluated | ||
| - `rationale` — Why this frame was selected | ||
| ## Error Recovery Guide | ||
| | error_code | What happened | Agent action | | ||
| |---|---|---| | ||
| | `CONFIG_ERROR` | No API key configured | Tell user to set ANTHROPIC_API_KEY, OPENAI_API_KEY, or GOOGLE_API_KEY | | ||
| | `INPUT_ERROR` | Bad parameters | Fix the input (check file path exists, is absolute, etc.) | | ||
| | `INPUT_TOO_LARGE` | Image > 30MB | Resize the image before retrying | | ||
| | `VLM_TIMEOUT` | API call timed out | **Retry the same request** | | ||
| | `VLM_NETWORK_ERROR` | Network failure | **Retry the same request** | | ||
| | `VLM_HTTP_TRANSIENT` | API overloaded (5xx/429) | **Wait 3 seconds, then retry** | | ||
| | `VLM_HTTP_ERROR` | API client error (4xx) | Check API key and model configuration | | ||
| | `VLM_PARSE_ERROR` | Malformed VLM output | **Retry the same request** | | ||
| | `VLM_SCHEMA_ERROR` | Unexpected VLM format | **Retry the same request** | | ||
| ## Environment Variables | ||
| | Variable | Required | Description | | ||
| |---|---|---| | ||
| | `ANTHROPIC_API_KEY` | One of these | Anthropic Claude API key | | ||
| | `OPENAI_API_KEY` | is required | OpenAI GPT API key | | ||
| | `GOOGLE_API_KEY` | | Google Gemini API key | | ||
| | `AGENTLUX_CUSTOM_BASE_URL` | Optional pair | Custom OpenAI-compatible base URL | | ||
| | `AGENTLUX_CUSTOM_API_KEY` | | Custom API key | | ||
| | `AGENTLUX_CURATOR_MODEL` | Optional | Override curator model (e.g. `claude-sonnet-4-20250514`) | | ||
| | `AGENTLUX_MASTER_MODEL` | Optional | Override master model (e.g. `gpt-4o`) | | ||
| ## What This Skill Does (For Context) | ||
| 1. **Curator Agent** analyzes the image and selects the best-fit master photographer (Bresson, Alex Webb, Fan Ho, Koudelka, Salgado, Moriyama), Leica color profile (M10, M9, Monochrom, Tri-X, Portra), and lens character (Summilux, Noctilux, Summicron, Elmarit). | ||
| 2. **Master Agent** embodies the selected photographer and computes the optimal crop. | ||
| 3. **Image Pipeline** applies Leica color science, lens vignette + micro-contrast, and film grain. | ||
| 4. Result is returned with a `presentation` narrative ready to show the user. |
| name: CI | ||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| matrix: | ||
| node-version: [20, 22] | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: ${{ matrix.node-version }} | ||
| - run: npm ci | ||
| - run: npm run lint | ||
| - run: npm test |
| import js from '@eslint/js'; | ||
| import globals from 'globals'; | ||
| export default [ | ||
| js.configs.recommended, | ||
| { | ||
| languageOptions: { | ||
| ecmaVersion: 2022, | ||
| globals: { ...globals.node } | ||
| }, | ||
| rules: { | ||
| 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }] | ||
| } | ||
| } | ||
| ]; |
| # AgentLux QA Review Report | ||
| ## Scope | ||
| - Repository: AgentLux | ||
| - Review mode: full-repo QA audit (correctness, reliability, security, regression safety) | ||
| - Product decision locked: `delete_after=true` default is intentional and preserved | ||
| ## Design-Confirmed Items (Not Defects) | ||
| - Default source file deletion (`delete_after=true`) remains the expected plug-and-play behavior. | ||
| - Review treats this as policy, and evaluates only controllability/visibility around it. | ||
| ## Findings by Severity | ||
| ### Critical | ||
| 1. No automated regression gates | ||
| - Evidence: `package.json` test script was placeholder, no CI workflow. | ||
| - Impact: high risk of shipping breakages undetected. | ||
| - Resolution: added `node --test` suite and GitHub Actions CI matrix. | ||
| - Status: fixed. | ||
| ### High | ||
| 1. Unvalidated VLM payload used directly in crop math | ||
| - Evidence: direct JSON parse and field trust in runtime path. | ||
| - Impact: malformed model output could crash or create invalid crop. | ||
| - Resolution: added schema validation + typed parsing + defensive sanitization. | ||
| - Status: fixed. | ||
| 2. No timeout/retry for external vision call | ||
| - Evidence: raw `fetch` call without timeout/backoff. | ||
| - Impact: hanging requests and fragile transient failure behavior. | ||
| - Resolution: added timeout (`AbortController`), bounded retries, exponential backoff. | ||
| - Status: fixed. | ||
| 3. Weak error classification | ||
| - Evidence: generic `{ status: "error", message }` only. | ||
| - Impact: poor operability and ambiguous caller behavior. | ||
| - Resolution: introduced structured `error_code` and optional `details`. | ||
| - Status: fixed. | ||
| ### Medium | ||
| 1. Input/path boundary assumptions | ||
| - Evidence: no absolute-path check, no pre-stat validation, no size limits. | ||
| - Impact: reliability issues and potential misuse of arbitrary paths. | ||
| - Resolution: added absolute path check, file existence/type checks, max size guard. | ||
| - Status: fixed. | ||
| 2. Metadata assumptions on width/height | ||
| - Evidence: pipeline relied on width/height presence without validation. | ||
| - Impact: runtime failures on malformed images. | ||
| - Resolution: metadata positive-integer validation added. | ||
| - Status: fixed. | ||
| 3. Source deletion observability gap | ||
| - Evidence: unlink failure only logged to console, not surfaced in API result. | ||
| - Impact: callers cannot reliably reason about retention outcome. | ||
| - Resolution: result now exposes `source_file_deletion` and message on failure. | ||
| - Status: fixed. | ||
| ### Low | ||
| 1. Docs/implementation mismatch on provider support | ||
| - Evidence: docs implied multi-provider support while runtime is OpenAI-only. | ||
| - Impact: operator confusion and onboarding errors. | ||
| - Resolution: aligned docs to OpenAI-only runtime contract and env vars. | ||
| - Status: fixed. | ||
| ## Implemented Changes | ||
| - Runtime hardening in `index.js` | ||
| - `AgentLuxError` with stable error codes | ||
| - input/file/size/metadata validation | ||
| - VLM timeout/retry/backoff and structured HTTP parse | ||
| - crop schema parse + bound sanitization with min dimensions | ||
| - explicit source deletion status in success payload | ||
| - Regression gates | ||
| - `test/index.test.js` with 5 deterministic tests: | ||
| - default deletion behavior | ||
| - `delete_after=false` retention | ||
| - VLM schema failure path | ||
| - transient network retry success | ||
| - crop bounds clamping and min-size enforcement | ||
| - `package.json` test script to `node --test` | ||
| - `.github/workflows/ci.yml` for Node 20/22 | ||
| - Contract alignment | ||
| - README and SKILL updated to match real runtime behavior and env contract. | ||
| ## Second-Pass Review Findings (post-fix audit) | ||
| ### High | ||
| 1. Non-retryable errors were retried | ||
| - Evidence: `VLM_SCHEMA_ERROR`, `VLM_PARSE_ERROR`, and HTTP 4xx all entered the retry loop with backoff, wasting time and API calls. | ||
| - Impact: schema error test took 605ms instead of ~12ms; deterministic failures burned retry budget. | ||
| - Resolution: classified error codes into retryable (`VLM_TIMEOUT`, `VLM_NETWORK_ERROR`, `VLM_HTTP_TRANSIENT` for 5xx/429) vs non-retryable; break immediately on non-retryable. | ||
| - Status: fixed. | ||
| ### Medium | ||
| 1. Dead code in `applyLeicaM10Color` | ||
| - Evidence: `cx`, `cy`, `r` variables computed but never used (SVG uses percentage-based gradient). | ||
| - Resolution: removed the three unused variables. | ||
| - Status: fixed. | ||
| 2. Fragile environment variable parsing | ||
| - Evidence: `Number("abc")` returns `NaN`, causing silent config corruption at module load. | ||
| - Resolution: added `envInt()` helper with validation and fail-fast on invalid values. | ||
| - Status: fixed. | ||
| 3. No input validation test coverage | ||
| - Evidence: report claimed input validation was fixed, but zero tests exercised those paths. | ||
| - Resolution: added 4 tests: empty path, relative path, non-existent file, missing API key. | ||
| - Status: fixed. | ||
| ### Low | ||
| 1. Test environment leakage | ||
| - Evidence: `process.env.OPENAI_API_KEY` was set in each test but never restored. | ||
| - Resolution: added `setup()`/`teardown()` helpers that save and restore env + `global.fetch`. | ||
| - Status: fixed. | ||
| ## Third-Pass Review Findings (round-2 closure) | ||
| ### High | ||
| 1. `parseCropBox` non-object input defense | ||
| - Evidence: VLM returning `null`, a primitive, or an array causes `in` operator to throw native `TypeError`, misclassified as `VLM_NETWORK_ERROR` (retryable). | ||
| - Resolution: added type guard `!raw || typeof raw !== 'object' || Array.isArray(raw)` → `VLM_SCHEMA_ERROR`. | ||
| - Status: fixed. | ||
| 2. `delete_after` string-boolean misuse risk | ||
| - Evidence: passing `delete_after: "false"` is truthy, silently deletes source file. | ||
| - Resolution: added `typeof delete_after !== 'boolean'` check → `INPUT_ERROR`. | ||
| - Status: fixed. | ||
| ### Medium | ||
| 1. No lint/static-check gate in CI | ||
| - Evidence: CI had `npm test` only, no static analysis. | ||
| - Resolution: added ESLint (`@eslint/js` recommended + Node globals), `npm run lint` script, and CI lint step before test. | ||
| - Status: fixed. | ||
| 2. Missing regression tests for delete_failed, timeout, 5xx retry | ||
| - Evidence: `delete_failed` branch, `VLM_TIMEOUT`, and 503/429 retry paths had zero test coverage. | ||
| - Resolution: added 5 tests: string delete_after rejection, delete_failed permission error, null crop defense, VLM_TIMEOUT on hung fetch, HTTP 503 retry-then-succeed. | ||
| - Status: fixed. | ||
| ### Low (deferred) | ||
| 1. Path allowlist/sandbox constraint | ||
| - Acknowledged residual risk; acceptable under current plug-and-play scope. | ||
| - Will add optional `AGENTLUX_ALLOWED_ROOT` in a future iteration. | ||
| ## Test Evidence | ||
| - Command: `npm run lint && npm test` | ||
| - Lint: 0 errors, 0 warnings | ||
| - Tests: 15 passed, 0 failed | ||
| ## Priority Roadmap | ||
| - P0 (done): runtime guardrails + error taxonomy + core tests | ||
| - P1 (done): docs/contract alignment + CI test gate | ||
| - P1.5 (done): retry correctness + env validation + input validation tests + test isolation | ||
| - P2 (done): parseCropBox defense + delete_after type guard + ESLint gate + full regression coverage | ||
| - P3 (recommended next): | ||
| - add optional structured logs with request correlation | ||
| - add load tests for large-image throughput profile | ||
| - add optional `AGENTLUX_ALLOWED_ROOT` path sandbox | ||
| ## Release Blockers | ||
| - None remaining. | ||
| - Recommended pre-release checks: | ||
| - verify `OPENAI_API_KEY` present in deployment env | ||
| - verify `AGENTLUX_MAX_IMAGE_BYTES` policy matches production limits | ||
| const test = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
| const os = require('node:os'); | ||
| const path = require('node:path'); | ||
| const fs = require('node:fs/promises'); | ||
| const sharp = require('sharp'); | ||
| const agentlux = require('../index.js'); | ||
| const SAVED_KEY = process.env.OPENAI_API_KEY; | ||
| const SAVED_FETCH = global.fetch; | ||
| function setup() { | ||
| process.env.OPENAI_API_KEY = 'test-key'; | ||
| } | ||
| function teardown() { | ||
| global.fetch = SAVED_FETCH; | ||
| if (SAVED_KEY === undefined) delete process.env.OPENAI_API_KEY; | ||
| else process.env.OPENAI_API_KEY = SAVED_KEY; | ||
| } | ||
| function mockOpenAIResponse(cropBox) { | ||
| const payload = { | ||
| choices: [{ message: { content: JSON.stringify(cropBox) } }] | ||
| }; | ||
| return { | ||
| ok: true, | ||
| status: 200, | ||
| statusText: 'OK', | ||
| text: async () => JSON.stringify(payload) | ||
| }; | ||
| } | ||
| async function createFixtureImage(filePath, width = 120, height = 80) { | ||
| await sharp({ | ||
| create: { width, height, channels: 3, background: { r: 120, g: 140, b: 160 } } | ||
| }).jpeg().toFile(filePath); | ||
| } | ||
| // --- Happy path --- | ||
| test('default delete_after=true deletes source file on success', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: 10, y: 10, width: 60, height: 40, rule: 'rule' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(result.source_file_deletion, 'deleted'); | ||
| assert.match(result.image_data_uri, /^data:image\/jpeg;base64,/); | ||
| await assert.rejects(fs.access(imagePath)); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('delete_after=false keeps source file', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: 0, y: 0, width: 30, height: 30, rule: 'rule' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(result.source_file_deletion, 'disabled'); | ||
| await fs.access(imagePath); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- VLM error paths --- | ||
| test('invalid VLM JSON schema returns VLM_SCHEMA_ERROR without retry', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let fetchCalls = 0; | ||
| global.fetch = async () => { fetchCalls += 1; return mockOpenAIResponse({ x: 1, y: 1, height: 20, rule: 'missing width' }); }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'VLM_SCHEMA_ERROR'); | ||
| assert.equal(fetchCalls, 1, 'schema errors must not trigger retries'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('transient network error retries and succeeds', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let calls = 0; | ||
| global.fetch = async () => { | ||
| calls += 1; | ||
| if (calls === 1) throw new Error('temporary network failure'); | ||
| return mockOpenAIResponse({ x: 2, y: 2, width: 20, height: 20, rule: 'retry rule' }); | ||
| }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(calls, 2); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('HTTP 4xx is not retried', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let fetchCalls = 0; | ||
| global.fetch = async () => { | ||
| fetchCalls += 1; | ||
| return { ok: false, status: 401, statusText: 'Unauthorized', text: async () => 'bad key' }; | ||
| }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'VLM_HTTP_ERROR'); | ||
| assert.equal(fetchCalls, 1, 'client errors must not trigger retries'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- Crop bounds --- | ||
| test('crop sanitization clamps to image bounds and enforces min size', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: -100, y: 1000, width: -5, height: 9999, rule: 'extreme values' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(result.coordinates.x, 0); | ||
| assert.equal(result.coordinates.y, 79); | ||
| assert.equal(result.coordinates.width, 1); | ||
| assert.equal(result.coordinates.height, 1); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- Input validation --- | ||
| test('rejects empty image_path', async () => { | ||
| setup(); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: '' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'INPUT_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| } | ||
| }); | ||
| test('rejects relative image_path', async () => { | ||
| setup(); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: 'relative/path.jpg' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'INPUT_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| } | ||
| }); | ||
| test('rejects non-existent file', async () => { | ||
| setup(); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: '/tmp/agentlux_nonexistent_' + Date.now() + '.jpg' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'INPUT_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| } | ||
| }); | ||
| test('rejects missing OPENAI_API_KEY', async () => { | ||
| delete process.env.OPENAI_API_KEY; | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: 0, y: 0, width: 30, height: 30, rule: 'r' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'CONFIG_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('rejects string delete_after to prevent truthy misuse', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: 'false' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'INPUT_ERROR'); | ||
| await fs.access(imagePath); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- Deletion failure --- | ||
| test('delete_failed branch surfaces status and message', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: 0, y: 0, width: 30, height: 30, rule: 'r' }); | ||
| await fs.unlink(imagePath); | ||
| await createFixtureImage(imagePath); | ||
| await fs.chmod(tmpDir, 0o555); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: true }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(result.source_file_deletion, 'delete_failed'); | ||
| assert.equal(typeof result.source_file_deletion_message, 'string'); | ||
| assert.ok(result.source_file_deletion_message.length > 0); | ||
| } finally { | ||
| await fs.chmod(tmpDir, 0o755); | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- VLM null/array defense --- | ||
| test('VLM returning null is VLM_SCHEMA_ERROR without retry', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let fetchCalls = 0; | ||
| global.fetch = async () => { fetchCalls += 1; return mockOpenAIResponse(null); }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'VLM_SCHEMA_ERROR'); | ||
| assert.equal(fetchCalls, 1, 'null crop must not trigger retries'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- Timeout and 5xx retry --- | ||
| test('VLM_TIMEOUT on hung fetch', async () => { | ||
| setup(); | ||
| process.env.AGENTLUX_VLM_TIMEOUT_MS = '100'; | ||
| process.env.AGENTLUX_VLM_MAX_RETRIES = '0'; | ||
| let mod; | ||
| try { | ||
| delete require.cache[require.resolve('../index.js')]; | ||
| mod = require('../index.js'); | ||
| } finally { | ||
| delete process.env.AGENTLUX_VLM_TIMEOUT_MS; | ||
| delete process.env.AGENTLUX_VLM_MAX_RETRIES; | ||
| } | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async (_url, opts) => { | ||
| await new Promise((_resolve, reject) => { | ||
| opts.signal.addEventListener('abort', () => reject(new DOMException('The operation was aborted.', 'AbortError'))); | ||
| }); | ||
| }; | ||
| try { | ||
| const result = await mod.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'VLM_TIMEOUT'); | ||
| } finally { | ||
| delete require.cache[require.resolve('../index.js')]; | ||
| require('../index.js'); | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('HTTP 503 retries then succeeds', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let fetchCalls = 0; | ||
| global.fetch = async () => { | ||
| fetchCalls += 1; | ||
| if (fetchCalls === 1) { | ||
| return { ok: false, status: 503, statusText: 'Service Unavailable', text: async () => 'overloaded' }; | ||
| } | ||
| return mockOpenAIResponse({ x: 5, y: 5, width: 40, height: 30, rule: '503 retry' }); | ||
| }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(fetchCalls, 2, '503 should be retried once then succeed'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 7 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
55144
47.51%690
22.34%180
282.98%2
-85.71%5
-37.5%22
69.23%