diff --git a/docs/api-spec.json b/docs/api-spec.json index a4b8478..3ee0690 100644 --- a/docs/api-spec.json +++ b/docs/api-spec.json @@ -95,7 +95,7 @@ }, "/api/v1/templates/code/{code}/execute": { "post": { - "description": "\n 根据模板代码执行AI生成任务,支持同步和异步两种审核模式:\n - 同步模式:立即返回审核结果,若通过则开始执行,返回 executionId 和 taskId\n - 异步模式:返回 executionId 和 auditTaskId,等待审核回调完成后开始执行\n \n ID说明:\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID,同步模式时返回\n - auditTaskId: 审核服务任务ID,异步模式时返回\n ", + "description": "\n 根据模板代码执行AI生成任务,直接执行模板无需后端审核。\n \n 参数说明:\n - imageUrl: 输入图片URL(必需)\n - auditTaskId: 前端审核任务ID(可选),用于追踪审核记录\n \n 返回ID说明:\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID,用于追踪模板执行状态\n - auditTaskId: 前端传入的审核任务ID\n ", "operationId": "TemplateController_executeTemplateByCode", "parameters": [ { @@ -131,7 +131,9 @@ "data": { "executionId": 123, "taskId": "n8n_task_456", - "status": "processing" + "auditTaskId": "audit_task_789", + "status": "processing", + "message": "模板执行已启动" } } } @@ -158,20 +160,6 @@ } } }, - "403": { - "description": "图片审核未通过", - "content": { - "application/json": { - "schema": { - "example": { - "code": 403, - "message": "图片审核未通过: 包含不当内容", - "data": null - } - } - } - } - }, "429": { "description": "任务数量限制", "content": { @@ -1094,52 +1082,95 @@ ] } }, - "/api/v1/enhanced/templates/code/{code}/execute": { + "/api/v1/image-composition/compose": { "post": { - "description": "统一异步模式:先提交审核,然后通过回调继续执行模板。支持实时状态查询。", - "operationId": "EnhancedTemplateController_executeTemplateByCode", - "parameters": [ - { - "name": "code", - "required": true, - "in": "path", - "schema": { - "type": "string" + "description": "将多张图片按指定位置拼接到一个画布上,支持层级、透明度等设置", + "operationId": "ImageCompositionController_composeImages", + "parameters": [], + "requestBody": { + "required": true, + "description": "图片拼接配置 - 简化版本", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCompositionDto" + }, + "examples": { + "horizontal": { + "summary": "水平排列示例", + "value": { + "images": [ + "https://picsum.photos/300/200", + "https://picsum.photos/300/200", + "https://picsum.photos/300/200" + ], + "direction": "horizontal", + "outputFormat": "jpeg", + "maxWidth": 1920, + "maxHeight": 1080, + "quality": 80 + } + }, + "vertical": { + "summary": "垂直排列示例", + "value": { + "images": [ + "https://picsum.photos/200/300", + "https://picsum.photos/200/300", + "https://picsum.photos/200/300" + ], + "direction": "vertical", + "outputFormat": "webp", + "maxWidth": 800, + "maxHeight": 1200, + "quality": 85 + } + }, + "grid": { + "summary": "网格排列示例", + "value": { + "images": [ + "https://picsum.photos/200/200", + "https://picsum.photos/200/200", + "https://picsum.photos/200/200", + "https://picsum.photos/200/200", + "https://picsum.photos/200/200" + ], + "direction": "grid", + "outputFormat": "jpeg", + "maxWidth": 1024, + "maxHeight": 1024, + "quality": 75 + } + } + } } } - ], - "responses": { - "201": { - "description": "" - } }, - "summary": "异步执行模板 (增强版)", - "tags": [ - "AI模板系统 (增强版)" - ] - } - }, - "/api/v1/enhanced/templates/{executionId}/status": { - "get": { - "operationId": "EnhancedTemplateController_getExecutionStatus", - "parameters": [ - { - "name": "executionId", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], "responses": { "200": { - "description": "" + "description": "图片拼接成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCompositionResponseDto" + } + } + } + }, + "400": { + "description": "请求参数错误" + }, + "401": { + "description": "未授权访问" + }, + "500": { + "description": "服务器内部错误" } }, - "summary": "查询执行状态 (增强版)", + "summary": "图片拼接", "tags": [ - "AI模板系统 (增强版)" + "图片处理" ] } }, @@ -1778,7 +1809,7 @@ ], "servers": [ { - "url": "http://localhost:3004", + "url": "http://localhost:3003", "description": "开发环境" }, { @@ -1850,6 +1881,11 @@ "type": "string", "description": "输入图片URL", "example": "https://cdn.example.com/upload/image.jpg" + }, + "auditTaskId": { + "type": "string", + "description": "审核任务ID(可选)", + "example": "audit_task_12345" } }, "required": [ @@ -2227,6 +2263,115 @@ } } }, + "ImageCompositionDto": { + "type": "object", + "properties": { + "images": { + "description": "图片URL或base64数据列表", + "example": [ + "https://picsum.photos/300/200", + "https://picsum.photos/300/200", + "https://picsum.photos/300/200" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "direction": { + "type": "string", + "description": "排列方向", + "enum": [ + "horizontal", + "vertical", + "grid" + ], + "example": "horizontal" + }, + "outputFormat": { + "type": "string", + "description": "输出格式", + "enum": [ + "jpeg", + "png", + "webp" + ], + "example": "png", + "default": "png" + }, + "maxWidth": { + "type": "number", + "description": "最大输出宽度(像素),超过会等比压缩", + "example": 1920, + "default": 1920, + "minimum": 100, + "maximum": 4096 + }, + "maxHeight": { + "type": "number", + "description": "最大输出高度(像素),超过会等比压缩", + "example": 1080, + "default": 1080, + "minimum": 100, + "maximum": 4096 + }, + "quality": { + "type": "number", + "description": "图片质量(1-100),仅对JPEG/WebP有效", + "example": 80, + "default": 80, + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "images", + "direction" + ] + }, + "ImageCompositionResponseDto": { + "type": "object", + "properties": { + "imageUrl": { + "type": "string", + "description": "拼接后的图片URL", + "example": "https://s3.amazonaws.com/bucket/composed-image-1234567890.png" + }, + "format": { + "type": "string", + "description": "图片格式", + "example": "png" + }, + "size": { + "type": "number", + "description": "图片大小(字节)", + "example": 12345 + }, + "width": { + "type": "number", + "description": "画布宽度", + "example": 800 + }, + "height": { + "type": "number", + "description": "画布高度", + "example": 600 + }, + "processingTime": { + "type": "number", + "description": "处理耗时(毫秒)", + "example": 150 + } + }, + "required": [ + "imageUrl", + "format", + "size", + "width", + "height", + "processingTime" + ] + }, "PlatformType": { "type": "string", "enum": [ diff --git a/package.json b/package.json index dbe8ded..3a0be7c 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,11 @@ "axios": "^1.11.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "form-data": "^4.0.4", "mysql2": "^3.9.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "sharp": "^0.34.4", "typeorm": "^0.3.20" }, "devDependencies": { @@ -55,6 +57,7 @@ "@types/express": "^5.0.0", "@types/jest": "^30.0.0", "@types/node": "^22.10.7", + "@types/sharp": "^0.32.0", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 481f5c0..a9546e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,12 +32,6 @@ importers: '@nestjs/typeorm': specifier: ^11.0.0 version: 11.0.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.26(mysql2@3.14.4)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.2))) - '@open-dy/open_api_credential': - specifier: ^1.0.0 - version: 1.0.0 - '@open-dy/open_api_sdk': - specifier: 1.1.2 - version: 1.1.2 axios: specifier: ^1.11.0 version: 1.11.0 @@ -47,6 +41,9 @@ importers: class-validator: specifier: ^0.14.2 version: 0.14.2 + form-data: + specifier: ^4.0.4 + version: 4.0.4 mysql2: specifier: ^3.9.7 version: 3.14.4 @@ -56,6 +53,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + sharp: + specifier: ^0.34.4 + version: 0.34.4 typeorm: specifier: ^0.3.20 version: 0.3.26(mysql2@3.14.4)(reflect-metadata@0.2.2)(ts-node@10.9.2(@types/node@22.18.0)(typescript@5.9.2)) @@ -84,6 +84,9 @@ importers: '@types/node': specifier: ^22.10.7 version: 22.18.0 + '@types/sharp': + specifier: ^0.32.0 + version: 0.32.0 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 @@ -132,9 +135,6 @@ importers: packages: - '@alicloud/tea-typescript@1.8.0': - resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==} - '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -400,6 +400,132 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.4': + resolution: {integrity: sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.4': + resolution: {integrity: sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.3': + resolution: {integrity: sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.3': + resolution: {integrity: sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.3': + resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.3': + resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.3': + resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.3': + resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.3': + resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.3': + resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.4': + resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.4': + resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.4': + resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.4': + resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.4': + resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.4': + resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.4': + resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.4': + resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.4': + resolution: {integrity: sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.4': + resolution: {integrity: sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.4': + resolution: {integrity: sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/checkbox@4.2.2': resolution: {integrity: sha512-E+KExNurKcUJJdxmjglTl141EwxWyAHplvsYJQgSwXf8qiNWkTxTuCCqmhFEmbIXd4zLaGMfQFJ6WrZ7fSeV3g==} engines: {node: '>=18'} @@ -816,15 +942,6 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true - '@open-dy/open_api_credential@1.0.0': - resolution: {integrity: sha512-MmLrJL2QFYHV3RNJ2/hQEXcjzBCa6mtklW+AJXG+xS/x8cJ7/AgW3wQjvNwJ0oMRyxch1nQ10h9EnOGr1kew0g==} - - '@open-dy/open_api_sdk@1.1.2': - resolution: {integrity: sha512-Z+TzDxzFf+25Hw6E3crKLNRrFrN45I+BrfmnxPHm0QpGHO284FmvbEaug1lXq480jD32kPZMjJQR+r5d8LYW9g==} - - '@open-dy/open_api_util@1.0.1': - resolution: {integrity: sha512-vgUTsyNqB0mtn0LclEr7J2E0eoXTHym1Xh4hO5gXXGYS4MoZGpu9IbYlkR4LMVUSOmJs5ZhRxmNqvEt4D/5fLg==} - '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} @@ -936,12 +1053,6 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} - '@types/node@12.20.55': - resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - - '@types/node@20.19.13': - resolution: {integrity: sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==} - '@types/node@22.18.0': resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} @@ -957,6 +1068,10 @@ packages: '@types/serve-static@1.15.8': resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@types/sharp@0.32.0': + resolution: {integrity: sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==} + deprecated: This is a stub types definition. sharp provides its own type definitions, so you do not need this installed. + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1076,49 +1191,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1620,6 +1727,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-libc@2.1.1: + resolution: {integrity: sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -2037,9 +2148,6 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} - httpx@2.3.3: - resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==} - human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2872,6 +2980,10 @@ packages: engines: {node: '>= 0.10'} hasBin: true + sharp@0.34.4: + resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -3397,13 +3509,6 @@ packages: snapshots: - '@alicloud/tea-typescript@1.8.0': - dependencies: - '@types/node': 12.20.55 - httpx: 2.3.3 - transitivePeerDependencies: - - supports-color - '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -3713,6 +3818,94 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.0.0': {} + + '@img/sharp-darwin-arm64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.3 + optional: true + + '@img/sharp-darwin-x64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.3': + optional: true + + '@img/sharp-linux-arm64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.3 + optional: true + + '@img/sharp-linux-arm@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.3 + optional: true + + '@img/sharp-linux-ppc64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.3 + optional: true + + '@img/sharp-linux-s390x@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.3 + optional: true + + '@img/sharp-linux-x64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + optional: true + + '@img/sharp-wasm32@0.34.4': + dependencies: + '@emnapi/runtime': 1.5.0 + optional: true + + '@img/sharp-win32-arm64@0.34.4': + optional: true + + '@img/sharp-win32-ia32@0.34.4': + optional: true + + '@img/sharp-win32-x64@0.34.4': + optional: true + '@inquirer/checkbox@4.2.2(@types/node@22.18.0)': dependencies: '@inquirer/core': 10.2.0(@types/node@22.18.0) @@ -4257,27 +4450,6 @@ snapshots: dependencies: consola: 3.4.2 - '@open-dy/open_api_credential@1.0.0': - dependencies: - axios: 1.11.0 - transitivePeerDependencies: - - debug - - '@open-dy/open_api_sdk@1.1.2': - dependencies: - '@alicloud/tea-typescript': 1.8.0 - '@open-dy/open_api_credential': 1.0.0 - '@open-dy/open_api_util': 1.0.1 - transitivePeerDependencies: - - debug - - supports-color - - '@open-dy/open_api_util@1.0.1': - dependencies: - '@alicloud/tea-typescript': 1.8.0 - transitivePeerDependencies: - - supports-color - '@paralleldrive/cuid2@2.2.2': dependencies: '@noble/hashes': 1.8.0 @@ -4408,12 +4580,6 @@ snapshots: '@types/mime@1.3.5': {} - '@types/node@12.20.55': {} - - '@types/node@20.19.13': - dependencies: - undici-types: 6.21.0 - '@types/node@22.18.0': dependencies: undici-types: 6.21.0 @@ -4433,6 +4599,10 @@ snapshots: '@types/node': 22.18.0 '@types/send': 0.17.5 + '@types/sharp@0.32.0': + dependencies: + sharp: 0.34.4 + '@types/stack-utils@2.0.3': {} '@types/superagent@8.1.9': @@ -5103,6 +5273,8 @@ snapshots: depd@2.0.0: {} + detect-libc@2.1.1: {} + detect-newline@3.1.0: {} dezalgo@1.0.4: @@ -5577,13 +5749,6 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 - httpx@2.3.3: - dependencies: - '@types/node': 20.19.13 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - human-signals@2.1.0: {} iconv-lite@0.6.3: @@ -6542,6 +6707,35 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.1 + sharp@0.34.4: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.1 + semver: 7.7.2 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.4 + '@img/sharp-darwin-x64': 0.34.4 + '@img/sharp-libvips-darwin-arm64': 1.2.3 + '@img/sharp-libvips-darwin-x64': 1.2.3 + '@img/sharp-libvips-linux-arm': 1.2.3 + '@img/sharp-libvips-linux-arm64': 1.2.3 + '@img/sharp-libvips-linux-ppc64': 1.2.3 + '@img/sharp-libvips-linux-s390x': 1.2.3 + '@img/sharp-libvips-linux-x64': 1.2.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.3 + '@img/sharp-libvips-linuxmusl-x64': 1.2.3 + '@img/sharp-linux-arm': 0.34.4 + '@img/sharp-linux-arm64': 0.34.4 + '@img/sharp-linux-ppc64': 0.34.4 + '@img/sharp-linux-s390x': 0.34.4 + '@img/sharp-linux-x64': 0.34.4 + '@img/sharp-linuxmusl-arm64': 0.34.4 + '@img/sharp-linuxmusl-x64': 0.34.4 + '@img/sharp-wasm32': 0.34.4 + '@img/sharp-win32-arm64': 0.34.4 + '@img/sharp-win32-ia32': 0.34.4 + '@img/sharp-win32-x64': 0.34.4 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/src/app.module.ts b/src/app.module.ts index 03c8d4f..122089f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,8 @@ import { UserSubscriptionEntity } from './entities/user-subscription.entity'; import { AdWatchEntity } from './entities/ad-watch.entity'; import { N8nTemplateFactoryService } from './services/n8n-template-factory.service'; import { TemplateController } from './controllers/template.controller'; +import { ImageCompositionController } from './controllers/image-composition.controller'; +import { ImageCompositionService } from './services/image-composition.service'; import { PlatformModule } from './platform/platform.module'; import { ContentModerationModule } from './content-moderation/content-moderation.module'; import { PaymentModule } from './payment/payment.module'; @@ -54,8 +56,8 @@ import { PaymentModule } from './payment/payment.module'; ContentModerationModule, PaymentModule, ], - controllers: [AppController, TemplateController], - providers: [TemplateService, TemplateManager, N8nTemplateFactoryService], + controllers: [AppController, TemplateController, ImageCompositionController], + providers: [TemplateService, TemplateManager, N8nTemplateFactoryService, ImageCompositionService], exports: [N8nTemplateFactoryService], }) export class AppModule {} diff --git a/src/controllers/__tests__/image-composition.controller.spec.ts b/src/controllers/__tests__/image-composition.controller.spec.ts new file mode 100644 index 0000000..8bb3251 --- /dev/null +++ b/src/controllers/__tests__/image-composition.controller.spec.ts @@ -0,0 +1,95 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpService } from '@nestjs/axios'; +import { ImageCompositionController } from '../image-composition.controller'; +import { ImageCompositionService } from '../../services/image-composition.service'; +import { + ImageCompositionDto, + ArrangementDirection, + OutputFormat, +} from '../../dto/image-composition.dto'; + +describe('ImageCompositionController', () => { + let controller: ImageCompositionController; + let service: ImageCompositionService; + + const mockHttpService = { + get: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ImageCompositionController], + providers: [ + ImageCompositionService, + { + provide: HttpService, + useValue: mockHttpService, + }, + ], + }).compile(); + + controller = module.get( + ImageCompositionController, + ); + service = module.get(ImageCompositionService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('composeImages', () => { + it('should validate minimum requirements', async () => { + const invalidDto: ImageCompositionDto = { + images: [], // 空数组应该抛出错误 + direction: ArrangementDirection.HORIZONTAL, + outputFormat: OutputFormat.PNG, + }; + + await expect(controller.composeImages(invalidDto)).rejects.toThrow( + '至少需要提供一张图片', + ); + }); + + it('should validate image count limit', async () => { + const images = Array.from( + { length: 25 }, + (_, i) => `https://example.com/test${i}.jpg`, + ); + + const invalidDto: ImageCompositionDto = { + images, // 超过20张图片限制 + direction: ArrangementDirection.HORIZONTAL, + outputFormat: OutputFormat.PNG, + }; + + await expect(controller.composeImages(invalidDto)).rejects.toThrow( + '图片数量不能超过20张', + ); + }); + + it('should validate arrangement direction', async () => { + const invalidDto: any = { + images: ['https://example.com/test.jpg'], + direction: 'invalid-direction', // 无效的排列方向 + outputFormat: OutputFormat.PNG, + }; + + await expect(controller.composeImages(invalidDto)).rejects.toThrow( + '排列方向必须是以下值之一', + ); + }); + + it('should validate image URL format', async () => { + const invalidDto: ImageCompositionDto = { + images: ['invalid-url'], // 无效的URL格式 + direction: ArrangementDirection.HORIZONTAL, + outputFormat: OutputFormat.PNG, + }; + + await expect(controller.composeImages(invalidDto)).rejects.toThrow( + '图片必须是有效的HTTP(S) URL或base64格式', + ); + }); + }); +}); diff --git a/src/controllers/__tests__/image-composition.example.ts b/src/controllers/__tests__/image-composition.example.ts new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/controllers/__tests__/image-composition.example.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/controllers/image-composition.controller.ts b/src/controllers/image-composition.controller.ts new file mode 100644 index 0000000..a38e504 --- /dev/null +++ b/src/controllers/image-composition.controller.ts @@ -0,0 +1,215 @@ +import { + Controller, + Post, + Body, + HttpException, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse as SwaggerApiResponse, + ApiBody, +} from '@nestjs/swagger'; +import { ImageCompositionService } from '../services/image-composition.service'; +import { + ImageCompositionDto, + ImageCompositionResponseDto, + ArrangementDirection, +} from '../dto/image-composition.dto'; +import { ApiCommonResponses } from '../decorators/api-common-responses.decorator'; +import { PlatformAuthGuard } from '../platform/guards/platform-auth.guard'; +import { ResponseUtil, ApiResponse } from '../utils/response.util'; + +@ApiTags('图片处理') +@Controller('image-composition') +@ApiCommonResponses() +export class ImageCompositionController { + constructor( + private readonly imageCompositionService: ImageCompositionService, + ) {} + + @Post('compose') + @UseGuards(PlatformAuthGuard) + @ApiOperation({ + summary: '图片拼接', + description: '将多张图片按指定位置拼接到一个画布上,支持层级、透明度等设置', + }) + @ApiBody({ + type: ImageCompositionDto, + description: '图片拼接配置 - 简化版本', + examples: { + horizontal: { + summary: '水平排列示例', + value: { + images: [ + 'https://picsum.photos/300/200', + 'https://picsum.photos/300/200', + 'https://picsum.photos/300/200' + ], + direction: 'horizontal', + outputFormat: 'jpeg', + maxWidth: 1920, + maxHeight: 1080, + quality: 80 + }, + }, + vertical: { + summary: '垂直排列示例', + value: { + images: [ + 'https://picsum.photos/200/300', + 'https://picsum.photos/200/300', + 'https://picsum.photos/200/300' + ], + direction: 'vertical', + outputFormat: 'webp', + maxWidth: 800, + maxHeight: 1200, + quality: 85 + }, + }, + grid: { + summary: '网格排列示例', + value: { + images: [ + 'https://picsum.photos/200/200', + 'https://picsum.photos/200/200', + 'https://picsum.photos/200/200', + 'https://picsum.photos/200/200', + 'https://picsum.photos/200/200' + ], + direction: 'grid', + outputFormat: 'jpeg', + maxWidth: 1024, + maxHeight: 1024, + quality: 75 + }, + }, + }, + }) + @SwaggerApiResponse({ + status: 200, + description: '图片拼接成功', + type: ImageCompositionResponseDto, + }) + @SwaggerApiResponse({ + status: 400, + description: '请求参数错误', + }) + @SwaggerApiResponse({ + status: 401, + description: '未授权访问', + }) + @SwaggerApiResponse({ + status: 500, + description: '服务器内部错误', + }) + async composeImages( + @Body() compositionDto: ImageCompositionDto, + ): Promise> { + try { + // 基础参数验证 + this.validateCompositionRequest(compositionDto); + + const result = await this.imageCompositionService.composeImages(compositionDto); + + return ResponseUtil.success(result, '图片拼接成功'); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `图片拼接失败: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + /** + * 验证拼接请求参数 - 简化版本 + */ + private validateCompositionRequest(dto: ImageCompositionDto): void { + // 验证图片数量 + if (!dto.images || dto.images.length === 0) { + throw new HttpException( + '至少需要提供一张图片', + HttpStatus.BAD_REQUEST, + ); + } + + if (dto.images.length > 20) { + throw new HttpException( + '图片数量不能超过20张', + HttpStatus.BAD_REQUEST, + ); + } + + // 验证排列方向 + const validDirections = Object.values(ArrangementDirection); + if (!validDirections.includes(dto.direction)) { + throw new HttpException( + `排列方向必须是以下值之一: ${validDirections.join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + + // 验证图片URL格式 + for (const imageUrl of dto.images) { + if (!imageUrl || typeof imageUrl !== 'string') { + throw new HttpException( + '图片URL不能为空', + HttpStatus.BAD_REQUEST, + ); + } + + // 检查是否为有效的URL或base64格式 + const isValidUrl = this.isValidImageUrl(imageUrl); + const isValidBase64 = imageUrl.startsWith('data:image/'); + + if (!isValidUrl && !isValidBase64) { + throw new HttpException( + '图片必须是有效的HTTP(S) URL或base64格式', + HttpStatus.BAD_REQUEST, + ); + } + } + + // 验证尺寸参数 + if (dto.maxWidth !== undefined && (dto.maxWidth < 100 || dto.maxWidth > 4096)) { + throw new HttpException( + '最大宽度必须在100-4096像素之间', + HttpStatus.BAD_REQUEST, + ); + } + + if (dto.maxHeight !== undefined && (dto.maxHeight < 100 || dto.maxHeight > 4096)) { + throw new HttpException( + '最大高度必须在100-4096像素之间', + HttpStatus.BAD_REQUEST, + ); + } + + // 验证质量参数 + if (dto.quality !== undefined && (dto.quality < 1 || dto.quality > 100)) { + throw new HttpException( + '图片质量必须在1-100之间', + HttpStatus.BAD_REQUEST, + ); + } + } + + /** + * 验证图片URL格式 + */ + private isValidImageUrl(url: string): boolean { + try { + const parsedUrl = new URL(url); + return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'; + } catch { + return false; + } + } +} diff --git a/src/dto/image-composition.dto.ts b/src/dto/image-composition.dto.ts new file mode 100644 index 0000000..d5c8807 --- /dev/null +++ b/src/dto/image-composition.dto.ts @@ -0,0 +1,117 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsArray, IsEnum, IsOptional, IsNumber, Min, Max } from 'class-validator'; + +/** + * 排列方向枚举 + */ +export enum ArrangementDirection { + HORIZONTAL = 'horizontal', // 水平排列 + VERTICAL = 'vertical', // 垂直排列 + GRID = 'grid', // 网格排列(自动计算行列) +} + +/** + * 输出格式枚举 + */ +export enum OutputFormat { + JPEG = 'jpeg', + PNG = 'png', + WEBP = 'webp', +} + +/** + * 图片拼接请求DTO - 简化版本 + */ +export class ImageCompositionDto { + @ApiProperty({ + description: '图片URL或base64数据列表', + example: [ + 'https://picsum.photos/300/200', + 'https://picsum.photos/300/200', + 'https://picsum.photos/300/200' + ] + }) + @IsArray() + @IsString({ each: true }) + images: string[]; + + @ApiProperty({ + description: '排列方向', + enum: ArrangementDirection, + example: ArrangementDirection.HORIZONTAL + }) + @IsEnum(ArrangementDirection) + direction: ArrangementDirection; + + @ApiPropertyOptional({ + description: '输出格式', + enum: OutputFormat, + example: OutputFormat.PNG, + default: OutputFormat.PNG + }) + @IsOptional() + @IsEnum(OutputFormat) + outputFormat?: OutputFormat = OutputFormat.PNG; + + @ApiPropertyOptional({ + description: '最大输出宽度(像素),超过会等比压缩', + example: 1920, + default: 1920, + minimum: 100, + maximum: 4096 + }) + @IsOptional() + @IsNumber() + @Min(100) + @Max(4096) + maxWidth?: number = 1920; + + @ApiPropertyOptional({ + description: '最大输出高度(像素),超过会等比压缩', + example: 1080, + default: 1080, + minimum: 100, + maximum: 4096 + }) + @IsOptional() + @IsNumber() + @Min(100) + @Max(4096) + maxHeight?: number = 1080; + + @ApiPropertyOptional({ + description: '图片质量(1-100),仅对JPEG/WebP有效', + example: 80, + default: 80, + minimum: 1, + maximum: 100 + }) + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + quality?: number = 80; +} + +/** + * 图片拼接响应DTO + */ +export class ImageCompositionResponseDto { + @ApiProperty({ description: '拼接后的图片URL', example: 'https://s3.amazonaws.com/bucket/composed-image-1234567890.png' }) + imageUrl: string; + + @ApiProperty({ description: '图片格式', example: 'png' }) + format: string; + + @ApiProperty({ description: '图片大小(字节)', example: 12345 }) + size: number; + + @ApiProperty({ description: '画布宽度', example: 800 }) + width: number; + + @ApiProperty({ description: '画布高度', example: 600 }) + height: number; + + @ApiProperty({ description: '处理耗时(毫秒)', example: 150 }) + processingTime: number; +} diff --git a/src/services/image-composition.service.ts b/src/services/image-composition.service.ts new file mode 100644 index 0000000..8d0d42c --- /dev/null +++ b/src/services/image-composition.service.ts @@ -0,0 +1,369 @@ +import { Injectable, BadRequestException, Logger } from '@nestjs/common'; +import sharp from 'sharp'; +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import FormData from 'form-data'; +import { + ImageCompositionDto, + ImageCompositionResponseDto, + ArrangementDirection, + OutputFormat, +} from '../dto/image-composition.dto'; + +@Injectable() +export class ImageCompositionService { + private readonly logger = new Logger(ImageCompositionService.name); + + constructor(private readonly httpService: HttpService) {} + + /** + * 拼接图片 - 简化版本 + */ + async composeImages( + compositionDto: ImageCompositionDto, + ): Promise { + const startTime = Date.now(); + + try { + this.logger.log('开始图片拼接处理'); + + // 加载所有图片并获取尺寸信息,同时进行预处理 + const imageInfos = await Promise.all( + compositionDto.images.map(async (imageUrl) => { + const buffer = await this.loadImage(imageUrl); + let processedBuffer = buffer; + + // 压缩单张图片如果太大 + const metadata = await sharp(buffer).metadata(); + const originalWidth = metadata.width || 0; + const originalHeight = metadata.height || 0; + + // 如果单张图片超过800x800,先压缩 + if (originalWidth > 800 || originalHeight > 800) { + processedBuffer = await sharp(buffer) + .resize(800, 800, { fit: 'inside', withoutEnlargement: true }) + .jpeg({ quality: 85 }) + .toBuffer(); + + const newMetadata = await sharp(processedBuffer).metadata(); + this.logger.log(`压缩图片: ${originalWidth}x${originalHeight} -> ${newMetadata.width}x${newMetadata.height}`); + + return { + buffer: processedBuffer, + width: newMetadata.width || 0, + height: newMetadata.height || 0, + }; + } + + return { + buffer: processedBuffer, + width: originalWidth, + height: originalHeight, + }; + }), + ); + + // 根据排列方向计算画布尺寸和图片位置 + const layout = this.calculateLayout(imageInfos, compositionDto.direction); + + // 创建画布 + const canvas = sharp({ + create: { + width: layout.canvasWidth, + height: layout.canvasHeight, + channels: 4, + background: '#ffffff', + }, + }); + + // 准备所有图片的composite参数 + const compositeInputs = await Promise.all( + imageInfos.map(async (imageInfo, index) => { + const position = layout.positions[index]; + let processedImage = sharp(imageInfo.buffer); + + // 如果需要调整尺寸 + if ( + position.width !== imageInfo.width || + position.height !== imageInfo.height + ) { + processedImage = processedImage.resize( + position.width, + position.height, + { fit: 'fill' }, + ); + } + + return { + input: await processedImage.png().toBuffer(), + left: position.x, + top: position.y, + }; + }), + ); + + // 执行图片合成 + let finalImage = canvas.composite(compositeInputs); + + // 检查是否需要压缩最终图片 + const maxWidth = compositionDto.maxWidth || 1920; + const maxHeight = compositionDto.maxHeight || 1080; + + if (layout.canvasWidth > maxWidth || layout.canvasHeight > maxHeight) { + this.logger.log(`最终图片需要压缩: ${layout.canvasWidth}x${layout.canvasHeight} -> 最大${maxWidth}x${maxHeight}`); + finalImage = finalImage.resize(maxWidth, maxHeight, { + fit: 'inside', + withoutEnlargement: true + }); + } + + // 根据输出格式处理 + const quality = compositionDto.quality || 80; + switch (compositionDto.outputFormat) { + case OutputFormat.JPEG: + finalImage = finalImage.jpeg({ quality, progressive: true }); + break; + case OutputFormat.WEBP: + finalImage = finalImage.webp({ quality, effort: 6 }); + break; + case OutputFormat.PNG: + default: + // PNG格式使用适度压缩 + finalImage = finalImage.png({ compressionLevel: 8, adaptiveFiltering: true }); + break; + } + + const resultBuffer = await finalImage.toBuffer(); + const metadata = await sharp(resultBuffer).metadata(); + + const processingTime = Date.now() - startTime; + this.logger.log(`图片拼接完成,耗时: ${processingTime}ms`); + + // 上传图片到S3并获取URL + const imageUrl = await this.uploadToS3( + resultBuffer, + compositionDto.outputFormat || 'png', + ); + + return { + imageUrl, + format: compositionDto.outputFormat || 'png', + size: resultBuffer.length, + width: metadata.width || layout.canvasWidth, + height: metadata.height || layout.canvasHeight, + processingTime, + }; + } catch (error) { + this.logger.error('图片拼接失败', error); + throw new BadRequestException(`图片拼接失败: ${error.message}`); + } + } + + /** + * 根据排列方向计算布局 + */ + private calculateLayout( + imageInfos: Array<{ width: number; height: number }>, + direction: ArrangementDirection, + ) { + const positions: Array<{ + x: number; + y: number; + width: number; + height: number; + }> = []; + let canvasWidth = 0; + let canvasHeight = 0; + + switch (direction) { + case ArrangementDirection.HORIZONTAL: + // 水平排列:所有图片保持原始高度,宽度累加 + const maxHeight = Math.max(...imageInfos.map((img) => img.height)); + canvasHeight = maxHeight; + + let currentX = 0; + imageInfos.forEach((imageInfo) => { + positions.push({ + x: currentX, + y: Math.floor((maxHeight - imageInfo.height) / 2), // 垂直居中 + width: imageInfo.width, + height: imageInfo.height, + }); + currentX += imageInfo.width; + }); + canvasWidth = currentX; + break; + + case ArrangementDirection.VERTICAL: + // 垂直排列:所有图片保持原始宽度,高度累加 + const maxWidth = Math.max(...imageInfos.map((img) => img.width)); + canvasWidth = maxWidth; + + let currentY = 0; + imageInfos.forEach((imageInfo) => { + positions.push({ + x: Math.floor((maxWidth - imageInfo.width) / 2), // 水平居中 + y: currentY, + width: imageInfo.width, + height: imageInfo.height, + }); + currentY += imageInfo.height; + }); + canvasHeight = currentY; + break; + + case ArrangementDirection.GRID: + // 网格排列:自动计算行列数 + const imageCount = imageInfos.length; + const cols = Math.ceil(Math.sqrt(imageCount)); + const rows = Math.ceil(imageCount / cols); + + // 计算每个格子的尺寸(使用最大图片尺寸) + const cellWidth = Math.max(...imageInfos.map((img) => img.width)); + const cellHeight = Math.max(...imageInfos.map((img) => img.height)); + + canvasWidth = cols * cellWidth; + canvasHeight = rows * cellHeight; + + imageInfos.forEach((imageInfo, index) => { + const row = Math.floor(index / cols); + const col = index % cols; + + positions.push({ + x: col * cellWidth + Math.floor((cellWidth - imageInfo.width) / 2), + y: + row * cellHeight + + Math.floor((cellHeight - imageInfo.height) / 2), + width: imageInfo.width, + height: imageInfo.height, + }); + }); + break; + + default: + throw new BadRequestException(`不支持的排列方向: ${direction}`); + } + + return { canvasWidth, canvasHeight, positions }; + } + + /** + * 加载图片 + * 支持URL和base64格式 + */ + private async loadImage(imageUrl: string): Promise { + try { + // 检查是否为base64格式 + if (imageUrl.startsWith('data:image/')) { + const base64Data = imageUrl.split(',')[1]; + return Buffer.from(base64Data, 'base64'); + } + + // 检查是否为HTTP(S) URL + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + const response = await firstValueFrom( + this.httpService.get(imageUrl, { + responseType: 'arraybuffer', + timeout: 10000, // 10秒超时 + maxContentLength: 10 * 1024 * 1024, // 最大10MB + }), + ); + return Buffer.from(response.data); + } + + throw new Error('不支持的图片格式,请提供HTTP(S) URL或base64数据'); + } catch (error) { + this.logger.error(`加载图片失败: ${imageUrl}`, error); + throw new BadRequestException(`加载图片失败: ${error.message}`); + } + } + + /** + * 上传图片到S3 + */ + private async uploadToS3( + imageBuffer: Buffer, + format: string, + ): Promise { + try { + this.logger.log('开始上传图片到S3'); + + // 创建FormData + const formData = new FormData(); + const fileName = `composed-image-${Date.now()}.${format}`; + + formData.append('file', imageBuffer, { + filename: fileName, + contentType: `image/${format}`, + }); + + // 上传到S3 + const uploadResponse = await firstValueFrom( + this.httpService.post( + 'https://bowongai-prod--text-video-agent-fastapi-app.modal.run/api/file/upload/s3', + formData, + { + headers: { + ...formData.getHeaders(), + }, + timeout: 30000, // 30秒超时 + }, + ), + ); + + // 记录S3上传状态 + this.logger.log('S3上传完成,状态:', uploadResponse.data.status); + + // 检查响应并返回URL + if ( + uploadResponse.data && + uploadResponse.data.status && + uploadResponse.data.data + ) { + this.logger.log(`图片上传成功: ${uploadResponse.data.data}`); + return uploadResponse.data.data; + } else { + this.logger.error('S3上传响应格式异常,响应数据:', uploadResponse.data); + throw new Error('S3上传响应格式异常'); + } + } catch (error) { + this.logger.error('上传图片到S3失败,详细错误:', { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data, + config: { + url: error.config?.url, + method: error.config?.method, + headers: error.config?.headers, + }, + }); + + // 如果S3上传失败,返回base64格式作为备选方案 + this.logger.warn('S3上传失败,使用base64格式返回'); + return `data:image/${format};base64,${imageBuffer.toString('base64')}`; + } + } + + /** + * 验证图片格式 + */ + private async validateImage(buffer: Buffer): Promise { + try { + const metadata = await sharp(buffer).metadata(); + if (!metadata.format) { + throw new Error('无法识别的图片格式'); + } + + // 检查图片尺寸限制 + if (metadata.width && metadata.width > 4096) { + throw new Error('图片宽度超过限制(最大4096px)'); + } + if (metadata.height && metadata.height > 4096) { + throw new Error('图片高度超过限制(最大4096px)'); + } + } catch (error) { + throw new BadRequestException(`图片验证失败: ${error.message}`); + } + } +} diff --git a/swagger.json b/swagger.json index a4b8478..3ee0690 100644 --- a/swagger.json +++ b/swagger.json @@ -95,7 +95,7 @@ }, "/api/v1/templates/code/{code}/execute": { "post": { - "description": "\n 根据模板代码执行AI生成任务,支持同步和异步两种审核模式:\n - 同步模式:立即返回审核结果,若通过则开始执行,返回 executionId 和 taskId\n - 异步模式:返回 executionId 和 auditTaskId,等待审核回调完成后开始执行\n \n ID说明:\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID,同步模式时返回\n - auditTaskId: 审核服务任务ID,异步模式时返回\n ", + "description": "\n 根据模板代码执行AI生成任务,直接执行模板无需后端审核。\n \n 参数说明:\n - imageUrl: 输入图片URL(必需)\n - auditTaskId: 前端审核任务ID(可选),用于追踪审核记录\n \n 返回ID说明:\n - executionId: 数据库执行记录主键,用于查询执行状态\n - taskId: 外部服务(N8N)任务ID,用于追踪模板执行状态\n - auditTaskId: 前端传入的审核任务ID\n ", "operationId": "TemplateController_executeTemplateByCode", "parameters": [ { @@ -131,7 +131,9 @@ "data": { "executionId": 123, "taskId": "n8n_task_456", - "status": "processing" + "auditTaskId": "audit_task_789", + "status": "processing", + "message": "模板执行已启动" } } } @@ -158,20 +160,6 @@ } } }, - "403": { - "description": "图片审核未通过", - "content": { - "application/json": { - "schema": { - "example": { - "code": 403, - "message": "图片审核未通过: 包含不当内容", - "data": null - } - } - } - } - }, "429": { "description": "任务数量限制", "content": { @@ -1094,52 +1082,95 @@ ] } }, - "/api/v1/enhanced/templates/code/{code}/execute": { + "/api/v1/image-composition/compose": { "post": { - "description": "统一异步模式:先提交审核,然后通过回调继续执行模板。支持实时状态查询。", - "operationId": "EnhancedTemplateController_executeTemplateByCode", - "parameters": [ - { - "name": "code", - "required": true, - "in": "path", - "schema": { - "type": "string" + "description": "将多张图片按指定位置拼接到一个画布上,支持层级、透明度等设置", + "operationId": "ImageCompositionController_composeImages", + "parameters": [], + "requestBody": { + "required": true, + "description": "图片拼接配置 - 简化版本", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCompositionDto" + }, + "examples": { + "horizontal": { + "summary": "水平排列示例", + "value": { + "images": [ + "https://picsum.photos/300/200", + "https://picsum.photos/300/200", + "https://picsum.photos/300/200" + ], + "direction": "horizontal", + "outputFormat": "jpeg", + "maxWidth": 1920, + "maxHeight": 1080, + "quality": 80 + } + }, + "vertical": { + "summary": "垂直排列示例", + "value": { + "images": [ + "https://picsum.photos/200/300", + "https://picsum.photos/200/300", + "https://picsum.photos/200/300" + ], + "direction": "vertical", + "outputFormat": "webp", + "maxWidth": 800, + "maxHeight": 1200, + "quality": 85 + } + }, + "grid": { + "summary": "网格排列示例", + "value": { + "images": [ + "https://picsum.photos/200/200", + "https://picsum.photos/200/200", + "https://picsum.photos/200/200", + "https://picsum.photos/200/200", + "https://picsum.photos/200/200" + ], + "direction": "grid", + "outputFormat": "jpeg", + "maxWidth": 1024, + "maxHeight": 1024, + "quality": 75 + } + } + } } } - ], - "responses": { - "201": { - "description": "" - } }, - "summary": "异步执行模板 (增强版)", - "tags": [ - "AI模板系统 (增强版)" - ] - } - }, - "/api/v1/enhanced/templates/{executionId}/status": { - "get": { - "operationId": "EnhancedTemplateController_getExecutionStatus", - "parameters": [ - { - "name": "executionId", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], "responses": { "200": { - "description": "" + "description": "图片拼接成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageCompositionResponseDto" + } + } + } + }, + "400": { + "description": "请求参数错误" + }, + "401": { + "description": "未授权访问" + }, + "500": { + "description": "服务器内部错误" } }, - "summary": "查询执行状态 (增强版)", + "summary": "图片拼接", "tags": [ - "AI模板系统 (增强版)" + "图片处理" ] } }, @@ -1778,7 +1809,7 @@ ], "servers": [ { - "url": "http://localhost:3004", + "url": "http://localhost:3003", "description": "开发环境" }, { @@ -1850,6 +1881,11 @@ "type": "string", "description": "输入图片URL", "example": "https://cdn.example.com/upload/image.jpg" + }, + "auditTaskId": { + "type": "string", + "description": "审核任务ID(可选)", + "example": "audit_task_12345" } }, "required": [ @@ -2227,6 +2263,115 @@ } } }, + "ImageCompositionDto": { + "type": "object", + "properties": { + "images": { + "description": "图片URL或base64数据列表", + "example": [ + "https://picsum.photos/300/200", + "https://picsum.photos/300/200", + "https://picsum.photos/300/200" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "direction": { + "type": "string", + "description": "排列方向", + "enum": [ + "horizontal", + "vertical", + "grid" + ], + "example": "horizontal" + }, + "outputFormat": { + "type": "string", + "description": "输出格式", + "enum": [ + "jpeg", + "png", + "webp" + ], + "example": "png", + "default": "png" + }, + "maxWidth": { + "type": "number", + "description": "最大输出宽度(像素),超过会等比压缩", + "example": 1920, + "default": 1920, + "minimum": 100, + "maximum": 4096 + }, + "maxHeight": { + "type": "number", + "description": "最大输出高度(像素),超过会等比压缩", + "example": 1080, + "default": 1080, + "minimum": 100, + "maximum": 4096 + }, + "quality": { + "type": "number", + "description": "图片质量(1-100),仅对JPEG/WebP有效", + "example": 80, + "default": 80, + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "images", + "direction" + ] + }, + "ImageCompositionResponseDto": { + "type": "object", + "properties": { + "imageUrl": { + "type": "string", + "description": "拼接后的图片URL", + "example": "https://s3.amazonaws.com/bucket/composed-image-1234567890.png" + }, + "format": { + "type": "string", + "description": "图片格式", + "example": "png" + }, + "size": { + "type": "number", + "description": "图片大小(字节)", + "example": 12345 + }, + "width": { + "type": "number", + "description": "画布宽度", + "example": 800 + }, + "height": { + "type": "number", + "description": "画布高度", + "example": 600 + }, + "processingTime": { + "type": "number", + "description": "处理耗时(毫秒)", + "example": 150 + } + }, + "required": [ + "imageUrl", + "format", + "size", + "width", + "height", + "processingTime" + ] + }, "PlatformType": { "type": "string", "enum": [