Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion spx-backend/SVG_GENERATION_CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,41 @@ Generates an image and returns metadata information.
}
```

### POST /character/style/change
Changes character styling while preserving character identity.

**Request Body (multipart/form-data):**
```
image: [PNG file] # Required: Character image to be restyled
style_prompt: "change to casual clothes" # Required: Description of style changes
strength: 0.3 # Optional: Transformation strength (0-1, default: 0.3)
style: "realistic_image" # Optional: Image style
sub_style: "detailed" # Optional: Sub-style
negative_prompt: "ugly, distorted" # Optional: What to avoid
provider: "recraft" # Optional: Provider (default: recraft)
preserve_identity: true # Optional: Preserve character identity (default: true)
```

**Response:**
```json
{
"id": "recraft_style_1234567890",
"url": "https://example.com/styled-character.png",
"kodo_url": "https://kodo.example.com/styled-character.svg",
"ai_resource_id": 12345,
"original_prompt": "change to casual clothes",
"style_prompt": "保持角色的面部特征、体型和基本外观不变,只改变change to casual clothes,确保角色身份完全保持不变",
"negative_prompt": "ugly, distorted, 改变面部特征, 改变角色身份, 不同的人",
"style": "realistic_image",
"strength": 0.3,
"width": 1024,
"height": 1024,
"provider": "recraft",
"preserve_identity": true,
"created_at": "2025-01-01T12:00:00Z"
}
```

## Provider Selection

You can specify which provider to use in the request:
Expand All @@ -174,7 +209,7 @@ You can specify which provider to use in the request:
Each provider has different strengths:

- **SVG.IO**: Direct SVG generation, good for simple vector graphics
- **Recraft**: High-quality AI image generation with vectorization
- **Recraft**: High-quality AI image generation with vectorization, supports image beautification and character style changes
- **OpenAI**: LLM-powered SVG code generation, highly customizable

## Development Setup
Expand Down Expand Up @@ -214,4 +249,12 @@ curl -X POST http://localhost:8080/image \
"model": "recraftv3",
"size": "1024x1024"
}'

# Change character style
curl -X POST http://localhost:8080/character/style/change \
-F "[email protected]" \
-F "style_prompt=change to medieval knight armor" \
-F "strength=0.4" \
-F "preserve_identity=true" \
-F "provider=recraft"
```
84 changes: 84 additions & 0 deletions spx-backend/cmd/spx-backend/post_character_style_change.yap
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Change character styling while preserving character identity.
//
// Request:
// POST /character/style/change

import (
"io"
"strconv"

"github.com/goplus/builder/spx-backend/internal/controller"
"github.com/goplus/builder/spx-backend/internal/svggen"
)

ctx := &Context
if _, ok := ensureAuthenticatedUser(ctx); !ok {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security - Missing Quota/Rate Limiting

Unlike copilot endpoints, this doesn't enforce any quota limits. This could allow abuse of expensive Recraft API calls.

Consider adding quota enforcement similar to post_copilot_message.yap:

if err := authz.ConsumeQuota(ctx.Context(), authz.ResourceAIImageGen, 1); err != nil {
	replyWithCodeMsg(ctx, errorTooManyRequests, "Image generation quota exceeded")
	return
}

return
}

// Parse multipart form data
err := ctx.Request.ParseMultipartForm(10 << 20) // 10MB max memory
if err != nil {
replyWithCodeMsg(ctx, errorInvalidArgs, "Failed to parse multipart form")
return
}

// Get image file from form
file, _, err := ctx.Request.FormFile("image")
if err != nil {
replyWithCodeMsg(ctx, errorInvalidArgs, "Image file is required")
return
}
defer file.Close()

// Read image data
imageData, err := io.ReadAll(file)
if err != nil {
replyWithCodeMsg(ctx, errorInvalidArgs, "Failed to read image data")
return
}

// Parse other form parameters
params := &controller.ChangeCharacterStyleParams{
StylePrompt: ctx.Request.FormValue("style_prompt"),
Strength: 0.3, // Default value for character preservation
Style: ctx.Request.FormValue("style"),
SubStyle: ctx.Request.FormValue("sub_style"),
NegativePrompt: ctx.Request.FormValue("negative_prompt"),
Provider: svggen.ProviderRecraft, // Default to recraft
PreserveIdentity: true, // Default to preserve identity
}

// Parse strength if provided
if strengthStr := ctx.Request.FormValue("strength"); strengthStr != "" {
if strength, err := strconv.ParseFloat(strengthStr, 64); err == nil {
params.Strength = strength
}
Comment on lines +54 to +56

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Parsing for the strength parameter silently ignores errors. If a user provides a non-numeric value (e.g., strength=abc), the error from strconv.ParseFloat is ignored, and the default value is used without notifying the user. This can be confusing. It's better to return an error for invalid input.

if strength, err := strconv.ParseFloat(strengthStr, 64); err == nil {
		params.Strength = strength
	} else {
		replyWithCodeMsg(ctx, errorInvalidArgs, "Invalid value for strength parameter")
		return
	}

}

// Parse provider if provided
if providerStr := ctx.Request.FormValue("provider"); providerStr != "" {
params.Provider = svggen.Provider(providerStr)
}

// Parse preserve_identity if provided
if preserveStr := ctx.Request.FormValue("preserve_identity"); preserveStr != "" {
if preserve, err := strconv.ParseBool(preserveStr); err == nil {
params.PreserveIdentity = preserve
}
}

// Validate parameters
if ok, msg := params.Validate(); !ok {
replyWithCodeMsg(ctx, errorInvalidArgs, msg)
return
}

// Change character style
result, err := ctrl.ChangeCharacterStyle(ctx.Context(), params, imageData)
if err != nil {
replyWithInnerError(ctx, err)
return
}

json result
148 changes: 148 additions & 0 deletions spx-backend/internal/controller/character_style_change_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package controller

import (
"context"
"testing"

"github.com/goplus/builder/spx-backend/internal/svggen"
)

func TestController_ChangeCharacterStyle_ValidationErrors(t *testing.T) {
ctrl := &Controller{}

ctx := context.Background()

tests := []struct {
name string
params *ChangeCharacterStyleParams
imageData []byte
wantErr string
}{
{
name: "empty image data",
params: &ChangeCharacterStyleParams{
StylePrompt: "change outfit",
Strength: 0.5,
Provider: svggen.ProviderRecraft,
PreserveIdentity: true,
},
imageData: []byte{},
wantErr: "image data is required",
},
{
name: "oversized image data",
params: &ChangeCharacterStyleParams{
StylePrompt: "change outfit",
Strength: 0.5,
Provider: svggen.ProviderRecraft,
PreserveIdentity: true,
},
imageData: make([]byte, 6*1024*1024), // 6MB, exceeds 5MB limit
wantErr: "image size exceeds 5MB limit",
},
{
name: "invalid image format - not PNG",
params: &ChangeCharacterStyleParams{
StylePrompt: "change outfit",
Strength: 0.5,
Provider: svggen.ProviderRecraft,
PreserveIdentity: true,
},
imageData: []byte{0xFF, 0xD8, 0xFF, 0xE0}, // JPEG signature
wantErr: "only PNG format is supported for character style change",
},
{
name: "valid PNG but params validation fails",
params: &ChangeCharacterStyleParams{
StylePrompt: "hi", // Too short
Strength: 0.5,
Provider: svggen.ProviderRecraft,
},
imageData: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, // Valid PNG
wantErr: "style_prompt must be at least 3 characters",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Validate params first if they should fail
if tt.wantErr == "style_prompt must be at least 3 characters" {
ok, msg := tt.params.Validate()
if ok {
t.Errorf("Expected params validation to fail")
return
}
if msg != tt.wantErr {
t.Errorf("Expected validation error %q, got %q", tt.wantErr, msg)
}
return
}

_, err := ctrl.ChangeCharacterStyle(ctx, tt.params, tt.imageData)
if err == nil {
t.Errorf("ChangeCharacterStyle() should return error, got nil")
return
}

if err.Error() != tt.wantErr {
t.Errorf("ChangeCharacterStyle() error = %q, want %q", err.Error(), tt.wantErr)
}
})
}
}

// Note: Controller integration tests with full mocking would require dependency injection.
// The core business logic is already tested through:
// 1. Parameter validation tests (in svg_test.go)
// 2. Recraft service tests (in recraft_test.go)
// 3. Input validation tests (below)

// Test the PNG format validation helper function
func TestController_isPNGFormat_Additional(t *testing.T) {
ctrl := &Controller{}

tests := []struct {
name string
data []byte
wantPNG bool
}{
{
name: "valid PNG with extra data",
data: append([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, make([]byte, 100)...),
wantPNG: true,
},
{
name: "partial PNG signature",
data: []byte{0x89, 0x50, 0x4E, 0x47}, // Only first 4 bytes
wantPNG: false,
},
{
name: "corrupted PNG signature - wrong middle bytes",
data: []byte{0x89, 0x50, 0x4E, 0x48, 0x0D, 0x0A, 0x1A, 0x0A}, // 0x48 instead of 0x47
wantPNG: false,
},
{
name: "GIF signature",
data: []byte{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}, // GIF89a
wantPNG: false,
},
{
name: "BMP signature",
data: []byte{0x42, 0x4D}, // BM
wantPNG: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ctrl.isPNGFormat(tt.data)
if got != tt.wantPNG {
t.Errorf("isPNGFormat() = %v, want %v", got, tt.wantPNG)
}
})
}
}

// Note: Tests for Kodo failure, Database failure, and Vector service failure scenarios
// would be implemented as integration tests with proper dependency injection.
// These scenarios are critical for production reliability but require more complex test setup.
Loading
Loading