diff --git a/app/Console/Commands/Bundle/DescribeAttachments.php b/app/Console/Commands/Bundle/DescribeAttachments.php
new file mode 100644
index 0000000..56813e0
--- /dev/null
+++ b/app/Console/Commands/Bundle/DescribeAttachments.php
@@ -0,0 +1,87 @@
+signature = 'bundle:describe-attachments
+ { --r|recursive : Also upgrade sub-bundles }
+ { --source-disk= : Use specified content disk - Defaults to ' . env('CONTENT_DISK') . ' }
+ { path? : Path to a specific bundle to upgrade - Default to / }
+ ';
+
+ parent::__construct();
+ }
+
+ /**
+ * Execute the console command.
+ */
+ public function handle()
+ {
+ $this->selectDisk()
+ ->selectBundles()
+ ->perform();
+ }
+
+ private function perform()
+ {
+ progress('Updating bundles...', $this->bundles, function (Bundle $bundle, $progress) {
+ $this->handleBundle($bundle, $progress);
+ });
+ }
+
+ private function handleBundle(Bundle $bundle, $progress)
+ {
+ $attachmentsManager = $bundle->attachments(AttachmentsManager::Images);
+
+ foreach ($attachmentsManager->manager()->get('files') ?? [] as $ref => $data) {
+ if (!empty($data['alt'])) {
+ continue;
+ }
+
+ $altSelected = false;
+ $fullPath = $attachmentsManager->getAttachmentFullPath($ref);
+ $content = $this->sourceDisk->get($fullPath);
+ $content = Image::read($content)->toJpeg();
+ $base64 = base64_encode($content);
+
+ while (!$altSelected) {
+ $sentence = Ollama::describeImage([$base64]);
+ $translated = Translator::translate($sentence);
+ $alt = textarea(sprintf('Image description for %s', $fullPath), '', $translated);
+
+ if (confirm('Use this text as the `alt` attribute for image?', true, 'Yes', 'No', false, null, $alt)) {
+ $attachmentsManager->manager()->set(sprintf('files.%s.alt', $ref), $alt);
+ $attachmentsManager->manager()->save();
+
+ $altSelected = true;
+ }
+ }
+ }
+ }
+}
diff --git a/app/Services/Ollama.php b/app/Services/Ollama.php
new file mode 100644
index 0000000..c376139
--- /dev/null
+++ b/app/Services/Ollama.php
@@ -0,0 +1,24 @@
+timeout(240)->post(sprintf('%s/api/generate', env('OLLAMA_HOST')), [
+ 'model' => 'llava',
+ 'prompt' => 'Describe this image in a sentence or two',
+ 'stream' => false,
+ 'images' => $base64JpegImages,
+ ])->json();
+
+ return $result['response'];
+ }
+}
diff --git a/app/Services/Translator.php b/app/Services/Translator.php
new file mode 100644
index 0000000..ba9a89e
--- /dev/null
+++ b/app/Services/Translator.php
@@ -0,0 +1,22 @@
+post(sprintf('%s/translate', env('TRANSLATOR_URL')), [
+ 'q' => $sentence,
+ 'source' => $sourceLang,
+ 'target' => $targetLang,
+ ])->json();
+
+ return $response['translatedText'];
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index da540b6..1b9185b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -38,6 +38,7 @@ services:
- selenium
- nu-validator
- base_laravel.test
+ - libretranslate
pgsql:
image: 'postgres:15'
ports:
@@ -88,6 +89,16 @@ services:
- sail
nu-validator:
image: ghcr.io/validator/validator:latest
+ extra_hosts:
+ - 'host.docker.internal:host-gateway'
+ networks:
+ - sail
+ libretranslate:
+ image: libretranslate/libretranslate
+ extra_hosts:
+ - 'host.docker.internal:host-gateway'
+ environment:
+ - DBUS_SESSION_BUS_ADDRESS=/dev/null
networks:
- sail
networks: