Skip to content

[6.x] AI image analysis: alt text, titles, focal points, tagging, image types, metadata, and text colors - #20103

Merged
brandonkelly merged 42 commits into
6.xfrom
feature/ai-image-tasks
Oct 27, 2026
Merged

brandonkelly merged 42 commits into
6.xfrom
feature/ai-image-tasks

Conversation

@brandonkelly

@brandonkelly brandonkelly commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

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.

on upload / file replace / indexing (new or changed file, or any image with the “Analyze images” option) / “Analyze images” asset action
  ImageAnalyzer::queue(asset)                 // only if some task has something to fill in, or a new image
                                              // is still missing colors or metadata (sample: true)
    AnalyzeImage job (low-priority queue, unique per asset)
      ImageAnalyzer::analyze(asset)
        sample colors / metadata the image is missing, from the same copy of the file
        group enabled, needed tasks by provider + model
        for each group: one structured-output request with a ≤1568px copy of the image
          each task adds its instructions + JSON schema properties, then applies its part
        save per-site changes (alt text, titles) and asset-wide ones (focal point, analysis)
 Core AI tasks (Settings → AI Connections)
+  craft:altText     Generate alt text   per site language; sites that share alt text get one
+  craft:title       Generate titles     replaces filename titles (“IMG 4759”), per site language
+  craft:focalPoint  Detect focal points sets missing focal points, invalidates transforms
+  craft:tagImage    Tag images          keywords + text in the image → searchable
+  craft:imageType   Classify images     photo / illustration / screenshot / diagram / logo /
                                         document / pattern → filterable

 Assets
+  $analysis (ImageAnalysis)             one `analysis` JSON column: colors, metadata, type,
                                         keywords, text
-  $colors                               moved to $analysis->colors (also in GraphQL)
+  analysis->colors->palette, labels()   main colors and their names (no AI; server + browser)
+  analysis->colors->weights             how much of the image each palette color covers (server + browser)
+  analysis->colors->luminance           P10/P90 lightness per grid region (server + browser)
+  analysis->colors->textColor(region)   white or black text over the image or an edge, + contrast
                                         (worked out from luminance when read, not stored)
+  analysis->metadata                    XMP/IPTC/EXIF title, description, alt text, creator,
                                         credit, copyright, keywords, date taken, camera, GPS
                                         latitude/longitude, city, state, and country, read before
                                         cleaning (server + browser); coordinates aren’t in GraphQL
                                         or sandboxed templates
+  hasDefaultTitle()                     whether the title is still the filename one
+  getImageType()                        the image's type, from analysis
+  assets.imageType                      generated, indexed column mirroring analysis.type
 Queries / conditions / GraphQL
+  AssetQuery::imageType()               'logo', ['screenshot', 'diagram'], ['not', 'logo'], ':empty:'
+  “Image Type” condition rule           index filters, field sources, etc.
+  assets(imageType: [...])              GraphQL argument
 Search keywords
+  keywords                              AI keywords + embedded keywords + embedded description
+  text                                  text in the image (OCR)
 Uploads
+  title from embedded metadata          when the file has one, or a replaced file’s while the title is still
                                         the filename one; alt text too (Asset::valuesFromEmbeddedMetadata(),
                                         shared with the queue job; replaces Asset’s own XMP/IPTC alt reading)
+  metadata read in the browser          image-metadata.ts, a port of ImageMetadataReader
~  completion request                    sends `analysis: {colors, metadata}`, like what’s stored (was
                                         `colors`); only those two parts are taken, AI results never are
~  Asset::$uploadColors                  replaced by $uploadAnalysis
+  remote staging disks                  images aren’t downloaded unless they’re cleaned or their dimensions
                                         can’t be read from the start of the file; without browser data,
                                         the AnalyzeImage job samples them
+  S3 → S3                               staged uploads are copied within S3 when both disks share
                                         credentials and an endpoint

 Asset Indexes utility / index-assets commands
+  “Analyze images” / --analyzeImages     also sample colors and queue AI tasks for unchanged images
                                         that are missing something (off by default)

 Edit page
+  Alternative Text field → “Generate alt text”   fills in a suggestion to review; nothing is saved
+  Info panel                            what was found, after a rule: image type, a color bar
                                         (sized by weight), embedded title/alt text, creator,
                                         credit, copyright, date taken, camera, and location
                                         (place name, Google Maps link, OSM-tile map)
~  “Location” (the asset’s folder)       now “Folder”, in the Info panel, index column, and card

 Asset indexes
+  columns + card attributes             Image Type, Colors, Creator, Credit, Copyright, Date Taken,
                                         Camera, Embedded Keywords

 MCP
+  assets.analyze                        queues images for the image analysis tasks that are set up
+  content-model.audit notes             missing-alt-text says whether “Generate alt text” is set up, and to
                                         use assets.analyze if so

 Elements
+  ElementInterface::getMetadataGroups() metadata groups, separated by a rule in the sidebar
~  ContentHtml::metadataHtml()           takes several groups, and lists of values (one <dd> each)

What was found in an image, in its Info panel:

An asset’s Info panel after a rule: Image type Photo, a bar of five tan and brown colors, Date taken 10/22/2008, Camera NIKON COOLPIX P6000, and Location Arezzo, Tuscany, Italy, with a View in Google Maps link and a map below it

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:

The Tasks table in AI Connections, listing Generate alt text, Generate titles, Detect focal points, Tag images, and Classify images, each needing vision and structured output

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, .keywords and .text, and GraphQL queries analysis { 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 in beforeSave(), 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:

@php
    $image = $entry->heroImage->one();

    // null when the image hasn’t been analyzed, or its colors haven’t been sampled
    $text = $image?->analysis?->colors?->textColor('bottom');

    // no data, or neither white nor black is legible on its own: white over a scrim
    $scrim = $text === null || $text['contrast'] < 4.5;
    $color = $scrim ? '#ffffff' : $text['color'];
@endphp

@if ($image)
    <div class="hero">
        <img src="{{ $image->getUrl(['width' => 1600]) }}" alt="{{ $image->alt }}">
        <h1 @class(['hero__title', 'hero__title--scrim' => $scrim]) style="--text: {{ $color }}">
            {{ $entry->title }}
        </h1>
    </div>
@else
    <h1>{{ $entry->title }}</h1>
@endif
.hero { position: relative; }
.hero img { display: block; width: 100%; }
.hero__title { position: absolute; inset: auto 0 0; margin: 0; padding: 2rem; color: var(--text); }
/* 65% black keeps white text above 4.5:1, even over pure white */
.hero__title--scrim { padding-top: 4rem; background: linear-gradient(transparent, rgb(0 0 0 / 65%) 3rem); }

Use textColor('top'), 'left' or 'right' for text along another edge, or textColor() 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 through ImageAnalysisChanges::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 generic craft:generate-field-value event, so other fields can reuse it.

Craft’s tasks are registered in AiTasks itself. Plugins can add their own image analysis tasks by extending ImageAnalysisTask and registering them from their service providers. Tasks that use the same provider and model share one request per image.

src/
├── Asset/Analysis/
│   ├── ImageAnalysisTask.php / Contracts/      # task contract + base (requires vision, structured output)
│   ├── ImageAnalyzer.php                       # grouping, image prep, analyze() / suggest(), saving
│   ├── ImageAnalysisAgent.php                  # combined prompt + schema
│   ├── Data/ImageAnalysis.php                  # colors + metadata + type + keywords + text, one JSON column
│   ├── Jobs/AnalyzeImage.php
│   └── Tasks/                                  # GenerateAltText, GenerateTitles, DetectFocalPoint, TagImage,
│                                               # ClassifyImage, TranslatedSite (per-site groups)
├── Asset/Enums/ImageType.php
├── Asset/Actions/AnalyzeImages.php
├── Database/Migrations/…_add_analysis_to_assets.php  # moves colors (and dev builds’ keywords/visibleText) into analysis
├── Database/Migrations/…_add_image_type_column_to_assets.php  # generated imageType column + index; schema 6.0.0.31
├── Element/Queries/Concerns/Asset/QueriesImageType.php, Asset/Conditions/ImageTypeConditionRule.php
├── Gql/Types/ImageAnalysis.php (+ generator)
├── Http/Controllers/Assets/GenerateAltTextController.php
├── Image/ColorNames.php                        # Oklch-based color names
├── Image/ColorGrid.php                         # + luminance() P10/P90 per region
├── Image/ImageMetadataReader.php, Data/ImageMetadata.php   # XMP → IPTC → EXIF
├── Filesystem/Data/UploadedFile.php            # isLocal(), imageSize(), S3 → S3 copies
└── Gql/Types/{TextColor,ImageMetadata}.php (+ generators)
resources/js/modules/uploader/
├── image-metadata.ts                           # browser port of ImageMetadataReader
└── image-completion-data.ts                    # `analysis` (colors + metadata), sent when the upload completes

Evidence

All model replies are faked through laravel/ai.

  • tests/Feature/Asset/ImageAnalyzerTest.php:
    • one request for tasks that share a model, separate requests otherwise
    • what succeeded is saved when another group fails
    • only tasks that are available and needed run
    • images are queued on replace and by the bulk action
  • 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:
    • alt text per site language, and one value for sites that share it
    • only fills in missing alt text unless overwriting
    • the endpoint suggests without saving, and refuses users who can’t save the asset
    • the field action only appears when the task is set up
  • 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 by imageType() 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 without ImageAnalysisChanges::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, and hasLocation follows the stored file (fails without the post-cleaning check).
  • Server/browser parity: ColorGridTest/ImagesTest and image-colors.test.ts pin 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’s Mt19937 Randomizer that Intervention uses; php-randomizer.ts now ports it, and php-randomizer.test.ts checks it against PHP’s own output. The ImagesTest case passes with GD and with Imagick. ImageColorsTest covers textColor(); the upload controller rejects out-of-range luminance (fails without the new rules).
  • tests/Feature/Asset/Analysis/DetectFocalPointTest.php and TagImageTest.php:
    • values are clamped and cleaned up
    • transforms are invalidated when the focal point changes
    • keywords and text are searchable, in GraphQL, and tagging keeps the image’s colors
    • nothing is overwritten unless asked
  • Colors:
    • tests/Unit/Image/ColorNamesTest.php covers reference colors.
    • ImageColorsTest, ImagesTest and AssetColorsTest cover the palette, labels and GraphQL.
    • ImagesTest: images without an alpha channel are sampled as opaque, which some ImageMagick builds otherwise get wrong.
  • Image type filtering: AssetConditionRulesTest covers the condition rule; AddImageTypeColumnToAssetsTest checks the generated column follows analysis.type. Passes on SQLite, MySQL and Postgres.
  • tests/Feature/Database/Migrations/AddAnalysisToAssetsTest.php: colors, keywords and visible text move into analysis and 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, and analysis can’t be set from a request.
  • Browser metadata: 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.
  • Direct uploads: AssetUploadHandlerTest stages 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. UploadSessionControllerTest drops invalid browser metadata, and ignores AI results sent in analysis (type, keywords, text). S3UploaderTest copies between S3 disks with CopyObject, without GetObject/PutObject (fails without the copy). ImageAnalyzerTest queues 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 is aria-busy while generating, and errors are reported.
  • Mutation-checked: removing the upload/replace trigger, the indexing trigger, or the transform invalidation each fails its test.
  • composer ci: Pint, Rector and PHPStan pass. Pest only fails HandleInertiaRequestsTest locally, which happens whenever the Vite dev server is running. vue-tsc, vp lint and the JS tests pass. Since the analysis change: 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 lint and 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 old fread() 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/country in GraphQL, and no latitude.
  • Embedded metadata fill and MCP: AssetsTest titles a replaced file from its embedded title while the title is still the filename one (fails on the old new-assets-only rule); MCP AssetsTest covers assets.analyze (queued, skipped, no tasks set up, unknown tasks); ContentModelTest covers the missing-alt-text note.
  • 4,803 PHP tests across the affected areas, 80 JS tests, PHPStan, Pint, Stylelint and vue-tsc pass. 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

brandonkelly and others added 7 commits October 26, 2026 07:16
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>
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

📚 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>
@brandonkelly
brandonkelly requested a review from riasvdv October 26, 2026 15:25
Comment thread src/Asset/AssetServiceProvider.php Resolved
Comment thread src/Asset/Elements/Asset.php Resolved
# Conflicts:
#	src/Asset/Elements/Asset.php
#	src/Element/Queries/AssetQuery.php
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved both conflicts and committed the merge as d04e344. Neither conflict was ambiguous, so I added no CONFLICT-REVIEW comments. Both conflicts came from the same pair of changes: this branch added keywords and visibleText for the "Tag images" AI task, while 6.x replaced keptFile with trashedFileExists and added deletedWithVolume to the query.

  • src/Asset/Elements/Asset.php: I kept this branch's keywords property (including its setter) and its visibleText property. I then used 6.x's version of the old keptFile property, which is now public bool $trashedFileExists = false with 6.x's docblock.
  • src/Element/Queries/AssetQuery.php: The query now selects assets.keywords and assets.visibleText from this branch, plus assets.deletedWithVolume and assets.trashedFileExists from 6.x. I dropped the assets.keptFile select because 6.x's migration removes that column.

A search found no remaining keptFile references outside migrations. I didn't run the tests. php -l couldn't check the files either, because the runner has PHP 8.3, which can't parse the property hooks this code uses; the pre-merge version of Asset.php fails the same way.

brandonkelly and others added 10 commits October 26, 2026 09:02
# Conflicts:
#	src/Asset/Elements/Asset.php
#	src/Element/Queries/AssetQuery.php
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# 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
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
@brandonkelly
brandonkelly requested a review from riasvdv October 26, 2026 23:54
brandonkelly and others added 5 commits October 26, 2026 17:28
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
@brandonkelly brandonkelly changed the title [6.x] AI image analysis: alt text, focal points, tagging, and color names [6.x] AI image analysis: alt text, titles, focal points, tagging, image types, metadata, and text colors Oct 27, 2026
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
brandonkelly and others added 5 commits October 26, 2026 21:30
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
brandonkelly and others added 2 commits October 27, 2026 05:48
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
brandonkelly and others added 2 commits October 27, 2026 07:40
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
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved the merge conflict and committed the merge as 2fa43b0. I haven't pushed it.

The only conflict was in CHANGELOG.md, in the unreleased section. Both branches had added one line at the end of the same list. This branch added the entry about uploads staged on S3 being copied within S3 (#20103), and 6.x added the fix for withTransforms criteria being ignored for eager-loaded assets (#20117). I kept both lines, this branch's first and then 6.x's, and changed nothing else.

The other changed files from 6.x merged without conflicts. Nothing was ambiguous, so I added no CONFLICT-REVIEW comments. I didn't run any tests or checks.

Changed

  • CHANGELOG.md: kept both entries and removed the conflict markers.
  • Committed the merge with git commit --no-edit.

Found

brandonkelly and others added 3 commits October 27, 2026 07:52
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
@github-actions

Copy link
Copy Markdown

This PR had conflicts with 6.x. Claude attempted an automatic resolution and pushed a merge commit. Please review the merge commit carefully before approving/merging.

Claude's response

I resolved the one conflict and committed the merge as 8e4445d. I haven't pushed it.

The conflict: it was in resources/js/modules/uploader/asset-upload-queue.ts, and only in the import block.

  • This branch replaced the imageColorsCompletionData import with imageCompletionData, which sends image colors and embedded metadata to the server together (4eea57c).
  • 6.x added an import for the ConflictResolution type from @/modules/assets/assetMover.

I kept both: this branch's imageCompletionData import and 6.x's ConflictResolution import. ConflictResolution is used in resolve(), and nothing else imports imageColorsCompletionData, so dropping that old import is safe. 6.x's newer color work in image-colors.ts, including BlurHashes (30d2d2a), still reaches uploads through readImageColors, which imageCompletionData calls. I didn't think any of this was ambiguous, so there are no CONFLICT-REVIEW comments.

Not checked: I didn't get a type-check or the uploader tests to run. npx refuses to run here because the repo requires pnpm. Running pnpm vitest run resources/js/modules/uploader and a type-check would confirm the merge.

brandonkelly and others added 5 commits October 27, 2026 11:08
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
…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
@brandonkelly
brandonkelly merged commit a0abddf into 6.x Oct 27, 2026
20 checks passed
@brandonkelly
brandonkelly deleted the feature/ai-image-tasks branch October 27, 2026 22:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants