Keep Gemini image edits role-aware on the server path
The server now resolves source, identity, and style artifacts separately, emits role-labeled Gemini parts, and logs the effective request shape so deployed debugging can confirm which references reached upstream. Constraint: The test deployment must stay on gemini-3.1-flash-image-preview while gaining better multi-image editing semantics Rejected: Change the default model to gemini-3-pro-image-preview | test-server parity required keeping flash as default Confidence: high Scope-risk: moderate Reversibility: clean Directive: Preserve the source/identity/style logging until the upstream gateway exposes equivalent structured traces Tested: go test ./internal/server Tested: image img2img against http://101.42.99.35:18080/v1 (job_5af212df2c27) Not-tested: Non-Gemini multi-image providers still fall back to the first reference only
This commit is contained in:
+138
-27
@@ -1159,7 +1159,7 @@ func (s *Server) executeImageToImageJob(record *job) {
|
||||
defer cancel()
|
||||
|
||||
input := buildImageToImageInput(record)
|
||||
ref, err := s.resolveImageToImageReference(ctx, record, input)
|
||||
imageRefs, err := s.resolveImageToImageReferences(ctx, record, input)
|
||||
if err != nil {
|
||||
if repoErr := s.store.failJob(record.JobID, "REFERENCE_IMAGE_RESOLUTION_FAILED", err.Error(), map[string]any{
|
||||
"skill_id": record.SkillID,
|
||||
@@ -1169,15 +1169,23 @@ func (s *Server) executeImageToImageJob(record *job) {
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf(
|
||||
"popiartServer: image job %s using model=%s refs=%s preserve_composition=%t strength=%v",
|
||||
record.JobID,
|
||||
modelID,
|
||||
summarizeImageEditReferenceRoles(imageRefs),
|
||||
boolValue(input["preserve_composition"]),
|
||||
input["strength"],
|
||||
)
|
||||
|
||||
var (
|
||||
refs []resultRef
|
||||
usage map[string]any
|
||||
resultRefs []resultRef
|
||||
usage map[string]any
|
||||
)
|
||||
if useMiniMaxImageGenerations(modelID) {
|
||||
refs, usage, err = s.newapi.generateMiniMaxImageRefs(ctx, record.UpstreamKey, modelID, input, []imageEditReference{ref})
|
||||
resultRefs, usage, err = s.newapi.generateMiniMaxImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
|
||||
} else {
|
||||
refs, usage, err = s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, ref)
|
||||
resultRefs, usage, err = s.newapi.generateEditedImageRefs(ctx, record.UpstreamKey, modelID, input, imageRefs)
|
||||
}
|
||||
if err != nil {
|
||||
if repoErr := s.store.failJob(record.JobID, "MODEL_REQUEST_FAILED", err.Error(), map[string]any{
|
||||
@@ -1189,7 +1197,7 @@ func (s *Server) executeImageToImageJob(record *job) {
|
||||
return
|
||||
}
|
||||
|
||||
refs, err = s.persistResultRefs(ctx, record, refs)
|
||||
resultRefs, err = s.persistResultRefs(ctx, record, resultRefs)
|
||||
if err != nil {
|
||||
if repoErr := s.store.failJob(record.JobID, "MEDIA_PERSIST_FAILED", "failed to persist generated media", map[string]any{
|
||||
"details": err.Error(),
|
||||
@@ -1201,7 +1209,7 @@ func (s *Server) executeImageToImageJob(record *job) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.completeJobWithResults(record.JobID, refs, usage); err != nil {
|
||||
if err := s.store.completeJobWithResults(record.JobID, resultRefs, usage); err != nil {
|
||||
log.Printf("popiartServer: complete job %s failed: %v", record.JobID, err)
|
||||
if failErr := s.store.failJob(record.JobID, "RESULT_REF_PERSIST_FAILED", "failed to persist job result refs", map[string]any{
|
||||
"details": err.Error(),
|
||||
@@ -1394,33 +1402,45 @@ func inferRouteKeyForModel(modelID string, input map[string]any) string {
|
||||
return "image.text2image"
|
||||
}
|
||||
|
||||
func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, input map[string]any) (imageEditReference, error) {
|
||||
func (s *Server) resolveImageToImageReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
|
||||
if record == nil {
|
||||
return imageEditReference{}, fmt.Errorf("job record is required")
|
||||
return nil, fmt.Errorf("job record is required")
|
||||
}
|
||||
if artifactID := strings.TrimSpace(stringValue(input["source_artifact_id"])); artifactID != "" {
|
||||
item, ref, exists, err := s.store.artifactRef(record.UserID, artifactID)
|
||||
sourceRef, err := s.resolveArtifactImageReference(ctx, record, artifactID, "source")
|
||||
if err != nil {
|
||||
return imageEditReference{}, err
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return imageEditReference{}, fmt.Errorf("source artifact not found: %s", artifactID)
|
||||
refs := []imageEditReference{sourceRef}
|
||||
seen := map[string]struct{}{artifactID: {}}
|
||||
var appendArtifactRefs = func(role string, values []string) error {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[value]; exists {
|
||||
continue
|
||||
}
|
||||
ref, err := s.resolveArtifactImageReference(ctx, record, value, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref)
|
||||
if err != nil {
|
||||
return imageEditReference{}, err
|
||||
if err := appendArtifactRefs("identity", stringSliceValue(input["identity_reference_artifact_ids"])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return imageEditReference{}, err
|
||||
if err := appendArtifactRefs("style", stringSliceValue(input["style_reference_artifact_ids"])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return imageEditReference{
|
||||
Filename: item.Filename,
|
||||
ContentType: defaultString(contentType, item.ContentType),
|
||||
Content: content,
|
||||
URL: item.URL,
|
||||
}, nil
|
||||
if err := appendArtifactRefs("reference", stringSliceValue(input["reference_artifact_ids"])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
refURL := strings.TrimSpace(stringValue(
|
||||
@@ -1432,9 +1452,25 @@ func (s *Server) resolveImageToImageReference(ctx context.Context, record *job,
|
||||
refURL = defaultAliceReferenceURL
|
||||
}
|
||||
if refURL == "" {
|
||||
return nil, fmt.Errorf("reference image is required")
|
||||
}
|
||||
ref, err := s.downloadReferenceImage(ctx, refURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ref.Role = "source"
|
||||
return []imageEditReference{ref}, nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveImageToImageReference(ctx context.Context, record *job, input map[string]any) (imageEditReference, error) {
|
||||
refs, err := s.resolveImageToImageReferences(ctx, record, input)
|
||||
if err != nil {
|
||||
return imageEditReference{}, err
|
||||
}
|
||||
if len(refs) == 0 {
|
||||
return imageEditReference{}, fmt.Errorf("reference image is required")
|
||||
}
|
||||
return s.downloadReferenceImage(ctx, refURL)
|
||||
return refs[0], nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveVideoReferences(ctx context.Context, record *job, input map[string]any) ([]imageEditReference, error) {
|
||||
@@ -1651,6 +1687,32 @@ func buildAliceShowcasePrompt(input map[string]any, scenePrompt string) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func (s *Server) resolveArtifactImageReference(ctx context.Context, record *job, artifactID, role string) (imageEditReference, error) {
|
||||
item, ref, exists, err := s.store.artifactRef(record.UserID, artifactID)
|
||||
if err != nil {
|
||||
return imageEditReference{}, err
|
||||
}
|
||||
if !exists {
|
||||
return imageEditReference{}, fmt.Errorf("%s artifact not found: %s", defaultString(role, "reference"), artifactID)
|
||||
}
|
||||
contentType, _, reader, err := s.newapi.openResultRef(ctx, record.UpstreamKey, ref)
|
||||
if err != nil {
|
||||
return imageEditReference{}, err
|
||||
}
|
||||
defer reader.Close()
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return imageEditReference{}, err
|
||||
}
|
||||
return imageEditReference{
|
||||
Role: role,
|
||||
Filename: item.Filename,
|
||||
ContentType: defaultString(contentType, item.ContentType),
|
||||
Content: content,
|
||||
URL: item.URL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildGenericImageToImagePrompt(input map[string]any, prompt string) string {
|
||||
parts := make([]string, 0, 6)
|
||||
if prompt != "" {
|
||||
@@ -1668,6 +1730,21 @@ func buildGenericImageToImagePrompt(input map[string]any, prompt string) string
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func summarizeImageEditReferenceRoles(refs []imageEditReference) string {
|
||||
if len(refs) == 0 {
|
||||
return "none"
|
||||
}
|
||||
parts := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
role := strings.TrimSpace(ref.Role)
|
||||
if role == "" {
|
||||
role = "reference"
|
||||
}
|
||||
parts = append(parts, role)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func stringValue(values ...any) string {
|
||||
for _, value := range values {
|
||||
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
|
||||
@@ -1677,6 +1754,40 @@ func stringValue(values ...any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func boolValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceValue(value any) []string {
|
||||
switch typed := value.(type) {
|
||||
case []string:
|
||||
return cleanedStringValues(typed)
|
||||
case []any:
|
||||
items := make([]string, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
items = append(items, strings.TrimSpace(fmt.Sprint(item)))
|
||||
}
|
||||
return cleanedStringValues(items)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func cleanedStringValues(values []string) []string {
|
||||
items := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
items = append(items, trimmed)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildVideoResultRef(baseURL, taskID, modelID string, task *videoTaskResult) resultRef {
|
||||
urlValue := strings.TrimSpace(task.URL)
|
||||
if urlValue == "" {
|
||||
|
||||
Reference in New Issue
Block a user