789f51b29e
Add a brand kit domain with memory and postgres stores, a BrandKitService for CRUD and resolution, and REST endpoints to list, upsert, and delete brand kits. Projects can bind a brand kit whose resolved context and reference images feed into the agent's long-term memory and design prompts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
511 lines
17 KiB
Go
511 lines
17 KiB
Go
package application
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"img_infinite_canvas/internal/domain/brandkit"
|
|
"img_infinite_canvas/internal/domain/design"
|
|
)
|
|
|
|
const (
|
|
maxBrandKitDocumentBytes = 24 << 20
|
|
maxBrandKitContextRunes = 12000
|
|
)
|
|
|
|
var (
|
|
brandKitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$`)
|
|
brandColorPattern = regexp.MustCompile(`^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$`)
|
|
)
|
|
|
|
type BrandKitService struct {
|
|
store brandkit.Store
|
|
now func() time.Time
|
|
}
|
|
|
|
type ResolvedBrandKit struct {
|
|
ID string
|
|
Context string
|
|
ReferenceImages []string
|
|
}
|
|
|
|
type brandKitDocument struct {
|
|
Version int `json:"version"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
BrandName string `json:"brandName"`
|
|
Tagline string `json:"tagline"`
|
|
Summary string `json:"summary"`
|
|
Audience string `json:"audience"`
|
|
Positioning string `json:"positioning"`
|
|
Personality string `json:"personality"`
|
|
Voice brandKitVoice `json:"voice"`
|
|
Logos []brandKitLogo `json:"logos"`
|
|
CoverImage *brandKitReferenceImage `json:"coverImage,omitempty"`
|
|
ColorGroups []brandKitColorGroup `json:"colorGroups"`
|
|
Typography []brandKitTypography `json:"typography"`
|
|
ReferenceImages []brandKitReferenceImage `json:"referenceImages"`
|
|
Visual brandKitVisual `json:"visual"`
|
|
ApplyToNewProjects bool `json:"applyToNewProjects"`
|
|
CreatedAt string `json:"createdAt"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
type brandKitVoice struct {
|
|
Traits string `json:"traits"`
|
|
Guidance string `json:"guidance"`
|
|
Sample string `json:"sample"`
|
|
}
|
|
|
|
type brandKitLogo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Variant string `json:"variant"`
|
|
Usage string `json:"usage"`
|
|
Source string `json:"source"`
|
|
DataURL string `json:"dataUrl,omitempty"`
|
|
}
|
|
|
|
type brandKitColorGroup struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Colors []brandKitColor `json:"colors"`
|
|
}
|
|
|
|
type brandKitColor struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Hex string `json:"hex"`
|
|
Usage string `json:"usage"`
|
|
}
|
|
|
|
type brandKitTypography struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Family string `json:"family"`
|
|
Weight string `json:"weight"`
|
|
Sizes string `json:"sizes"`
|
|
Description string `json:"description"`
|
|
FontFile *brandKitFontFile `json:"fontFile,omitempty"`
|
|
}
|
|
|
|
type brandKitFontFile struct {
|
|
Name string `json:"name"`
|
|
Format string `json:"format"`
|
|
Size int64 `json:"size"`
|
|
DataURL string `json:"dataUrl"`
|
|
}
|
|
|
|
type brandKitReferenceImage struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Source string `json:"source"`
|
|
DataURL string `json:"dataUrl,omitempty"`
|
|
}
|
|
|
|
type brandKitVisual struct {
|
|
Keywords string `json:"keywords"`
|
|
Composition string `json:"composition"`
|
|
Imagery string `json:"imagery"`
|
|
Dos string `json:"dos"`
|
|
Donts string `json:"donts"`
|
|
Prompt string `json:"prompt"`
|
|
}
|
|
|
|
func NewBrandKitService(store brandkit.Store) *BrandKitService {
|
|
return &BrandKitService{store: store, now: time.Now}
|
|
}
|
|
|
|
func (s *BrandKitService) Close() error {
|
|
if s == nil || s.store == nil {
|
|
return nil
|
|
}
|
|
return s.store.Close()
|
|
}
|
|
|
|
func (s *BrandKitService) List(ctx context.Context) ([]brandkit.Kit, error) {
|
|
if s == nil || s.store == nil {
|
|
return []brandkit.Kit{}, nil
|
|
}
|
|
return s.store.List(ctx, design.UserIDFromContext(ctx))
|
|
}
|
|
|
|
func (s *BrandKitService) Get(ctx context.Context, id string) (brandkit.Kit, error) {
|
|
id = strings.TrimSpace(id)
|
|
if !brandKitIDPattern.MatchString(id) {
|
|
return brandkit.Kit{}, fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput)
|
|
}
|
|
return s.store.Get(ctx, design.UserIDFromContext(ctx), id)
|
|
}
|
|
|
|
func (s *BrandKitService) Upsert(ctx context.Context, id string, document string, isDefault bool) (brandkit.Kit, error) {
|
|
if s == nil || s.store == nil {
|
|
return brandkit.Kit{}, brandkit.ErrNotFound
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if !brandKitIDPattern.MatchString(id) {
|
|
return brandkit.Kit{}, fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput)
|
|
}
|
|
if len(document) == 0 || len(document) > maxBrandKitDocumentBytes {
|
|
return brandkit.Kit{}, fmt.Errorf("%w: document size is invalid", brandkit.ErrInvalidInput)
|
|
}
|
|
|
|
now := s.now().UTC()
|
|
createdAt := now
|
|
if existing, err := s.store.Get(ctx, design.UserIDFromContext(ctx), id); err == nil {
|
|
createdAt = existing.CreatedAt
|
|
} else if !errors.Is(err, brandkit.ErrNotFound) {
|
|
return brandkit.Kit{}, err
|
|
}
|
|
doc, err := normalizeBrandKitDocument(id, []byte(document), isDefault, createdAt, now)
|
|
if err != nil {
|
|
return brandkit.Kit{}, err
|
|
}
|
|
canonical, err := json.Marshal(doc)
|
|
if err != nil {
|
|
return brandkit.Kit{}, fmt.Errorf("%w: encode document", brandkit.ErrInvalidInput)
|
|
}
|
|
return s.store.Upsert(ctx, brandkit.Kit{
|
|
ID: id,
|
|
UserID: design.UserIDFromContext(ctx),
|
|
Name: doc.Name,
|
|
Document: canonical,
|
|
IsDefault: isDefault,
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
func (s *BrandKitService) Delete(ctx context.Context, id string) error {
|
|
id = strings.TrimSpace(id)
|
|
if !brandKitIDPattern.MatchString(id) {
|
|
return fmt.Errorf("%w: invalid id", brandkit.ErrInvalidInput)
|
|
}
|
|
return s.store.Delete(ctx, design.UserIDFromContext(ctx), id)
|
|
}
|
|
|
|
func (s *BrandKitService) ResolveContext(ctx context.Context, id string) (string, error) {
|
|
resolved, err := s.Resolve(ctx, id)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return resolved.Context, nil
|
|
}
|
|
|
|
func (s *BrandKitService) Resolve(ctx context.Context, id string) (ResolvedBrandKit, error) {
|
|
kit, err := s.Get(ctx, id)
|
|
if err != nil {
|
|
return ResolvedBrandKit{}, err
|
|
}
|
|
compiled, err := compileBrandKitContext(kit.Document)
|
|
if err != nil {
|
|
return ResolvedBrandKit{}, err
|
|
}
|
|
references, err := brandKitReferenceImages(kit.Document)
|
|
if err != nil {
|
|
return ResolvedBrandKit{}, err
|
|
}
|
|
return ResolvedBrandKit{ID: kit.ID, Context: compiled, ReferenceImages: references}, nil
|
|
}
|
|
|
|
func (s *BrandKitService) ResolveDefaultContext(ctx context.Context) (string, string, error) {
|
|
resolved, err := s.ResolveDefault(ctx)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return resolved.ID, resolved.Context, nil
|
|
}
|
|
|
|
func (s *BrandKitService) ResolveDefault(ctx context.Context) (ResolvedBrandKit, error) {
|
|
if s == nil || s.store == nil {
|
|
return ResolvedBrandKit{}, brandkit.ErrNotFound
|
|
}
|
|
kit, err := s.store.GetDefault(ctx, design.UserIDFromContext(ctx))
|
|
if err != nil {
|
|
return ResolvedBrandKit{}, err
|
|
}
|
|
compiled, err := compileBrandKitContext(kit.Document)
|
|
if err != nil {
|
|
return ResolvedBrandKit{}, err
|
|
}
|
|
references, err := brandKitReferenceImages(kit.Document)
|
|
if err != nil {
|
|
return ResolvedBrandKit{}, err
|
|
}
|
|
return ResolvedBrandKit{ID: kit.ID, Context: compiled, ReferenceImages: references}, nil
|
|
}
|
|
|
|
func normalizeBrandKitDocument(id string, raw []byte, isDefault bool, createdAt time.Time, updatedAt time.Time) (brandKitDocument, error) {
|
|
var doc brandKitDocument
|
|
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&doc); err != nil {
|
|
return brandKitDocument{}, fmt.Errorf("%w: malformed document", brandkit.ErrInvalidInput)
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
|
return brandKitDocument{}, fmt.Errorf("%w: malformed document", brandkit.ErrInvalidInput)
|
|
}
|
|
if doc.Version != 1 || strings.TrimSpace(doc.ID) != id {
|
|
return brandKitDocument{}, fmt.Errorf("%w: unsupported version or mismatched id", brandkit.ErrInvalidInput)
|
|
}
|
|
doc.Name = strings.TrimSpace(doc.Name)
|
|
if doc.Name == "" || !textWithin(doc.Name, 120) {
|
|
return brandKitDocument{}, fmt.Errorf("%w: name is required", brandkit.ErrInvalidInput)
|
|
}
|
|
if err := validateBrandKitDocument(doc); err != nil {
|
|
return brandKitDocument{}, err
|
|
}
|
|
doc.ID = id
|
|
doc.ApplyToNewProjects = isDefault
|
|
doc.CreatedAt = createdAt.UTC().Format(time.RFC3339)
|
|
doc.UpdatedAt = updatedAt.UTC().Format(time.RFC3339)
|
|
return doc, nil
|
|
}
|
|
|
|
func validateBrandKitDocument(doc brandKitDocument) error {
|
|
for _, value := range []string{doc.BrandName, doc.Tagline, doc.Summary, doc.Audience, doc.Positioning, doc.Personality, doc.Voice.Traits, doc.Voice.Guidance, doc.Voice.Sample} {
|
|
if !textWithin(value, 4000) {
|
|
return fmt.Errorf("%w: text field is too long", brandkit.ErrInvalidInput)
|
|
}
|
|
}
|
|
if len(doc.Logos) > 12 || len(doc.ColorGroups) > 20 || len(doc.Typography) > 20 || len(doc.ReferenceImages) > 20 {
|
|
return fmt.Errorf("%w: too many document items", brandkit.ErrInvalidInput)
|
|
}
|
|
for _, logo := range doc.Logos {
|
|
if !brandKitIDPattern.MatchString(logo.ID) || !oneOf(logo.Variant, "primary", "secondary", "mark") || !oneOf(logo.Source, "upload", "moteva-mark") || !textWithin(logo.Name, 240) || !textWithin(logo.Usage, 4000) || !validImageDataURL(logo.DataURL) {
|
|
return fmt.Errorf("%w: invalid logo", brandkit.ErrInvalidInput)
|
|
}
|
|
}
|
|
if doc.CoverImage != nil {
|
|
if err := validateReferenceImage(*doc.CoverImage); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, group := range doc.ColorGroups {
|
|
if !brandKitIDPattern.MatchString(group.ID) || !textWithin(group.Name, 120) || len(group.Colors) > 32 {
|
|
return fmt.Errorf("%w: invalid color group", brandkit.ErrInvalidInput)
|
|
}
|
|
for _, color := range group.Colors {
|
|
if !brandKitIDPattern.MatchString(color.ID) || !brandColorPattern.MatchString(strings.TrimSpace(color.Hex)) || !textWithin(color.Name, 120) || !textWithin(color.Usage, 1000) {
|
|
return fmt.Errorf("%w: invalid color", brandkit.ErrInvalidInput)
|
|
}
|
|
}
|
|
}
|
|
for _, item := range doc.Typography {
|
|
if !brandKitIDPattern.MatchString(item.ID) || !textWithin(item.Label, 120) || !textWithin(item.Family, 240) || !textWithin(item.Weight, 80) || !textWithin(item.Sizes, 240) || !textWithin(item.Description, 4000) {
|
|
return fmt.Errorf("%w: invalid typography", brandkit.ErrInvalidInput)
|
|
}
|
|
if item.FontFile != nil && (strings.TrimSpace(item.FontFile.Name) == "" || !textWithin(item.FontFile.Name, 240) || !textWithin(item.FontFile.Format, 32) || item.FontFile.Size < 0 || item.FontFile.Size > 8<<20 || !validDataURL(item.FontFile.DataURL)) {
|
|
return fmt.Errorf("%w: invalid font file", brandkit.ErrInvalidInput)
|
|
}
|
|
}
|
|
for _, image := range doc.ReferenceImages {
|
|
if err := validateReferenceImage(image); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
for _, value := range []string{doc.Visual.Keywords, doc.Visual.Composition, doc.Visual.Imagery, doc.Visual.Dos, doc.Visual.Donts} {
|
|
if !textWithin(value, 4000) {
|
|
return fmt.Errorf("%w: visual field is too long", brandkit.ErrInvalidInput)
|
|
}
|
|
}
|
|
if !textWithin(doc.Visual.Prompt, 8000) {
|
|
return fmt.Errorf("%w: reusable direction is too long", brandkit.ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateReferenceImage(image brandKitReferenceImage) error {
|
|
if !brandKitIDPattern.MatchString(image.ID) || !oneOf(image.Source, "upload", "moteva-board") || !textWithin(image.Name, 240) || !textWithin(image.Description, 4000) || !validImageDataURL(image.DataURL) {
|
|
return fmt.Errorf("%w: invalid reference image", brandkit.ErrInvalidInput)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func compileBrandKitContext(raw []byte) (string, error) {
|
|
var doc brandKitDocument
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
return "", fmt.Errorf("%w: malformed stored document", brandkit.ErrInvalidInput)
|
|
}
|
|
sections := []string{
|
|
"Brand Kit: " + doc.Name,
|
|
brandKitContextSection("Brand", compactValues(doc.BrandName, doc.Tagline, doc.Summary)),
|
|
brandKitContextSection("Audience", compactValues(doc.Audience)),
|
|
brandKitContextSection("Positioning and personality", compactValues(doc.Positioning, doc.Personality)),
|
|
brandKitContextSection("Voice", compactValues(doc.Voice.Traits, doc.Voice.Guidance, doc.Voice.Sample)),
|
|
}
|
|
var logoLines []string
|
|
for _, logo := range doc.Logos {
|
|
logoLines = appendNonEmpty(logoLines, strings.Join(compactValues(logo.Name, logo.Variant, logo.Usage), " | "))
|
|
}
|
|
sections = append(sections, brandKitContextSection("Logo rules", logoLines))
|
|
if doc.CoverImage != nil {
|
|
sections = append(sections, brandKitContextSection("Cover image", compactValues(doc.CoverImage.Name, doc.CoverImage.Description)))
|
|
}
|
|
var colorLines []string
|
|
for _, group := range doc.ColorGroups {
|
|
for _, color := range group.Colors {
|
|
colorLines = append(colorLines, strings.Join(compactValues(group.Name+" / "+color.Name, color.Hex, color.Usage), " | "))
|
|
}
|
|
}
|
|
sections = append(sections, brandKitContextSection("Color system", colorLines))
|
|
var typeLines []string
|
|
for _, item := range doc.Typography {
|
|
fileName := ""
|
|
if item.FontFile != nil {
|
|
fileName = item.FontFile.Name
|
|
}
|
|
typeLines = appendNonEmpty(typeLines, strings.Join(compactValues(item.Label, item.Family, fileName, item.Weight, item.Sizes, item.Description), " | "))
|
|
}
|
|
sections = append(sections, brandKitContextSection("Typography", typeLines))
|
|
var referenceLines []string
|
|
for _, image := range doc.ReferenceImages {
|
|
referenceLines = appendNonEmpty(referenceLines, strings.Join(compactValues(image.Name, image.Description), ": "))
|
|
}
|
|
sections = append(sections, brandKitContextSection("Reference images", referenceLines))
|
|
sections = append(sections, brandKitContextSection("Attached Brand Kit images (appended after user references)", brandKitReferenceImageContext(doc)))
|
|
sections = append(sections,
|
|
brandKitContextSection("Visual direction", compactValues(doc.Visual.Keywords, doc.Visual.Composition, doc.Visual.Imagery)),
|
|
brandKitContextSection("Use", compactValues(doc.Visual.Dos)),
|
|
brandKitContextSection("Avoid", compactValues(doc.Visual.Donts)),
|
|
brandKitContextSection("Reusable direction", compactValues(doc.Visual.Prompt)),
|
|
)
|
|
sections = compactValues(sections...)
|
|
contextBlock := "Binding creative system for this canvas. Apply it to planning, copy, composition, typography, color, and generated imagery unless the user explicitly overrides a rule in the current request.\n\n" + strings.Join(sections, "\n\n")
|
|
return clampRunes(contextBlock, maxBrandKitContextRunes), nil
|
|
}
|
|
|
|
func brandKitReferenceImages(raw []byte) ([]string, error) {
|
|
var doc brandKitDocument
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
return nil, fmt.Errorf("%w: malformed stored document", brandkit.ErrInvalidInput)
|
|
}
|
|
images := make([]string, 0, len(doc.Logos)+len(doc.ReferenceImages)+1)
|
|
seen := make(map[string]struct{})
|
|
add := func(value string) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return
|
|
}
|
|
if _, exists := seen[value]; exists {
|
|
return
|
|
}
|
|
seen[value] = struct{}{}
|
|
images = append(images, value)
|
|
}
|
|
for _, logo := range doc.Logos {
|
|
add(logo.DataURL)
|
|
}
|
|
if doc.CoverImage != nil {
|
|
add(doc.CoverImage.DataURL)
|
|
}
|
|
for _, image := range doc.ReferenceImages {
|
|
add(image.DataURL)
|
|
}
|
|
return images, nil
|
|
}
|
|
|
|
func brandKitReferenceImageContext(doc brandKitDocument) []string {
|
|
lines := make([]string, 0, len(doc.Logos)+len(doc.ReferenceImages)+1)
|
|
index := 0
|
|
for _, logo := range doc.Logos {
|
|
if strings.TrimSpace(logo.DataURL) == "" {
|
|
continue
|
|
}
|
|
index++
|
|
lines = append(lines, fmt.Sprintf("Brand image %d | logo | %s", index, strings.Join(compactValues(logo.Name, logo.Variant, logo.Usage), " | ")))
|
|
}
|
|
if doc.CoverImage != nil && strings.TrimSpace(doc.CoverImage.DataURL) != "" {
|
|
index++
|
|
lines = append(lines, fmt.Sprintf("Brand image %d | cover | %s", index, strings.Join(compactValues(doc.CoverImage.Name, doc.CoverImage.Description), " | ")))
|
|
}
|
|
for _, image := range doc.ReferenceImages {
|
|
if strings.TrimSpace(image.DataURL) == "" {
|
|
continue
|
|
}
|
|
index++
|
|
lines = append(lines, fmt.Sprintf("Brand image %d | visual reference | %s", index, strings.Join(compactValues(image.Name, image.Description), " | ")))
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func brandKitContextSection(title string, values []string) string {
|
|
values = compactValues(values...)
|
|
if len(values) == 0 {
|
|
return ""
|
|
}
|
|
return title + ":\n- " + strings.Join(values, "\n- ")
|
|
}
|
|
|
|
func compactValues(values ...string) []string {
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
result = append(result, value)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func appendNonEmpty(values []string, value string) []string {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
return append(values, value)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func textWithin(value string, max int) bool {
|
|
return utf8.RuneCountInString(value) <= max
|
|
}
|
|
|
|
func validDataURL(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
return value == "" || (strings.HasPrefix(value, "data:") && len(value) <= 12<<20)
|
|
}
|
|
|
|
func validImageDataURL(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return true
|
|
}
|
|
if len(value) > 12<<20 {
|
|
return false
|
|
}
|
|
for _, prefix := range []string{
|
|
"data:image/png;base64,",
|
|
"data:image/jpeg;base64,",
|
|
"data:image/webp;base64,",
|
|
"data:image/svg+xml;base64,",
|
|
} {
|
|
if strings.HasPrefix(value, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func oneOf(value string, allowed ...string) bool {
|
|
for _, candidate := range allowed {
|
|
if value == candidate {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func clampRunes(value string, max int) string {
|
|
if max <= 0 || utf8.RuneCountInString(value) <= max {
|
|
return value
|
|
}
|
|
runes := []rune(value)
|
|
return string(runes[:max])
|
|
}
|