diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d45756d5c6..df3afb6c7e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -180,7 +180,11 @@ type BatchImageConfig struct { Enabled bool `mapstructure:"enabled"` MaxItemsPerJobDefault int `mapstructure:"max_items_per_job_default"` MaxItemsPerJobTrial int `mapstructure:"max_items_per_job_trial"` + MaxOutputImagesPerJob int `mapstructure:"max_output_images_per_job"` + MaxOutputImagesPerItem int `mapstructure:"max_output_images_per_item"` MaxPromptCharsPerItem int `mapstructure:"max_prompt_chars_per_item"` + MaxReferenceImagesPerJob int `mapstructure:"max_reference_images_per_job"` + MaxReferenceInlineBytesPerJob int `mapstructure:"max_reference_inline_bytes_per_job"` DefaultResponseMimeType string `mapstructure:"default_response_mime_type"` DefaultImageSize string `mapstructure:"default_image_size"` MaxDownloadItemsZip int `mapstructure:"max_download_items_zip"` @@ -1781,15 +1785,19 @@ func setDefaults() { // Batch Image queue viper.SetDefault("batch_image.enabled", false) - viper.SetDefault("batch_image.max_items_per_job_default", 500) + viper.SetDefault("batch_image.max_items_per_job_default", 200) viper.SetDefault("batch_image.max_items_per_job_trial", 50) + viper.SetDefault("batch_image.max_output_images_per_job", 200) + viper.SetDefault("batch_image.max_output_images_per_item", 4) viper.SetDefault("batch_image.max_prompt_chars_per_item", 8000) + viper.SetDefault("batch_image.max_reference_images_per_job", 1000) + viper.SetDefault("batch_image.max_reference_inline_bytes_per_job", 134217728) viper.SetDefault("batch_image.default_response_mime_type", "image/png") viper.SetDefault("batch_image.default_image_size", "1K") - viper.SetDefault("batch_image.max_download_items_zip", 1000) - viper.SetDefault("batch_image.max_download_bytes_per_request", 2147483648) + viper.SetDefault("batch_image.max_download_items_zip", 200) + viper.SetDefault("batch_image.max_download_bytes_per_request", 536870912) viper.SetDefault("batch_image.max_download_duration_seconds", 600) - viper.SetDefault("batch_image.max_download_concurrency_per_user", 2) + viper.SetDefault("batch_image.max_download_concurrency_per_user", 1) viper.SetDefault("batch_image.input_retention_after_terminal_hours", 24) viper.SetDefault("batch_image.output_retention_after_terminal_hours", 72) viper.SetDefault("batch_image.output_retention_max_days", 7) diff --git a/backend/internal/service/batch_image.go b/backend/internal/service/batch_image.go index 68dd217f22..4fad11dd5e 100644 --- a/backend/internal/service/batch_image.go +++ b/backend/internal/service/batch_image.go @@ -70,6 +70,10 @@ var ( ErrBatchImageInvalidItems = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_INVALID_ITEMS", "batch image items are invalid") ErrBatchImageDuplicateCustomIDInRequest = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_DUPLICATE_CUSTOM_ID", "batch image custom ids must be unique") ErrBatchImagePromptTooLong = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_PROMPT_TOO_LONG", "batch image prompt is too long") + ErrBatchImageInvalidReferenceImage = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_INVALID_REFERENCE_IMAGE", "batch image reference image is invalid") + ErrBatchImageTooManyReferenceImages = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_TOO_MANY_REFERENCE_IMAGES", "too many batch image reference images for this model") + ErrBatchImageReferenceImagesTooLarge = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_REFERENCE_IMAGES_TOO_LARGE", "batch image reference images are too large") + ErrBatchImageTooManyOutputImages = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_TOO_MANY_OUTPUT_IMAGES", "too many batch image output images") ErrBatchImageProviderSubmitFailed = infraerrors.New(http.StatusBadGateway, "BATCH_IMAGE_PROVIDER_SUBMIT_FAILED", "batch image provider submit failed") ErrBatchImageQueueFailed = infraerrors.New(http.StatusBadGateway, "BATCH_IMAGE_QUEUE_FAILED", "batch image queue failed") ErrBatchImageIdempotencyConflict = infraerrors.New(http.StatusConflict, "BATCH_IMAGE_IDEMPOTENCY_CONFLICT", "idempotency key reused with different batch image request") @@ -83,6 +87,7 @@ var ( ErrBatchImageResultMissing = infraerrors.New(http.StatusInternalServerError, "BATCH_IMAGE_RESULT_MISSING", "batch image result is missing") ErrBatchImageDownloadLimited = infraerrors.New(http.StatusTooManyRequests, "BATCH_IMAGE_DOWNLOAD_LIMITED", "too many batch image downloads") ErrBatchImageDownloadFailed = infraerrors.New(http.StatusInternalServerError, "BATCH_IMAGE_DOWNLOAD_FAILED", "batch image download failed") + ErrBatchImageDownloadTooLarge = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_DOWNLOAD_TOO_LARGE", "batch image download is too large") ErrBatchImageItemImageIndexOutOfRange = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_ITEM_IMAGE_INDEX_OUT_OF_RANGE", "batch image item image index is out of range") ErrBatchImageZipTooManyItems = infraerrors.New(http.StatusBadRequest, "BATCH_IMAGE_ZIP_TOO_MANY_ITEMS", "batch image ZIP contains too many items; use single item downloads") ErrBatchImageOutputDeleteNotReady = infraerrors.New(http.StatusConflict, "BATCH_IMAGE_OUTPUT_DELETE_NOT_READY", "batch image output can only be deleted after completion") diff --git a/backend/internal/service/batch_image_download.go b/backend/internal/service/batch_image_download.go index d12540d9c9..f5a0cdb5c7 100644 --- a/backend/internal/service/batch_image_download.go +++ b/backend/internal/service/batch_image_download.go @@ -6,6 +6,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -21,12 +22,15 @@ import ( ) const ( - defaultBatchImageZipMaxItems = 1000 + defaultBatchImageZipMaxItems = 200 + defaultBatchImageZipMaxBytes = 512 * 1024 * 1024 defaultBatchImageDownloadDuration = 10 * time.Minute - defaultBatchImageDownloadConcurrency = 2 + defaultBatchImageDownloadConcurrency = 1 batchImageDownloadScannerMaxLineBytes = 16 * 1024 * 1024 ) +var errBatchImageDownloadSizeExceeded = errors.New("batch image download size limit exceeded") + type BatchImageDownloadLimiter interface { Acquire(ctx context.Context, userID string, kind string) (BatchImageDownloadPermit, error) } @@ -74,6 +78,24 @@ type BatchImageDownloadService struct { Config *config.Config } +type batchImageDownloadLimitWriter struct { + w io.Writer + limit int64 + written int64 +} + +func (w *batchImageDownloadLimitWriter) Write(p []byte) (int, error) { + if w == nil || w.w == nil { + return 0, io.ErrClosedPipe + } + if w.limit > 0 && w.written+int64(len(p)) > w.limit { + return 0, errBatchImageDownloadSizeExceeded + } + n, err := w.w.Write(p) + w.written += int64(n) + return n, err +} + func NewBatchImageDownloadService(repo BatchImageRepository, accountRepo AccountRepository, limiter BatchImageDownloadLimiter, cfg *config.Config) *BatchImageDownloadService { return &BatchImageDownloadService{ Repo: repo, @@ -205,10 +227,14 @@ func (s *BatchImageDownloadService) StreamZip(ctx context.Context, owner BatchIm } defer cancel() - zipWriter := zip.NewWriter(w) + limitedWriter := &batchImageDownloadLimitWriter{w: w, limit: s.maxDownloadBytes()} + zipWriter := zip.NewWriter(limitedWriter) result, manifestFiles, zipErrors, err := s.writeZipImages(streamCtx, zipWriter, r, successItems) if err != nil { _ = zipWriter.Close() + if errors.Is(err, errBatchImageDownloadSizeExceeded) { + return result, ErrBatchImageDownloadTooLarge.WithCause(err) + } return result, ErrBatchImageDownloadFailed.WithCause(err) } zipErrors = append(zipErrors, batchImageZipErrorsFromItems(failedItems)...) @@ -221,14 +247,23 @@ func (s *BatchImageDownloadService) StreamZip(ctx context.Context, owner BatchIm Files: manifestFiles, }); err != nil { _ = zipWriter.Close() + if errors.Is(err, errBatchImageDownloadSizeExceeded) { + return result, ErrBatchImageDownloadTooLarge.WithCause(err) + } return result, ErrBatchImageDownloadFailed.WithCause(err) } if err := writeBatchImageZipJSON(zipWriter, "errors.json", zipErrors); err != nil { _ = zipWriter.Close() + if errors.Is(err, errBatchImageDownloadSizeExceeded) { + return result, ErrBatchImageDownloadTooLarge.WithCause(err) + } return result, ErrBatchImageDownloadFailed.WithCause(err) } result.ErrorCount = len(zipErrors) if err := zipWriter.Close(); err != nil { + if errors.Is(err, errBatchImageDownloadSizeExceeded) { + return result, ErrBatchImageDownloadTooLarge.WithCause(err) + } return result, ErrBatchImageDownloadFailed.WithCause(err) } return result, nil @@ -369,6 +404,13 @@ func (s *BatchImageDownloadService) maxZipItems() int { return defaultBatchImageZipMaxItems } +func (s *BatchImageDownloadService) maxDownloadBytes() int64 { + if s != nil && s.Config != nil && s.Config.BatchImage.MaxDownloadBytesPerRequest > 0 { + return s.Config.BatchImage.MaxDownloadBytesPerRequest + } + return defaultBatchImageZipMaxBytes +} + func (s *BatchImageDownloadService) maxDownloadDuration() time.Duration { if s != nil && s.Config != nil && s.Config.BatchImage.MaxDownloadDurationSeconds > 0 { return time.Duration(s.Config.BatchImage.MaxDownloadDurationSeconds) * time.Second diff --git a/backend/internal/service/batch_image_provider.go b/backend/internal/service/batch_image_provider.go index 4a638aa328..9f4bcca6e4 100644 --- a/backend/internal/service/batch_image_provider.go +++ b/backend/internal/service/batch_image_provider.go @@ -88,8 +88,11 @@ type BatchImageInputItem struct { } type BatchImageReference struct { + ID string + Type string MimeType string Data []byte + FileURI string } type BatchProviderJob struct { diff --git a/backend/internal/service/batch_image_provider_gemini.go b/backend/internal/service/batch_image_provider_gemini.go index e03e2655c9..bc0e574642 100644 --- a/backend/internal/service/batch_image_provider_gemini.go +++ b/backend/internal/service/batch_image_provider_gemini.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -248,7 +249,19 @@ type geminiContent struct { } type geminiPart struct { - Text string `json:"text,omitempty"` + Text string `json:"text,omitempty"` + InlineData *geminiInlineData `json:"inlineData,omitempty"` + FileData *geminiFileData `json:"fileData,omitempty"` +} + +type geminiInlineData struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` +} + +type geminiFileData struct { + MimeType string `json:"mimeType"` + FileURI string `json:"fileUri"` } type geminiGenerationConfig struct { @@ -280,8 +293,9 @@ func BuildGeminiBatchJSONL(input BatchImageInput) ([]byte, error) { if prompt == "" { return nil, batchImageProviderInputError("prompt is required for custom_id %q", customID) } - if len(item.ReferenceImages) > 0 { - return nil, batchImageProviderInputError("reference images are not supported in PR3") + parts, err := batchImageGeminiParts(prompt, item.ReferenceImages) + if err != nil { + return nil, err } // TODO(batch-image): add response_mime_type/aspect_ratio/image_size once the @@ -290,7 +304,7 @@ func BuildGeminiBatchJSONL(input BatchImageInput) ([]byte, error) { Key: customID, Request: geminiGenerateRequest{ Contents: []geminiContent{{ - Parts: []geminiPart{{Text: prompt}}, + Parts: parts, }}, GenerationConfig: geminiGenerationConfig{ ResponseModalities: []string{"TEXT", "IMAGE"}, @@ -304,6 +318,32 @@ func BuildGeminiBatchJSONL(input BatchImageInput) ([]byte, error) { return buf.Bytes(), nil } +func batchImageGeminiParts(prompt string, refs []BatchImageReference) ([]geminiPart, error) { + parts := []geminiPart{{Text: prompt}} + for _, ref := range refs { + mimeType := normalizeBatchImageReferenceMimeType(ref.MimeType) + if mimeType == "" { + return nil, batchImageProviderInputError("reference image mime_type is required") + } + fileURI := strings.TrimSpace(ref.FileURI) + switch { + case len(ref.Data) > 0 && fileURI == "": + parts = append(parts, geminiPart{InlineData: &geminiInlineData{ + MimeType: mimeType, + Data: base64.StdEncoding.EncodeToString(ref.Data), + }}) + case len(ref.Data) == 0 && fileURI != "": + parts = append(parts, geminiPart{FileData: &geminiFileData{ + MimeType: mimeType, + FileURI: fileURI, + }}) + default: + return nil, batchImageProviderInputError("reference image must contain exactly one of data or file_uri") + } + } + return parts, nil +} + func mapGeminiBatchState(batch *GeminiBatchJob) *BatchProviderStatus { state := strings.TrimSpace(batch.State) normalized := strings.ToUpper(state) diff --git a/backend/internal/service/batch_image_provider_gemini_test.go b/backend/internal/service/batch_image_provider_gemini_test.go index dd44a957fd..101c188b2d 100644 --- a/backend/internal/service/batch_image_provider_gemini_test.go +++ b/backend/internal/service/batch_image_provider_gemini_test.go @@ -74,6 +74,33 @@ func TestBuildGeminiBatchJSONL_RejectsEmptyPrompt(t *testing.T) { require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput) } +func TestBuildGeminiBatchJSONL_WritesReferenceImages(t *testing.T) { + input := validGeminiBatchInput() + input.Items[0].ReferenceImages = []BatchImageReference{ + {MimeType: "image/webp", Data: []byte("webp-bytes")}, + {MimeType: "image/jpeg", FileURI: "gs://bucket/refs/style.jpg"}, + } + + jsonl, err := BuildGeminiBatchJSONL(input) + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n") + require.Len(t, lines, 1) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &got)) + request := got["request"].(map[string]any) + contents := request["contents"].([]any) + parts := contents[0].(map[string]any)["parts"].([]any) + require.Len(t, parts, 3) + require.Equal(t, "A clean product hero image", parts[0].(map[string]any)["text"]) + inlineData := parts[1].(map[string]any)["inlineData"].(map[string]any) + require.Equal(t, "image/webp", inlineData["mimeType"]) + require.Equal(t, "d2VicC1ieXRlcw==", inlineData["data"]) + fileData := parts[2].(map[string]any)["fileData"].(map[string]any) + require.Equal(t, "image/jpeg", fileData["mimeType"]) + require.Equal(t, "gs://bucket/refs/style.jpg", fileData["fileUri"]) +} + func TestGeminiProvider_SubmitUploadsJSONLThenCreatesBatch(t *testing.T) { client := &fakeGeminiBatchClient{ uploaded: &GeminiUploadedFile{Name: "files/input-jsonl"}, diff --git a/backend/internal/service/batch_image_provider_vertex.go b/backend/internal/service/batch_image_provider_vertex.go index b37a0c35e8..fe28481c59 100644 --- a/backend/internal/service/batch_image_provider_vertex.go +++ b/backend/internal/service/batch_image_provider_vertex.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -492,15 +493,16 @@ func BuildVertexBatchJSONL(input BatchImageInput) ([]byte, error) { if prompt == "" { return nil, batchImageProviderInputError("prompt is required for custom_id %q", customID) } - if len(item.ReferenceImages) > 0 { - return nil, batchImageProviderInputError("reference images are not supported in PR4") + parts, err := vertexBatchImageParts(prompt, item.ReferenceImages) + if err != nil { + return nil, err } line := map[string]any{ "key": customID, "request": map[string]any{ "contents": []any{map[string]any{ "role": "user", - "parts": []any{map[string]any{"text": prompt}}, + "parts": parts, }}, "generationConfig": map[string]any{ "responseModalities": []string{"TEXT", "IMAGE"}, @@ -514,6 +516,36 @@ func BuildVertexBatchJSONL(input BatchImageInput) ([]byte, error) { return buf.Bytes(), nil } +func vertexBatchImageParts(prompt string, refs []BatchImageReference) ([]any, error) { + parts := []any{map[string]any{"text": prompt}} + for _, ref := range refs { + mimeType := normalizeBatchImageReferenceMimeType(ref.MimeType) + if mimeType == "" { + return nil, batchImageProviderInputError("reference image mime_type is required") + } + fileURI := strings.TrimSpace(ref.FileURI) + switch { + case len(ref.Data) > 0 && fileURI == "": + parts = append(parts, map[string]any{ + "inlineData": map[string]any{ + "mimeType": mimeType, + "data": base64.StdEncoding.EncodeToString(ref.Data), + }, + }) + case len(ref.Data) == 0 && fileURI != "": + parts = append(parts, map[string]any{ + "fileData": map[string]any{ + "mimeType": mimeType, + "fileUri": fileURI, + }, + }) + default: + return nil, batchImageProviderInputError("reference image must contain exactly one of data or file_uri") + } + } + return parts, nil +} + func NormalizeVertexBatchModelPath(model string) string { model = strings.Trim(strings.TrimSpace(model), "/") if strings.HasPrefix(model, "publishers/") || strings.HasPrefix(model, "projects/") { diff --git a/backend/internal/service/batch_image_provider_vertex_test.go b/backend/internal/service/batch_image_provider_vertex_test.go index ff97ca4a8f..4ad68f64a3 100644 --- a/backend/internal/service/batch_image_provider_vertex_test.go +++ b/backend/internal/service/batch_image_provider_vertex_test.go @@ -72,6 +72,33 @@ func TestBuildVertexBatchJSONL_RejectsEmptyPrompt(t *testing.T) { require.ErrorIs(t, err, ErrBatchImageProviderInvalidInput) } +func TestBuildVertexBatchJSONL_WritesReferenceImages(t *testing.T) { + input := validVertexBatchInput() + input.Items[0].ReferenceImages = []BatchImageReference{ + {MimeType: "image/png", Data: []byte("png-bytes")}, + {MimeType: "image/jpeg", FileURI: "gs://bucket/refs/style.jpg"}, + } + + jsonl, err := BuildVertexBatchJSONL(input) + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(string(jsonl)), "\n") + require.Len(t, lines, 1) + + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &got)) + request := got["request"].(map[string]any) + contents := request["contents"].([]any) + parts := contents[0].(map[string]any)["parts"].([]any) + require.Len(t, parts, 3) + require.Equal(t, "A clean product hero image", parts[0].(map[string]any)["text"]) + inlineData := parts[1].(map[string]any)["inlineData"].(map[string]any) + require.Equal(t, "image/png", inlineData["mimeType"]) + require.Equal(t, "cG5nLWJ5dGVz", inlineData["data"]) + fileData := parts[2].(map[string]any)["fileData"].(map[string]any) + require.Equal(t, "image/jpeg", fileData["mimeType"]) + require.Equal(t, "gs://bucket/refs/style.jpg", fileData["fileUri"]) +} + func TestNormalizeVertexBatchModelPath(t *testing.T) { require.Equal(t, "publishers/google/models/gemini-3.1-flash-image", NormalizeVertexBatchModelPath("gemini-3.1-flash-image")) require.Equal(t, "publishers/google/models/gemini-2.5-flash-image", NormalizeVertexBatchModelPath("publishers/google/models/gemini-2.5-flash-image")) diff --git a/backend/internal/service/batch_image_public.go b/backend/internal/service/batch_image_public.go index 19c2590837..ad838dac8c 100644 --- a/backend/internal/service/batch_image_public.go +++ b/backend/internal/service/batch_image_public.go @@ -17,13 +17,18 @@ import ( ) const ( - defaultBatchImageMaxItems = 500 + defaultBatchImageMaxItems = 200 + defaultBatchImageMaxOutputImages = 200 + defaultBatchImageMaxOutputCount = 4 defaultBatchImageMaxPromptChars = 8000 defaultBatchImageResponseMime = "image/png" defaultBatchImageImageSize = "1K" defaultBatchImageDiscountMultiplier = 0.5 defaultBatchImageHoldMultiplier = 0.6 maxBatchImagePublicErrorChars = 500 + maxBatchImageReferenceImageBytes = 10 * 1024 * 1024 + defaultBatchImageMaxReferenceImages = 1000 + defaultBatchImageMaxReferenceBytes = 128 * 1024 * 1024 ) type BatchImageAccountSelectionRepository interface { @@ -53,8 +58,18 @@ type BatchImageSubmitRequest struct { } type BatchImageSubmitItem struct { - CustomID string `json:"custom_id"` - Prompt string `json:"prompt"` + CustomID string `json:"custom_id"` + Prompt string `json:"prompt"` + OutputCount int `json:"output_count,omitempty"` + ReferenceImages []BatchImageReferenceInput `json:"reference_images,omitempty"` +} + +type BatchImageReferenceInput struct { + ID string `json:"id,omitempty"` + Type string `json:"type,omitempty"` + MimeType string `json:"mime_type"` + Data []byte `json:"data,omitempty"` + FileURI string `json:"file_uri,omitempty"` } type BatchImageOwner struct { @@ -293,7 +308,21 @@ func (s *BatchImagePublicService) Submit(ctx context.Context, owner BatchImageOw Items: make([]BatchImageInputItem, 0, len(normalized.Items)), } for _, item := range normalized.Items { - input.Items = append(input.Items, BatchImageInputItem{CustomID: item.CustomID, Prompt: item.Prompt}) + refs := make([]BatchImageReference, 0, len(item.ReferenceImages)) + for _, ref := range item.ReferenceImages { + refs = append(refs, BatchImageReference{ + ID: ref.ID, + Type: ref.Type, + MimeType: ref.MimeType, + Data: ref.Data, + FileURI: ref.FileURI, + }) + } + input.Items = append(input.Items, BatchImageInputItem{ + CustomID: item.CustomID, + Prompt: item.Prompt, + ReferenceImages: refs, + }) } providerJob, err := provider.Submit(ctx, job, account, input) @@ -662,11 +691,26 @@ func (s *BatchImagePublicService) validateSubmitRequest(req BatchImageSubmitRequ req.Metadata = sanitizeBatchImageMetadata(req.Metadata) seen := make(map[string]struct{}, len(req.Items)) + totalReferenceImages := 0 + totalInlineReferenceBytes := 0 + totalOutputImages := 0 + expandedItems := make([]BatchImageSubmitItem, 0, len(req.Items)) for i := range req.Items { req.Items[i].CustomID = strings.TrimSpace(req.Items[i].CustomID) if req.Items[i].CustomID == "" { req.Items[i].CustomID = fmt.Sprintf("item_%06d", i+1) } + outputCount := req.Items[i].OutputCount + if outputCount == 0 { + outputCount = 1 + } + if outputCount < 1 || outputCount > s.maxOutputImagesPerItem() { + return req, ErrBatchImageInvalidItems + } + totalOutputImages += outputCount + if totalOutputImages > s.maxOutputImagesPerJob() { + return req, ErrBatchImageTooManyOutputImages + } req.Items[i].Prompt = strings.TrimSpace(req.Items[i].Prompt) if req.Items[i].Prompt == "" { return req, ErrBatchImageInvalidItems @@ -674,14 +718,103 @@ func (s *BatchImagePublicService) validateSubmitRequest(req BatchImageSubmitRequ if len(req.Items[i].Prompt) > s.maxPromptChars() { return req, ErrBatchImagePromptTooLong } - if _, ok := seen[req.Items[i].CustomID]; ok { - return req, ErrBatchImageDuplicateCustomIDInRequest + referenceCount, inlineReferenceBytes, err := normalizeBatchImageReferenceInputs(req.Model, &req.Items[i]) + if err != nil { + return req, err + } + totalReferenceImages += referenceCount * outputCount + if totalReferenceImages > s.maxReferenceImagesPerJob() { + return req, ErrBatchImageTooManyReferenceImages + } + totalInlineReferenceBytes += inlineReferenceBytes * outputCount + if totalInlineReferenceBytes > s.maxReferenceInlineBytesPerJob() { + return req, ErrBatchImageReferenceImagesTooLarge + } + for repeatIndex := 1; repeatIndex <= outputCount; repeatIndex++ { + expanded := req.Items[i] + expanded.OutputCount = 0 + if outputCount > 1 { + expanded.CustomID = fmt.Sprintf("%s_%0*d", req.Items[i].CustomID, batchImageRepeatSuffixWidth(outputCount), repeatIndex) + } + if _, ok := seen[expanded.CustomID]; ok { + return req, ErrBatchImageDuplicateCustomIDInRequest + } + seen[expanded.CustomID] = struct{}{} + expandedItems = append(expandedItems, expanded) } - seen[req.Items[i].CustomID] = struct{}{} } + req.Items = expandedItems return req, nil } +func normalizeBatchImageReferenceInputs(model string, item *BatchImageSubmitItem) (int, int, error) { + if item == nil || len(item.ReferenceImages) == 0 { + return 0, 0, nil + } + maxRefs := maxBatchImageReferenceImagesForModel(model) + if maxRefs <= 0 || len(item.ReferenceImages) > maxRefs { + return 0, 0, ErrBatchImageTooManyReferenceImages + } + out := make([]BatchImageReferenceInput, 0, len(item.ReferenceImages)) + inlineBytes := 0 + for _, ref := range item.ReferenceImages { + ref.ID = truncateBatchImageMessage(strings.TrimSpace(ref.ID), 80) + ref.Type = truncateBatchImageMessage(strings.TrimSpace(ref.Type), 40) + ref.MimeType = normalizeBatchImageReferenceMimeType(ref.MimeType) + ref.FileURI = strings.TrimSpace(ref.FileURI) + if ref.MimeType == "" { + return 0, 0, ErrBatchImageInvalidReferenceImage + } + if len(ref.Data) == 0 && ref.FileURI == "" { + return 0, 0, ErrBatchImageInvalidReferenceImage + } + if len(ref.Data) > 0 && ref.FileURI != "" { + return 0, 0, ErrBatchImageInvalidReferenceImage + } + if len(ref.Data) > maxBatchImageReferenceImageBytes { + return 0, 0, ErrBatchImageInvalidReferenceImage + } + if ref.FileURI != "" && !strings.HasPrefix(ref.FileURI, "gs://") { + return 0, 0, ErrBatchImageInvalidReferenceImage + } + inlineBytes += len(ref.Data) + out = append(out, ref) + } + item.ReferenceImages = out + return len(out), inlineBytes, nil +} + +func normalizeBatchImageReferenceMimeType(v string) string { + switch strings.ToLower(strings.TrimSpace(v)) { + case "image/jpeg", "image/jpg": + return "image/jpeg" + case "image/png": + return "image/png" + case "image/webp": + return "image/webp" + default: + return "" + } +} + +func batchImageRepeatSuffixWidth(count int) int { + if count < 10 { + return 2 + } + return len(strconv.Itoa(count)) +} + +func maxBatchImageReferenceImagesForModel(model string) int { + model = strings.ToLower(strings.TrimSpace(model)) + if strings.Contains(model, "pro-image") { + return 14 + } + if strings.Contains(model, "flash-image") { + return 3 + } + return 0 +} + func (s *BatchImagePublicService) selectProviderAndAccount(ctx context.Context, owner BatchImageOwner, requestedProvider, model string) (BatchImageProvider, *Account, error) { providers := batchImageProviderSelectionOrder(requestedProvider) for _, providerName := range providers { @@ -840,6 +973,20 @@ func (s *BatchImagePublicService) maxItems() int { return defaultBatchImageMaxItems } +func (s *BatchImagePublicService) maxOutputImagesPerJob() int { + if s != nil && s.Config != nil && s.Config.BatchImage.MaxOutputImagesPerJob > 0 { + return s.Config.BatchImage.MaxOutputImagesPerJob + } + return defaultBatchImageMaxOutputImages +} + +func (s *BatchImagePublicService) maxOutputImagesPerItem() int { + if s != nil && s.Config != nil && s.Config.BatchImage.MaxOutputImagesPerItem > 0 { + return s.Config.BatchImage.MaxOutputImagesPerItem + } + return defaultBatchImageMaxOutputCount +} + func (s *BatchImagePublicService) maxPromptChars() int { if s != nil && s.Config != nil && s.Config.BatchImage.MaxPromptCharsPerItem > 0 { return s.Config.BatchImage.MaxPromptCharsPerItem @@ -847,6 +994,20 @@ func (s *BatchImagePublicService) maxPromptChars() int { return defaultBatchImageMaxPromptChars } +func (s *BatchImagePublicService) maxReferenceImagesPerJob() int { + if s != nil && s.Config != nil && s.Config.BatchImage.MaxReferenceImagesPerJob > 0 { + return s.Config.BatchImage.MaxReferenceImagesPerJob + } + return defaultBatchImageMaxReferenceImages +} + +func (s *BatchImagePublicService) maxReferenceInlineBytesPerJob() int { + if s != nil && s.Config != nil && s.Config.BatchImage.MaxReferenceInlineBytesPerJob > 0 { + return s.Config.BatchImage.MaxReferenceInlineBytesPerJob + } + return defaultBatchImageMaxReferenceBytes +} + func (s *BatchImagePublicService) defaultResponseMimeType() string { if s != nil && s.Config != nil && strings.TrimSpace(s.Config.BatchImage.DefaultResponseMimeType) != "" { return strings.TrimSpace(s.Config.BatchImage.DefaultResponseMimeType) diff --git a/backend/internal/service/batch_image_public_test.go b/backend/internal/service/batch_image_public_test.go index 2d5c5a2533..4060c7de1b 100644 --- a/backend/internal/service/batch_image_public_test.go +++ b/backend/internal/service/batch_image_public_test.go @@ -178,6 +178,28 @@ func TestBatchImagePublicService_Submit(t *testing.T) { require.Equal(t, "item_000002", gemini.submits[0].Items[1].CustomID) }) + t.Run("expands output count into separate billable items", func(t *testing.T) { + svc, repo, _, gemini, _ := newTestBatchImagePublicService(true) + req := validBatchImageSubmitRequest() + req.Items = []BatchImageSubmitItem{ + {CustomID: "cover", Prompt: "hero", OutputCount: 3, ReferenceImages: []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("ref")}}}, + } + + got, err := svc.Submit(ctx, testBatchImageOwner(), req, "") + require.NoError(t, err) + require.Equal(t, 3, got.ItemCount) + require.InDelta(t, 0.375, got.EstimatedCost, 1e-12) + require.Len(t, gemini.submits, 1) + require.Len(t, gemini.submits[0].Items, 3) + require.Equal(t, []string{"cover_01", "cover_02", "cover_03"}, []string{ + gemini.submits[0].Items[0].CustomID, + gemini.submits[0].Items[1].CustomID, + gemini.submits[0].Items[2].CustomID, + }) + require.Len(t, gemini.submits[0].Items[0].ReferenceImages, 1) + require.Len(t, repo.items[got.ID], 3) + }) + t.Run("validates request fields", func(t *testing.T) { tests := []struct { name string @@ -191,6 +213,24 @@ func TestBatchImagePublicService_Submit(t *testing.T) { {name: "prompt_too_long", mutate: func(r *BatchImageSubmitRequest) { r.Items[0].Prompt = strings.Repeat("x", 9) }, want: ErrBatchImagePromptTooLong}, {name: "unsupported_provider", mutate: func(r *BatchImageSubmitRequest) { r.Provider = "other" }, want: ErrBatchImageUnsupportedProvider}, {name: "vertex_rejects_2k", mutate: func(r *BatchImageSubmitRequest) { r.Provider = BatchImageProviderVertex; r.ImageSize = "2K" }, want: ErrBatchImageInvalidItems}, + {name: "too_many_outputs_per_item", mutate: func(r *BatchImageSubmitRequest) { + r.Items[0].OutputCount = 5 + }, want: ErrBatchImageInvalidItems}, + {name: "too_many_reference_images_for_flash", mutate: func(r *BatchImageSubmitRequest) { + r.Model = "gemini-2.5-flash-image" + r.Items[0].ReferenceImages = []BatchImageReferenceInput{ + {MimeType: "image/png", Data: []byte("1")}, + {MimeType: "image/png", Data: []byte("2")}, + {MimeType: "image/png", Data: []byte("3")}, + {MimeType: "image/png", Data: []byte("4")}, + } + }, want: ErrBatchImageTooManyReferenceImages}, + {name: "bad_reference_mime", mutate: func(r *BatchImageSubmitRequest) { + r.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "application/octet-stream", Data: []byte("x")}} + }, want: ErrBatchImageInvalidReferenceImage}, + {name: "reference_requires_data_or_file_uri", mutate: func(r *BatchImageSubmitRequest) { + r.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png"}} + }, want: ErrBatchImageInvalidReferenceImage}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -213,6 +253,48 @@ func TestBatchImagePublicService_Submit(t *testing.T) { require.ErrorIs(t, err, ErrBatchImageInvalidItems) }) + t.Run("rejects too many output images", func(t *testing.T) { + svc, _, _, _, _ := newTestBatchImagePublicService(true) + svc.Config.BatchImage.MaxOutputImagesPerJob = 3 + req := validBatchImageSubmitRequest() + req.Items[0].OutputCount = 2 + req.Items[1].OutputCount = 2 + + _, err := svc.Submit(ctx, testBatchImageOwner(), req, "") + require.ErrorIs(t, err, ErrBatchImageTooManyOutputImages) + }) + + t.Run("rejects too many reference images across request", func(t *testing.T) { + svc, _, _, _, _ := newTestBatchImagePublicService(true) + svc.Config.BatchImage.MaxReferenceImagesPerJob = 3 + req := validBatchImageSubmitRequest() + req.Model = "gemini-2.5-flash-image" + req.Items[0].ReferenceImages = []BatchImageReferenceInput{ + {MimeType: "image/png", Data: []byte("1")}, + {MimeType: "image/png", Data: []byte("2")}, + } + req.Items[1].ReferenceImages = []BatchImageReferenceInput{ + {MimeType: "image/png", Data: []byte("3")}, + {MimeType: "image/png", Data: []byte("4")}, + } + + _, err := svc.Submit(ctx, testBatchImageOwner(), req, "") + require.ErrorIs(t, err, ErrBatchImageTooManyReferenceImages) + }) + + t.Run("rejects too much inline reference image data across request", func(t *testing.T) { + svc, _, _, _, _ := newTestBatchImagePublicService(true) + svc.Config.BatchImage.MaxReferenceImagesPerJob = 10 + svc.Config.BatchImage.MaxReferenceInlineBytesPerJob = 4 + req := validBatchImageSubmitRequest() + req.Model = "gemini-2.5-flash-image" + req.Items[0].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("123")}} + req.Items[1].ReferenceImages = []BatchImageReferenceInput{{MimeType: "image/png", Data: []byte("456")}} + + _, err := svc.Submit(ctx, testBatchImageOwner(), req, "") + require.ErrorIs(t, err, ErrBatchImageReferenceImagesTooLarge) + }) + t.Run("selects requested provider", func(t *testing.T) { svc, _, _, gemini, vertex := newTestBatchImagePublicService(true) req := validBatchImageSubmitRequest() diff --git a/deploy/Dockerfile b/deploy/Dockerfile index c9222da50f..aacd121c12 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -19,8 +19,8 @@ FROM ${NODE_IMAGE} AS frontend-builder WORKDIR /app/frontend -# Install pnpm -RUN corepack enable && corepack prepare pnpm@latest --activate +# Install pnpm. Keep this aligned with CI to avoid lockfile metadata drift. +RUN corepack enable && corepack prepare pnpm@9 --activate # Install dependencies first (better caching) COPY frontend/package.json frontend/pnpm-lock.yaml ./ diff --git a/docs/BATCH_IMAGE_MVP.md b/docs/BATCH_IMAGE_MVP.md index 091893ff5c..2ca2dc00f8 100644 --- a/docs/BATCH_IMAGE_MVP.md +++ b/docs/BATCH_IMAGE_MVP.md @@ -30,7 +30,22 @@ Submit request: "items": [ { "custom_id": "cover_001", - "prompt": "A clean product hero image..." + "prompt": "A clean product hero image...", + "output_count": 1, + "reference_images": [ + { + "id": "product-front", + "type": "subject", + "mime_type": "image/png", + "data": "" + }, + { + "id": "style", + "type": "style", + "mime_type": "image/jpeg", + "file_uri": "gs://internal-managed-bucket/batch-image/refs/style.jpg" + } + ] } ], "image_size": "1K", @@ -38,6 +53,19 @@ Submit request: } ``` +`reference_images` is optional per item. Inline `data` is a base64 string decoded by the backend; `file_uri` is reserved for internal Google Cloud Storage references and must be a `gs://` URI. Each reference image must use one of `image/png`, `image/jpeg`, or `image/webp`. Current model limits are: + +- `gemini-2.5-flash-image` and other Flash Image aliases: up to 3 reference images per item. +- `gemini-3-pro-image` and other Pro Image aliases: up to 14 reference images per item. +- Per batch job: up to 1000 reference image attachments total after `output_count` expansion across all items. This is an internal Sub2API guardrail for request size and cost control, not the generated-image cap and not a Pro Image per-item capability. The generated-output cap is 200 images per job. +- Per batch job: up to 128 MB decoded inline reference image data total. For large batches or repeated reference images, prefer `gs://` `file_uri` references or split the request into multiple jobs. + +`output_count` is optional per item and defaults to `1`. It means "repeat this prompt and reference image set N times" rather than relying on Gemini to return multiple images from one upstream request. The backend expands each repeat into a separate provider JSONL line with suffixed custom ids such as `cover_001_01`, `cover_001_02`. Current limits are: + +- Per prompt item: up to 4 output images. +- Per batch job: up to 200 expected output images after expansion. This is the hard generated-output cap for a single job; clients and Codex skills must split larger workloads before submission. +- The output-image limit intentionally matches the default ZIP item limit so newly submitted jobs are always downloadable as one ZIP by item count. ZIP byte size is still capped separately by `max_download_bytes_per_request`. + Public batch response: ```json @@ -136,8 +164,10 @@ MVP billing rules: - Settlement runs after result indexing. - Only successful images are charged. - Failed items are not charged. +- Reference images are sent to Gemini as input and can create small upstream input-token and temporary storage cost. They are counted once per expanded output request when `output_count > 1`, but the public MVP billing model does not add a separate reference-image surcharge. User-facing estimated, held, and settled amounts are still based on the output image count and configured batch image unit price. - Settlement request id is `batch_image_settlement:{batch_id}`. - Settlement is idempotent; re-running settlement must not double charge. +- Settlement billing failures are retried with a bounded retry limit. After the retry limit is reached, the job is failed and the remaining hold is released through the idempotent release path. Exact production pricing is resolved through model pricing configuration and is not defined here. @@ -170,6 +200,7 @@ For the managed Vertex/GCS batch bucket, disable Cloud Storage soft delete or co - Uses Gemini Batch API with JSONL file mode. - Result file refs are internal. - API keys are never returned. +- The provider can be selected and submitted through Sub2API when an administrator configures a Gemini API-key upstream account. In the 2026-07-07 PR validation, this path was verified as selectable/callable, but successful image generation was not continued because the test API key had no prepayment. `vertex`: @@ -179,6 +210,34 @@ For the managed Vertex/GCS batch bucket, disable Cloud Storage soft delete or co - Batch image output should be treated as `1K`/default only in MVP. - Do not promise `2K` or `4K`. +## Official Google Enablement + +Operators must enable Gemini/Vertex capability in Google's official console before turning on Sub2API batch image for any group. Sub2API feature flags and group switches do not create Google-side access by themselves. + +Recommended production path: + +- Use a Google Cloud project with billing enabled. +- Enable the relevant Gemini API / Vertex AI APIs for the project. +- Use a service account or Application Default Credentials for the Sub2API runtime. +- Create one fixed Cloud Storage bucket for batch image input and output, then grant the runtime and Vertex service agent the minimum required bucket permissions. +- Configure Sub2API with the project id, location, managed bucket, provider account, model whitelist, and pricing. +- Enable `BATCH_IMAGE_ENABLED` globally and `allow_batch_image_generation` only on the intended Gemini group. + +API-key path: + +- Google API keys are suitable for Gemini API development and supported Gemini methods. +- The Sub2API `x-goog-api-key` compatibility header still expects a Sub2API key, not a plain Google key. +- Plain Google API keys should not be documented as the default production credential for Vertex service-account batch jobs. +- If an administrator configures a Gemini API-key upstream account, validate it with one low-cost batch image after the Google account has the required billing/prepayment state. If it has no prepayment, record only that the provider is selectable/callable and that failed submit releases hold. + +Official references: + +- Gemini API key guide: https://ai.google.dev/gemini-api/docs/api-key +- Gemini API Batch API: https://ai.google.dev/gemini-api/docs/batch-api +- Gemini API image generation and batch image notes: https://ai.google.dev/gemini-api/docs/image-generation +- Vertex/Gemini batch inference: https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/capabilities/batch-inference +- Vertex batch predictions API: https://docs.cloud.google.com/gemini-enterprise-agent-platform/reference/models/batch-prediction-api + ## Config These keys exist in `backend/internal/config/config.go`: @@ -186,16 +245,20 @@ These keys exist in `backend/internal/config/config.go`: ```yaml batch_image: enabled: false - max_items_per_job_default: 500 + max_items_per_job_default: 200 max_items_per_job_trial: 50 + max_output_images_per_job: 200 + max_output_images_per_item: 4 max_prompt_chars_per_item: 8000 + max_reference_images_per_job: 1000 + max_reference_inline_bytes_per_job: 134217728 default_response_mime_type: "image/png" default_image_size: "1K" - max_download_items_zip: 1000 - max_download_bytes_per_request: 2147483648 + max_download_items_zip: 200 + max_download_bytes_per_request: 536870912 max_download_duration_seconds: 600 - max_download_concurrency_per_user: 2 + max_download_concurrency_per_user: 1 input_retention_after_terminal_hours: 24 output_retention_after_terminal_hours: 72 diff --git a/frontend/src/api/batchImage.ts b/frontend/src/api/batchImage.ts index e08743c74d..e6a26d79bd 100644 --- a/frontend/src/api/batchImage.ts +++ b/frontend/src/api/batchImage.ts @@ -15,6 +15,16 @@ export type BatchImageStatus = export interface BatchImageSubmitItem { custom_id: string prompt: string + output_count?: number + reference_images?: BatchImageReferenceImage[] +} + +export interface BatchImageReferenceImage { + id?: string + type?: string + mime_type: string + data?: string + file_uri?: string } export interface BatchImageSubmitRequest { diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 8078a4a4c7..372dedbb38 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2312,7 +2312,7 @@ export default { imageMultiplier: 'Image multiplier', batchDiscountMultiplier: 'Batch image discount', batchHoldMultiplier: 'Batch hold price ratio', - batchSectionHint: 'Batch image settings only apply to batch jobs: settlement applies the batch discount, and the upfront hold is normal image price × batch hold price ratio.', + batchSectionHint: 'Batch image settings only apply to batch jobs: settlement applies the batch discount, and the upfront hold is normal image price × batch hold price ratio. Reference images also create upstream input-token usage, so a batch image discount above 0.5 is recommended.', batchDisabledHint: 'Enable image generation for this group before enabling batch image generation.', modeHint: 'By default, image billing uses image price × current effective group multiplier. Independent mode uses image price × image multiplier.', finalPricePreview: 'Final per-image price preview', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index d98558fe42..e3fa897c86 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2394,7 +2394,7 @@ export default { imageMultiplier: '生图独立倍率', batchDiscountMultiplier: '批量生图折扣倍率', batchHoldMultiplier: '批量冻结价格比例', - batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。', + batchSectionHint: '批量生图仅影响批量任务:结算价格会叠加批量折扣倍率,提交时冻结金额按普通生图原价 × 批量冻结价格比例计算。参考图也会产生上游输入 token 消耗,建议批量生图折扣倍率设置大于 0.5。', batchDisabledHint: '请先开启当前分组生图,才能开启批量生图。', modeHint: '默认关闭独立倍率时,图片费用 = 图片价格 × 当前分组有效倍率;开启独立倍率后,图片费用 = 图片价格 × 生图独立倍率。', finalPricePreview: '最终单张价格预览', diff --git a/frontend/src/views/user/BatchImageGuideView.vue b/frontend/src/views/user/BatchImageGuideView.vue index 1267299e31..70387766a8 100644 --- a/frontend/src/views/user/BatchImageGuideView.vue +++ b/frontend/src/views/user/BatchImageGuideView.vue @@ -587,9 +587,9 @@
- +
- {{ parsedItems.length }} 条 + {{ estimatedOutputCount }} 张 / {{ promptRows.length }} 条
@@ -600,24 +600,65 @@ 已添加 {{ promptRows.length }} 条
-
+