Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
145 commits
Select commit Hold shift + click to select a range
c3375ae
ImageLoader maybeEmbedder is dependant on the optional queue URL and …
tonytw1 Jul 8, 2026
6a6685f
MediaAPI config handling of embedder queueUrl is consistent with imag…
tonytw1 Jul 10, 2026
2f15dc6
Media API optional embedder consistant with image loader.
tonytw1 Jul 24, 2026
9260ce3
[generalise-embedding-impl] Extract common embedding implementation i…
tonytw1 Jul 24, 2026
6e8dc8e
[generalise-embedding-impl] createImageEmbeddings requires a mimeType.
tonytw1 Jul 24, 2026
d498cc7
[generalise-embedding-impl] EmbeddingImplementation returns a full Em…
tonytw1 Jul 24, 2026
db57e8e
Port Bedrock create image embedding back from lambda. Bedrock impleme…
tonytw1 Jul 3, 2026
af958c5
createImageEmbedding is available on Embedder interface.
tonytw1 Jul 24, 2026
1ee6d80
Unit
tonytw1 Jul 24, 2026
b54abfb
[generalise-embedding-impl] Embedder takes a general EmbeddingImpleme…
tonytw1 Jun 28, 2026
5220027
[embedding-source] embeddingSourceImageFormat exposed on Embedder so …
tonytw1 Jul 24, 2026
0ad5e8f
[embedding-source] ImageOperations.createEmbeddingSource to normalise…
tonytw1 May 9, 2026
20fb993
[embedding-source] In memory copy. Strips metadata and may not render…
tonytw1 Jul 16, 2026
9bbaa76
[embedding-source] Explicitly strip metadata so that we be sure that …
tonytw1 Jun 28, 2026
cd10b2a
[embedding-source] Support optional letter boxing the embedding sourc…
tonytw1 May 20, 2026
f8d4fca
[embedding-source] Increasing the output size for the createEmbedding…
tonytw1 Jul 18, 2026
58192c9
[send-embedder-sqs-message] Introduce the embedding source S3 bucket …
tonytw1 Jun 28, 2026
2d193de
[embedding-source] createEmbeddingSource is file based for consistanc…
tonytw1 Jul 15, 2026
ac0120e
[thrall-embedding] Placeholder for SQS embed required message consume…
tonytw1 Jun 20, 2026
9869607
[thrall-embedding] Parse EmbedderMessage
tonytw1 Jul 4, 2026
822b023
[thrall-embedding] EmbeddingSqsConsumer gets store and embedder
tonytw1 Jul 4, 2026
2471e17
[thrall-embedding] Picking up the embed source file from S3 in thrall.
tonytw1 Jun 25, 2026
feed8c1
[thrall-embedding] Parse embedding source image mimeType.
tonytw1 Jul 12, 2026
3d0d64f
[thrall-embedding] Make create embedding call and produce an Embedding.
tonytw1 Jul 26, 2026
52a3ada
[thrall-embedding] Send update embed message.
tonytw1 Jul 4, 2026
b287c47
[thrall-embedding] embedding source mimetype is extracted from S3 hea…
tonytw1 Jul 24, 2026
4a3acb8
Drop fileType from EmbedderMessage; it is already past on the S3 obje…
tonytw1 Jul 12, 2026
c9646c1
[mapping] Provide a geminiEmbedding2 field on Embedding.
tonytw1 May 11, 2026
bcd9ff2
[gemini-embedding] Initial Gemini Embedding 2 client.
tonytw1 May 26, 2026
c11f6cd
[upstream] testcontainers-localstack 2.0.5 and testcontainers-elastic…
tonytw1 Jul 30, 2026
6bbbeea
[gemini-embedding] Implement text query to vector
tonytw1 May 10, 2026
2410538
[gemini-embedding] Use task type on query embedding.
tonytw1 May 13, 2026
eb6e84b
[gemini-embedding] Revert; worked better without?
tonytw1 May 16, 2026
a280b8e
[gemini-embedding] Embedding for uploaded image contains title and de…
tonytw1 Jun 28, 2026
c175d79
[gemini-embedding] Refactor; setup for config.
tonytw1 Jun 20, 2026
7446dcb
[gemini-embedding] provides embeddingSourceImageFormat.
tonytw1 Jun 28, 2026
ebc3560
[gemini-embedding] createImageEmbeddings requires a mimeType.
tonytw1 Jul 24, 2026
8aef288
[gemini-embedding] extends embedding implementation interface.
tonytw1 Jun 28, 2026
cadf0b2
[gemini-embedding] Provide a geminiEmbedding2 mapping with Google's r…
tonytw1 Jul 5, 2026
8c980bf
[gemini-embedding] Define googleCloudEmbedding component to replace B…
tonytw1 Jun 20, 2026
42428af
[gemini-embedding] Billing is not size dependant so we can use a larg…
tonytw1 Jul 18, 2026
3084369
[gemini-embedding] Does not need letter boxing.
tonytw1 Jul 18, 2026
9190062
[gemini-embedding] Use JPEG
tonytw1 Jul 18, 2026
0ad33eb
[gemini-embedding] set taskTypes on embedding request.
tonytw1 Jul 19, 2026
342e8d1
Thrall switches to GCP embedding.
tonytw1 Jul 12, 2026
8a32676
[query] Use geminiEmbedding2 Elastic mapping field.
tonytw1 May 11, 2026
8d25c05
Test
tonytw1 Jul 23, 2026
e40a188
[query] knnSearch mapping field to use is a constant.
tonytw1 Jun 13, 2026
47aedfb
[query] Switch knn search field to embeddin.geminiEmbedding2.image.
tonytw1 Jun 13, 2026
3b674c0
[query] Restrict KNN with similarity filter to prevent long crawls.
tonytw1 Jul 5, 2026
bb2cc3c
[query] Setting up to use similar too as boolean clause of normal sea…
tonytw1 Jul 5, 2026
06208d5
[query] Pass maybeSimilarToVector down to normal search.
tonytw1 May 16, 2026
8d69023
[query] knn is constrained to the withFilter query. knn should look l…
tonytw1 May 16, 2026
b3945cf
[query] searchRequest is a normal query or a knn with the query as it…
tonytw1 May 17, 2026
0aeaff8
[query] Ignore similar when building Elastic query from structuredQue…
tonytw1 May 17, 2026
865f986
[query] Not needed.
tonytw1 Jul 23, 2026
839c561
Happy with numCandiates to be 2 * k given we have a constraint on sim…
tonytw1 Jun 14, 2026
9d65e6f
Bigger cast?
tonytw1 May 17, 2026
f25e62b
[ui] More List This link is conditional on this image having an embed…
tonytw1 May 18, 2026
6518ea5
[ui] Clicking More Like This does not need to set the Use AI Search t…
tonytw1 May 18, 2026
766d3ec
[thrall-embedding] Pass uploaded image metadata to the embedder via t…
tonytw1 Jul 12, 2026
c7ec19e
[thrall-embedding] Make image metadata from the SQS message availble …
tonytw1 Jul 12, 2026
61934fe
Test fixes.
tonytw1 Jul 24, 2026
13caf77
Relax / Tighten.
tonytw1 Jul 14, 2026
cf81d87
Bound the semanticRequest knn by similarity to prevent long explores …
tonytw1 Jul 24, 2026
b3f7001
Provide media api config hooks for tuning aiSearchMinimumSimilarity a…
tonytw1 Jul 26, 2026
deca2d6
Store the embedding results and project for future reindexing.
tonytw1 Jul 26, 2026
ce4fc4b
[te] fetchEmbeddingResult checkes for file existance.
tonytw1 Jul 26, 2026
758d843
[te] Rename and source key on fetch.
tonytw1 Jul 26, 2026
defd361
[te] createEmbeddingsSource moves to ImageUploadOpsDependencies so th…
tonytw1 Jul 26, 2026
0f3976e
[te] Clean up; more natural ordering.
tonytw1 Jul 26, 2026
3f05949
[te] Clean up; similar name.
tonytw1 Jul 26, 2026
4d59b4c
[te] Embeddings move to their own bucket for simpler life cycling.
tonytw1 Jul 26, 2026
8c084b6
[te] Standard path.
tonytw1 Jul 26, 2026
1b936c6
[te] Embedding are deleted on from the embedding bucket on hard reaping.
tonytw1 Aug 2, 2026
13d43a6
[upstream] Clean up; delete unused S3Vectors.
tonytw1 Aug 2, 2026
c3e4df1
Reindexing from S3. Thrall endpoint to trigger a projection of all S3…
tonytw1 Sep 23, 2024
1d2b658
Reindex process queues project image operations.
tonytw1 Mar 30, 2025
cdd21e0
Reindex should write projection results as UpsertFromProjectionMessag…
tonytw1 Mar 30, 2025
cf05575
Reindex logging message.
tonytw1 Apr 18, 2025
76ef291
Reindex messages are put into the low priority queue.
tonytw1 Apr 18, 2025
f91fa04
[reindexing] Form to request reindex of single image.
tonytw1 Mar 25, 2026
05d6805
[reindexing] Reindex from CSV end point.
tonytw1 Mar 25, 2026
967310b
[reindexing] Prompt fort full reindex.
tonytw1 Mar 26, 2026
9395c10
[reindexing] Combine.
tonytw1 Mar 26, 2026
bbfef12
[reindexing] Send reindexMessages in batches for faster enqueuing.
tonytw1 Mar 26, 2026
ec9fccd
Queue reindex requests by most recently last modified first.
tonytw1 Mar 26, 2026
b53e977
Reindex kinesis stream runs separately with it's own concurrency.
tonytw1 Mar 27, 2026
7b25ae4
CSV reindex is batched and put onto the low priority queue.
tonytw1 Apr 22, 2026
4e3cf4a
reindexMessage processing needs to throw to be retried.
tonytw1 Apr 23, 2026
dcf5266
Spike; simpler SyndicationFilter
tonytw1 Sep 18, 2025
c697dc5
Add syndicationStatus as a filter in the q parameter so that it can b…
tonytw1 Sep 19, 2025
2f5bfa6
Filter test.
tonytw1 Sep 19, 2025
42bb38b
Queued for syndication must be syndicatableCategory / owned not just …
tonytw1 Oct 5, 2025
c9b601c
Image.syndicationStatus aligns with syndicationFilter on not needing …
tonytw1 Oct 11, 2025
f2677e4
Image.syndicationStatus aligns with syndicationFilter on defination o…
tonytw1 Oct 12, 2025
079dcb5
Update ImageTest syndication tests to match isOwned syndication right…
tonytw1 Dec 30, 2025
1ef13f8
Update ElasticSearchTest syndication tests to match isOwned syndicati…
tonytw1 Dec 30, 2025
a9b33b5
syndicationStatus query suggestion in UI.
tonytw1 Feb 5, 2026
89e4257
Update test; we do not syndicate replaced images.
tonytw1 May 2, 2026
5868df8
Get getCssColour supports sub folder colours overriding parent colour…
tonytw1 Aug 2, 2025
f38b7af
Extract collection paths for reuse in CssColours.
tonytw1 Aug 2, 2025
ac84462
Recent print usages highlight only counts recent published usages.
tonytw1 Jun 21, 2025
d93b0ed
Refactor; image screen usage panel considers whole usage when making …
tonytw1 Jun 21, 2025
0b4b5ef
Image screen usage panel does not highlight non published print uses …
tonytw1 Jun 21, 2025
042b666
Update Observer collection colours.
tonytw1 Jun 22, 2025
7906278
Fork Tortoise usage rights with minimal config.
tonytw1 Feb 13, 2025
604e438
Add GNM as an agency.
tonytw1 Feb 13, 2025
0aef750
Set Tortoise contact photographers.
tonytw1 Apr 10, 2025
78f3896
Chris Riddell is an Observer contacted illustrator.
tonytw1 Jun 22, 2025
059776f
Remove Dave Benett Collection from list of explicity pay to use Getty…
tonytw1 Feb 25, 2026
0a5fac9
migrationStatusRefresher interval.
tonytw1 Sep 24, 2025
5b08a57
Thrall "/thrall" prefix via explicit Routes.withPrefix because play.h…
tonytw1 Nov 16, 2025
cbcd4a0
No +nocsrf on thrall UI routes.
tonytw1 Nov 16, 2025
23ea940
Usages updates should go on the main kinesis stream so that they are …
tonytw1 Nov 16, 2025
6e61f16
Revert "remove preview view"
tonytw1 Nov 26, 2025
fc167d8
Fixes stickiness when paging through preview images.
tonytw1 Nov 26, 2025
4f58895
Immutable placeholders look out of place; change to allow ids to upda…
tonytw1 Nov 27, 2025
e199db5
Fixes inSelectionMode not been past to ui-preview-image-large. Fixes …
tonytw1 Nov 27, 2025
e13a21d
Allow more than 1 item to be selected from preview mode. Allow range …
tonytw1 Nov 29, 2025
855267e
Always show reapable filter.
tonytw1 Feb 5, 2026
4302d4a
Media API offers an end point for downloading the master crop.
tonytw1 Feb 20, 2026
0b087af
Kahana picks up master asset for download.
tonytw1 Feb 20, 2026
530be2f
Enable iPhone HEIC support.
tonytw1 May 26, 2025
174a14b
Explicit human-readable crop display names.
tonytw1 Jan 11, 2025
f83fe98
Register 3:2 as a known aspect ratio so that in can be cleaned with t…
tonytw1 Dec 7, 2024
ab87e22
Register 3:2 as a crop option.
tonytw1 Dec 7, 2024
ff25806
Observer wants 5:3 crop options.
tonytw1 Mar 1, 2026
45f8608
Crops download as the original filename falling back to Grid image id…
tonytw1 Mar 8, 2026
ba6a61c
Set start dates for publication photographers to 22 April 2025 for r…
tonytw1 Mar 25, 2026
975685a
Logging. Projected logs the media-id not the whole response.
tonytw1 Mar 26, 2026
b4d7b59
Lower concurrency on reindex.
tonytw1 Apr 22, 2026
f362d15
Log ignored reindex ids.
tonytw1 Apr 23, 2026
aa0f833
refactor; continueScrolling rename to be general.
tonytw1 Apr 25, 2026
8f2ae92
refactor; continueScrolling and closeScroll move to ElasticSearchClie…
tonytw1 Apr 25, 2026
e2bb9b3
End point to output all currently held document ids to a tmp file.
tonytw1 Apr 25, 2026
6a81892
Clarifying rename; this parameter is the number of retries after the …
tonytw1 Apr 28, 2026
bf4ed40
Fixes 4775
tonytw1 May 4, 2026
eef65e8
Remove duplicate toFileMetadata which only populates log markers.
tonytw1 May 9, 2026
32af2bf
Run media-api tests using docker in docker elasticsearch.
tonytw1 Jul 30, 2026
f6a5466
Run collections and leases tests.
tonytw1 Jul 30, 2026
2021633
Combine.
tonytw1 Jul 30, 2026
f9ddf5a
Revert "Apply circular mask by default"
tonytw1 Aug 2, 2026
4147993
Claude. Bucket config moves to a nest object so that optional bucket …
tonytw1 Aug 29, 2026
aeff64a
Use static S3 credentials if s3 end point has been overridden.
tonytw1 Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ val commonSettings = Seq(
"org.scalatestplus" %% "mockito-3-4" % "3.1.4.0" % Test,
"org.mockito" % "mockito-core" % "2.18.0" % Test,
"org.scalamock" %% "scalamock" % "5.1.0" % Test,
"org.testcontainers" % "localstack" % "1.21.4" % Test
"org.testcontainers" % "testcontainers-localstack" % "2.0.5" % Test
),
dependencyOverrides ++= jacksonOverrides,

Expand Down Expand Up @@ -124,10 +124,10 @@ lazy val commonLib = project("common-lib").settings(
"software.amazon.awssdk" % "cloudwatch" % awsSdkV2Version,
// declare explicit dependency on desired version of aws sdk v2 bedrock runtime
"software.amazon.awssdk" % "bedrockruntime" % awsSdkV2Version,
"software.amazon.awssdk" % "s3vectors" % awsSdkV2Version,
"com.adobe.xmp" % "xmpcore" % "6.1.11",
ws,
"org.testcontainers" % "testcontainers-elasticsearch" % "2.0.2" % Test,
"com.google.genai" % "google-genai" % "1.53.0" exclude("com.fasterxml.jackson.module", "jackson-module-kotlin"),
"org.testcontainers" % "testcontainers-elasticsearch" % "2.0.5" % Test,
),
dependencyOverrides += "ch.qos.logback" % "logback-classic" % "1.2.13" % Test
)
Expand Down Expand Up @@ -185,10 +185,14 @@ lazy val thrall = playProject("thrall", 9002)
"com.gu" %% "kcl-pekko-stream" % "0.1.2",
"org.testcontainers" % "testcontainers-elasticsearch" % "2.0.2" % Test,
"com.google.protobuf" % "protobuf-java" % "3.19.6",
"software.amazon.awssdk" % "sqs" % awsSdkV2Version
"software.amazon.awssdk" % "sqs" % awsSdkV2Version,
"org.apache.pekko" %% "pekko-connectors-sqs" % "1.0.2"
),
dependencyOverrides ++= Seq(
"org.apache.pekko" %% "pekko-stream" % "1.0.3"
"org.apache.pekko" %% "pekko-stream" % "1.0.3",
"org.apache.pekko" %% "pekko-http" % "1.0.1",
"org.apache.pekko" %% "pekko-http-core" % "1.0.1",
"org.apache.pekko" %% "pekko-parsing" % "1.0.1"
)
)

Expand Down
7 changes: 5 additions & 2 deletions cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
options:
machineType: 'N1_HIGHCPU_8'
timeout: '1200s'
steps:
- name: 'node:24-alpine'
entrypoint: 'npm'
Expand All @@ -15,9 +16,11 @@ steps:
args: [ 'run', 'dist' ]

- name: 'gcr.io/$PROJECT_ID/scala-sbt:1.11.7-jdk-25'
args: ['docker:publishLocal']
args: ['compile', 'collections/test', 'leases/test', 'media-api/test', 'docker:publishLocal']
env:
- 'DOCKER_API_VERSION=1.41'
- 'DOCKER_HOST=unix:///var/run/docker.sock'
- 'DOCKER_API_VERSION=1.41'

- name: 'gcr.io/cloud-builders/docker'
args: ['tag', 'auth:0.1', 'eu.gcr.io/$PROJECT_ID/auth:$BRANCH_NAME']
- name: 'gcr.io/cloud-builders/docker'
Expand Down
7 changes: 3 additions & 4 deletions collections/test/store/CollectionsStoreTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.time.{Millis, Seconds, Span}
import org.testcontainers.containers.localstack.LocalStackContainer
import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB
import org.testcontainers.localstack.LocalStackContainer
import org.testcontainers.utility.DockerImageName
import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider}
import software.amazon.awssdk.regions.Region
Expand All @@ -25,11 +24,11 @@ class CollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutures wi

implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(2, Seconds), interval = Span(100, Millis))

private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB)
private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices("dynamodb")
dynamoContainer.start()

private val dynamoClient = DynamoDbAsyncClient.builder().
endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)).
endpointOverride(dynamoContainer.getEndpoint).
region(Region.of(dynamoContainer.getRegion)).
credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build()

Expand Down
7 changes: 3 additions & 4 deletions collections/test/store/ImageCollectionsStoreTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import org.scalatest.concurrent.ScalaFutures
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.time.{Millis, Seconds, Span}
import org.testcontainers.containers.localstack.LocalStackContainer
import org.testcontainers.containers.localstack.LocalStackContainer.Service.DYNAMODB
import org.testcontainers.localstack.LocalStackContainer
import org.testcontainers.utility.DockerImageName
import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, StaticCredentialsProvider}
import software.amazon.awssdk.regions.Region
Expand All @@ -22,11 +21,11 @@ class ImageCollectionsStoreTest extends AnyFunSpec with Matchers with ScalaFutur

implicit val defaultPatience: PatienceConfig = PatienceConfig(timeout = Span(5, Seconds), interval = Span(500, Millis))

private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices(DYNAMODB)
private val dynamoContainer = new LocalStackContainer(DockerImageName.parse("localstack/localstack:1.4.0")).withServices("dynamodb")
dynamoContainer.start()

private val dynamoClient = DynamoDbAsyncClient.builder().
endpointOverride(dynamoContainer.getEndpointOverride(DYNAMODB)).
endpointOverride(dynamoContainer.getEndpoint).
region(Region.of(dynamoContainer.getRegion)).
credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(dynamoContainer.getAccessKey, dynamoContainer.getSecretKey))).build()

Expand Down
5 changes: 4 additions & 1 deletion common-lib/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ authentication.providers {
machine {
className = "com.gu.mediaservice.lib.auth.provider.ApiKeyAuthenticationProvider"
config {
# authKeyStoreBucket = <s3-bucket-with-api-keys>
# authKeyStoreBucket.name = <s3-bucket-with-api-keys>
# authKeyStoreBucket.endpoint = <optional-s3-endpoint-override>
# authKeyStoreBucket.pathStyleUrls = false
# authKeyStoreBucket.region = <optional-region-override>
}
}
# TODO: short term we put panda here for backwards compatibility but the default provider should be something better
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,9 @@ class GridClient(services: Services, originDomain: Instance => String)(implicit

def getImageLoaderProjection(mediaId: String, imageLoaderEndpoint: String, authFn: WSRequest => WSRequest)
(implicit ec: ExecutionContext): Future[Option[Image]] = {
logger.info("attempt to get image projection from image-loader")
val url = new URL(s"$imageLoaderEndpoint/images/project/$mediaId")
val projectUrl = s"$imageLoaderEndpoint/images/project/$mediaId"
logger.info(s"attempt to get image projection from image-loader: $projectUrl")
val url = new URL(projectUrl)
makeGetRequestAsync(url, authFn, requestTimeout = Some(300.seconds)) map {
case Found(json, _) => Some(json.as[Image])
case NotFound(_, _) => None
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.gu.mediaservice.lib


import _root_.play.api.libs.json._
import com.gu.mediaservice.lib.aws.{S3Bucket, S3Object}
import com.gu.mediaservice.lib.config.CommonConfig
import com.gu.mediaservice.lib.logging.LogMarker
import com.gu.mediaservice.model.{Instance, MimeType}
import com.gu.mediaservice.model.{Embedding, Instance, MimeType}
import com.typesafe.scalalogging.StrictLogging
import org.joda.time.DateTime
import software.amazon.awssdk.core.ResponseInputStream
import software.amazon.awssdk.services.s3.model.GetObjectResponse

import java.io.File
import scala.concurrent.Future
Expand All @@ -16,19 +18,22 @@ object ImageIngestOperations {

def optimisedPngKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + "optimised/" + snippetForId(id: String)

def embeddingKeyFromId(id: String)(implicit instance: Instance): String = instance.id + "/" + snippetForId(id)

private def snippetForId(id: String) = id.take(6).mkString("/") + "/" + id
}

class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, config: CommonConfig, isVersionedS3: Boolean = false)
class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, embeddingSourceBucket: S3Bucket, embeddingsBucket: S3Bucket, config: CommonConfig, isVersionedS3: Boolean = false)
extends S3ImageStorage(config) with StrictLogging {

import ImageIngestOperations.{fileKeyFromId, optimisedPngKeyFromId}
import ImageIngestOperations.{embeddingKeyFromId, fileKeyFromId, optimisedPngKeyFromId}

def store(storableImage: StorableImage)
(implicit logMarker: LogMarker): Future[S3Object] = storableImage match {
case s:StorableOriginalImage => storeOriginalImage(s)
case s:StorableThumbImage => storeThumbnailImage(s)
case s:StorableOptimisedImage => storeOptimisedImage(s)
case s:StorableEmbeddingSourceImage => storeEmbeddingSourceImage(s)
}

private def storeOriginalImage(storableImage: StorableOriginalImage)
Expand All @@ -55,6 +60,23 @@ class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, co
overwrite = true)
}

private def storeEmbeddingSourceImage(storableImage: StorableEmbeddingSourceImage)
(implicit logMarker: LogMarker): Future[S3Object] = {
val instanceSpecificKey = fileKeyFromId(storableImage.id)(storableImage.instance)
logger.info(s"Storing embedding source to instance specific key: ${embeddingSourceBucket.name} / $instanceSpecificKey")
storeImage(embeddingSourceBucket, instanceSpecificKey, storableImage.file, Some(storableImage.mimeType),
overwrite = true)
}

def getEmbeddingStoreImage(key: String): ResponseInputStream[GetObjectResponse] = {
getObject(embeddingSourceBucket, key)
}

def storeEmbedding(key: String, embedding: Embedding): Unit = {
logger.info(s"Storing embedding source to key: ${embeddingsBucket.name} / $key")
putString(embeddingsBucket, key, Json.stringify(Json.toJson(embedding)))
}

private def bulkDelete(bucket: S3Bucket, keys: List[String]): Future[Map[String, Boolean]] = keys match {
case Nil => Future.successful(Map.empty)
case _ => Future {
Expand All @@ -68,6 +90,7 @@ class ImageIngestOperations(imageBucket: S3Bucket, thumbnailBucket: S3Bucket, co
def deleteThumbnails(ids: Set[String])(implicit instance: Instance) = bulkDelete(thumbnailBucket, ids.map(id => fileKeyFromId(id)).toList)
def deletePNG(id: String)(implicit logMarker: LogMarker, instance: Instance): Future[Unit] = deleteImage(imageBucket, optimisedPngKeyFromId(id))
def deletePNGs(ids: Set[String])(implicit instance: Instance) = bulkDelete(imageBucket, ids.map(id => optimisedPngKeyFromId(id)).toList)
def deleteEmbeddings(ids: Set[String])(implicit instance: Instance) = bulkDelete(embeddingsBucket, ids.map(id => embeddingKeyFromId(id)).toList)

def doesOriginalExist(id: String)(implicit instance: Instance): Boolean =
this.doesObjectExist(imageBucket, fileKeyFromId(id))
Expand Down Expand Up @@ -121,7 +144,16 @@ case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, me
meta = meta
)
}

case class StorableEmbeddingSourceImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, instance: Instance) extends StorableImage {
override def toProjectedS3Object(embeddingSourcesBucket: S3Bucket): S3Object = S3Object(
embeddingSourcesBucket,
ImageIngestOperations.fileKeyFromId(id)(instance),
file,
Some(mimeType),
lastModified = None,
meta = meta
)
}

/**
* @param id
Expand All @@ -135,5 +167,6 @@ case class StorableOptimisedImage(id: String, file: File, mimeType: MimeType, me
case class BrowserViewableImage(id: String, file: File, mimeType: MimeType, meta: Map[String, String] = Map.empty, isTransformedFromSource: Boolean = false, instance: Instance) extends ImageWrapper {
def asStorableOptimisedImage = StorableOptimisedImage(id, file, mimeType, meta, instance)
def asStorableThumbImage = StorableThumbImage(id, file, mimeType, meta, instance)
def asStorableEmbeddingSourceImage = StorableEmbeddingSourceImage(id, file, mimeType, meta, instance)
}

Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
package com.gu.mediaservice.lib.aws

import software.amazon.awssdk.services.bedrockruntime.model._
import software.amazon.awssdk.services.bedrockruntime._
import com.gu.mediaservice.lib.config.CommonConfig
import play.api.libs.json.Json
import software.amazon.awssdk.core.SdkBytes

import java.net.URI
import com.gu.mediaservice.lib.embeddings.{EmbeddingImplementation, EmbeddingSourceImageFormat}
import com.gu.mediaservice.lib.logging.LogMarker
import com.gu.mediaservice.model.{CohereV4Embedding, Embedding, ImageMetadata, Jpeg, MimeType}
import org.apache.commons.codec.binary.Base64
import play.api.libs.json.OFormat.oFormatFromReadsAndOWrites
import play.api.libs.json._
import software.amazon.awssdk.core.SdkBytes
import software.amazon.awssdk.services.bedrockruntime._
import software.amazon.awssdk.services.bedrockruntime.model._

import java.net.URI
import scala.concurrent.{ExecutionContext, Future}

object Bedrock {
Expand All @@ -22,10 +23,19 @@ object Bedrock {
)

private implicit val bedrockTextRequestFormat: OFormat[BedrockTextRequest] = Json.format[BedrockTextRequest]

case class BedrockImageRequest(
input_type: String,
embedding_types: List[String],
images: List[String],
output_dimension: Int
)

private implicit val bedrockImageRequestFormat: OFormat[BedrockImageRequest] = Json.format[BedrockImageRequest]
}

class Bedrock(config: CommonConfig)
extends AwsClientBuilderUtils {
extends EmbeddingImplementation with AwsClientBuilderUtils {

// TODO: figure out what the more usual pattern for turning off localstack behaviour is
override def awsLocalEndpointUri: Option[URI] = None
Expand All @@ -37,7 +47,7 @@ class Bedrock(config: CommonConfig)
.build()
}

private def createRequestBody(inputData: String): InvokeModelRequest = {
private def createSearchQueryRequestBody(inputData: String): InvokeModelRequest = {
val body = Bedrock.BedrockTextRequest(
input_type = "search_query",
embedding_types = List("float"),
Expand All @@ -58,6 +68,30 @@ class Bedrock(config: CommonConfig)
request
}

private def createImageSearchDocumentRequestBody(base64Image: String, imageMimeType: MimeType): InvokeModelRequest = {
val body = Bedrock.BedrockImageRequest(
input_type = "search_document",
embedding_types = List("float"),
images = List(
s"`data:${imageMimeType.name};base64,$base64Image`"
),
output_dimension = 1536
)

val jsonBody = Json.toJson(body).toString()

val request: InvokeModelRequest = {
InvokeModelRequest
.builder()
.accept("*/*")
.body(SdkBytes.fromUtf8String(jsonBody))
.contentType("application/json")
.modelId("global.cohere.embed-v4:0")
.build()
}
request
}

private def sendBedrockEmbeddingRequest(requestBody: InvokeModelRequest)(
implicit logMarker: LogMarker
): InvokeModelResponse = {
Expand All @@ -77,7 +111,7 @@ class Bedrock(config: CommonConfig)
}

def createTextEmbedding(inputData: String)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[List[Double]] = {
val requestBody = createRequestBody(inputData)
val requestBody = createSearchQueryRequestBody(inputData)
val bedrockFuture = Future { sendBedrockEmbeddingRequest(requestBody) }
bedrockFuture.map { response =>
val responseBody = response.body().asUtf8String()
Expand All @@ -91,4 +125,32 @@ class Bedrock(config: CommonConfig)
embedding
}
}

override def createImageEmbeddings(source: Array[Byte], mimeType: MimeType, maybeMetadata: Option[ImageMetadata])(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Embedding] = {
val base64ImageData = Base64.encodeBase64String(source)
val requestBody = createImageSearchDocumentRequestBody(
base64ImageData, embeddingSourceImageFormat().format
)
val bedrockFuture = Future {
sendBedrockEmbeddingRequest(requestBody)
}
bedrockFuture.map { response =>
val responseBody = response.body().asUtf8String()
val json = Json.parse(responseBody)
// Extract the embedding array (first element since it's an array of arrays)
val embeddings = (json \ "embeddings" \ "float")(0).as[List[Double]]
logger.info(
logMarker,
s"Successfully created image embedding. Vector size: ${embeddings.size}"
)
embeddings
}.map { embeddings =>
Embedding(
cohereEmbedV4 = Some(CohereV4Embedding(embeddings))
)
}
}

override def embeddingSourceImageFormat(): EmbeddingSourceImageFormat = EmbeddingSourceImageFormat(longestAxis = 3000, format = Jpeg, letterBox = false)

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ trait ContentDisposition extends GridLogging {
getContentDisposition(filename, fallbackLatin1Filename(image, extension))
}

def getContentDisposition(image: Image, crop: Crop, asset: Asset, shortenDownloadFilename: Boolean): String = {
val cropId: String = crop.id.map(id => s"($id)").getOrElse("")
def getContentDisposition(image: Image, crop: Crop, asset: Asset): String = {
val extension: String = getExtension(image, asset)
val dimensions: String = asset.dimensions.map(dims => s"(${dims.width} x ${dims.height})").getOrElse("")
val filenameSuffix: String = s"(${image.id})$cropId$dimensions$extension"
val filename = getBaseFilename(image, filenameSuffix, shortenDownloadFilename)
val filename = image.uploadInfo.filename match {
case Some(filename) => filename
case _ => image.id
}

getContentDisposition(filename, fallbackLatin1Filename(image, extension))
// Drop original file's extension and replace with the crops actual extension
val withCropsExtension = removeExtension(filename) + extension
getContentDisposition(withCropsExtension, fallbackLatin1Filename(image, extension))
}

private def getExtension(image: Image, asset: Asset): String = asset.mimeType match {
Expand Down
Loading
Loading