+160
-17
@@ -229,2 +229,64 @@ const fs = require('fs').promises; | ||
| // ============================================================ | ||
| // Aesthetic Reference Library | ||
| // ============================================================ | ||
| const AESTHETIC_REFERENCE = { | ||
| compositionalRules: { | ||
| rule_of_thirds: 'Subject or key elements at intersection points of a 3x3 grid', | ||
| golden_ratio: 'Spiral or rectangular composition following 1:1.618 proportions', | ||
| dynamic_symmetry: 'Diagonal-based composition creating visual tension and movement', | ||
| leading_lines: 'Lines within the frame guiding the eye toward the subject', | ||
| negative_space: 'Intentional empty areas giving the subject room to breathe', | ||
| frame_within_frame: 'Natural elements forming a secondary frame around the subject', | ||
| subject_isolation: 'Clear separation between subject and background through placement or contrast' | ||
| }, | ||
| tonalMoods: { | ||
| high_key: 'Predominantly bright tones, airy and optimistic', | ||
| low_key: 'Predominantly dark tones, dramatic and moody', | ||
| high_contrast: 'Strong difference between lights and darks, bold and graphic', | ||
| soft_tonal: 'Gentle gradations, subtle and contemplative', | ||
| split_lighting: 'Half light / half shadow, dramatic character revelation', | ||
| golden_hour: 'Warm directional light with long shadows, nostalgic warmth', | ||
| overcast_diffuse: 'Even soft lighting without harsh shadows, understated calm' | ||
| }, | ||
| genreFrameworks: { | ||
| portrait: { | ||
| key_concerns: 'Eye contact, expression, skin tone fidelity, background separation', | ||
| crop_guidance: 'Preserve headroom; avoid cutting at joints; maintain eye-level framing', | ||
| effects_bias: 'Favor clean rendering; grain only for editorial/fashion mood' | ||
| }, | ||
| landscape: { | ||
| key_concerns: 'Horizon placement, depth layers (foreground/mid/background), sky drama', | ||
| crop_guidance: 'Respect the horizon line; rule of thirds for sky/ground division', | ||
| effects_bias: 'Subtle vignette to frame; grain rarely appropriate' | ||
| }, | ||
| street: { | ||
| key_concerns: 'Decisive moment, gesture, context, layering, spontaneity', | ||
| crop_guidance: 'Tighten to emphasize geometry and tension; embrace edge activity', | ||
| effects_bias: 'Grain and contrast serve the raw documentary feel' | ||
| }, | ||
| architecture: { | ||
| key_concerns: 'Verticals, symmetry, geometric patterns, light on surfaces', | ||
| crop_guidance: 'Preserve symmetry axes; respect structural geometry', | ||
| effects_bias: 'Clean rendering preferred; vignette can emphasize central forms' | ||
| }, | ||
| still_life: { | ||
| key_concerns: 'Arrangement, texture, light quality, color harmony', | ||
| crop_guidance: 'Tight framing to emphasize texture; negative space if intentional', | ||
| effects_bias: 'Depends on mood — commercial wants clean, art wants character' | ||
| } | ||
| } | ||
| }; | ||
| function buildAestheticPromptSection() { | ||
| const rules = Object.entries(AESTHETIC_REFERENCE.compositionalRules) | ||
| .map(([k, v]) => ` - ${k.replace(/_/g, ' ')}: ${v}`).join('\n'); | ||
| const moods = Object.entries(AESTHETIC_REFERENCE.tonalMoods) | ||
| .map(([k, v]) => ` - ${k.replace(/_/g, ' ')}: ${v}`).join('\n'); | ||
| const genres = Object.entries(AESTHETIC_REFERENCE.genreFrameworks) | ||
| .map(([k, g]) => ` - ${k}: ${g.key_concerns}. Crop: ${g.crop_guidance}. Effects: ${g.effects_bias}`).join('\n'); | ||
| return `Compositional patterns to evaluate:\n${rules}\n\nTonal moods to consider:\n${moods}\n\nGenre frameworks:\n${genres}`; | ||
| } | ||
| // ============================================================ | ||
| // VLM Provider Abstraction | ||
@@ -429,2 +491,6 @@ // ============================================================ | ||
| const VALID_COMPOSITION_QUALITY = new Set(['excellent', 'good', 'fair', 'poor']); | ||
| const VALID_PROCESSING_INTENSITY = new Set(['minimal', 'moderate', 'full']); | ||
| const VALID_VIGNETTE_INTENSITY = new Set(['none', 'subtle', 'standard', 'dramatic']); | ||
| function parseCuratorResponse(raw) { | ||
@@ -440,3 +506,10 @@ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { | ||
| colorRationale: typeof raw.color_rationale === 'string' ? raw.color_rationale : '', | ||
| lensRationale: typeof raw.lens_rationale === 'string' ? raw.lens_rationale : '' | ||
| lensRationale: typeof raw.lens_rationale === 'string' ? raw.lens_rationale : '', | ||
| compositionQuality: typeof raw.composition_quality === 'string' && VALID_COMPOSITION_QUALITY.has(raw.composition_quality) ? raw.composition_quality : 'fair', | ||
| cropRecommended: typeof raw.crop_recommended === 'boolean' ? raw.crop_recommended : true, | ||
| cropRationale: typeof raw.crop_rationale === 'string' ? raw.crop_rationale : '', | ||
| processingIntensity: typeof raw.processing_intensity === 'string' && VALID_PROCESSING_INTENSITY.has(raw.processing_intensity) ? raw.processing_intensity : 'full', | ||
| grainAppropriate: typeof raw.grain_appropriate === 'boolean' ? raw.grain_appropriate : null, | ||
| grainRationale: typeof raw.grain_rationale === 'string' ? raw.grain_rationale : '', | ||
| vignetteIntensity: typeof raw.vignette_intensity === 'string' && VALID_VIGNETTE_INTENSITY.has(raw.vignette_intensity) ? raw.vignette_intensity : 'standard' | ||
| }; | ||
@@ -454,5 +527,6 @@ } | ||
| const lensList = Object.entries(LENS_PROFILES).map(([k, l]) => ` - "${k}": ${l.name}`).join('\n'); | ||
| const langInstruction = lang !== 'en' ? `\n\nIMPORTANT: Write ALL text values (master_rationale, color_rationale, lens_rationale) in ${lang}. Keep JSON keys and selection keys in English.` : ''; | ||
| const aestheticSection = buildAestheticPromptSection(); | ||
| const langInstruction = lang !== 'en' ? `\n\nIMPORTANT: Write ALL text values (master_rationale, color_rationale, lens_rationale, crop_rationale, grain_rationale) in ${lang}. Keep JSON keys and selection keys in English.` : ''; | ||
| 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. | ||
| 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. You exercise restraint — a great curator knows when NOT to intervene. | ||
@@ -464,3 +538,21 @@ Analyze this photograph (${width}x${height} pixels). Consider: | ||
| 4. Color palette, contrast characteristics, and mood | ||
| 5. Whether the existing composition is already strong — if so, respect it | ||
| ${aestheticSection} | ||
| COMPOSITION ASSESSMENT: | ||
| Evaluate the image's existing composition. Rate composition_quality: | ||
| - "excellent": Strong intentional composition (clear use of recognized patterns, balanced framing, purposeful subject placement). Do NOT recommend cropping. | ||
| - "good": Solid composition with minor improvement opportunities. Cropping may refine but is not critical. | ||
| - "fair": Composition has potential but needs reframing to reach its best form. | ||
| - "poor": Weak or accidental framing that would greatly benefit from recomposition. | ||
| Set crop_recommended to false ONLY when composition_quality is "excellent" and the original framing should be preserved entirely. | ||
| EFFECTS ASSESSMENT: | ||
| Evaluate what processing this image genuinely needs: | ||
| - processing_intensity: "minimal" (color grade only), "moderate" (color + selective effects), or "full" (the complete treatment) | ||
| - grain_appropriate: true/false — would film grain genuinely enhance this image's mood, or would it feel like a filter slapped on? | ||
| - vignette_intensity: "none", "subtle", "standard", or "dramatic" — how much vignette serves this specific image? | ||
| Select the ONE master photographer whose compositional philosophy best matches this image: | ||
@@ -476,3 +568,3 @@ ${masterList} | ||
| Return ONLY a JSON object: | ||
| {"master": "key", "master_rationale": "brief why", "color_profile": "key", "color_rationale": "brief why", "lens": "key", "lens_rationale": "brief why"}${langInstruction}`; | ||
| {"master": "key", "master_rationale": "brief why", "color_profile": "key", "color_rationale": "brief why", "lens": "key", "lens_rationale": "brief why", "composition_quality": "excellent|good|fair|poor", "crop_recommended": true/false, "crop_rationale": "brief why", "processing_intensity": "minimal|moderate|full", "grain_appropriate": true/false, "grain_rationale": "brief why", "vignette_intensity": "none|subtle|standard|dramatic"}${langInstruction}`; | ||
@@ -483,7 +575,15 @@ const request = buildVLMRequest(provider, prompt, imageBase64); | ||
| async function masterCompose(imageBase64, width, height, masterKey, lang) { | ||
| async function masterCompose(imageBase64, width, height, masterKey, lang, curation) { | ||
| if (curation && curation.compositionQuality === 'excellent' && !curation.cropRecommended) { | ||
| return { | ||
| x: 0, y: 0, width, height, | ||
| rule: curation.cropRationale || 'Original composition preserved — already strong.' | ||
| }; | ||
| } | ||
| const provider = resolveProvider(process.env.AGENTLUX_MASTER_MODEL); | ||
| const master = MASTER_REGISTRY[masterKey] || MASTER_REGISTRY.bresson; | ||
| const aestheticHint = `\n\nAesthetic context: ${buildAestheticPromptSection()}\n\nIf the composition is already strong, you may return coordinates covering the full frame (x:0, y:0, width:${width}, height:${height}) with a rule explaining why the original framing is optimal.`; | ||
| const langInstruction = lang !== 'en' ? `\n\nIMPORTANT: Write the "rule" value in ${lang}. Keep JSON keys, x, y, width, height as numbers.` : ''; | ||
| const request = buildVLMRequest(provider, master.prompt(width, height) + langInstruction, imageBase64); | ||
| const request = buildVLMRequest(provider, master.prompt(width, height) + aestheticHint + langInstruction, imageBase64); | ||
| return parseCropBox(await callVLM(request)); | ||
@@ -579,4 +679,5 @@ } | ||
| const curation = await curateImage(base64, width, height, lang); | ||
| const cropBox = await masterCompose(base64, width, height, curation.master, lang); | ||
| const cropBox = await masterCompose(base64, width, height, curation.master, lang, curation); | ||
| const safeCrop = sanitizeCropBox(cropBox, width, height); | ||
| const cropped = !(safeCrop.x === 0 && safeCrop.y === 0 && safeCrop.width === width && safeCrop.height === height); | ||
@@ -588,5 +689,17 @@ const profile = LEICA_PROFILES[curation.colorProfile] || LEICA_PROFILES.m10; | ||
| const isMinimal = curation.processingIntensity === 'minimal'; | ||
| const shouldApplyGrain = !isMinimal | ||
| && curation.grainAppropriate !== false | ||
| && !!profile.grain; | ||
| const VIGNETTE_SCALE = { none: 0, subtle: 0.5, standard: 1.0, dramatic: 1.5 }; | ||
| const effectiveVignetteLevel = isMinimal ? 'none' : curation.vignetteIntensity; | ||
| const vignetteScale = VIGNETTE_SCALE[effectiveVignetteLevel] ?? 1.0; | ||
| const overlays = []; | ||
| overlays.push({ input: Buffer.from(buildVignetteSvg(cw, ch, lens)), blend: 'multiply' }); | ||
| if (profile.grain) { | ||
| if (vignetteScale > 0) { | ||
| const scaledLens = { ...lens, vignetteStrength: Math.min(0.9, lens.vignetteStrength * vignetteScale) }; | ||
| overlays.push({ input: Buffer.from(buildVignetteSvg(cw, ch, scaledLens)), blend: 'multiply' }); | ||
| } | ||
| if (shouldApplyGrain) { | ||
| const grainBuf = await generateFilmGrain(cw, ch, profile.grain); | ||
@@ -599,5 +712,7 @@ if (grainBuf) overlays.push({ input: grainBuf, blend: 'soft-light' }); | ||
| processed = applyLeicaColor(processed, profile); | ||
| processed = processed.sharpen({ sigma: lens.sharpenSigma, flat: lens.sharpenFlat, jagged: lens.sharpenJagged }); | ||
| if (overlays.length > 0) { | ||
| processed = processed.composite(overlays); | ||
| } | ||
| const outputBuffer = await processed | ||
| .sharpen({ sigma: lens.sharpenSigma, flat: lens.sharpenFlat, jagged: lens.sharpenJagged }) | ||
| .composite(overlays) | ||
| .withMetadata() | ||
@@ -611,2 +726,11 @@ .jpeg({ quality: 92 }) | ||
| const grainApplied = shouldApplyGrain; | ||
| const processingApplied = { | ||
| cropped, | ||
| grain_applied: grainApplied, | ||
| grain_rationale: curation.grainRationale || '', | ||
| vignette_level: effectiveVignetteLevel, | ||
| color_graded: true | ||
| }; | ||
| const result = { | ||
@@ -619,2 +743,8 @@ status: 'success', | ||
| coordinates: safeCrop, | ||
| composition_assessment: { | ||
| quality: curation.compositionQuality, | ||
| crop_applied: cropped, | ||
| crop_rationale: curation.cropRationale | ||
| }, | ||
| processing_applied: processingApplied, | ||
| color_profile: profile.name, | ||
@@ -642,11 +772,24 @@ color_rationale: curation.colorRationale, | ||
| if (lang === 'en') { | ||
| narrativeParts.push(`Recomposed through the eye of ${masterName} (${masterStyle}).`); | ||
| if (safeCrop.rule) narrativeParts.push(safeCrop.rule); | ||
| if (cropped) { | ||
| narrativeParts.push(`Recomposed through the eye of ${masterName} (${masterStyle}).`); | ||
| if (safeCrop.rule) narrativeParts.push(safeCrop.rule); | ||
| } else { | ||
| const quality = curation.cropRationale || 'strength in its original framing'; | ||
| narrativeParts.push(`Your composition captures ${quality}.`); | ||
| } | ||
| narrativeParts.push(`Color grade: ${profile.name}.`); | ||
| narrativeParts.push(`Lens character: ${lensName}.`); | ||
| if (effectiveVignetteLevel !== 'none') { | ||
| narrativeParts.push(`Lens character: ${lensName}.`); | ||
| } | ||
| } else { | ||
| narrativeParts.push(`${masterName} · ${masterStyle}`); | ||
| if (safeCrop.rule) narrativeParts.push(safeCrop.rule); | ||
| if (cropped) { | ||
| narrativeParts.push(`${masterName} · ${masterStyle}`); | ||
| if (safeCrop.rule) narrativeParts.push(safeCrop.rule); | ||
| } else { | ||
| narrativeParts.push(curation.cropRationale || masterName); | ||
| } | ||
| narrativeParts.push(profile.name); | ||
| narrativeParts.push(lensName); | ||
| if (effectiveVignetteLevel !== 'none') { | ||
| narrativeParts.push(lensName); | ||
| } | ||
| } | ||
@@ -653,0 +796,0 @@ result.presentation = narrativeParts.join('\n'); |
+1
-1
| { | ||
| "name": "agentlux", | ||
| "version": "2.1.0", | ||
| "version": "3.0.0", | ||
| "description": "Multi-master Leica composition engine with dynamic color science, lens simulation, film grain, and decisive moment selection for autonomous vision agents.", | ||
@@ -5,0 +5,0 @@ "main": "index.js", |
+22
-13
@@ -20,2 +20,3 @@ # AgentLux | ||
| - **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. | ||
| - **Only Restrained.** A great curator knows when NOT to intervene. If your composition is already strong, it stays. If grain doesn't serve the image, it's skipped. Processing is proportional, never mechanical. | ||
@@ -33,16 +34,20 @@ ## Architecture | ||
| │ · Lens character│ │ rule narrative │ | ||
| └──────────────────┘ └────────┬─────────┘ | ||
| │ | ||
| ▼ | ||
| ┌──────────────────────┐ | ||
| │ Image Pipeline │ | ||
| │ (sharp, no AI) │ | ||
| │ │ │ │ | ||
| │ Assesses: │ │ OR: preserves │ | ||
| │ · Composition │ │ original frame │ | ||
| │ quality │ │ if excellent │ | ||
| │ · Grain needed? │ └────────┬─────────┘ | ||
| │ · Vignette │ │ | ||
| │ intensity │ ▼ | ||
| │ · Processing │ ┌──────────────────────┐ | ||
| │ intensity │ │ Image Pipeline │ | ||
| └──────────────────┘ │ (sharp, no AI) │ | ||
| │ │ | ||
| │ · Precision crop │ | ||
| │ · Crop (if needed) │ | ||
| │ · Leica color │ | ||
| │ science (recomb) │ | ||
| │ · Lens vignette + │ | ||
| │ micro-contrast │ | ||
| │ · Silver halide │ | ||
| │ film grain │ | ||
| │ science (always) │ | ||
| │ · Vignette │ | ||
| │ (if appropriate) │ | ||
| │ · Film grain │ | ||
| │ (if appropriate) │ | ||
| └──────────┬───────────┘ | ||
@@ -55,3 +60,5 @@ │ | ||
| │ · presentation text │ | ||
| │ · full metadata │ | ||
| │ · processing_applied│ | ||
| │ · composition_ │ | ||
| │ assessment │ | ||
| └──────────────────────┘ | ||
@@ -119,2 +126,4 @@ ``` | ||
| | `lens_rationale` | `string` | Why this lens character | | ||
| | `composition_assessment` | `object` | `{quality, crop_applied, crop_rationale}` — how the existing composition was evaluated. `quality`: `"excellent"` / `"good"` / `"fair"` / `"poor"` | | ||
| | `processing_applied` | `object` | `{cropped, grain_applied, grain_rationale, vignette_level, color_graded}` — what processing was actually applied | | ||
| | `burst_selection` | `object` | (Burst only) `{selected_index, total_images, rationale}` | | ||
@@ -121,0 +130,0 @@ | `source_file_deletion` | `string` | `"deleted"` / `"partial"` / `"delete_failed"` / `"disabled"` | |
+11
-5
@@ -55,6 +55,8 @@ # AgentLux | ||
| - `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.presentation` — A ready-to-use narrative explaining the creative decisions. **Show this text to the user.** The narrative is proportional: if the original composition was preserved, it says so honestly rather than fabricating a crop story. | ||
| - `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.composition_assessment` — `{quality, crop_applied, crop_rationale}` — how the system evaluated the original composition. `quality` is `"excellent"`, `"good"`, `"fair"`, or `"poor"`. When `quality` is `"excellent"`, the original framing is preserved. | ||
| - `result.processing_applied` — `{cropped, grain_applied, grain_rationale, vignette_level, color_graded}` — what was actually done to the image. Use this to understand the processing decisions. | ||
| - `result.color_profile` — e.g. "Leica M Monochrom" | ||
@@ -124,5 +126,9 @@ - `result.lens_profile` — e.g. "Noctilux-M 50mm f/0.95 ASPH" | ||
| 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. | ||
| 1. **Curator Agent** analyzes the image and makes all creative decisions in a single VLM pass: | ||
| - Selects the best-fit master photographer (Bresson, Alex Webb, Fan Ho, Koudelka, Salgado, Moriyama) | ||
| - Selects Leica color profile (M10, M9, Monochrom, Tri-X, Portra) and lens character (Summilux, Noctilux, Summicron, Elmarit) | ||
| - **Assesses composition quality** (`excellent` / `good` / `fair` / `poor`) — if the original composition is already strong, it is preserved | ||
| - **Decides effects intensity** — whether grain and vignette genuinely serve this image, or would feel like filters slapped on | ||
| 2. **Master Agent** embodies the selected photographer and computes the optimal crop. If the Curator assessed the composition as excellent, this step is skipped and the original framing is preserved. | ||
| 3. **Image Pipeline** applies Leica color science. Vignette and film grain are applied **conditionally** based on the Curator's aesthetic judgment — not mechanically. | ||
| 4. Result is returned with a `presentation` narrative ready to show the user. The narrative is honest: it describes what was actually done, not what could have been done. |
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
67871
19.28%835
18.78%193
4.89%24
4.35%