From 7a36c662cedd5288cd202b9e589d0fa5c474cdc0 Mon Sep 17 00:00:00 2001 From: Shen Li Date: Tue, 2 Jun 2026 01:23:07 -0400 Subject: [PATCH 1/6] blur tool and changes as list --- db/migrations/000006_changes_array.down.sql | 9 + db/migrations/000006_changes_array.up.sql | 9 + editing/blur.go | 196 ++++++++++++ editing/changeset.go | 40 ++- editing/changeset_test.go | 12 +- editing/watermark.go | 16 +- editing/watermark_test.go | 6 +- graph/generated.go | 320 ++++++++++++++++++++ graph/images.resolvers.go | 122 +++++++- graph/model/models_gen.go | 17 ++ graph/schema/images.graphqls | 18 ++ image/repo.go | 4 +- web_client/src/__generated__/gql.ts | 6 + web_client/src/__generated__/graphql.ts | 32 ++ web_client/src/editor/BlurTool.tsx | 81 +++++ web_client/src/editor/EditorCanvas.tsx | 127 ++++++-- web_client/src/editor/ImageDetail.tsx | 11 +- web_client/src/editor/ImageEditor.tsx | 181 ++++++++--- web_client/src/editor/data.tsx | 27 ++ web_client/src/localization/en.json | 13 +- web_client/src/localization/ko.json | 13 +- web_client/src/localization/ru.json | 13 +- web_client/src/localization/zh_hans.json | 13 +- web_client/src/localization/zh_hant.json | 13 +- 24 files changed, 1190 insertions(+), 109 deletions(-) create mode 100644 db/migrations/000006_changes_array.down.sql create mode 100644 db/migrations/000006_changes_array.up.sql create mode 100644 editing/blur.go create mode 100644 web_client/src/editor/BlurTool.tsx diff --git a/db/migrations/000006_changes_array.down.sql b/db/migrations/000006_changes_array.down.sql new file mode 100644 index 0000000..72b1c20 --- /dev/null +++ b/db/migrations/000006_changes_array.down.sql @@ -0,0 +1,9 @@ +-- Restore empty arrays to empty objects +UPDATE image_table SET changes = '{}' WHERE changes = '[]'; + +-- Unwrap single-element arrays back to bare objects +UPDATE image_table + SET changes = changes->0 + WHERE jsonb_array_length(changes) = 1; + +ALTER TABLE image_table ALTER COLUMN changes SET DEFAULT '{}'; diff --git a/db/migrations/000006_changes_array.up.sql b/db/migrations/000006_changes_array.up.sql new file mode 100644 index 0000000..b607050 --- /dev/null +++ b/db/migrations/000006_changes_array.up.sql @@ -0,0 +1,9 @@ +-- Migrate single-change objects to single-element arrays +UPDATE image_table + SET changes = jsonb_build_array(changes) + WHERE changes != '{}' AND changes != '[]'; + +-- Convert legacy empty-object default to empty array +UPDATE image_table SET changes = '[]' WHERE changes = '{}'; + +ALTER TABLE image_table ALTER COLUMN changes SET DEFAULT '[]'; diff --git a/editing/blur.go b/editing/blur.go new file mode 100644 index 0000000..348c194 --- /dev/null +++ b/editing/blur.go @@ -0,0 +1,196 @@ +package editing + +import ( + "bytes" + "encoding/json" + "fmt" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + + "github.com/ericls/imgdd/utils" +) + +func init() { + Register("blur", &BlurEditor{}) +} + +type BlurRegion struct { + X1 float64 `json:"x1"` + Y1 float64 `json:"y1"` + X2 float64 `json:"x2"` + Y2 float64 `json:"y2"` +} + +type BlurParams struct { + Region BlurRegion `json:"region"` + Radius int `json:"radius"` +} + +func NewBlurChange(params BlurParams) (Change, error) { + if params.Region.X1 < 0 || params.Region.X1 > 1 || + params.Region.Y1 < 0 || params.Region.Y1 > 1 || + params.Region.X2 < 0 || params.Region.X2 > 1 || + params.Region.Y2 < 0 || params.Region.Y2 > 1 { + return Change{}, fmt.Errorf("region coordinates must be between 0 and 1") + } + if params.Region.X1 >= params.Region.X2 || params.Region.Y1 >= params.Region.Y2 { + return Change{}, fmt.Errorf("region must have positive area (x1 100 { + return Change{}, fmt.Errorf("radius must be between 1 and 100") + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return Change{}, fmt.Errorf("failed to serialize params: %w", err) + } + return Change{ + Type: "blur", + Params: paramsJSON, + }, nil +} + +type BlurEditor struct{} + +func (e *BlurEditor) Apply(baseBytes []byte, cs Change, _ FetchImageFunc) ([]byte, string, error) { + var params BlurParams + if err := json.Unmarshal(cs.Params, ¶ms); err != nil { + return nil, "", fmt.Errorf("invalid blur params: %w", err) + } + if params.Region.X1 >= params.Region.X2 || params.Region.Y1 >= params.Region.Y2 { + return nil, "", fmt.Errorf("region must have positive area") + } + if params.Radius < 1 || params.Radius > 100 { + return nil, "", fmt.Errorf("radius must be between 1 and 100") + } + + baseMime := utils.DetectMIMEType(&baseBytes) + + baseImg, _, err := image.Decode(bytes.NewReader(baseBytes)) + if err != nil { + return nil, "", fmt.Errorf("failed to decode base image: %w", err) + } + + bounds := baseImg.Bounds() + w := bounds.Dx() + h := bounds.Dy() + + // Convert normalized region to pixel coords + rx1 := int(params.Region.X1 * float64(w)) + ry1 := int(params.Region.Y1 * float64(h)) + rx2 := int(params.Region.X2 * float64(w)) + ry2 := int(params.Region.Y2 * float64(h)) + + // Clamp to image bounds + if rx1 < 0 { + rx1 = 0 + } + if ry1 < 0 { + ry1 = 0 + } + if rx2 > w { + rx2 = w + } + if ry2 > h { + ry2 = h + } + + result := applyBoxBlurRegion(baseImg, rx1, ry1, rx2, ry2, params.Radius) + + var buf bytes.Buffer + switch baseMime { + case "image/png": + err = png.Encode(&buf, result) + case "image/jpeg": + err = jpeg.Encode(&buf, result, &jpeg.Options{Quality: 95}) + case "image/gif": + err = gif.Encode(&buf, result, nil) + default: + err = png.Encode(&buf, result) + baseMime = "image/png" + } + if err != nil { + return nil, "", fmt.Errorf("failed to encode result: %w", err) + } + return buf.Bytes(), baseMime, nil +} + +// applyBoxBlurRegion copies the full image into an RGBA buffer and applies a +// box blur (iterated 3× for a Gaussian-like effect) only to the specified +// pixel rectangle. +func applyBoxBlurRegion(src image.Image, rx1, ry1, rx2, ry2, radius int) *image.RGBA { + bounds := src.Bounds() + w := bounds.Dx() + h := bounds.Dy() + + // Copy source into an RGBA buffer + rgba := image.NewRGBA(bounds) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + rgba.Set(bounds.Min.X+x, bounds.Min.Y+y, src.At(bounds.Min.X+x, bounds.Min.Y+y)) + } + } + + // Extract the region pixels, blur, write back – three passes for smoother result + for pass := 0; pass < 3; pass++ { + blurred := boxBlurPass(rgba, bounds, rx1, ry1, rx2, ry2, radius) + for y := ry1; y < ry2; y++ { + for x := rx1; x < rx2; x++ { + p := blurred[(y-ry1)*(rx2-rx1)+(x-rx1)] + rgba.SetRGBA(bounds.Min.X+x, bounds.Min.Y+y, color.RGBA{R: p.R, G: p.G, B: p.B, A: p.A}) + } + } + } + + return rgba +} + +type rgbaPixel struct { + R, G, B, A uint8 +} + +func boxBlurPass(src *image.RGBA, bounds image.Rectangle, rx1, ry1, rx2, ry2, radius int) []rgbaPixel { + rw := rx2 - rx1 + rh := ry2 - ry1 + out := make([]rgbaPixel, rw*rh) + + for y := ry1; y < ry2; y++ { + for x := rx1; x < rx2; x++ { + var rSum, gSum, bSum, aSum int + count := 0 + for dy := -radius; dy <= radius; dy++ { + sy := bounds.Min.Y + y + dy + if sy < bounds.Min.Y+ry1 { + sy = bounds.Min.Y + ry1 + } + if sy >= bounds.Min.Y+ry2 { + sy = bounds.Min.Y + ry2 - 1 + } + for dx := -radius; dx <= radius; dx++ { + sx := bounds.Min.X + x + dx + if sx < bounds.Min.X+rx1 { + sx = bounds.Min.X + rx1 + } + if sx >= bounds.Min.X+rx2 { + sx = bounds.Min.X + rx2 - 1 + } + c := src.RGBAAt(sx, sy) + rSum += int(c.R) + gSum += int(c.G) + bSum += int(c.B) + aSum += int(c.A) + count++ + } + } + out[(y-ry1)*rw+(x-rx1)] = rgbaPixel{ + R: uint8(rSum / count), + G: uint8(gSum / count), + B: uint8(bSum / count), + A: uint8(aSum / count), + } + } + } + return out +} diff --git a/editing/changeset.go b/editing/changeset.go index ec4e2f4..d23bb6c 100644 --- a/editing/changeset.go +++ b/editing/changeset.go @@ -5,17 +5,21 @@ import ( "fmt" ) -type ChangeSet struct { +// Change represents a single named edit operation with its parameters. +type Change struct { Type string `json:"type"` Params json.RawMessage `json:"params"` } +// ChangeSet is an ordered list of changes applied atomically to produce one image. +type ChangeSet []Change + // FetchImageFunc retrieves image bytes by image ID. type FetchImageFunc func(id string) ([]byte, error) -// Editor applies a ChangeSet to base image bytes, producing new image bytes. +// Editor applies a Change to base image bytes, producing new image bytes. type Editor interface { - Apply(base []byte, cs ChangeSet, fetchImage FetchImageFunc) ([]byte, string, error) + Apply(base []byte, c Change, fetchImage FetchImageFunc) ([]byte, string, error) } // ApplyResult holds the output of applying a ChangeSet. @@ -25,22 +29,26 @@ type ApplyResult struct { ChangesJSON []byte } -// ApplyChangeSet orchestrates applying a change set: looks up the editor, -// fetches the base image bytes, applies the edit, and serializes the changes. +// ApplyChangeSet applies each Change in the ChangeSet in order, piping +// output bytes into the next step, then serializes the full ChangeSet. func ApplyChangeSet(cs ChangeSet, baseImageId string, fetchImage FetchImageFunc) (*ApplyResult, error) { - editor, err := GetEditor(cs.Type) - if err != nil { - return nil, err - } - - baseBytes, err := fetchImage(baseImageId) + currentBytes, err := fetchImage(baseImageId) if err != nil { return nil, fmt.Errorf("failed to fetch base image: %w", err) } - resultBytes, resultMime, err := editor.Apply(baseBytes, cs, fetchImage) - if err != nil { - return nil, fmt.Errorf("failed to apply %s: %w", cs.Type, err) + var currentMIME string + for _, c := range cs { + editor, err := GetEditor(c.Type) + if err != nil { + return nil, err + } + resultBytes, resultMIME, err := editor.Apply(currentBytes, c, fetchImage) + if err != nil { + return nil, fmt.Errorf("failed to apply %s: %w", c.Type, err) + } + currentBytes = resultBytes + currentMIME = resultMIME } changesJSON, err := json.Marshal(cs) @@ -49,8 +57,8 @@ func ApplyChangeSet(cs ChangeSet, baseImageId string, fetchImage FetchImageFunc) } return &ApplyResult{ - Bytes: resultBytes, - MIMEType: resultMime, + Bytes: currentBytes, + MIMEType: currentMIME, ChangesJSON: changesJSON, }, nil } diff --git a/editing/changeset_test.go b/editing/changeset_test.go index 2073d3e..7056a0c 100644 --- a/editing/changeset_test.go +++ b/editing/changeset_test.go @@ -18,10 +18,11 @@ func TestChangeSetSerialize(t *testing.T) { t.Fatal(err) } - cs := ChangeSet{ + c := Change{ Type: "watermark", Params: paramsJSON, } + cs := ChangeSet{c} data, err := json.Marshal(cs) if err != nil { @@ -32,12 +33,15 @@ func TestChangeSetSerialize(t *testing.T) { if err := json.Unmarshal(data, &decoded); err != nil { t.Fatal(err) } - if decoded.Type != "watermark" { - t.Fatalf("expected type 'watermark', got '%s'", decoded.Type) + if len(decoded) != 1 { + t.Fatalf("expected 1 change, got %d", len(decoded)) + } + if decoded[0].Type != "watermark" { + t.Fatalf("expected type 'watermark', got '%s'", decoded[0].Type) } var decodedParams WatermarkParams - if err := json.Unmarshal(decoded.Params, &decodedParams); err != nil { + if err := json.Unmarshal(decoded[0].Params, &decodedParams); err != nil { t.Fatal(err) } if decodedParams.OverlayImageID != "abc-123" { diff --git a/editing/watermark.go b/editing/watermark.go index b5ceca8..6ff233e 100644 --- a/editing/watermark.go +++ b/editing/watermark.go @@ -46,22 +46,22 @@ type WatermarkParams struct { Scale float64 `json:"scale"` } -// NewWatermarkChangeSet validates params and builds a ChangeSet. -func NewWatermarkChangeSet(params WatermarkParams) (ChangeSet, error) { +// NewWatermarkChange validates params and builds a Change. +func NewWatermarkChange(params WatermarkParams) (Change, error) { if params.Opacity < 0 || params.Opacity > 1 { - return ChangeSet{}, fmt.Errorf("opacity must be between 0 and 1") + return Change{}, fmt.Errorf("opacity must be between 0 and 1") } if params.Scale <= 0 || params.Scale > 1 { - return ChangeSet{}, fmt.Errorf("scale must be between 0 (exclusive) and 1") + return Change{}, fmt.Errorf("scale must be between 0 (exclusive) and 1") } if params.Position.X < 0 || params.Position.X > 1 || params.Position.Y < 0 || params.Position.Y > 1 { - return ChangeSet{}, fmt.Errorf("position values must be between 0 and 1") + return Change{}, fmt.Errorf("position values must be between 0 and 1") } paramsJSON, err := json.Marshal(params) if err != nil { - return ChangeSet{}, fmt.Errorf("failed to serialize params: %w", err) + return Change{}, fmt.Errorf("failed to serialize params: %w", err) } - return ChangeSet{ + return Change{ Type: "watermark", Params: paramsJSON, }, nil @@ -69,7 +69,7 @@ func NewWatermarkChangeSet(params WatermarkParams) (ChangeSet, error) { type WatermarkEditor struct{} -func (e *WatermarkEditor) Apply(baseBytes []byte, cs ChangeSet, fetchImage FetchImageFunc) ([]byte, string, error) { +func (e *WatermarkEditor) Apply(baseBytes []byte, cs Change, fetchImage FetchImageFunc) ([]byte, string, error) { var params WatermarkParams if err := json.Unmarshal(cs.Params, ¶ms); err != nil { return nil, "", fmt.Errorf("invalid watermark params: %w", err) diff --git a/editing/watermark_test.go b/editing/watermark_test.go index 2f6e4c9..85bf01c 100644 --- a/editing/watermark_test.go +++ b/editing/watermark_test.go @@ -25,7 +25,7 @@ func makeTestPNG(w, h int, c color.Color) []byte { return buf.Bytes() } -func makeParams(overlayID string, x, y float64, anchor Anchor, opacity, scale float64) ChangeSet { +func makeParams(overlayID string, x, y float64, anchor Anchor, opacity, scale float64) Change { params := WatermarkParams{ OverlayImageID: overlayID, Position: WatermarkPosition{X: x, Y: y}, @@ -34,7 +34,7 @@ func makeParams(overlayID string, x, y float64, anchor Anchor, opacity, scale fl Scale: scale, } paramsJSON, _ := json.Marshal(params) - return ChangeSet{Type: "watermark", Params: paramsJSON} + return Change{Type: "watermark", Params: paramsJSON} } func TestWatermarkBasic(t *testing.T) { @@ -246,7 +246,7 @@ func TestWatermarkFetchFailure(t *testing.T) { func TestWatermarkInvalidJSON(t *testing.T) { base := makeTestPNG(100, 100, color.White) - cs := ChangeSet{Type: "watermark", Params: []byte("not json")} + cs := Change{Type: "watermark", Params: []byte("not json")} editor := &WatermarkEditor{} _, _, err := editor.Apply(base, cs, func(id string) ([]byte, error) { return base, nil diff --git a/graph/generated.go b/graph/generated.go index a5c4dff..e1b8c1a 100644 --- a/graph/generated.go +++ b/graph/generated.go @@ -44,6 +44,10 @@ type DirectiveRoot struct { } type ComplexityRoot struct { + ApplyBlurResult struct { + Image func(childComplexity int) int + } + ApplyWatermarkResult struct { Image func(childComplexity int) int } @@ -101,6 +105,7 @@ type ComplexityRoot struct { } Mutation struct { + ApplyBlur func(childComplexity int, input model.ApplyBlurInput) int ApplyWatermark func(childComplexity int, input model.ApplyWatermarkInput) int Authenticate func(childComplexity int, email string, password string, organizationID *string) int CheckStorageDefinitionConnectivity func(childComplexity int, input model.CheckStorageDefinitionConnectivityInput) int @@ -244,6 +249,7 @@ type MutationResolver interface { ResetPassword(ctx context.Context, input model.ResetPasswordInput) (*model.ResetPasswordResult, error) DeleteImage(ctx context.Context, input model.DeleteImageInput) (*model.DeleteImageResult, error) ApplyWatermark(ctx context.Context, input model.ApplyWatermarkInput) (*model.ApplyWatermarkResult, error) + ApplyBlur(ctx context.Context, input model.ApplyBlurInput) (*model.ApplyBlurResult, error) CreateStorageDefinition(ctx context.Context, input model.CreateStorageDefinitionInput) (*model.StorageDefinition, error) UpdateStorageDefinition(ctx context.Context, input model.UpdateStorageDefinitionInput) (*model.StorageDefinition, error) CheckStorageDefinitionConnectivity(ctx context.Context, input model.CheckStorageDefinitionConnectivityInput) (*model.StorageDefinitionConnectivityResult, error) @@ -284,6 +290,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin _ = ec switch typeName + "." + field { + case "ApplyBlurResult.image": + if e.ComplexityRoot.ApplyBlurResult.Image == nil { + break + } + + return e.ComplexityRoot.ApplyBlurResult.Image(childComplexity), true + case "ApplyWatermarkResult.image": if e.ComplexityRoot.ApplyWatermarkResult.Image == nil { break @@ -484,6 +497,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.ImagesResult.PageInfo(childComplexity), true + case "Mutation.applyBlur": + if e.ComplexityRoot.Mutation.ApplyBlur == nil { + break + } + + args, err := ec.field_Mutation_applyBlur_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.ApplyBlur(childComplexity, args["input"].(model.ApplyBlurInput)), true case "Mutation.applyWatermark": if e.ComplexityRoot.Mutation.ApplyWatermark == nil { break @@ -981,7 +1005,9 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { opCtx := graphql.GetOperationContext(ctx) ec := newExecutionContext(opCtx, e, make(chan graphql.DeferredResult)) inputUnmarshalMap := graphql.BuildUnmarshalerMap( + ec.unmarshalInputApplyBlurInput, ec.unmarshalInputApplyWatermarkInput, + ec.unmarshalInputBlurRegionInput, ec.unmarshalInputCreateUserWithOrganizationInput, ec.unmarshalInputDeleteImageInput, ec.unmarshalInputImageFilterInput, @@ -1108,6 +1134,17 @@ func (ec *executionContext) dir_captchaProtected_args(ctx context.Context, rawAr return args, nil } +func (ec *executionContext) field_Mutation_applyBlur_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNApplyBlurInput2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyBlurInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_applyWatermark_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1392,6 +1429,69 @@ func (ec *executionContext) field___Type_fields_args(ctx context.Context, rawArg // region **************************** field.gotpl ***************************** +func (ec *executionContext) _ApplyBlurResult_image(ctx context.Context, field graphql.CollectedField, obj *model.ApplyBlurResult) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_ApplyBlurResult_image, + func(ctx context.Context) (any, error) { + return obj.Image, nil + }, + nil, + ec.marshalOImage2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐImage, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_ApplyBlurResult_image(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ApplyBlurResult", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Image_id(ctx, field) + case "url": + return ec.fieldContext_Image_url(ctx, field) + case "name": + return ec.fieldContext_Image_name(ctx, field) + case "identifier": + return ec.fieldContext_Image_identifier(ctx, field) + case "nominalWidth": + return ec.fieldContext_Image_nominalWidth(ctx, field) + case "nominalHeight": + return ec.fieldContext_Image_nominalHeight(ctx, field) + case "nominalByteSize": + return ec.fieldContext_Image_nominalByteSize(ctx, field) + case "root": + return ec.fieldContext_Image_root(ctx, field) + case "parent": + return ec.fieldContext_Image_parent(ctx, field) + case "changes": + return ec.fieldContext_Image_changes(ctx, field) + case "lineage": + return ec.fieldContext_Image_lineage(ctx, field) + case "revisions": + return ec.fieldContext_Image_revisions(ctx, field) + case "createdAt": + return ec.fieldContext_Image_createdAt(ctx, field) + case "storedImages": + return ec.fieldContext_Image_storedImages(ctx, field) + case "MIMEType": + return ec.fieldContext_Image_MIMEType(ctx, field) + case "createdBy": + return ec.fieldContext_Image_createdBy(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Image", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _ApplyWatermarkResult_image(ctx context.Context, field graphql.CollectedField, obj *model.ApplyWatermarkResult) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -2927,6 +3027,64 @@ func (ec *executionContext) fieldContext_Mutation_applyWatermark(ctx context.Con return fc, nil } +func (ec *executionContext) _Mutation_applyBlur(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_applyBlur, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().ApplyBlur(ctx, fc.Args["input"].(model.ApplyBlurInput)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + if ec.Directives.IsAuthenticated == nil { + var zeroVal *model.ApplyBlurResult + return zeroVal, errors.New("directive isAuthenticated is not implemented") + } + return ec.Directives.IsAuthenticated(ctx, nil, directive0) + } + + next = directive1 + return next + }, + ec.marshalNApplyBlurResult2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyBlurResult, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_applyBlur(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "image": + return ec.fieldContext_ApplyBlurResult_image(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ApplyBlurResult", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_applyBlur_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_createStorageDefinition(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6646,6 +6804,50 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field // region **************************** input.gotpl ***************************** +func (ec *executionContext) unmarshalInputApplyBlurInput(ctx context.Context, obj any) (model.ApplyBlurInput, error) { + var it model.ApplyBlurInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"baseImageId", "region", "radius"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "baseImageId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("baseImageId")) + data, err := ec.unmarshalNID2string(ctx, v) + if err != nil { + return it, err + } + it.BaseImageID = data + case "region": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("region")) + data, err := ec.unmarshalNBlurRegionInput2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐBlurRegionInput(ctx, v) + if err != nil { + return it, err + } + it.Region = data + case "radius": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("radius")) + data, err := ec.unmarshalNInt2int(ctx, v) + if err != nil { + return it, err + } + it.Radius = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputApplyWatermarkInput(ctx context.Context, obj any) (model.ApplyWatermarkInput, error) { var it model.ApplyWatermarkInput if obj == nil { @@ -6711,6 +6913,57 @@ func (ec *executionContext) unmarshalInputApplyWatermarkInput(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputBlurRegionInput(ctx context.Context, obj any) (model.BlurRegionInput, error) { + var it model.BlurRegionInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"x1", "y1", "x2", "y2"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "x1": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("x1")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.X1 = data + case "y1": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("y1")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.Y1 = data + case "x2": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("x2")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.X2 = data + case "y2": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("y2")) + data, err := ec.unmarshalNFloat2float64(ctx, v) + if err != nil { + return it, err + } + it.Y2 = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputCreateUserWithOrganizationInput(ctx context.Context, obj any) (model.CreateUserWithOrganizationInput, error) { var it model.CreateUserWithOrganizationInput if obj == nil { @@ -7179,6 +7432,42 @@ func (ec *executionContext) _StorageConfig(ctx context.Context, sel ast.Selectio // region **************************** object.gotpl **************************** +var applyBlurResultImplementors = []string{"ApplyBlurResult"} + +func (ec *executionContext) _ApplyBlurResult(ctx context.Context, sel ast.SelectionSet, obj *model.ApplyBlurResult) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, applyBlurResultImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ApplyBlurResult") + case "image": + out.Values[i] = ec._ApplyBlurResult_image(ctx, field, obj) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var applyWatermarkResultImplementors = []string{"ApplyWatermarkResult"} func (ec *executionContext) _ApplyWatermarkResult(ctx context.Context, sel ast.SelectionSet, obj *model.ApplyWatermarkResult) graphql.Marshaler { @@ -7897,6 +8186,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "applyBlur": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_applyBlur(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "createStorageDefinition": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_createStorageDefinition(ctx, field) @@ -9551,6 +9847,25 @@ func (ec *executionContext) marshalNAnchor2githubᚗcomᚋericlsᚋimgddᚋgraph return v } +func (ec *executionContext) unmarshalNApplyBlurInput2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyBlurInput(ctx context.Context, v any) (model.ApplyBlurInput, error) { + res, err := ec.unmarshalInputApplyBlurInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNApplyBlurResult2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyBlurResult(ctx context.Context, sel ast.SelectionSet, v model.ApplyBlurResult) graphql.Marshaler { + return ec._ApplyBlurResult(ctx, sel, &v) +} + +func (ec *executionContext) marshalNApplyBlurResult2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyBlurResult(ctx context.Context, sel ast.SelectionSet, v *model.ApplyBlurResult) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ApplyBlurResult(ctx, sel, v) +} + func (ec *executionContext) unmarshalNApplyWatermarkInput2githubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐApplyWatermarkInput(ctx context.Context, v any) (model.ApplyWatermarkInput, error) { res, err := ec.unmarshalInputApplyWatermarkInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -9570,6 +9885,11 @@ func (ec *executionContext) marshalNApplyWatermarkResult2ᚖgithubᚗcomᚋericl return ec._ApplyWatermarkResult(ctx, sel, v) } +func (ec *executionContext) unmarshalNBlurRegionInput2ᚖgithubᚗcomᚋericlsᚋimgddᚋgraphᚋmodelᚐBlurRegionInput(ctx context.Context, v any) (*model.BlurRegionInput, error) { + res, err := ec.unmarshalInputBlurRegionInput(ctx, v) + return &res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/graph/images.resolvers.go b/graph/images.resolvers.go index c0dc867..fd0595d 100644 --- a/graph/images.resolvers.go +++ b/graph/images.resolvers.go @@ -66,7 +66,7 @@ func (r *imageResolver) Parent(ctx context.Context, obj *model.Image) (*model.Im // Changes is the resolver for the changes field. func (r *imageResolver) Changes(ctx context.Context, obj *model.Image) (*string, error) { - if obj.RawChanges == "" || obj.RawChanges == "{}" { + if obj.RawChanges == "" || obj.RawChanges == "[]" || obj.RawChanges == "{}" { return nil, nil } return &obj.RawChanges, nil @@ -238,7 +238,7 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply return nil, fmt.Errorf("invalid anchor: %s", input.Anchor) } - cs, err := editing.NewWatermarkChangeSet(editing.WatermarkParams{ + c, err := editing.NewWatermarkChange(editing.WatermarkParams{ OverlayImageID: input.OverlayImageID, Position: editing.WatermarkPosition{X: input.Position.X, Y: input.Position.Y}, Anchor: anchor, @@ -250,7 +250,7 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply } fetchImage := editing.NewFetchImageFunc(r.StoredImageRepo, r.StorageDefRepo) - result, err := editing.ApplyChangeSet(cs, input.BaseImageID, fetchImage) + result, err := editing.ApplyChangeSet(editing.ChangeSet{c}, input.BaseImageID, fetchImage) if err != nil { return nil, err } @@ -333,6 +333,122 @@ func (r *mutationResolver) ApplyWatermark(ctx context.Context, input model.Apply }, nil } +// ApplyBlur is the resolver for the applyBlur field. +func (r *mutationResolver) ApplyBlur(ctx context.Context, input model.ApplyBlurInput) (*model.ApplyBlurResult, error) { + currentUser := identity.GetCurrentOrganizationUser(r.ContextUserManager, ctx) + if currentUser == nil { + return nil, fmt.Errorf("unauthorized") + } + + if _, err := uuid.Parse(input.BaseImageID); err != nil { + return nil, fmt.Errorf("base image not found") + } + + baseImage, err := r.ImageRepo.GetImageById(input.BaseImageID) + if err != nil || baseImage == nil { + return nil, fmt.Errorf("base image not found") + } + if baseImage.CreatedById != currentUser.Id { + return nil, fmt.Errorf("unauthorized") + } + + if input.Region == nil { + return nil, fmt.Errorf("region is required") + } + + c, err := editing.NewBlurChange(editing.BlurParams{ + Region: editing.BlurRegion{ + X1: input.Region.X1, + Y1: input.Region.Y1, + X2: input.Region.X2, + Y2: input.Region.Y2, + }, + Radius: input.Radius, + }) + if err != nil { + return nil, err + } + + fetchImage := editing.NewFetchImageFunc(r.StoredImageRepo, r.StorageDefRepo) + result, err := editing.ApplyChangeSet(editing.ChangeSet{c}, input.BaseImageID, fetchImage) + if err != nil { + return nil, err + } + + width, height, err := utils.GetImageDimensions(result.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to get result dimensions: %w", err) + } + + storageDefs, err := r.StorageDefRepo.ListStorageDefinitions() + if err != nil { + return nil, fmt.Errorf("failed to list storage definitions: %w", err) + } + var storageDef *domainmodels.StorageDefinition + for _, def := range storageDefs { + if def.IsEnabled { + storageDef = def + break + } + } + if storageDef == nil { + return nil, fmt.Errorf("no enabled storage backend") + } + + storageInstance, err := storage.GetStorage(storageDef) + if err != nil { + return nil, fmt.Errorf("failed to get storage: %w", err) + } + + tx, err := r.DBConn.Begin() + if err != nil { + return nil, fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + dbImageRepo, ok := r.ImageRepo.(db.DBRepo) + if !ok { + return nil, fmt.Errorf("image repo does not support transactions") + } + txImageRepo := dbImageRepo.WithTransaction(tx).(*image.DBImageRepo) + + dbImageRelRepo, ok := r.ImageRelRepo.(db.DBRepo) + if !ok { + return nil, fmt.Errorf("image relationship repo does not support transactions") + } + txImageRelRepo := dbImageRelRepo.WithTransaction(tx).(*image.DBImageRelationshipRepo) + + newImage := domainmodels.Image{ + Identifier: uuid.New().String(), + Name: baseImage.Name, + ParentId: baseImage.Id, + Changes: string(result.ChangesJSON), + CreatedById: currentUser.Id, + MIMEType: result.MIMEType, + NominalWidth: width, + NominalHeight: height, + NominalByteSize: int32(len(result.Bytes)), + } + + storedImage, err := txImageRepo.CreateAndSaveUploadedImage(&newImage, result.MIMEType, result.Bytes, storageDef.Id, storageInstance.Save) + if err != nil { + return nil, fmt.Errorf("failed to save result image: %w", err) + } + + newImageId := storedImage.Image.Id + if _, err := txImageRelRepo.CreateRelationship(newImageId, input.BaseImageID, image.RelationshipTypeBase); err != nil { + return nil, fmt.Errorf("failed to create base relationship: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("failed to commit transaction: %w", err) + } + + return &model.ApplyBlurResult{ + Image: model.FromImage(storedImage.Image), + }, nil +} + // Image is the resolver for the image field. func (r *viewerResolver) Image(ctx context.Context, obj *model.Viewer, id string) (*model.Image, error) { currentUser := identity.GetCurrentOrganizationUser(r.ContextUserManager, ctx) diff --git a/graph/model/models_gen.go b/graph/model/models_gen.go index e929665..3c9ee1e 100644 --- a/graph/model/models_gen.go +++ b/graph/model/models_gen.go @@ -9,6 +9,16 @@ import ( "strconv" ) +type ApplyBlurInput struct { + BaseImageID string `json:"baseImageId"` + Region *BlurRegionInput `json:"region"` + Radius int `json:"radius"` +} + +type ApplyBlurResult struct { + Image *Image `json:"image,omitempty"` +} + type ApplyWatermarkInput struct { BaseImageID string `json:"baseImageId"` OverlayImageID string `json:"overlayImageId"` @@ -22,6 +32,13 @@ type ApplyWatermarkResult struct { Image *Image `json:"image,omitempty"` } +type BlurRegionInput struct { + X1 float64 `json:"x1"` + Y1 float64 `json:"y1"` + X2 float64 `json:"x2"` + Y2 float64 `json:"y2"` +} + type CreateUserWithOrganizationInput struct { UserEmail string `json:"userEmail"` UserPassword string `json:"userPassword"` diff --git a/graph/schema/images.graphqls b/graph/schema/images.graphqls index a1a1c77..720f526 100644 --- a/graph/schema/images.graphqls +++ b/graph/schema/images.graphqls @@ -99,8 +99,26 @@ type DeleteImageResult { id: ID } +input BlurRegionInput { + x1: Float! + y1: Float! + x2: Float! + y2: Float! +} + +input ApplyBlurInput { + baseImageId: ID! + region: BlurRegionInput! + radius: Int! +} + +type ApplyBlurResult { + image: Image +} + extend type Mutation { deleteImage(input: DeleteImageInput!): DeleteImageResult! @isAuthenticated applyWatermark(input: ApplyWatermarkInput!): ApplyWatermarkResult! @isAuthenticated + applyBlur(input: ApplyBlurInput!): ApplyBlurResult! @isAuthenticated } diff --git a/image/repo.go b/image/repo.go index 83957f8..7252664 100644 --- a/image/repo.go +++ b/image/repo.go @@ -171,8 +171,8 @@ func (repo *DBImageRepo) CreateImage(image *dm.Image) (*dm.Image, error) { } changes := image.Changes - if changes == "" { - changes = "{}" + if changes == "" || changes == "{}" { + changes = "[]" } stmt := ImageTable.INSERT( diff --git a/web_client/src/__generated__/gql.ts b/web_client/src/__generated__/gql.ts index 9bb2606..f54b02a 100644 --- a/web_client/src/__generated__/gql.ts +++ b/web_client/src/__generated__/gql.ts @@ -23,6 +23,7 @@ type Documents = { "\n query ImageDetail($id: ID!) {\n viewer {\n id\n organizationUser {\n id\n }\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n createdBy {\n id\n }\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": typeof types.ImageDetailDocument, "\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": typeof types.ImageForEditorDocument, "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": typeof types.ApplyWatermarkDocument, + "\n mutation ApplyBlur($input: ApplyBlurInput!) {\n applyBlur(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": typeof types.ApplyBlurDocument, "\nquery Auth {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n}\n": typeof types.AuthDocument, "\nmutation Logout {\n logout {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n }\n}\n": typeof types.LogoutDocument, "\nmutation StorageDefTableConnectivityCellMutation(\n $input: checkStorageDefinitionConnectivityInput!\n ) {\n checkStorageDefinitionConnectivity(input: $input) {\n ok\n error\n }\n }\n": typeof types.StorageDefTableConnectivityCellMutationDocument, @@ -44,6 +45,7 @@ const documents: Documents = { "\n query ImageDetail($id: ID!) {\n viewer {\n id\n organizationUser {\n id\n }\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n createdAt\n changes\n createdBy {\n id\n }\n lineage {\n id\n url\n name\n changes\n createdAt\n }\n }\n }\n }\n": types.ImageDetailDocument, "\n query ImageForEditor($id: ID!) {\n viewer {\n id\n image(id: $id) {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": types.ImageForEditorDocument, "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": types.ApplyWatermarkDocument, + "\n mutation ApplyBlur($input: ApplyBlurInput!) {\n applyBlur(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n": types.ApplyBlurDocument, "\nquery Auth {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n}\n": types.AuthDocument, "\nmutation Logout {\n logout {\n viewer {\n id\n organizationUser {\n id\n user {\n id\n email\n name\n }\n }\n hasAdminAccess: hasPermission(permission: AdminAccess)\n hasSiteOwnerAccess: hasPermission(permission: SiteOwnerAccess)\n }\n }\n}\n": types.LogoutDocument, "\nmutation StorageDefTableConnectivityCellMutation(\n $input: checkStorageDefinitionConnectivityInput!\n ) {\n checkStorageDefinitionConnectivity(input: $input) {\n ok\n error\n }\n }\n": types.StorageDefTableConnectivityCellMutationDocument, @@ -106,6 +108,10 @@ export function gql(source: "\n query ImageForEditor($id: ID!) {\n viewer {\ * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function gql(source: "\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"): (typeof documents)["\n mutation ApplyWatermark($input: ApplyWatermarkInput!) {\n applyWatermark(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"]; +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql(source: "\n mutation ApplyBlur($input: ApplyBlurInput!) {\n applyBlur(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"): (typeof documents)["\n mutation ApplyBlur($input: ApplyBlurInput!) {\n applyBlur(input: $input) {\n image {\n id\n url\n name\n identifier\n nominalWidth\n nominalHeight\n nominalByteSize\n MIMEType\n parent {\n id\n name\n }\n changes\n }\n }\n }\n"]; /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web_client/src/__generated__/graphql.ts b/web_client/src/__generated__/graphql.ts index 92a70f6..c735e34 100644 --- a/web_client/src/__generated__/graphql.ts +++ b/web_client/src/__generated__/graphql.ts @@ -25,6 +25,17 @@ export enum Anchor { TopRight = 'TOP_RIGHT' } +export type ApplyBlurInput = { + baseImageId: Scalars['ID']['input']; + radius: Scalars['Int']['input']; + region: BlurRegionInput; +}; + +export type ApplyBlurResult = { + __typename?: 'ApplyBlurResult'; + image?: Maybe; +}; + export type ApplyWatermarkInput = { anchor: Anchor; baseImageId: Scalars['ID']['input']; @@ -39,6 +50,13 @@ export type ApplyWatermarkResult = { image?: Maybe; }; +export type BlurRegionInput = { + x1: Scalars['Float']['input']; + x2: Scalars['Float']['input']; + y1: Scalars['Float']['input']; + y2: Scalars['Float']['input']; +}; + export type CreateUserWithOrganizationInput = { organizationName: Scalars['String']['input']; userEmail: Scalars['String']['input']; @@ -123,6 +141,7 @@ export type ImagesResult = { export type Mutation = { __typename?: 'Mutation'; + applyBlur: ApplyBlurResult; applyWatermark: ApplyWatermarkResult; authenticate: ViewerResult; checkStorageDefinitionConnectivity?: Maybe; @@ -136,6 +155,11 @@ export type Mutation = { }; +export type MutationApplyBlurArgs = { + input: ApplyBlurInput; +}; + + export type MutationApplyWatermarkArgs = { input: ApplyWatermarkInput; }; @@ -471,6 +495,13 @@ export type ApplyWatermarkMutationVariables = Exact<{ export type ApplyWatermarkMutation = { __typename?: 'Mutation', applyWatermark: { __typename?: 'ApplyWatermarkResult', image?: { __typename?: 'Image', id: string, url: string, name: string, identifier: string, nominalWidth: number, nominalHeight: number, nominalByteSize: number, MIMEType: string, changes?: string | null, parent?: { __typename?: 'Image', id: string, name: string } | null } | null } }; +export type ApplyBlurMutationVariables = Exact<{ + input: ApplyBlurInput; +}>; + + +export type ApplyBlurMutation = { __typename?: 'Mutation', applyBlur: { __typename?: 'ApplyBlurResult', image?: { __typename?: 'Image', id: string, url: string, name: string, identifier: string, nominalWidth: number, nominalHeight: number, nominalByteSize: number, MIMEType: string, changes?: string | null, parent?: { __typename?: 'Image', id: string, name: string } | null } | null } }; + export type AuthQueryVariables = Exact<{ [key: string]: never; }>; @@ -572,6 +603,7 @@ export const DeleteImageDocument = {"kind":"Document","definitions":[{"kind":"Op export const ImageDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImageDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organizationUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"image"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"createdBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"lineage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"changes"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ImageForEditorDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ImageForEditor"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changes"}}]}}]}}]}}]} as unknown as DocumentNode; export const ApplyWatermarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ApplyWatermark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApplyWatermarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyWatermark"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"nominalByteSize"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changes"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ApplyBlurDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ApplyBlur"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApplyBlurInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyBlur"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"identifier"}},{"kind":"Field","name":{"kind":"Name","value":"nominalWidth"}},{"kind":"Field","name":{"kind":"Name","value":"nominalHeight"}},{"kind":"Field","name":{"kind":"Name","value":"nominalByteSize"}},{"kind":"Field","name":{"kind":"Name","value":"MIMEType"}},{"kind":"Field","name":{"kind":"Name","value":"parent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"changes"}}]}}]}}]}}]} as unknown as DocumentNode; export const AuthDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Auth"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organizationUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"hasAdminAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"AdminAccess"}}]},{"kind":"Field","alias":{"kind":"Name","value":"hasSiteOwnerAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"SiteOwnerAccess"}}]}]}}]}}]} as unknown as DocumentNode; export const LogoutDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"Logout"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logout"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"viewer"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"organizationUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","alias":{"kind":"Name","value":"hasAdminAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"AdminAccess"}}]},{"kind":"Field","alias":{"kind":"Name","value":"hasSiteOwnerAccess"},"name":{"kind":"Name","value":"hasPermission"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"permission"},"value":{"kind":"EnumValue","value":"SiteOwnerAccess"}}]}]}}]}}]}}]} as unknown as DocumentNode; export const StorageDefTableConnectivityCellMutationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"StorageDefTableConnectivityCellMutation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"checkStorageDefinitionConnectivityInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkStorageDefinitionConnectivity"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ok"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; diff --git a/web_client/src/editor/BlurTool.tsx b/web_client/src/editor/BlurTool.tsx new file mode 100644 index 0000000..11886f6 --- /dev/null +++ b/web_client/src/editor/BlurTool.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { Button } from "~src/ui/button"; +import { InputWithLabel } from "~src/ui/input"; +import classNames from "classnames"; +import { TEXT_COLOR, SECONDARY_TEXT_COLOR_DIM } from "~src/ui/classNames"; +import { useTranslation } from "react-i18next"; + +export type BlurRegion = { + x1: number; + y1: number; + x2: number; + y2: number; +}; + +export type BlurSettings = { + region: BlurRegion | null; + radius: number; +}; + +type BlurToolProps = { + settings: BlurSettings; + onSettingsChange: (settings: BlurSettings) => void; + onApply: () => void; + applying: boolean; +}; + +export function BlurTool({ + settings, + onSettingsChange, + onApply, + applying, +}: BlurToolProps) { + const { t } = useTranslation(); + const hasRegion = + settings.region !== null && + settings.region.x2 > settings.region.x1 && + settings.region.y2 > settings.region.y1; + + return ( +
+

+ {t("blurTool.hint")} +

+ + {hasRegion && settings.region ? ( +
+ {t("blurTool.regionSelected", { + x1: Math.round(settings.region.x1 * 100), + y1: Math.round(settings.region.y1 * 100), + x2: Math.round(settings.region.x2 * 100), + y2: Math.round(settings.region.y2 * 100), + })} +
+ ) : ( +
+ {t("blurTool.noRegion")} +
+ )} + + + onSettingsChange({ ...settings, radius: parseInt(e.target.value) }) + } + /> + + +
+ ); +} diff --git a/web_client/src/editor/EditorCanvas.tsx b/web_client/src/editor/EditorCanvas.tsx index eb7003f..d9f3342 100644 --- a/web_client/src/editor/EditorCanvas.tsx +++ b/web_client/src/editor/EditorCanvas.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Anchor } from "~src/__generated__/graphql"; +import { BlurRegion } from "./BlurTool"; export type OverlayState = { image: HTMLImageElement | null; @@ -14,6 +15,10 @@ type EditorCanvasProps = { baseImageUrl: string; overlay: OverlayState; onPositionChange: (x: number, y: number) => void; + blurRegion?: BlurRegion | null; + blurRadius?: number; + onBlurRegionChange?: (region: BlurRegion | null) => void; + mode?: "watermark" | "blur"; className?: string; }; @@ -21,12 +26,17 @@ export function EditorCanvas({ baseImageUrl, overlay, onPositionChange, + blurRegion, + blurRadius = 10, + onBlurRegionChange, + mode = "watermark", className, }: EditorCanvasProps) { const canvasRef = React.useRef(null); const baseImgRef = React.useRef(null); const [baseLoaded, setBaseLoaded] = React.useState(false); const [dragging, setDragging] = React.useState(false); + const dragStartRef = React.useRef<{ x: number; y: number } | null>(null); // Load base image React.useEffect(() => { @@ -55,16 +65,13 @@ export function EditorCanvas({ const ctx = canvas.getContext("2d"); if (!ctx) return; - // Size canvas to fit container while maintaining aspect ratio const container = canvas.parentElement; if (!container) return; const dpr = window.devicePixelRatio || 1; const maxCssW = container.clientWidth; const ratio = baseImg.naturalWidth / baseImg.naturalHeight; - // CSS display size accounts for DPR const cssW = Math.min(maxCssW, Math.round(baseImg.naturalWidth / dpr)); const cssH = Math.round(cssW / ratio); - // Canvas buffer renders at full resolution for sharpness const bufferW = Math.round(cssW * dpr); const bufferH = Math.round(cssH * dpr); @@ -73,12 +80,10 @@ export function EditorCanvas({ canvas.style.width = cssW + "px"; canvas.style.height = cssH + "px"; - // Draw base ctx.clearRect(0, 0, bufferW, bufferH); ctx.drawImage(baseImg, 0, 0, bufferW, bufferH); - // Draw overlay - if (overlay.image) { + if (mode === "watermark" && overlay.image) { const shortSide = Math.min(bufferW, bufferH); const targetSize = shortSide * overlay.scale; const overlayRatio = @@ -95,7 +100,6 @@ export function EditorCanvas({ const px = overlay.x * bufferW; const py = overlay.y * bufferH; - // Apply anchor offset to match backend behavior let ox: number, oy: number; switch (overlay.anchor) { case Anchor.TopLeft: @@ -125,7 +129,39 @@ export function EditorCanvas({ ctx.drawImage(overlay.image, px + ox, py + oy, ow, oh); ctx.globalAlpha = 1; } - }, [baseLoaded, overlay]); + + if (mode === "blur" && blurRegion) { + const rx1 = blurRegion.x1 * bufferW; + const ry1 = blurRegion.y1 * bufferH; + const rw = (blurRegion.x2 - blurRegion.x1) * bufferW; + const rh = (blurRegion.y2 - blurRegion.y1) * bufferH; + if (rw > 0 && rh > 0) { + // Scale the radius from full-res image pixels down to canvas buffer pixels + const scaledRadius = Math.max( + 1, + Math.round((blurRadius * bufferW) / baseImg.naturalWidth), + ); + + // Clip to region, apply CSS blur filter, redraw just that part of the + // base image — this gives a pixel-accurate preview of the blur effect. + ctx.save(); + ctx.beginPath(); + ctx.rect(rx1, ry1, rw, rh); + ctx.clip(); + ctx.filter = `blur(${scaledRadius}px)`; + ctx.drawImage(baseImg, 0, 0, bufferW, bufferH); + ctx.restore(); + + // Draw selection border on top + ctx.save(); + ctx.strokeStyle = "rgba(99, 102, 241, 0.9)"; + ctx.lineWidth = 2 * dpr; + ctx.setLineDash([6 * dpr, 3 * dpr]); + ctx.strokeRect(rx1, ry1, rw, rh); + ctx.restore(); + } + } + }, [baseLoaded, overlay, blurRegion, blurRadius, mode]); const getCanvasCoords = React.useCallback( (e: React.MouseEvent | React.Touch) => { @@ -141,36 +177,72 @@ export function EditorCanvas({ const handleMouseDown = React.useCallback( (e: React.MouseEvent) => { - if (!overlay.image) return; - setDragging(true); - const { x, y } = getCanvasCoords(e); - onPositionChange(x, y); + if (mode === "watermark") { + if (!overlay.image) return; + setDragging(true); + const { x, y } = getCanvasCoords(e); + onPositionChange(x, y); + } else { + setDragging(true); + const { x, y } = getCanvasCoords(e); + dragStartRef.current = { x, y }; + onBlurRegionChange?.(null); + } }, - [overlay.image, getCanvasCoords, onPositionChange], + [ + mode, + overlay.image, + getCanvasCoords, + onPositionChange, + onBlurRegionChange, + ], ); const handleMouseMove = React.useCallback( (e: React.MouseEvent) => { if (!dragging) return; const { x, y } = getCanvasCoords(e); - onPositionChange(x, y); + if (mode === "watermark") { + onPositionChange(x, y); + } else if (dragStartRef.current) { + const start = dragStartRef.current; + onBlurRegionChange?.({ + x1: Math.min(start.x, x), + y1: Math.min(start.y, y), + x2: Math.max(start.x, x), + y2: Math.max(start.y, y), + }); + } }, - [dragging, getCanvasCoords, onPositionChange], + [dragging, mode, getCanvasCoords, onPositionChange, onBlurRegionChange], ); const handleMouseUp = React.useCallback(() => { setDragging(false); + dragStartRef.current = null; }, []); const handleTouchStart = React.useCallback( (e: React.TouchEvent) => { - if (!overlay.image || e.touches.length === 0) return; + if (e.touches.length === 0) return; e.preventDefault(); setDragging(true); const { x, y } = getCanvasCoords(e.touches[0]); - onPositionChange(x, y); + if (mode === "watermark") { + if (!overlay.image) return; + onPositionChange(x, y); + } else { + dragStartRef.current = { x, y }; + onBlurRegionChange?.(null); + } }, - [overlay.image, getCanvasCoords, onPositionChange], + [ + mode, + overlay.image, + getCanvasCoords, + onPositionChange, + onBlurRegionChange, + ], ); const handleTouchMove = React.useCallback( @@ -178,16 +250,29 @@ export function EditorCanvas({ if (!dragging || e.touches.length === 0) return; e.preventDefault(); const { x, y } = getCanvasCoords(e.touches[0]); - onPositionChange(x, y); + if (mode === "watermark") { + onPositionChange(x, y); + } else if (dragStartRef.current) { + const start = dragStartRef.current; + onBlurRegionChange?.({ + x1: Math.min(start.x, x), + y1: Math.min(start.y, y), + x2: Math.max(start.x, x), + y2: Math.max(start.y, y), + }); + } }, - [dragging, getCanvasCoords, onPositionChange], + [dragging, mode, getCanvasCoords, onPositionChange, onBlurRegionChange], ); + const cursor = + mode === "blur" ? "crosshair" : overlay.image ? "crosshair" : "default"; + return ( c?.type) + .map( + (c: { type: string }) => + c.type.charAt(0).toUpperCase() + c.type.slice(1), + ); + if (labels.length > 0) { + return "Multiple changes"; } } catch { // ignore diff --git a/web_client/src/editor/ImageEditor.tsx b/web_client/src/editor/ImageEditor.tsx index b687d4c..66f3925 100644 --- a/web_client/src/editor/ImageEditor.tsx +++ b/web_client/src/editor/ImageEditor.tsx @@ -5,7 +5,8 @@ import { gql } from "~src/__generated__"; import { Anchor } from "~src/__generated__/graphql"; import { EditorCanvas, OverlayState } from "./EditorCanvas"; import { WatermarkTool, WatermarkSettings } from "./WatermarkTool"; -import { useApplyWatermark } from "./data"; +import { BlurTool, BlurSettings, BlurRegion } from "./BlurTool"; +import { useApplyWatermark, useApplyBlur } from "./data"; import { toast } from "react-toastify"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; @@ -42,22 +43,34 @@ const ImageForEditorDoc = gql(` } `); +type EditorTab = "watermark" | "blur"; + export function ImageEditor() { const { imageId } = useParams<{ imageId: string }>(); const navigate = useNavigate(); const [fetchImage, { data, loading, error }] = useLazyQuery(ImageForEditorDoc); const { t } = useTranslation(); - const { execute: applyWatermark, loading: applying } = useApplyWatermark(); - - const [settings, setSettings] = React.useState({ - overlayImageId: "", - overlayImageUrl: "", - opacity: 0.5, - scale: 0.25, - anchor: Anchor.Center, - positionX: 0.5, - positionY: 0.5, + const { execute: applyWatermark, loading: applyingWatermark } = + useApplyWatermark(); + const { execute: applyBlur, loading: applyingBlur } = useApplyBlur(); + + const [activeTab, setActiveTab] = React.useState("watermark"); + + const [watermarkSettings, setWatermarkSettings] = + React.useState({ + overlayImageId: "", + overlayImageUrl: "", + opacity: 0.5, + scale: 0.25, + anchor: Anchor.Center, + positionX: 0.5, + positionY: 0.5, + }); + + const [blurSettings, setBlurSettings] = React.useState({ + region: null, + radius: 10, }); const [overlayImg, setOverlayImg] = React.useState( @@ -70,9 +83,8 @@ export function ImageEditor() { } }, [imageId, fetchImage]); - // Load overlay image element when URL changes React.useEffect(() => { - if (!settings.overlayImageUrl) { + if (!watermarkSettings.overlayImageUrl) { return; } let cancelled = false; @@ -81,53 +93,60 @@ export function ImageEditor() { img.onload = () => { if (!cancelled) setOverlayImg(img); }; - img.src = absoluteURL(settings.overlayImageUrl); + img.src = absoluteURL(watermarkSettings.overlayImageUrl); return () => { cancelled = true; setOverlayImg(null); }; - }, [settings.overlayImageUrl]); + }, [watermarkSettings.overlayImageUrl]); const image = data?.viewer.image; const overlay: OverlayState = React.useMemo( () => ({ image: overlayImg, - x: settings.positionX, - y: settings.positionY, - opacity: settings.opacity, - scale: settings.scale, - anchor: settings.anchor, + x: watermarkSettings.positionX, + y: watermarkSettings.positionY, + opacity: watermarkSettings.opacity, + scale: watermarkSettings.scale, + anchor: watermarkSettings.anchor, }), [ overlayImg, - settings.positionX, - settings.positionY, - settings.opacity, - settings.scale, - settings.anchor, + watermarkSettings.positionX, + watermarkSettings.positionY, + watermarkSettings.opacity, + watermarkSettings.scale, + watermarkSettings.anchor, ], ); const handlePositionChange = React.useCallback((x: number, y: number) => { - setSettings((prev) => ({ ...prev, positionX: x, positionY: y })); + setWatermarkSettings((prev) => ({ ...prev, positionX: x, positionY: y })); }, []); - const handleApply = React.useCallback(async () => { - if (!imageId || !settings.overlayImageId) return; + const handleBlurRegionChange = React.useCallback( + (region: BlurRegion | null) => { + setBlurSettings((prev) => ({ ...prev, region })); + }, + [], + ); + + const handleApplyWatermark = React.useCallback(async () => { + if (!imageId || !watermarkSettings.overlayImageId) return; try { const result = await applyWatermark({ variables: { input: { baseImageId: imageId, - overlayImageId: settings.overlayImageId, + overlayImageId: watermarkSettings.overlayImageId, position: { - x: settings.positionX, - y: settings.positionY, + x: watermarkSettings.positionX, + y: watermarkSettings.positionY, }, - anchor: settings.anchor, - opacity: settings.opacity, - scale: settings.scale, + anchor: watermarkSettings.anchor, + opacity: watermarkSettings.opacity, + scale: watermarkSettings.scale, }, }, }); @@ -139,7 +158,34 @@ export function ImageEditor() { } catch (_err) { toast.error(t("imageEditor.watermarkFailed")); } - }, [t, imageId, settings, applyWatermark, navigate]); + }, [t, imageId, watermarkSettings, applyWatermark, navigate]); + + const handleApplyBlur = React.useCallback(async () => { + if (!imageId || !blurSettings.region) return; + try { + const result = await applyBlur({ + variables: { + input: { + baseImageId: imageId, + region: { + x1: blurSettings.region.x1, + y1: blurSettings.region.y1, + x2: blurSettings.region.x2, + y2: blurSettings.region.y2, + }, + radius: blurSettings.radius, + }, + }, + }); + const newImage = result.data?.applyBlur.image; + if (newImage) { + toast(t("imageEditor.blurApplied")); + navigate(routes.profile.image(newImage.id), { replace: true }); + } + } catch (_err) { + toast.error(t("imageEditor.blurFailed")); + } + }, [t, imageId, blurSettings, applyBlur, navigate]); if (loading) return ; if (error) @@ -184,6 +230,10 @@ export function ImageEditor() { baseImageUrl={absoluteURL(image.url)} overlay={overlay} onPositionChange={handlePositionChange} + blurRegion={blurSettings.region} + blurRadius={blurSettings.radius} + onBlurRegionChange={handleBlurRegionChange} + mode={activeTab} className="max-w-full mx-auto block" /> @@ -191,16 +241,57 @@ export function ImageEditor() {
-

- {t("imageEditor.watermark")} -

- +
+ + +
+ + {activeTab === "watermark" && ( + + )} + + {activeTab === "blur" && ( + + )}
diff --git a/web_client/src/editor/data.tsx b/web_client/src/editor/data.tsx index 8a16332..6fdfdb5 100644 --- a/web_client/src/editor/data.tsx +++ b/web_client/src/editor/data.tsx @@ -27,3 +27,30 @@ export function useApplyWatermark() { const [execute, { loading, error, data }] = useMutation(ApplyWatermarkDoc); return { execute, loading, error, data }; } + +const ApplyBlurDoc = gql(` + mutation ApplyBlur($input: ApplyBlurInput!) { + applyBlur(input: $input) { + image { + id + url + name + identifier + nominalWidth + nominalHeight + nominalByteSize + MIMEType + parent { + id + name + } + changes + } + } + } +`); + +export function useApplyBlur() { + const [execute, { loading, error, data }] = useMutation(ApplyBlurDoc); + return { execute, loading, error, data }; +} diff --git a/web_client/src/localization/en.json b/web_client/src/localization/en.json index b9fca8c..0e38025 100644 --- a/web_client/src/localization/en.json +++ b/web_client/src/localization/en.json @@ -166,7 +166,18 @@ "derivedFrom": "Derived from: {{name}}", "watermarkApplied": "Watermark applied successfully", "watermarkFailed": "Failed to apply watermark", - "watermark": "Watermark" + "watermark": "Watermark", + "blur": "Blur", + "blurApplied": "Blur applied successfully", + "blurFailed": "Failed to apply blur" + }, + "blurTool": { + "hint": "Drag on the image to select the region to blur.", + "noRegion": "No region selected", + "regionSelected": "({{x1}}%, {{y1}}%) → ({{x2}}%, {{y2}}%)", + "radius": "Blur radius: {{value}}", + "applying": "Applying...", + "applyBlur": "Apply Blur" }, "watermarkTool": { "overlayImage": "Overlay Image", diff --git a/web_client/src/localization/ko.json b/web_client/src/localization/ko.json index 137d319..11db8fc 100644 --- a/web_client/src/localization/ko.json +++ b/web_client/src/localization/ko.json @@ -166,7 +166,18 @@ "derivedFrom": "원본: {{name}}", "watermarkApplied": "워터마크가 적용되었습니다", "watermarkFailed": "워터마크 적용에 실패했습니다", - "watermark": "워터마크" + "watermark": "워터마크", + "blur": "블러", + "blurApplied": "블러가 적용되었습니다", + "blurFailed": "블러 적용에 실패했습니다" + }, + "blurTool": { + "hint": "이미지에서 드래그하여 블러 처리할 영역을 선택하세요.", + "noRegion": "영역이 선택되지 않았습니다", + "regionSelected": "({{x1}}%, {{y1}}%) → ({{x2}}%, {{y2}}%)", + "radius": "블러 반경: {{value}}", + "applying": "적용 중...", + "applyBlur": "블러 적용" }, "watermarkTool": { "overlayImage": "오버레이 이미지", diff --git a/web_client/src/localization/ru.json b/web_client/src/localization/ru.json index 3f9a009..e9e1331 100644 --- a/web_client/src/localization/ru.json +++ b/web_client/src/localization/ru.json @@ -166,7 +166,18 @@ "derivedFrom": "Источник: {{name}}", "watermarkApplied": "Водяной знак успешно применён", "watermarkFailed": "Не удалось применить водяной знак", - "watermark": "Водяной знак" + "watermark": "Водяной знак", + "blur": "Размытие", + "blurApplied": "Размытие успешно применено", + "blurFailed": "Не удалось применить размытие" + }, + "blurTool": { + "hint": "Перетащите по изображению, чтобы выбрать область размытия.", + "noRegion": "Область не выбрана", + "regionSelected": "({{x1}}%, {{y1}}%) → ({{x2}}%, {{y2}}%)", + "radius": "Радиус размытия: {{value}}", + "applying": "Применение...", + "applyBlur": "Применить размытие" }, "watermarkTool": { "overlayImage": "Накладываемое изображение", diff --git a/web_client/src/localization/zh_hans.json b/web_client/src/localization/zh_hans.json index 452f9a6..4cefa15 100644 --- a/web_client/src/localization/zh_hans.json +++ b/web_client/src/localization/zh_hans.json @@ -166,7 +166,18 @@ "derivedFrom": "来源: {{name}}", "watermarkApplied": "水印已成功应用", "watermarkFailed": "水印应用失败", - "watermark": "水印" + "watermark": "水印", + "blur": "模糊", + "blurApplied": "模糊效果应用成功", + "blurFailed": "模糊效果应用失败" + }, + "blurTool": { + "hint": "在图片上拖拽以选择要模糊的区域。", + "noRegion": "未选择区域", + "regionSelected": "({{x1}}%, {{y1}}%) → ({{x2}}%, {{y2}}%)", + "radius": "模糊半径:{{value}}", + "applying": "应用中...", + "applyBlur": "应用模糊" }, "watermarkTool": { "overlayImage": "叠加图片", diff --git a/web_client/src/localization/zh_hant.json b/web_client/src/localization/zh_hant.json index 2b0a95f..52cb486 100644 --- a/web_client/src/localization/zh_hant.json +++ b/web_client/src/localization/zh_hant.json @@ -166,7 +166,18 @@ "derivedFrom": "來源: {{name}}", "watermarkApplied": "浮水印已成功套用", "watermarkFailed": "浮水印套用失敗", - "watermark": "浮水印" + "watermark": "浮水印", + "blur": "模糊", + "blurApplied": "模糊效果套用成功", + "blurFailed": "模糊效果套用失敗" + }, + "blurTool": { + "hint": "在圖片上拖曳以選取要模糊的區域。", + "noRegion": "未選取區域", + "regionSelected": "({{x1}}%, {{y1}}%) → ({{x2}}%, {{y2}}%)", + "radius": "模糊半徑:{{value}}", + "applying": "套用中...", + "applyBlur": "套用模糊" }, "watermarkTool": { "overlayImage": "疊加圖片", From cd31bf12fc64cb37473ced85b28cb481d676921c Mon Sep 17 00:00:00 2001 From: Shen Li Date: Tue, 2 Jun 2026 01:27:04 -0400 Subject: [PATCH 2/6] fix label --- web_client/src/editor/ImageDetail.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web_client/src/editor/ImageDetail.tsx b/web_client/src/editor/ImageDetail.tsx index 3ed47b3..e368625 100644 --- a/web_client/src/editor/ImageDetail.tsx +++ b/web_client/src/editor/ImageDetail.tsx @@ -290,7 +290,10 @@ function parseChangeType(changesJson: string): string | null { (c: { type: string }) => c.type.charAt(0).toUpperCase() + c.type.slice(1), ); - if (labels.length > 0) { + if (labels.length === 1) { + return labels[0]; + } + if (labels.length > 1) { return "Multiple changes"; } } catch { From 8849b7b2115b0043bf175f7d6e4c33809d15424b Mon Sep 17 00:00:00 2001 From: Shen Li Date: Tue, 2 Jun 2026 01:40:24 -0400 Subject: [PATCH 3/6] fixes --- db/migrations/000006_changes_array.up.sql | 4 ++-- editing/blur.go | 16 ++++++++++++++++ web_client/src/editor/EditorCanvas.tsx | 2 +- web_client/src/editor/ImageEditor.tsx | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/db/migrations/000006_changes_array.up.sql b/db/migrations/000006_changes_array.up.sql index b607050..1539263 100644 --- a/db/migrations/000006_changes_array.up.sql +++ b/db/migrations/000006_changes_array.up.sql @@ -1,7 +1,7 @@ --- Migrate single-change objects to single-element arrays +-- Migrate single-change objects to single-element arrays (skip rows already in array form) UPDATE image_table SET changes = jsonb_build_array(changes) - WHERE changes != '{}' AND changes != '[]'; + WHERE jsonb_typeof(changes) = 'object' AND changes != '{}'; -- Convert legacy empty-object default to empty array UPDATE image_table SET changes = '[]' WHERE changes = '{}'; diff --git a/editing/blur.go b/editing/blur.go index 348c194..d2f7a8b 100644 --- a/editing/blur.go +++ b/editing/blur.go @@ -96,6 +96,22 @@ func (e *BlurEditor) Apply(baseBytes []byte, cs Change, _ FetchImageFunc) ([]byt if ry2 > h { ry2 = h } + // Truncation of small normalized coords can collapse the region to 0×0; + // expand to at least 1px so the blur is always applied. + if rx2 <= rx1 { + if rx1 > 0 { + rx1-- + } else { + rx2++ + } + } + if ry2 <= ry1 { + if ry1 > 0 { + ry1-- + } else { + ry2++ + } + } result := applyBoxBlurRegion(baseImg, rx1, ry1, rx2, ry2, params.Radius) diff --git a/web_client/src/editor/EditorCanvas.tsx b/web_client/src/editor/EditorCanvas.tsx index d9f3342..7fbe8df 100644 --- a/web_client/src/editor/EditorCanvas.tsx +++ b/web_client/src/editor/EditorCanvas.tsx @@ -225,11 +225,11 @@ export function EditorCanvas({ const handleTouchStart = React.useCallback( (e: React.TouchEvent) => { if (e.touches.length === 0) return; + if (mode === "watermark" && !overlay.image) return; e.preventDefault(); setDragging(true); const { x, y } = getCanvasCoords(e.touches[0]); if (mode === "watermark") { - if (!overlay.image) return; onPositionChange(x, y); } else { dragStartRef.current = { x, y }; diff --git a/web_client/src/editor/ImageEditor.tsx b/web_client/src/editor/ImageEditor.tsx index 66f3925..e4c97a7 100644 --- a/web_client/src/editor/ImageEditor.tsx +++ b/web_client/src/editor/ImageEditor.tsx @@ -244,6 +244,7 @@ export function ImageEditor() {