pollImage/pollVideo capped themselves at 3min/10min while the caller's genCtx budget is 8min (image) / 12min (video), so slow-but-valid generations were killed early with 'adobe generation timed out'. Now the loop honors ctx.Err() and uses the full budget.
1061 lines
34 KiB
Go
1061 lines
34 KiB
Go
package adobe
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
http "github.com/bogdanfinn/fhttp"
|
|
tlsclient "github.com/bogdanfinn/tls-client"
|
|
"github.com/bogdanfinn/tls-client/profiles"
|
|
)
|
|
|
|
const (
|
|
submitURL = "https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async"
|
|
image5SubmitURL = "https://image-v5.ff.adobe.io/v1/images/generate-async"
|
|
videoSubmitURL = "https://firefly-3p.ff.adobe.io/v2/3p-videos/generate-async"
|
|
// Firefly-native video model (project id "firefly-video"): distinct host,
|
|
// submit path and storage host from the 3p (veo/luma) video flow.
|
|
fireflyVideoSubmitURL = "https://video-v1.ff.adobe.io/v2/videos/generate"
|
|
fireflyVideoUploadURL = "https://video-v1.ff.adobe.io/v2/storage/image"
|
|
uploadURL = "https://firefly-3p.ff.adobe.io/v2/storage/image"
|
|
creditsURL = "https://firefly.adobe.io/v1/credits/balance"
|
|
creditsAPIKey = "SunbreakWebUI1"
|
|
)
|
|
|
|
var (
|
|
ErrAuth = errors.New("adobe auth failed")
|
|
ErrQuotaExhausted = errors.New("adobe quota exhausted")
|
|
ErrTemporaryUpstream = errors.New("adobe upstream temporary error")
|
|
ErrDeadUpstream = errors.New("adobe upstream fatal error")
|
|
// ErrContentRejected is Adobe's content-safety filter refusing the prompt or
|
|
// the generated image (HTTP 451 image_unsafe). It is the prompt's fault, not
|
|
// the account's — every account rejects the same content — so the caller must
|
|
// surface it as-is without penalizing/killing the account or failing over.
|
|
ErrContentRejected = errors.New("adobe content rejected")
|
|
)
|
|
|
|
// isContentRejection reports whether an Adobe response (status + body) is a
|
|
// content-safety refusal rather than a genuine upstream/account failure. Adobe
|
|
// returns HTTP 451 with an "*_unsafe" error_code when moderation blocks the
|
|
// prompt or the produced image.
|
|
func isContentRejection(status int, body string) bool {
|
|
return status == 451 && strings.Contains(body, "unsafe")
|
|
}
|
|
|
|
var profileURLs = []string{
|
|
"https://ims-na1.adobelogin.com/ims/profile/v1",
|
|
"https://adobeid-na1.services.adobe.com/ims/profile/v1",
|
|
}
|
|
|
|
type Client struct {
|
|
apiKey string
|
|
proxy string
|
|
}
|
|
|
|
func NewClient(apiKey, proxy string) *Client {
|
|
return &Client{
|
|
apiKey: defaultString(apiKey, clientID),
|
|
proxy: strings.TrimSpace(proxy),
|
|
}
|
|
}
|
|
|
|
func (c *Client) SetProxy(proxy string) {
|
|
c.proxy = strings.TrimSpace(proxy)
|
|
}
|
|
|
|
func (c *Client) ExchangeCookie(ctx context.Context, cookie string) (*CookieExchangeResult, error) {
|
|
sess, err := c.newDirectTLSClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return exchangeCookieWithTLSClient(ctx, sess, cookie)
|
|
}
|
|
|
|
// uploadMaxRetries is how many extra in-place attempts a transient upload
|
|
// failure (transport error / timeout, 429/451/5xx) gets on a fresh connection
|
|
// before the error is surfaced.
|
|
const uploadMaxRetries = 5
|
|
|
|
// UploadImage stores a reference image and returns its blob id. Transient
|
|
// failures are retried in place (uploadMaxRetries times); the final error keeps
|
|
// its original (non-temporary) classification so the account is not penalized
|
|
// for a network blip.
|
|
func (c *Client) UploadImage(ctx context.Context, token string, content []byte, contentType, engine string) (string, error) {
|
|
// Reference-image upload runs on the local IP (not the proxy).
|
|
body, err, retryable := c.uploadImageOnce(ctx, token, content, contentType, engine)
|
|
for attempt := 0; err != nil && retryable && attempt < uploadMaxRetries && ctx.Err() == nil; attempt++ {
|
|
body, err, retryable = c.uploadImageOnce(ctx, token, content, contentType, engine)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return "", err
|
|
}
|
|
if images, ok := payload["images"].([]any); ok && len(images) > 0 {
|
|
if first, ok := images[0].(map[string]any); ok {
|
|
if id := strings.TrimSpace(stringValue(first["id"])); id != "" {
|
|
return id, nil
|
|
}
|
|
}
|
|
}
|
|
if id := strings.TrimSpace(stringValue(payload["id"])); id != "" {
|
|
return id, nil
|
|
}
|
|
return "", errors.New("adobe upload missing blob id")
|
|
}
|
|
|
|
// uploadImageOnce performs a single upload attempt and returns the raw response
|
|
// body plus whether a failure is retryable (transport error / 429/451/5xx).
|
|
// Auth failures (401/403) and other non-200s are not retryable.
|
|
func (c *Client) uploadImageOnce(ctx context.Context, token string, content []byte, contentType, engine string) ([]byte, error, bool) {
|
|
sess, err := c.newDirectTLSClient()
|
|
if err != nil {
|
|
return nil, err, false
|
|
}
|
|
|
|
endpoint := uploadURL
|
|
if engine == "firefly-video" {
|
|
endpoint = fireflyVideoUploadURL
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(content))
|
|
if err != nil {
|
|
return nil, err, false
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
|
"x-api-key": {c.apiKey},
|
|
"content-type": {defaultString(contentType, "image/png")},
|
|
"accept": {"*/*"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
http.HeaderOrderKey: {
|
|
"authorization",
|
|
"x-api-key",
|
|
"content-type",
|
|
"accept",
|
|
"user-agent",
|
|
},
|
|
}
|
|
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
// Transport error (incl. Client.Timeout on the storage endpoint) — a
|
|
// network blip, retryable on a fresh connection.
|
|
return nil, fmt.Errorf("adobe upload request: %w", err), true
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err, true
|
|
}
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, fmt.Errorf("%w (upload %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300)), false
|
|
}
|
|
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
|
return nil, fmt.Errorf("adobe upload failed: %d %s", resp.StatusCode, clip(body, 300)), true
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("adobe upload failed: %d %s", resp.StatusCode, clip(body, 300)), false
|
|
}
|
|
return body, nil, true
|
|
}
|
|
|
|
func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string) ([]byte, map[string]any, error) {
|
|
// Only the generate submit goes through the proxy; polling + download run on
|
|
// the local IP.
|
|
submitSess, err := c.newTLSClient()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
pollSess, err := c.newDirectTLSClient()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var lastBody []byte
|
|
var lastErr error
|
|
// Firefly Image 5 uses a different endpoint + request schema (modelVersion
|
|
// "image5", resolutionLevel, top-level aspectRatio label, no modelId/size).
|
|
endpoint := submitURL
|
|
var candidates []map[string]any
|
|
if modelID == "firefly-image-5" {
|
|
endpoint = image5SubmitURL
|
|
candidates = []map[string]any{buildImage5Payload(prompt, aspectRatio, resolution, blobIDs)}
|
|
} else {
|
|
candidates = BuildImagePayloadCandidates(modelID, prompt, aspectRatio, resolution, blobIDs)
|
|
}
|
|
for _, payload := range candidates {
|
|
respBody, pollURL, err := c.submitImage(ctx, submitSess, token, prompt, endpoint, payload)
|
|
if err == nil {
|
|
meta, data, pollErr := c.pollImage(ctx, pollSess, token, pollURL)
|
|
if pollErr != nil {
|
|
return nil, nil, pollErr
|
|
}
|
|
return data, meta, nil
|
|
}
|
|
lastBody = respBody
|
|
lastErr = err
|
|
if errors.Is(err, ErrAuth) || errors.Is(err, ErrQuotaExhausted) || errors.Is(err, ErrContentRejected) {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
// Content-safety refusal: the prompt/image is blocked, retrying other payloads
|
|
// or accounts is pointless — surface it as-is so the pool doesn't fail over.
|
|
if errors.Is(lastErr, ErrContentRejected) {
|
|
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrContentRejected, clip(lastBody, 300))
|
|
}
|
|
// Preserve the temporary classification so the pool retries (overload / 5xx /
|
|
// rate-limit) instead of failing the request outright.
|
|
if errors.Is(lastErr, ErrDeadUpstream) {
|
|
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrDeadUpstream, clip(lastBody, 300))
|
|
}
|
|
if errors.Is(lastErr, ErrTemporaryUpstream) {
|
|
return nil, nil, fmt.Errorf("%w: adobe submit: %s", ErrTemporaryUpstream, clip(lastBody, 300))
|
|
}
|
|
return nil, nil, fmt.Errorf("adobe submit failed: %s", clip(lastBody, 300))
|
|
}
|
|
|
|
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
|
|
// With downloadResult=false it returns nil bytes and the upstream presigned URL
|
|
// in meta["video_url"] — used by the async /v1/videos job, which proxies that URL
|
|
// on /content instead of persisting the file.
|
|
func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
|
|
// Only the submit goes through the proxy; polling + download run on the local IP.
|
|
submitSess, err := c.newTLSClient()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
pollSess, err := c.newDirectTLSClient()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
payload := BuildVideoPayload(engine, prompt, aspectRatio, durationSeconds, resolution, referenceMode, upstreamModel, blobIDs)
|
|
endpoint := videoSubmitURL
|
|
if engine == "firefly-video" {
|
|
endpoint = fireflyVideoSubmitURL
|
|
}
|
|
respBody, pollURL, err := c.submitVideo(ctx, submitSess, token, endpoint, payload)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
_ = respBody
|
|
meta, data, pollErr := c.pollVideo(ctx, pollSess, token, pollURL, downloadResult)
|
|
if pollErr != nil {
|
|
return nil, nil, pollErr
|
|
}
|
|
return data, meta, nil
|
|
}
|
|
|
|
func (c *Client) FetchAccountProfile(ctx context.Context, token string) (map[string]any, error) {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return map[string]any{}, nil
|
|
}
|
|
sess, err := c.newDirectTLSClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, rawURL := range profileURLs {
|
|
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"authorization": {"Bearer " + token},
|
|
"accept": {"application/json"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
http.HeaderOrderKey: {
|
|
"authorization",
|
|
"accept",
|
|
"user-agent",
|
|
},
|
|
}
|
|
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil || resp.StatusCode != 200 {
|
|
continue
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
continue
|
|
}
|
|
email := strings.TrimSpace(stringValue(payload["email"]))
|
|
displayName := strings.TrimSpace(stringValue(payload["displayName"]))
|
|
if displayName == "" {
|
|
displayName = strings.TrimSpace(stringValue(payload["name"]))
|
|
}
|
|
if displayName == "" {
|
|
displayName = strings.TrimSpace(stringValue(payload["fullName"]))
|
|
}
|
|
userID := strings.TrimSpace(stringValue(payload["userId"]))
|
|
if userID == "" {
|
|
userID = strings.TrimSpace(stringValue(payload["authId"]))
|
|
}
|
|
if email != "" || displayName != "" || userID != "" {
|
|
return map[string]any{
|
|
"email": emptyStringNil(email),
|
|
"display_name": emptyStringNil(displayName),
|
|
"user_id": emptyStringNil(userID),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return map[string]any{}, nil
|
|
}
|
|
|
|
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
|
|
token = strings.TrimSpace(token)
|
|
if token == "" {
|
|
return map[string]any{
|
|
"remaining": nil,
|
|
"used": nil,
|
|
"total": nil,
|
|
"available_until": nil,
|
|
"unknown": true,
|
|
"error": "empty token",
|
|
}, nil
|
|
}
|
|
|
|
accountID := ExtractAccountID(token)
|
|
if accountID == "" {
|
|
return map[string]any{
|
|
"remaining": nil,
|
|
"used": nil,
|
|
"total": nil,
|
|
"available_until": nil,
|
|
"unknown": true,
|
|
"error": "no account id",
|
|
}, nil
|
|
}
|
|
|
|
sess, err := c.newDirectTLSClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequest(http.MethodGet, creditsURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"authorization": {"Bearer " + token},
|
|
"x-api-key": {creditsAPIKey},
|
|
"x-account-id": {accountID},
|
|
"accept": {"application/json"},
|
|
"content-type": {"application/json"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
http.HeaderOrderKey: {
|
|
"authorization",
|
|
"x-api-key",
|
|
"x-account-id",
|
|
"accept",
|
|
"content-type",
|
|
"user-agent",
|
|
},
|
|
}
|
|
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
return map[string]any{
|
|
"remaining": nil,
|
|
"used": nil,
|
|
"total": nil,
|
|
"available_until": nil,
|
|
"unknown": true,
|
|
"error": "network: " + err.Error(),
|
|
}, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == 401 {
|
|
return nil, ErrAuth
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return map[string]any{
|
|
"remaining": nil,
|
|
"used": nil,
|
|
"total": nil,
|
|
"available_until": nil,
|
|
"unknown": true,
|
|
"error": fmt.Sprintf("http %d: %s", resp.StatusCode, clip(body, 160)),
|
|
}, nil
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return map[string]any{
|
|
"remaining": nil,
|
|
"used": nil,
|
|
"total": nil,
|
|
"available_until": nil,
|
|
"unknown": true,
|
|
"error": "non-json",
|
|
}, nil
|
|
}
|
|
|
|
totalInfo, _ := payload["total"].(map[string]any)
|
|
quota, _ := totalInfo["quota"].(map[string]any)
|
|
return map[string]any{
|
|
"remaining": intOrNil(quota["available"]),
|
|
"used": intOrNil(quota["used"]),
|
|
"total": intOrNil(quota["total"]),
|
|
"available_until": emptyStringNil(strings.TrimSpace(stringValue(totalInfo["availableUntil"]))),
|
|
"unknown": false,
|
|
"error": nil,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, prompt, endpoint string, payload map[string]any) ([]byte, string, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
|
"x-api-key": {c.apiKey},
|
|
"content-type": {"application/json"},
|
|
"accept": {"*/*"},
|
|
"origin": {"https://firefly.adobe.com"},
|
|
"referer": {"https://firefly.adobe.com/"},
|
|
"accept-language": {"en-US,en;q=0.9"},
|
|
"sec-ch-ua": {sess.fp.secCHUA},
|
|
"sec-ch-ua-mobile": {"?0"},
|
|
"sec-ch-ua-platform": {sess.fp.platform},
|
|
"sec-fetch-site": {"same-site"},
|
|
"sec-fetch-mode": {"cors"},
|
|
"sec-fetch-dest": {"empty"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
"x-arp-session-id": {buildARPSessionID()},
|
|
http.HeaderOrderKey: {
|
|
"authorization",
|
|
"x-api-key",
|
|
"content-type",
|
|
"accept",
|
|
"origin",
|
|
"referer",
|
|
"accept-language",
|
|
"sec-ch-ua",
|
|
"sec-ch-ua-mobile",
|
|
"sec-ch-ua-platform",
|
|
"sec-fetch-site",
|
|
"sec-fetch-mode",
|
|
"sec-fetch-dest",
|
|
"user-agent",
|
|
"x-nonce",
|
|
"x-arp-session-id",
|
|
},
|
|
}
|
|
if nonce := buildSubmitNonce(token, prompt); nonce != "" {
|
|
req.Header.Set("x-nonce", nonce)
|
|
}
|
|
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
|
|
return respBody, "", ErrQuotaExhausted
|
|
}
|
|
return respBody, "", fmt.Errorf("%w (submit %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
|
|
}
|
|
// "system under load" / timeout_error = adobe rate-limit/overload (can come on a
|
|
// non-5xx) — treat as temporary so the pool retries instead of failing.
|
|
if b := string(respBody); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") {
|
|
return respBody, "", ErrTemporaryUpstream
|
|
}
|
|
if isContentRejection(resp.StatusCode, string(respBody)) {
|
|
return respBody, "", ErrContentRejected
|
|
}
|
|
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
|
return respBody, "", ErrDeadUpstream
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return respBody, "", errors.New("submit rejected")
|
|
}
|
|
|
|
var payloadResp map[string]any
|
|
if err := json.Unmarshal(respBody, &payloadResp); err != nil {
|
|
return respBody, "", err
|
|
}
|
|
if override := strings.TrimSpace(resp.Header.Get("x-override-status-link")); override != "" {
|
|
return respBody, override, nil
|
|
}
|
|
if links, ok := payloadResp["links"].(map[string]any); ok {
|
|
if result, ok := links["result"].(map[string]any); ok {
|
|
if href := strings.TrimSpace(stringValue(result["href"])); href != "" {
|
|
return respBody, href, nil
|
|
}
|
|
}
|
|
if href := strings.TrimSpace(stringValue(links["result"])); href != "" {
|
|
return respBody, href, nil
|
|
}
|
|
}
|
|
return respBody, "", errors.New("submit ok but no poll url")
|
|
}
|
|
|
|
func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL string) (map[string]any, []byte, error) {
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("adobe generation timed out: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodGet, pollURL, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
|
"accept": {"*/*"},
|
|
"origin": {"https://firefly.adobe.com"},
|
|
"referer": {"https://firefly.adobe.com/"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
http.HeaderOrderKey: {
|
|
"authorization",
|
|
"accept",
|
|
"origin",
|
|
"referer",
|
|
"user-agent",
|
|
},
|
|
}
|
|
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
return nil, nil, readErr
|
|
}
|
|
if b := string(body); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") {
|
|
return nil, nil, ErrTemporaryUpstream
|
|
}
|
|
if isContentRejection(resp.StatusCode, string(body)) {
|
|
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
|
|
}
|
|
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
|
return nil, nil, ErrDeadUpstream
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, nil, fmt.Errorf("adobe poll failed: %d %s", resp.StatusCode, clip(body, 300))
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if outputs, ok := payload["outputs"].([]any); ok && len(outputs) > 0 {
|
|
if first, ok := outputs[0].(map[string]any); ok {
|
|
if image, ok := first["image"].(map[string]any); ok {
|
|
if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" {
|
|
data, err := c.download(ctx, sess, url)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return payload, data, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
status := strings.ToUpper(strings.TrimSpace(stringValue(payload["status"])))
|
|
if status == "FAILED" || status == "CANCELLED" || status == "ERROR" {
|
|
return nil, nil, fmt.Errorf("adobe job failed: %s", clip(body, 300))
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
}
|
|
|
|
func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpoint string, payload map[string]any) ([]byte, string, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
|
"x-api-key": {c.apiKey},
|
|
"content-type": {"application/json"},
|
|
"accept": {"*/*"},
|
|
"origin": {"https://firefly.adobe.com"},
|
|
"referer": {"https://firefly.adobe.com/"},
|
|
"accept-language": {"en-US,en;q=0.9"},
|
|
"sec-ch-ua": {sess.fp.secCHUA},
|
|
"sec-ch-ua-mobile": {"?0"},
|
|
"sec-ch-ua-platform": {sess.fp.platform},
|
|
"sec-fetch-site": {"same-site"},
|
|
"sec-fetch-mode": {"cors"},
|
|
"sec-fetch-dest": {"empty"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
"x-arp-session-id": {buildARPSessionID()},
|
|
http.HeaderOrderKey: {
|
|
"authorization",
|
|
"x-api-key",
|
|
"content-type",
|
|
"accept",
|
|
"origin",
|
|
"referer",
|
|
"accept-language",
|
|
"sec-ch-ua",
|
|
"sec-ch-ua-mobile",
|
|
"sec-ch-ua-platform",
|
|
"sec-fetch-site",
|
|
"sec-fetch-mode",
|
|
"sec-fetch-dest",
|
|
"user-agent",
|
|
"x-nonce",
|
|
"x-arp-session-id",
|
|
},
|
|
}
|
|
// The working video submit (HAR) carries x-nonce just like the image submit.
|
|
if prompt, _ := payload["prompt"].(string); prompt != "" {
|
|
if nonce := buildSubmitNonce(token, prompt); nonce != "" {
|
|
req.Header.Set("x-nonce", nonce)
|
|
}
|
|
}
|
|
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
if strings.EqualFold(resp.Header.Get("x-access-error"), "taste_exhausted") {
|
|
return respBody, "", ErrQuotaExhausted
|
|
}
|
|
// Surface Adobe's response body — "adobe auth failed" alone hides whether
|
|
// it's a bad token, a missing scope, or a WAF/fingerprint block.
|
|
return respBody, "", fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(respBody, 300))
|
|
}
|
|
if isContentRejection(resp.StatusCode, string(respBody)) {
|
|
return respBody, "", ErrContentRejected
|
|
}
|
|
if resp.StatusCode == 408 || resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
|
return respBody, "", ErrDeadUpstream
|
|
}
|
|
// "system under load" / timeout_error = adobe overload — treat as a temporary
|
|
// error so the tempFailover policy moves to the next account (same as the image path).
|
|
if b := string(respBody); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") {
|
|
return respBody, "", ErrTemporaryUpstream
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return respBody, "", fmt.Errorf("video submit rejected: %d %s", resp.StatusCode, clip(respBody, 300))
|
|
}
|
|
|
|
var payloadResp map[string]any
|
|
if err := json.Unmarshal(respBody, &payloadResp); err != nil {
|
|
return respBody, "", err
|
|
}
|
|
if override := strings.TrimSpace(resp.Header.Get("x-override-status-link")); override != "" {
|
|
return respBody, normalizeVideoPollURL(override), nil
|
|
}
|
|
if links, ok := payloadResp["links"].(map[string]any); ok {
|
|
if result, ok := links["result"].(map[string]any); ok {
|
|
if href := strings.TrimSpace(stringValue(result["href"])); href != "" {
|
|
return respBody, normalizeVideoPollURL(href), nil
|
|
}
|
|
}
|
|
if href := strings.TrimSpace(stringValue(links["result"])); href != "" {
|
|
return respBody, normalizeVideoPollURL(href), nil
|
|
}
|
|
}
|
|
return respBody, "", errors.New("video submit ok but no poll url")
|
|
}
|
|
|
|
func (c *Client) pollVideo(ctx context.Context, sess *tlsSession, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) {
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("adobe video generation timed out: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequest(http.MethodGet, pollURL, nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"authorization": {"Bearer " + strings.TrimSpace(token)},
|
|
"accept": {"*/*"},
|
|
"origin": {"https://firefly.adobe.com"},
|
|
"referer": {"https://firefly.adobe.com/"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
http.HeaderOrderKey: {
|
|
"authorization",
|
|
"accept",
|
|
"origin",
|
|
"referer",
|
|
"user-agent",
|
|
},
|
|
}
|
|
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
body, readErr := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
if readErr != nil {
|
|
return nil, nil, readErr
|
|
}
|
|
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
|
return nil, nil, fmt.Errorf("%w (%d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300))
|
|
}
|
|
if b := string(body); strings.Contains(b, "system under load") || strings.Contains(b, "timeout_error") {
|
|
return nil, nil, ErrTemporaryUpstream
|
|
}
|
|
if isContentRejection(resp.StatusCode, string(body)) {
|
|
return nil, nil, fmt.Errorf("%w: %s", ErrContentRejected, clip(body, 300))
|
|
}
|
|
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
|
return nil, nil, ErrDeadUpstream
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, nil, fmt.Errorf("adobe video poll failed: %d %s", resp.StatusCode, clip(body, 300))
|
|
}
|
|
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if outputs, ok := payload["outputs"].([]any); ok && len(outputs) > 0 {
|
|
if first, ok := outputs[0].(map[string]any); ok {
|
|
if video, ok := first["video"].(map[string]any); ok {
|
|
if raw := strings.TrimSpace(stringValue(video["presignedUrl"])); raw != "" {
|
|
payload["video_url"] = raw
|
|
if !downloadResult {
|
|
return payload, nil, nil
|
|
}
|
|
data, err := c.download(ctx, sess, raw)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return payload, data, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
status := strings.ToUpper(strings.TrimSpace(stringValue(payload["status"])))
|
|
if status == "FAILED" || status == "CANCELLED" || status == "ERROR" {
|
|
return nil, nil, fmt.Errorf("adobe video job failed: %s", clip(body, 300))
|
|
}
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
}
|
|
|
|
// download fetches the generated artifact from Adobe's presigned S3 URL. The
|
|
// asset is already produced at this point, so a transient network hiccup
|
|
// (connection EOF/reset mid-body, S3 accelerate 5xx) must not fail the whole
|
|
// generation — retry with backoff before giving up.
|
|
func (c *Client) download(ctx context.Context, sess *tlsSession, url string) ([]byte, error) {
|
|
var data []byte
|
|
var err error
|
|
for _, wait := range []time.Duration{0, 1 * time.Second, 2 * time.Second, 5 * time.Second, 10 * time.Second} {
|
|
if wait > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(wait):
|
|
}
|
|
}
|
|
data, err = c.downloadOnce(ctx, sess, url)
|
|
if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
|
|
break
|
|
}
|
|
}
|
|
return data, err
|
|
}
|
|
|
|
func (c *Client) downloadOnce(ctx context.Context, sess *tlsSession, url string) ([]byte, error) {
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"accept": {"*/*"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
http.HeaderOrderKey: {
|
|
"accept",
|
|
"user-agent",
|
|
},
|
|
}
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
// Network-level failure (EOF, reset, timeout) — transient, worth a retry.
|
|
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 500 {
|
|
return nil, fmt.Errorf("%w: adobe download failed: %d %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 200))
|
|
}
|
|
return nil, fmt.Errorf("adobe download failed: %d %s", resp.StatusCode, clip(body, 200))
|
|
}
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
// Body truncated mid-read (EOF) — transient, retry.
|
|
return nil, fmt.Errorf("%w: read body: %v", ErrTemporaryUpstream, err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// fingerprint bundles a TLS ClientProfile with the browser headers that match
|
|
// it, so the JA3/JA4 handshake and the advertised User-Agent / client hints
|
|
// stay internally consistent within a single request.
|
|
type fingerprint struct {
|
|
profile profiles.ClientProfile
|
|
userAgent string
|
|
secCHUA string
|
|
platform string // quoted sec-ch-ua-platform value, e.g. `"Windows"`
|
|
}
|
|
|
|
const (
|
|
osWindows = iota
|
|
osMac
|
|
)
|
|
|
|
func newChromeFingerprint(profile profiles.ClientProfile, major, osKind int) fingerprint {
|
|
ua := fmt.Sprintf("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", major)
|
|
platform := `"Windows"`
|
|
if osKind == osMac {
|
|
ua = fmt.Sprintf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.0.0 Safari/537.36", major)
|
|
platform = `"macOS"`
|
|
}
|
|
return fingerprint{
|
|
profile: profile,
|
|
userAgent: ua,
|
|
secCHUA: fmt.Sprintf(`"Not?A_Brand";v="99", "Google Chrome";v="%d", "Chromium";v="%d"`, major, major),
|
|
platform: platform,
|
|
}
|
|
}
|
|
|
|
// adobeFingerprints is a small pool of recent desktop Chrome builds across
|
|
// Windows/macOS. Each request draws one at random so the outbound TLS
|
|
// fingerprint and headers vary from call to call instead of presenting a single
|
|
// static signature to Adobe's anti-bot layer.
|
|
var adobeFingerprints = []fingerprint{
|
|
newChromeFingerprint(profiles.Chrome_133, 133, osWindows),
|
|
newChromeFingerprint(profiles.Chrome_131, 131, osWindows),
|
|
newChromeFingerprint(profiles.Chrome_124, 124, osWindows),
|
|
newChromeFingerprint(profiles.Chrome_133, 133, osMac),
|
|
newChromeFingerprint(profiles.Chrome_131, 131, osMac),
|
|
}
|
|
|
|
func randomFingerprint() fingerprint {
|
|
return adobeFingerprints[rand.Intn(len(adobeFingerprints))]
|
|
}
|
|
|
|
// tlsSession pairs a configured TLS client with the fingerprint it was built
|
|
// for, so downstream header builders advertise the matching UA / client hints.
|
|
type tlsSession struct {
|
|
client tlsclient.HttpClient
|
|
fp fingerprint
|
|
}
|
|
|
|
// newTLSClient builds a session that routes through the configured proxy (when
|
|
// set). newDirectTLSClient builds one that always uses the local IP. Only the
|
|
// image-generation submit goes through the proxy; reference-image upload,
|
|
// polling and result download run on the local IP.
|
|
func (c *Client) newTLSClient() (*tlsSession, error) {
|
|
return c.newTLSSession(randomFingerprint(), true)
|
|
}
|
|
|
|
func (c *Client) newDirectTLSClient() (*tlsSession, error) {
|
|
return c.newTLSSession(randomFingerprint(), false)
|
|
}
|
|
|
|
func (c *Client) newTLSSession(fp fingerprint, useProxy bool) (*tlsSession, error) {
|
|
options := []tlsclient.HttpClientOption{
|
|
tlsclient.WithTimeoutSeconds(60),
|
|
tlsclient.WithClientProfile(fp.profile),
|
|
tlsclient.WithNotFollowRedirects(),
|
|
tlsclient.WithRandomTLSExtensionOrder(),
|
|
}
|
|
if useProxy && c.proxy != "" {
|
|
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
|
}
|
|
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &tlsSession{client: client, fp: fp}, nil
|
|
}
|
|
|
|
func exchangeCookieWithTLSClient(ctx context.Context, sess *tlsSession, cookie string) (*CookieExchangeResult, error) {
|
|
cookie = normalizeCookie(cookie)
|
|
if cookie == "" {
|
|
return nil, ErrAdobeCookieEmpty
|
|
}
|
|
|
|
body := "client_id=" + clientID + "&guest_allowed=true&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C")
|
|
req, err := http.NewRequest(http.MethodPost, refreshURL, strings.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req = req.WithContext(ctx)
|
|
req.Header = http.Header{
|
|
"accept": {"*/*"},
|
|
"accept-language": {"zh-CN,zh;q=0.9"},
|
|
"content-type": {"application/x-www-form-urlencoded;charset=UTF-8"},
|
|
"cookie": {cookie},
|
|
"origin": {"https://firefly.adobe.com"},
|
|
"referer": {"https://firefly.adobe.com/"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
http.HeaderOrderKey: {
|
|
"accept",
|
|
"accept-language",
|
|
"content-type",
|
|
"cookie",
|
|
"origin",
|
|
"referer",
|
|
"user-agent",
|
|
},
|
|
}
|
|
resp, err := sess.client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("adobe cookie exchange network error: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
return nil, fmt.Errorf("adobe cookie exchange upstream %d: %s", resp.StatusCode, clip(respBody, 200))
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(respBody, &payload); err != nil {
|
|
return nil, fmt.Errorf("adobe cookie exchange invalid json: %w", err)
|
|
}
|
|
token := strings.TrimSpace(stringValue(payload["access_token"]))
|
|
if token == "" {
|
|
return nil, errors.New("adobe cookie exchange missing access_token")
|
|
}
|
|
return &CookieExchangeResult{
|
|
AccessToken: token,
|
|
ExpiresIn: intValue(payload["expires_in"]),
|
|
Raw: payload,
|
|
}, nil
|
|
}
|
|
|
|
func buildSubmitNonce(token, prompt string) string {
|
|
claims := decodeJWTPayload(token)
|
|
userID := strings.TrimSpace(stringValue(claims["user_id"]))
|
|
if userID == "" {
|
|
userID = strings.TrimSpace(stringValue(claims["aa_id"]))
|
|
}
|
|
if userID == "" {
|
|
userID = strings.TrimSpace(stringValue(claims["sub"]))
|
|
}
|
|
prompt = strings.TrimSpace(prompt)
|
|
if userID == "" || prompt == "" {
|
|
return ""
|
|
}
|
|
if len(prompt) > 256 {
|
|
prompt = prompt[:256]
|
|
}
|
|
sum := sha256.Sum256([]byte(userID + "-" + prompt))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func ExtractAccountID(token string) string {
|
|
claims := decodeJWTPayload(token)
|
|
userID := strings.TrimSpace(stringValue(claims["user_id"]))
|
|
if userID == "" {
|
|
userID = strings.TrimSpace(stringValue(claims["aa_id"]))
|
|
}
|
|
if userID == "" {
|
|
userID = strings.TrimSpace(stringValue(claims["sub"]))
|
|
}
|
|
return userID
|
|
}
|
|
|
|
func normalizeVideoPollURL(raw string) string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return raw
|
|
}
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return raw
|
|
}
|
|
host := parsed.Hostname()
|
|
if !strings.HasPrefix(host, "firefly-epo") {
|
|
return raw
|
|
}
|
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
|
if len(parts) == 0 {
|
|
return raw
|
|
}
|
|
jobID := strings.TrimSpace(parts[len(parts)-1])
|
|
hostSuffix := strings.TrimPrefix(host, "firefly-epo")
|
|
hostSuffix = strings.SplitN(hostSuffix, ".", 2)[0]
|
|
if len(hostSuffix) != 4 {
|
|
return raw
|
|
}
|
|
for _, ch := range hostSuffix {
|
|
if ch < '0' || ch > '9' {
|
|
return raw
|
|
}
|
|
}
|
|
return "https://bks-epo" + hostSuffix + ".adobe.io/v2/jobs/result/" + jobID + "?host=" + parsed.Host + "/"
|
|
}
|
|
|
|
func clip(v []byte, n int) string {
|
|
s := strings.TrimSpace(string(v))
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n]
|
|
}
|