diff --git a/README.md b/README.md
index 7416c86..4d1bc26 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ Business API без админки:
```bash
curl -sS http://localhost:3001/api/presets
curl -sS http://localhost:3001/api/assets
+curl -sS 'http://localhost:3001/api/assets/asset_demo/picture?preset=card&sizes=100vw'
curl -sS http://localhost:3001/api/assets/asset_demo/variants
curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \
-H 'content-type: application/json' \
diff --git a/apps/backend/src/assets/asset-picture-response.dto.ts b/apps/backend/src/assets/asset-picture-response.dto.ts
new file mode 100644
index 0000000..084518c
--- /dev/null
+++ b/apps/backend/src/assets/asset-picture-response.dto.ts
@@ -0,0 +1,55 @@
+import { ApiProperty } from "@nestjs/swagger"
+
+export class AssetPictureImageResponseDto {
+ @ApiProperty({ description: "Fallback image URL для `
`.", example: "http://localhost:8888/images/asset_demo/v1/card?w=960&q=80&f=jpg" })
+ src!: string
+
+ @ApiProperty({ description: "Fallback image format.", enum: ["avif", "webp", "jpg", "png"], example: "jpg" })
+ format!: string
+
+ @ApiProperty({ description: "Fallback image Content-Type.", example: "image/jpeg" })
+ type!: string
+
+ @ApiProperty({ description: "Fallback image width.", example: 960 })
+ width!: number
+
+ @ApiProperty({ description: "Fallback image height. `0` означает auto height.", example: 0 })
+ height!: number
+}
+
+export class AssetPictureSourceResponseDto {
+ @ApiProperty({ description: "Source format.", enum: ["avif", "webp", "jpg", "png"], example: "webp" })
+ format!: string
+
+ @ApiProperty({ description: "Source MIME type для ``.", example: "image/webp" })
+ type!: string
+
+ @ApiProperty({ description: "Готовая строка srcset с width descriptors." })
+ srcSet!: string
+}
+
+export class AssetPictureResponseDto {
+ @ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
+ publicId!: string
+
+ @ApiProperty({ description: "Preset, для которого построен picture contract.", example: "card" })
+ preset!: string
+
+ @ApiProperty({ description: "Версия source image.", example: 1 })
+ version!: number
+
+ @ApiProperty({ description: "Quality для всех URL.", example: 80 })
+ quality!: number
+
+ @ApiProperty({ description: "Значение sizes для `
`.", example: "100vw" })
+ sizes!: string
+
+ @ApiProperty({ description: "Width descriptors, вошедшие в srcset.", example: [320, 640, 960] })
+ widths!: number[]
+
+ @ApiProperty({ description: "Fallback image для `
`.", type: AssetPictureImageResponseDto })
+ image!: AssetPictureImageResponseDto
+
+ @ApiProperty({ description: "Sources для ``.", type: [AssetPictureSourceResponseDto] })
+ sources!: AssetPictureSourceResponseDto[]
+}
diff --git a/apps/backend/src/assets/assets.controller.ts b/apps/backend/src/assets/assets.controller.ts
index 7d900c8..6f159a6 100644
--- a/apps/backend/src/assets/assets.controller.ts
+++ b/apps/backend/src/assets/assets.controller.ts
@@ -12,6 +12,7 @@ import {
} from "@nestjs/swagger"
import { AssetsService } from "./assets.service"
+import { AssetPictureResponseDto } from "./asset-picture-response.dto"
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
@@ -77,6 +78,30 @@ export class AssetsController {
return this.assets.createAssetVersion(publicId, request)
}
+ @Get(":publicId/picture")
+ @ApiOperation({
+ summary: "получить picture/srcset URLs",
+ description:
+ "Возвращает готовый контракт для `` и `
` по static preset: sources, srcset, fallback src, sizes и versioned Gateway URLs. Endpoint не ставит generation jobs: Gateway сгенерирует bytes lazy или отдаст cache.",
+ })
+ @ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
+ @ApiQuery({ description: "Static preset для picture contract.", example: "card", name: "preset", required: true })
+ @ApiQuery({ description: "Версия source image. Если не передана, используется currentVersion asset.", example: 1, name: "version", required: false })
+ @ApiQuery({ description: "Quality. Если не передано, берётся default quality preset.", example: 80, name: "quality", required: false })
+ @ApiQuery({ description: "Значение для HTML `sizes`.", example: "(min-width: 768px) 50vw, 100vw", name: "sizes", required: false })
+ @ApiOkResponse({ description: "Picture/srcset contract возвращён.", type: AssetPictureResponseDto })
+ @ApiBadRequestResponse({ description: "Некорректный preset, version, quality или sizes." })
+ @ApiNotFoundResponse({ description: "Asset или version не найдены." })
+ getAssetPicture(
+ @Param("publicId") publicId: string,
+ @Query("preset") preset?: string,
+ @Query("version") version?: string,
+ @Query("quality") quality?: string,
+ @Query("sizes") sizes?: string,
+ ): Promise {
+ return this.assets.getAssetPicture(publicId, { preset, quality, sizes, version })
+ }
+
@Get(":publicId/variants")
@ApiOperation({
summary: "получить variants asset",
diff --git a/apps/backend/src/assets/assets.service.ts b/apps/backend/src/assets/assets.service.ts
index 05cd717..a755e60 100644
--- a/apps/backend/src/assets/assets.service.ts
+++ b/apps/backend/src/assets/assets.service.ts
@@ -17,6 +17,7 @@ import { createHash, randomUUID } from "node:crypto"
import { DatabaseService } from "../infra/database.service"
import { QueueService } from "../infra/queue.service"
+import type { AssetPictureResponseDto } from "./asset-picture-response.dto"
import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
@@ -38,6 +39,16 @@ type VariantTransform = NormalizedImageTransform & {
version: number
}
+type PublicImageUrlInput = {
+ assetVersion: number
+ format: ActualImageFormat
+ height: number | null
+ preset: string
+ quality: number
+ resizeMode: "fill" | "fit"
+ width: number
+}
+
@Injectable()
export class AssetsService {
private readonly allowedHosts = loadAllowedSourceHostsFromEnv()
@@ -218,6 +229,78 @@ export class AssetsService {
}
}
+ async getAssetPicture(
+ publicId: string,
+ input: { preset?: string; quality?: string; sizes?: string; version?: string },
+ ): Promise {
+ if (!input.preset) {
+ throw new BadRequestException("preset is required")
+ }
+
+ const preset = getImagePreset(input.preset)
+
+ if (!preset || input.preset === CUSTOM_PRESET_NAME) {
+ throw new BadRequestException("picture endpoint supports only static presets")
+ }
+
+ const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(input.version))
+ const quality = parseOptionalQuality(input.quality)
+ const sizes = normalizeSizes(input.sizes)
+ const widths = getPresetWidths(input.preset, preset)
+ const sources = preset.formats.map((format) => {
+ const srcSet = widths
+ .map((width) => {
+ const transform = this.normalizeTransform({
+ format,
+ preset: input.preset!,
+ quality,
+ version: asset.version,
+ width: preset.mode === "fixed" ? undefined : width,
+ })
+
+ return `${buildPublicImageUrl(this.publicImageBaseUrl, asset.publicId, transformToPublicUrlInput(transform))} ${transform.width}w`
+ })
+ .join(", ")
+
+ return {
+ format,
+ srcSet,
+ type: contentTypeForFormat(format),
+ }
+ })
+ const fallbackFormat = selectFallbackFormat(preset.formats)
+ const fallbackWidth = widths[widths.length - 1]
+
+ if (fallbackWidth === undefined) {
+ throw new BadRequestException(`preset ${input.preset} has no widths configured`)
+ }
+
+ const fallbackTransform = this.normalizeTransform({
+ format: fallbackFormat,
+ preset: input.preset,
+ quality,
+ version: asset.version,
+ width: preset.mode === "fixed" ? undefined : fallbackWidth,
+ })
+
+ return {
+ image: {
+ format: fallbackTransform.format,
+ height: fallbackTransform.height,
+ src: buildPublicImageUrl(this.publicImageBaseUrl, asset.publicId, transformToPublicUrlInput(fallbackTransform)),
+ type: contentTypeForFormat(fallbackTransform.format),
+ width: fallbackTransform.width,
+ },
+ preset: input.preset,
+ publicId: asset.publicId,
+ quality: fallbackTransform.quality,
+ sizes,
+ sources,
+ version: asset.version,
+ widths,
+ }
+ }
+
async listAssetVariants(publicId: string, versionInput?: string): Promise {
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput))
const conditions = [eq(imageVariants.assetId, asset.assetId)]
@@ -642,6 +725,38 @@ function parseOptionalVersion(value: string | undefined) {
return parsed
}
+function parseOptionalQuality(value: string | undefined) {
+ if (value === undefined) {
+ return undefined
+ }
+
+ if (!/^\d+$/.test(value)) {
+ throw new BadRequestException("quality must be a positive integer")
+ }
+
+ const parsed = Number.parseInt(value, 10)
+
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 100) {
+ throw new BadRequestException("quality must be between 1 and 100")
+ }
+
+ return parsed
+}
+
+function normalizeSizes(value: string | undefined) {
+ if (value === undefined) {
+ return "100vw"
+ }
+
+ const normalized = value.trim()
+
+ if (!normalized || normalized.length > 256 || /[<>]/.test(normalized)) {
+ throw new BadRequestException("sizes is invalid")
+ }
+
+ return normalized
+}
+
function selectDefaultFormat(preset: string, allowCustomTransforms: boolean): ActualImageFormat {
const config = getImagePreset(preset)
@@ -674,7 +789,49 @@ function createVariantHash(publicId: string, transform: VariantTransform) {
.slice(0, 32)
}
-function buildPublicImageUrl(baseUrl: string, publicId: string, variant: VariantRow) {
+function getPresetWidths(presetName: string, preset: NonNullable>) {
+ if (preset.mode === "fixed") {
+ if (!preset.width) {
+ throw new BadRequestException(`preset ${presetName} has no width configured`)
+ }
+
+ return [preset.width]
+ }
+
+ if (!preset.widths?.length) {
+ throw new BadRequestException(`preset ${presetName} has no widths configured`)
+ }
+
+ return [...preset.widths]
+}
+
+function selectFallbackFormat(formats: readonly ActualImageFormat[]) {
+ if (formats.includes("jpg")) {
+ return "jpg"
+ }
+
+ const format = formats[formats.length - 1]
+
+ if (!format) {
+ throw new BadRequestException("preset has no formats configured")
+ }
+
+ return format
+}
+
+function transformToPublicUrlInput(transform: VariantTransform): PublicImageUrlInput {
+ return {
+ assetVersion: transform.version,
+ format: transform.format,
+ height: transform.height,
+ preset: transform.preset,
+ quality: transform.quality,
+ resizeMode: transform.resize,
+ width: transform.width,
+ }
+}
+
+function buildPublicImageUrl(baseUrl: string, publicId: string, variant: PublicImageUrlInput) {
const url = new URL(`/images/${publicId}/v${variant.assetVersion}/${variant.preset}`, baseUrl)
const isFixedPresetUrl = variant.preset !== CUSTOM_PRESET_NAME && variant.height && variant.height > 0
@@ -695,3 +852,11 @@ function buildPublicImageUrl(baseUrl: string, publicId: string, variant: Variant
return url.toString()
}
+
+function contentTypeForFormat(format: ActualImageFormat) {
+ if (format === "jpg") {
+ return "image/jpeg"
+ }
+
+ return `image/${format}`
+}
diff --git a/docs/backend-contract-draft.md b/docs/backend-contract-draft.md
index d30caac..a68768c 100644
--- a/docs/backend-contract-draft.md
+++ b/docs/backend-contract-draft.md
@@ -111,12 +111,13 @@ POST /assets
GET /assets
GET /assets/:publicId
POST /assets/:publicId/versions
+GET /assets/:publicId/picture
GET /assets/:publicId/variants
POST /assets/:publicId/variants
DELETE /assets/:id
```
-Сейчас реализованы `POST /assets`, `GET /assets`, `GET /assets/:publicId`, `POST /assets/:publicId/versions`, `GET /assets/:publicId/variants`, `POST /assets/:publicId/variants`.
+Сейчас реализованы `POST /assets`, `GET /assets`, `GET /assets/:publicId`, `POST /assets/:publicId/versions`, `GET /assets/:publicId/picture`, `GET /assets/:publicId/variants`, `POST /assets/:publicId/variants`.
`POST /assets` request:
@@ -194,6 +195,35 @@ Response:
Новая версия становится `currentVersion` asset. Старые Gateway URLs с `/v1/` остаются immutable и не требуют purge.
+`GET /assets/:publicId/picture?preset=card&sizes=100vw` response:
+
+```json
+{
+ "publicId": "asset_demo",
+ "preset": "card",
+ "version": 2,
+ "quality": 80,
+ "sizes": "100vw",
+ "widths": [320, 640, 960],
+ "image": {
+ "src": "http://localhost:8888/images/asset_demo/v2/card?w=960&q=80&f=jpg",
+ "format": "jpg",
+ "type": "image/jpeg",
+ "width": 960,
+ "height": 0
+ },
+ "sources": [
+ {
+ "format": "avif",
+ "type": "image/avif",
+ "srcSet": "http://localhost:8888/images/asset_demo/v2/card?w=320&q=80&f=avif 320w, http://localhost:8888/images/asset_demo/v2/card?w=640&q=80&f=avif 640w, http://localhost:8888/images/asset_demo/v2/card?w=960&q=80&f=avif 960w"
+ }
+ ]
+}
+```
+
+Endpoint не ставит generation jobs: Gateway lazy path сгенерирует bytes на первом запросе или отдаст cache/S3.
+
## Variants
```text
diff --git a/docs/development.md b/docs/development.md
index da48676..7c2997d 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -90,6 +90,7 @@ curl -sS -X POST http://localhost:3001/api/assets \
curl -sS http://localhost:3001/api/presets
curl -sS http://localhost:3001/api/assets
curl -sS http://localhost:3001/api/assets/asset_demo
+curl -sS 'http://localhost:3001/api/assets/asset_demo/picture?preset=card&sizes=100vw'
curl -sS http://localhost:3001/api/assets/asset_demo/variants
```