package imagine import ( "bytes" "context" "encoding/json" "fmt" "io" "mime/multipart" "strconv" "strings" "time" http "github.com/bogdanfinn/fhttp" ) // GenerateImage runs the full Imagine.art pipeline: submit the txt2img job, poll // the org objects feed until the batch finishes, then download the produced // image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K). // HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs). func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string) ([]byte, map[string]any, error) { cr, ok := parseCred(cred) if !ok { return nil, nil, ErrAuth } userID := userIDFromToken(cr.Token) metadata, _ := json.Marshal(map[string]any{ "placeholderUuid": uuid4(), "promptWithoutManipulation": prompt, "modeId": 0, }) // parent_id MUST be a canvas node this account owns — the server rejects a // foreign id ("user does not have access to parent asset") and silently // orphans a parent-less generation (charged but never produced). It's supplied // at import (credential.parentId). fields := map[string]string{ "style_id": strconv.Itoa(styleID), "aspect_ratio": aspectRatio, "resolution": resolution, "variation": "txt2img", "prompt": prompt, "is_enhance": "0", "count": "1", "clientVersion": "1", "org_id": userID, "use_plugin": "false", "metadata": string(metadata), } if pid := strings.TrimSpace(cr.ParentID); pid != "" { fields["parent_id"] = pid } var buf bytes.Buffer w := multipart.NewWriter(&buf) for k, v := range fields { _ = w.WriteField(k, v) } _ = w.Close() body, status, err := c.apiPost(ctx, cr.Token, apiBase+"/v1/image/generations/upload", w.FormDataContentType(), buf.Bytes()) if err != nil { return nil, nil, fmt.Errorf("%w: submit: %s", ErrTemporaryUpstream, err.Error()) } if status == 401 || status == 403 { return nil, nil, ErrAuth } if status == 402 { return nil, nil, ErrQuotaExhausted } if status != 200 && status != 201 { return nil, nil, fmt.Errorf("%w: submit http %d: %s", ErrTemporaryUpstream, status, clip(body, 200)) } var jobs []struct { BatchID string `json:"batchId"` ID string `json:"id"` Status string `json:"status"` } if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].BatchID == "" { return nil, nil, fmt.Errorf("%w: no batch id: %s", ErrTemporaryUpstream, clip(body, 200)) } batchID := jobs[0].BatchID imageURL, err := c.pollImage(ctx, cr.Token, userID, batchID) if err != nil { return nil, nil, err } data, err := c.download(ctx, imageURL) if err != nil { return nil, nil, err } return data, map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}, nil } // pollImage polls the org objects feed until the entry for our batch finishes, // then extracts its asset URL (image_url is a JSON-encoded array string). func (c *Client) pollImage(ctx context.Context, token, userID, batchID string) (string, error) { ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() deadline := time.Now().Add(4 * time.Minute) url := teamsBase + "/v1/org/" + userID + "/objects?batch=true&limit=50&service=image,chat-image" for { body, status, err := c.apiGet(ctx, token, url) if err == nil && status == 200 { var resp struct { Data []struct { BatchID string `json:"batch_id"` Status string `json:"status"` Code int `json:"code"` // Current shape: the produced asset lives at url.generation[0]. URL struct { Generation []string `json:"generation"` } `json:"url"` ImageURL string `json:"image_url"` // legacy fallback } `json:"data"` } if json.Unmarshal(body, &resp) == nil { for _, o := range resp.Data { if o.BatchID != batchID { continue } st := strings.ToLower(strings.TrimSpace(o.Status)) switch { case st == "finished" || o.Code == 2: if u := firstNonEmpty(o.URL.Generation); u != "" { return u, nil } if u := firstImageURL(o.ImageURL); u != "" { return u, nil } case st == "failed" || st == "error": return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, st) } } } } else if status == 401 || status == 403 { return "", ErrAuth } if time.Now().After(deadline) { return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream) } select { case <-ctx.Done(): return "", ctx.Err() case <-ticker.C: } } } // firstNonEmpty returns the first non-blank string in a slice. func firstNonEmpty(ss []string) string { for _, s := range ss { if strings.TrimSpace(s) != "" { return strings.TrimSpace(s) } } return "" } // firstImageURL parses the image_url field — a JSON-encoded array of URLs — and // returns the first one. Tolerates a bare string too. func firstImageURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } var urls []string if json.Unmarshal([]byte(raw), &urls) == nil { for _, u := range urls { if strings.TrimSpace(u) != "" { return strings.TrimSpace(u) } } return "" } if strings.HasPrefix(raw, "http") { return raw } return "" } func (c *Client) download(ctx context.Context, url string) ([]byte, error) { client, err := c.newTLSClient() if err != nil { return nil, err } req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err } req = req.WithContext(ctx) req.Header = http.Header{ "accept": {"image/avif,image/webp,image/png,image/*,*/*;q=0.8"}, "user-agent": {userAgent}, "referer": {webOrigin + "/"}, } resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != 200 { return nil, fmt.Errorf("%w: image download http %d", ErrTemporaryUpstream, resp.StatusCode) } return b, nil } // apiPost issues a POST with a raw body + content-type, carrying the bearer token. func (c *Client) apiPost(ctx context.Context, token, url, contentType string, body []byte) ([]byte, int, error) { client, err := c.newTLSClient() if err != nil { return nil, 0, err } req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, 0, err } req = req.WithContext(ctx) req.Header = http.Header{ "accept": {"application/json, text/plain, */*"}, "authorization": {"Bearer " + token}, "content-type": {contentType}, "origin": {webOrigin}, "referer": {webOrigin + "/"}, "user-agent": {userAgent}, http.HeaderOrderKey: { "accept", "authorization", "content-type", "origin", "referer", "user-agent", }, } resp, err := client.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) return b, resp.StatusCode, err }