Repository navigation
[6.x] AI image analysis: alt text, titles, focal points, tagging, image types, metadata, and text colors - #20103
Conversation
Image analysis tasks add instructions and JSON schema properties to a shared prompt, and apply their part of the response to the asset. Tasks that resolve to the same provider and model share one request per image, with a scaled-down copy of it. Images are queued for analysis when they're uploaded, replaced, or indexed, and with a new "Analyze images" action, all of which only fill in what images are missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Craft writes alt text for images in the language of each site with its own alt text, per the volume's alt text translation method, filling in images that are missing it when they're analyzed. Alternative Text fields get a "Generate alt text" action that fills in a suggestion for editors to review before saving, through a new generic craft:generate-field-value field action event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Craft sets the focal points of images that don't have one when they're analyzed, so their crops keep the subject in view. The point is kept within the image, and the asset's transforms are invalidated when it changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assets get searchable `keywords` and `visibleText` attributes, stored in new `assets` columns and exposed to GraphQL. The "Tag images" AI task fills them in for images that haven't been tagged, with keywords in the primary site's language and any text that appears in the image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Image colors now include a palette of the image's main colors, sampled on the server and in the browser for uploads, with labels() naming them in plain words such as "red" or "teal" via a new ColorNames helper. Images are searchable by those names, the palette and labels are exposed to GraphQL, and images sampled before palettes were get sampled again the next time they're indexed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
📚 Storybook previews@craftcms/ui — open Storybook No changed components detected in this Storybook. resources/js — open Storybook No changed components detected in this Storybook. |
Saving and indexing assets now check which image analysis tasks can run, which reads cached capability checks. Injecting AiManager there failed where laravel/ai isn't booted, such as the Yii2 adapter's tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # src/Asset/Elements/Asset.php # src/Element/Queries/AssetQuery.php
|
This PR had conflicts with Claude's response
|
# Conflicts: # src/Asset/Elements/Asset.php # src/Element/Queries/AssetQuery.php
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ture/ai-image-tasks
# Conflicts: # src/Asset/Elements/Asset.php # src/Database/Migrations/Install.php
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
They're listed in DEFAULT_TYPES, like other registries' built-in types, rather than registered from the asset service provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
An image whose palette is red can now be found by searching rot on a German site, as well as red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Images' colors, and the keywords and text the Tag images AI task finds, are now stored together in assets' analysis column, read through Asset::$analysis (an ImageAnalysis) and the analysis GraphQL field. Asset::$colors and the colors GraphQL field have moved there. Replacing an image's file, or indexing one that's changed, now starts its analysis over with only the new colors, so the new file gets tagged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Indexing only samples colors and queues AI analysis for new and changed files, unless the Asset Indexes utility's Analyze images option, or the index-assets commands' --analyzeImages option, is on. Then it also does both for unchanged images that are missing something. It's stored on indexing sessions, since the utility indexes over several requests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Image colors now include each region's 10th and 90th percentile WCAG relative luminance, sampled on the server and in the browser alike, and ImageColors::textColor() picks white or black text for the whole image or one of its edges, with the contrast ratio, so templates can tell when text needs a scrim. Also available as the luminance and textColor GraphQL fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Images now store the title, description, alt text, creator, credit, copyright, keywords, date taken, and camera embedded in their XMP, IPTC, and EXIF data in analysis.metadata, and whether the file has GPS coordinates in it, without the coordinates. Uploads are read before they're cleaned, and the GPS flag is checked again after cleaning, so it reflects the stored file. Indexing reads unchanged images that are missing metadata when asked to analyze images. Embedded keywords are searchable. Alt text from metadata now comes from the same reader, which also converts text that isn't UTF-8 without erroring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Titles images that are still titled after their filename, such as “IMG 4759”, in each site's language, sharing one title between sites that share titles, like Generate alt text does for alt text. Asset::hasDefaultTitle() says whether a title is still the filename one. New uploads also take their title from their embedded metadata, if they have one, so the metadata is now read in beforeSave(), where titles are set, which is still before the file is cleaned. AltTextSite is now TranslatedSite, shared by both tasks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Finds what kind of image each image is (photo, illustration, screenshot, diagram, logo, document, or pattern) and stores it in analysis.type, as an ImageType. It's searchable by the type's name and its label in the site's language, and available as the type GraphQL field. Image analysis tasks that both change an asset's analysis in the same request, like this and Tag images, now build on each other's changes through ImageAnalysisChanges::get(), rather than the last one replacing the others'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Now that missing metadata also means an image is sampled again, the tests for images sampled before palettes or luminance store metadata, so they only pass because of the part they're about. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Image types are indexed as the imageType search attribute, so searches like imageType:logo find only images classified as logos, rather than as part of keywords, where they mixed with AI and embedded tags. Plain searches still find them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Image types are no longer a search attribute; keywords hold only AI and embedded keywords. Instead, a generated, indexed assets.imageType column mirrors analysis.type, and it backs AssetQuery::imageType(), the “Image Type” asset condition rule, and the imageType GraphQL argument. Asset::getImageType() returns an image's type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Asset search keywords now only add the keywords and text found in images to what was already indexed, so the colors search attribute is gone. ColorNames::label() still translates color names for display. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
The browser sampler seeded its k-means++ clustering with mulberry32, while Intervention draws from PHP's Mt19937 Randomizer, so the two started from different centroids and found different palettes and dominant colors for the same image. Port PHP's Randomizer over Mt19937 and use it in the browser, and test both samplers against the same fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Control panel uploads now read images' embedded metadata in the browser, with a port of ImageMetadataReader, and send it with their colors, so the server can store both without reading the file. Images staged on a remote disk are no longer downloaded unless they're cleaned (metadata is read first, so cleaning can't lose it) or their dimensions can't be read from the start of the file. If the uploader sent no colors or metadata, the AnalyzeImage job samples them instead; it's otherwise only queued when AI tasks are pending. New assets for files already in place, as direct uploads leave them, are queued the same way. Staged uploads are copied into their volume within S3 when both disks share credentials and an endpoint, instead of being downloaded and uploaded again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Upload completion requests now send the browser's colors and metadata as `analysis`, shaped like the analysis they're stored in, and `Asset::$uploadColors` is replaced by `$uploadAnalysis`. Only the colors and metadata are taken from it; what AI tasks find can't be set from a request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
# Conflicts: # CHANGELOG.md
|
This PR had conflicts with Claude's response
|
It samples missing metadata as well as colors. Shortened the help text and command option descriptions, and added the option's strings to en/app.php. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
# Conflicts: # resources/js/modules/uploader/asset-upload-queue.ts
|
This PR had conflicts with Claude's response
|
Asset edit pages' Info panels now show what was found in an image as a separate group after the rest of its metadata: its type, a bar of its main colors sized by how much of the image each covers, its embedded title, alt text, creator, credit, copyright, date taken, and camera, and where it was taken, with a map drawn from OpenStreetMap tiles (inverted for the dark theme) and a Google Maps link. The asset's folder is now labeled "Folder" everywhere. Asset indexes get matching table columns and card attributes. Image metadata now includes GPS coordinates, read before uploads are cleaned (kept out of GraphQL and sandboxed templates), and the city, state, and country, in both the PHP reader and its browser port; `hasLocation` is gone. Image colors now include palette weights, sampled the same way on the server and in the browser. Embedded descriptions are searchable along with embedded keywords. The PHP reader no longer misses XMP packets that end past the first 8 KB of a file, since fread() can return less than asked. Elements can show groups of metadata, separated by a rule, through ElementInterface::getMetadataGroups(), and ContentHtml::metadataHtml() takes several groups and lists of values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
…ture/ai-image-tasks
…t text Uploads and the analysis queue job now fill in an image's alt text and title from its embedded metadata through Asset::valuesFromEmbeddedMetadata(), with ImageMetadata::suggestedAlt(), so they follow the same rules: titles are filled in whenever they're still the filename one, including when a file is replaced. Adds the assets.analyze MCP tool, which queues images for the image analysis tasks that are set up, and lets content-model audit checks return notes; the missing-alt-text check's note says whether "Generate alt text" is set up, and to use assets.analyze if so. The AI docs and docblock examples use a plugin task that doesn't overlap with Craft's own image tasks, and point image tasks at ImageAnalysisTask. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
The migration test predates color weights, and the external link icon's SVG isn't available on CI, so only check that the icon is hidden from assistive technology. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PxhedKGW7eCLF4xLBVmh6o
Description
Adds AI image analysis to assets: alt text, titles, focal points, tagging, and image types, run through AI Connections, plus color names, text-color hints, and embedded metadata without AI. Builds on #20099: image tasks require vision and structured output.
What was found in an image, in its Info panel:
The map is drawn from OpenStreetMap tiles rather than an embed, so it has no controls or text over it, and it’s credited below. Tiles are inverted for the dark theme. It’s hidden from assistive technology, since the place name and link say the same.
The new tasks in Settings → AI Connections:
Images’ colors are sampled from the file, without AI, and stored alongside what the AI tasks find, so templates read
asset.analysis.colors,.metadata,.type,.keywordsand.text, and GraphQL queriesanalysis { colors { … luminance textColor(region: "bottom") { color contrast } } metadata { … } type keywords text }. A new or changed file starts its analysis over with only its colors and metadata, so it gets tagged again.textColor()compares white text with the lightest parts of a region (P90) and black with the darkest (P10), since those are hardest to read over; a contrast under 4.5 means the text needs a scrim. Metadata is read inbeforeSave(), before uploads are cleaned, and the GPS flag is checked again after cleaning, so it says whether the stored file still has coordinates.For example, to overlay a heading on the bottom edge of an entry’s hero image, falling back to white text over a scrim when the colors aren’t known yet (a new upload whose analysis hasn’t run, an image indexed before this change) or neither color is legible on its own:
{% set image = entry.heroImage.one() %} {% if image %} {# null when the image hasn’t been analyzed, or its colors haven’t been sampled #} {% set text = image.analysis.colors.textColor('bottom') ?? null %} {# no data, or neither white nor black is legible on its own: white over a scrim #} {% set scrim = not text or text.contrast < 4.5 %} {% set color = scrim ? '#ffffff' : text.color %} <div class="hero"> <img src="{{ image.getUrl({width: 1600}) }}" alt="{{ image.alt }}"> <h1 class="hero__title{{ scrim ? ' hero__title--scrim' }}" style="--text: {{ color }}"> {{ entry.title }} </h1> </div> {% else %} <h1>{{ entry.title }}</h1> {% endif %}The same in Blade, where
?->stops at a missing analysis or colors:Use
textColor('top'),'left'or'right'for text along another edge, ortextColor()for text that could sit anywhere on the image.Indexing only samples colors and queues analysis for new and changed files by default, so indexing a large existing volume doesn’t send every image to an AI provider, or download every remote image. Turning on “Analyze images” (or
--analyzeImages=1) backfills unchanged images too. The option is stored on the indexing session, since the utility indexes over several requests.Analysis only fills in what’s missing: titles are only replaced while they’re still the filename one. Tasks that change the same attribute in one request, like Tag images and Classify images both changing
analysis, build on each other throughImageAnalysisChanges::get(). The bulk action never overwrites; only the alt text field action replaces a value, and it isn’t saved until the editor saves. The field action uses a genericcraft:generate-field-valueevent, so other fields can reuse it.Craft’s tasks are registered in
AiTasksitself. Plugins can add their own image analysis tasks by extendingImageAnalysisTaskand registering them from their service providers. Tasks that use the same provider and model share one request per image.Evidence
All model replies are faked through
laravel/ai.tests/Feature/Asset/ImageAnalyzerTest.php:tests/Feature/Asset/AssetIndexerTest.php: new and changed images are queued and sampled; unchanged ones only are with “Analyze images”, including images missing colors or sampled before palettes. Both fail on the code before this change.tests/Feature/Asset/Analysis/GenerateAltTextTest.php:tests/Feature/Asset/Analysis/GenerateTitlesTest.php: titles per site language, one for sites that share it, only filename titles replaced unless overwriting.tests/Feature/Asset/Analysis/ClassifyImageTest.php: types are stored, filterable byimageType()in every form, and in GraphQL (as a field and an argument); unknown types are ignored; tagging and classifying in one request keep both (fails withoutImageAnalysisChanges::get()).tests/Unit/Image/ImageMetadataReaderTest.php: XMP, IPTC and EXIF (hand-built segments), precedence, camera names, dates, Windows-1252 text.AssetsTest: metadata is read before cleaning, fills alt text and new uploads’ titles, andhasLocationfollows the stored file (fails without the post-cleaning check).ColorGridTest/ImagesTestandimage-colors.test.tspin both samplers to the same luminance, palette and dominant color for one fixture. The palette test fails on the old browser sampler, which seeded its k-means++ with mulberry32 rather than PHP’sMt19937Randomizerthat Intervention uses;php-randomizer.tsnow ports it, andphp-randomizer.test.tschecks it against PHP’s own output. TheImagesTestcase passes with GD and with Imagick.ImageColorsTestcoverstextColor(); the upload controller rejects out-of-range luminance (fails without the new rules).tests/Feature/Asset/Analysis/DetectFocalPointTest.phpandTagImageTest.php:tests/Unit/Image/ColorNamesTest.phpcovers reference colors.ImageColorsTest,ImagesTestandAssetColorsTestcover the palette, labels and GraphQL.ImagesTest: images without an alpha channel are sampled as opaque, which some ImageMagick builds otherwise get wrong.AssetConditionRulesTestcovers the condition rule;AddImageTypeColumnToAssetsTestchecks the generated column followsanalysis.type. Passes on SQLite, MySQL and Postgres.tests/Feature/Database/Migrations/AddAnalysisToAssetsTest.php: colors, keywords and visible text move intoanalysisand their columns are dropped; up-to-date installs are left alone. Passes on SQLite and MySQL.tests/Feature/Asset/AssetsTest.php: replacing a file forgets the old file’s keywords and text, andanalysiscan’t be set from a request.image-metadata.browser.test.ts(Chromium, since HappyDOM lacks XML namespace lookups) covers XMP, IPTC, EXIF, precedence and Windows-1252 text. PHP’s reader returns the same values for the same five fixtures. Reading both colors and metadata from a 24-megapixel JPEG takes about 50 ms, almost all of it decoding for colors, and never blocks the page for more than 28 ms.AssetUploadHandlerTeststages images on a remote disk. With browser data they aren’t downloaded or queued; without it, they aren’t downloaded but are queued (fails on the old code, which always downloaded them); cleaned ones are read before cleaning.UploadSessionControllerTestdrops invalid browser metadata, and ignores AI results sent inanalysis(type,keywords,text).S3UploaderTestcopies between S3 disks withCopyObject, withoutGetObject/PutObject(fails without the copy).ImageAnalyzerTestqueues new images for files already in place, and samples what they’re missing, filling in alt text and titles.resources/js/modules/ui/FieldNodeGenerateValue.test.ts: the generated value fills the field, the field isaria-busywhile generating, and errors are reported.composer ci: Pint, Rector and PHPStan pass. Pest only failsHandleInertiaRequestsTestlocally, which happens whenever the Vite dev server is running.vue-tsc,vp lintand the JS tests pass. Since theanalysischange: the affected suites (1,227 tests), full PHPStan, Rector and Pint pass.Since the browser metadata and direct upload changes, the affected suites (1,481 tests), the architecture tests, PHPStan, Pint,
vue-tsc,vp lintand the uploader JS tests pass.Info panel, columns, location, and color weights:
EditAssetControllerTest: the analysis group follows a rule; the color bar’s segments, accessible name and title; embedded rows, leaving out a title that matches the asset’s; date taken in the time it was taken; the location row (place name without repeats, Google Maps link, tile map, OSM credit); table and card attributes; “Folder”.ImageMetadataReaderTest+image-metadata.browser.test.ts: GPS coordinates from XMP and EXIF (south/west negative), city/state/country from XMP and IPTC; XMP packets that end past the first 8 KB (fails on the oldfread()read, which returned 8 KB). Both readers return the same values for IPTC’s 2024.1 reference image and a Nikon sample.ImagesTest+image-colors.test.ts: both samplers give the same palette weights for the shared fixture.AssetsTest: coordinates are kept after cleaning; embedded descriptions are searchable.ContentHtmlTest,AssetColorsTest: metadata groups and list values;weights,city/state/countryin GraphQL, and nolatitude.AssetsTesttitles a replaced file from its embedded title while the title is still the filename one (fails on the old new-assets-only rule); MCPAssetsTestcoversassets.analyze(queued, skipped, no tasks set up, unknown tasks);ContentModelTestcovers the missing-alt-text note.vue-tscpass. Checked in Chrome in both themes; not checked with a screen reader or in forced colors mode.Not verified: real providers, Imagick’s XMP fallback (no Imagick locally), and a browser pass of the edit page’s “Generate alt text” action (keyboard, screen reader, forced colors).
Related issues
🤖 Generated with Claude Code