1093 lines
35 KiB
Go
1093 lines
35 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")
|
|
ErrRateLimited = errors.New("adobe rate limited")
|
|
// 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
|
|
arpSessionID string // cached per-client, reused across requests (matches adobe2api)
|
|
}
|
|
|
|
func NewClient(apiKey, proxy string) *Client {
|
|
return &Client{
|
|
apiKey: defaultString(apiKey, clientID),
|
|
proxy: strings.TrimSpace(proxy),
|
|
}
|
|
}
|
|
|
|
// getARPSessionID returns a cached ARP session id matching adobe2api's format:
|
|
// base64({"sid":"<uuid>","ftr":"<hex16>_<ts_ms>_<pid>_dUAL43-mnts-ants-d4_31ck__tt"})
|
|
// Generated once per client and reused — adobe2api reuses the same session id per
|
|
// token/profile instead of rotating every request.
|
|
func (c *Client) getARPSessionID() string {
|
|
if c.arpSessionID != "" {
|
|
return c.arpSessionID
|
|
}
|
|
c.arpSessionID = buildARPSessionID()
|
|
return c.arpSessionID
|
|
}
|
|
|
|
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 {
|
|
return nil, fmt.Errorf("%w (upload %d): %s", ErrRateLimited, resp.StatusCode, clip(body, 300)), false
|
|
}
|
|
if 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, downloadResult bool) ([]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, downloadResult)
|
|
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://new.express.adobe.com"},
|
|
"referer": {"https://new.express.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": {"cross-site"},
|
|
"sec-fetch-mode": {"cors"},
|
|
"sec-fetch-dest": {"empty"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
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",
|
|
},
|
|
}
|
|
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, normalizePollURL(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, normalizePollURL(href), nil
|
|
}
|
|
}
|
|
if href := strings.TrimSpace(stringValue(links["result"])); href != "" {
|
|
return respBody, normalizePollURL(href), nil
|
|
}
|
|
}
|
|
return respBody, "", errors.New("submit ok but no poll url")
|
|
}
|
|
|
|
func (c *Client) pollImage(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 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://new.express.adobe.com"},
|
|
"referer": {"https://new.express.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 != "" {
|
|
// Expose the presigned URL for callers that want it directly.
|
|
payload["image_url"] = url
|
|
if !downloadResult {
|
|
return payload, nil, nil
|
|
}
|
|
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://new.express.adobe.com"},
|
|
"referer": {"https://new.express.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": {"cross-site"},
|
|
"sec-fetch-mode": {"cors"},
|
|
"sec-fetch-dest": {"empty"},
|
|
"user-agent": {sess.fp.userAgent},
|
|
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",
|
|
},
|
|
}
|
|
// 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, normalizePollURL(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, normalizePollURL(href), nil
|
|
}
|
|
}
|
|
if href := strings.TrimSpace(stringValue(links["result"])); href != "" {
|
|
return respBody, normalizePollURL(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://new.express.adobe.com"},
|
|
"referer": {"https://new.express.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.
|
|
const downloadTimeout = 3 * time.Minute
|
|
|
|
func (c *Client) download(parent context.Context, sess *tlsSession, url string) ([]byte, error) {
|
|
// The artifact is already produced; detach from the (often nearly-elapsed)
|
|
// generation ctx deadline/cancel and give the download its own budget so a
|
|
// slow CDN read isn't killed mid-body and the backoff-retries can actually run.
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), downloadTimeout)
|
|
defer cancel()
|
|
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
|
|
}
|
|
|
|
// Prefer user_id (HAR behavior); fall back to guest_allowed for first-time login.
|
|
userID := extractUserIDFromCookie(cookie)
|
|
var body string
|
|
if userID != "" {
|
|
body = "client_id=" + clientID + "&scope=" + strings.ReplaceAll(scopeValue, ",", "%2C") + "&user_id=" + url.QueryEscape(userID)
|
|
} else {
|
|
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://new.express.adobe.com"},
|
|
"referer": {"https://new.express.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 normalizePollURL(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]
|
|
}
|