Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider account pools, billing/admin, Docker one-command deploy with auto HTTPS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshURL = "https://adobeid-na1.services.adobe.com/ims/check/v6/token?jslVersion=v2-v0.48.0-1-g1e322cb"
|
||||
clientID = "clio-playground-web"
|
||||
scopeValue = "AdobeID,firefly_api,openid,pps.read,pps.write,additional_info.projectedProductContext,additional_info.ownerOrg,uds_read,uds_write,ab.manage,read_organizations,additional_info.roles,account_cluster.read,creative_production,profile"
|
||||
)
|
||||
|
||||
var ErrAdobeCookieEmpty = errors.New("cookie is empty")
|
||||
|
||||
type CookieExchangeResult struct {
|
||||
AccessToken string
|
||||
ExpiresIn int
|
||||
Raw map[string]any
|
||||
}
|
||||
|
||||
func ExchangeCookieToAccessToken(ctx context.Context, client *http.Client, cookie string) (*CookieExchangeResult, error) {
|
||||
_ = client
|
||||
tlsClient, err := NewClient(clientID, "").newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exchangeCookieWithTLSClient(ctx, tlsClient, cookie)
|
||||
}
|
||||
|
||||
func normalizeCookie(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if strings.HasPrefix(strings.ToLower(v), "cookie:") {
|
||||
v = strings.TrimSpace(v[len("cookie:"):])
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"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")
|
||||
)
|
||||
|
||||
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) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exchangeCookieWithTLSClient(ctx, client, cookie)
|
||||
}
|
||||
|
||||
func (c *Client) UploadImage(ctx context.Context, token string, content []byte, contentType, engine string) (string, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
endpoint := uploadURL
|
||||
if engine == "firefly-video" {
|
||||
endpoint = fireflyVideoUploadURL
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
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": {defaultUserAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"content-type",
|
||||
"accept",
|
||||
"user-agent",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("adobe upload request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return "", fmt.Errorf("%w (upload %d %s: %s)", ErrAuth, resp.StatusCode, resp.Header.Get("x-access-error"), clip(body, 300))
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("adobe upload failed: %d %s", resp.StatusCode, clip(body, 300))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string) ([]byte, map[string]any, error) {
|
||||
client, err := c.newTLSClient()
|
||||
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, client, token, prompt, endpoint, payload)
|
||||
if err == nil {
|
||||
meta, data, pollErr := c.pollImage(ctx, client, 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) {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
// Preserve the temporary classification so the pool retries (overload / 5xx /
|
||||
// rate-limit) instead of failing the request outright.
|
||||
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) {
|
||||
client, err := c.newTLSClient()
|
||||
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, client, token, endpoint, payload)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_ = respBody
|
||||
meta, data, pollErr := c.pollVideo(ctx, client, 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
|
||||
}
|
||||
client, err := c.newTLSClient()
|
||||
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": {defaultUserAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"authorization",
|
||||
"accept",
|
||||
"user-agent",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := 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
|
||||
}
|
||||
|
||||
client, err := c.newTLSClient()
|
||||
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": {defaultUserAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"x-account-id",
|
||||
"accept",
|
||||
"content-type",
|
||||
"user-agent",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := 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, client tlsclient.HttpClient, 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": {defaultSecCHUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"user-agent": {defaultUserAgent},
|
||||
"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 := 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))
|
||||
}
|
||||
if resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return respBody, "", ErrTemporaryUpstream
|
||||
}
|
||||
// "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 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, client tlsclient.HttpClient, token, pollURL string) (map[string]any, []byte, error) {
|
||||
start := time.Now()
|
||||
for {
|
||||
if time.Since(start) > 3*time.Minute {
|
||||
return nil, nil, errors.New("adobe generation timed out")
|
||||
}
|
||||
|
||||
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": {defaultUserAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"authorization",
|
||||
"accept",
|
||||
"origin",
|
||||
"referer",
|
||||
"user-agent",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := 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 == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return nil, nil, ErrTemporaryUpstream
|
||||
}
|
||||
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, client, 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, client tlsclient.HttpClient, 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": {defaultSecCHUA},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"user-agent": {defaultUserAgent},
|
||||
"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 := 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 resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
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, client tlsclient.HttpClient, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) {
|
||||
start := time.Now()
|
||||
for {
|
||||
if time.Since(start) > 10*time.Minute {
|
||||
return nil, nil, errors.New("adobe video generation timed out")
|
||||
}
|
||||
|
||||
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": {defaultUserAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"authorization",
|
||||
"accept",
|
||||
"origin",
|
||||
"referer",
|
||||
"user-agent",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := 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 resp.StatusCode == 429 || resp.StatusCode == 451 || resp.StatusCode >= 500 {
|
||||
return nil, nil, ErrTemporaryUpstream
|
||||
}
|
||||
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, client, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, 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": {defaultUserAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept",
|
||||
"user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("adobe download failed: %d %s", resp.StatusCode, clip(body, 200))
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
||||
tlsclient.WithNotFollowRedirects(),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
func exchangeCookieWithTLSClient(ctx context.Context, client tlsclient.HttpClient, 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": {defaultUserAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept",
|
||||
"accept-language",
|
||||
"content-type",
|
||||
"cookie",
|
||||
"origin",
|
||||
"referer",
|
||||
"user-agent",
|
||||
},
|
||||
}
|
||||
resp, err := 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]
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type modelSpec struct {
|
||||
UpstreamModelID string
|
||||
UpstreamModelVersion string
|
||||
}
|
||||
|
||||
var lumaSize = map[string]map[string][2]int{
|
||||
"720p": {
|
||||
"21:9": {1280, 548}, "16:9": {1280, 720}, "4:3": {960, 720},
|
||||
"1:1": {720, 720}, "3:4": {720, 960}, "9:16": {720, 1280}, "9:21": {548, 1280},
|
||||
},
|
||||
"1080p": {
|
||||
"21:9": {1920, 822}, "16:9": {1920, 1080}, "4:3": {1440, 1080},
|
||||
"1:1": {1080, 1080}, "3:4": {1080, 1440}, "9:16": {1080, 1920}, "9:21": {822, 1920},
|
||||
},
|
||||
"4k": {
|
||||
"21:9": {3840, 1646}, "16:9": {3840, 2160}, "4:3": {2880, 2160},
|
||||
"1:1": {2160, 2160}, "3:4": {2160, 2880}, "9:16": {2160, 3840}, "9:21": {1646, 3840},
|
||||
},
|
||||
}
|
||||
|
||||
var gptImageSize = map[string]map[string][2]int{
|
||||
"1K": {"1:1": {1024, 1024}, "5:4": {1120, 896}, "9:16": {720, 1280}, "21:9": {1456, 624}, "16:9": {1280, 720}, "4:3": {1152, 864}, "3:2": {1248, 832}, "4:5": {896, 1120}, "3:4": {864, 1152}, "2:3": {832, 1248}},
|
||||
"2K": {"1:1": {2048, 2048}, "5:4": {2240, 1792}, "9:16": {1440, 2560}, "21:9": {3024, 1296}, "16:9": {2560, 1440}, "4:3": {2304, 1728}, "3:2": {2496, 1664}, "4:5": {1792, 2240}, "3:4": {1728, 2304}, "2:3": {1664, 2496}},
|
||||
"4K": {"1:1": {2880, 2880}, "5:4": {3200, 2560}, "9:16": {2160, 3840}, "21:9": {3696, 1584}, "16:9": {3840, 2160}, "4:3": {3264, 2448}, "3:2": {3504, 2336}, "4:5": {2560, 3200}, "3:4": {2448, 3264}, "2:3": {2336, 3504}},
|
||||
}
|
||||
|
||||
var fluxSize = map[string][2]int{
|
||||
"1:1": {1024, 1024},
|
||||
"16:9": {1408, 768},
|
||||
"9:16": {768, 1408},
|
||||
"4:3": {1280, 896},
|
||||
"3:4": {896, 1280},
|
||||
}
|
||||
|
||||
var defaultSize = map[string]map[string][2]int{
|
||||
"1K": {"1:1": {1024, 1024}, "1:8": {384, 3072}, "1:4": {512, 2048}, "16:9": {1360, 768}, "9:16": {768, 1360}, "4:1": {2048, 512}, "4:3": {1152, 864}, "3:4": {864, 1152}, "8:1": {3072, 384}},
|
||||
"2K": {"1:1": {2048, 2048}, "1:8": {768, 6144}, "1:4": {1024, 4096}, "16:9": {2752, 1536}, "9:16": {1536, 2752}, "4:1": {4096, 1024}, "4:3": {2048, 1536}, "3:4": {1536, 2048}, "8:1": {6144, 768}},
|
||||
"4K": {"1:1": {4096, 4096}, "1:8": {1536, 12288}, "1:4": {2048, 8192}, "16:9": {5504, 3072}, "9:16": {3072, 5504}, "4:1": {8192, 2048}, "4:3": {4096, 3072}, "3:4": {3072, 4096}, "8:1": {12288, 1536}},
|
||||
}
|
||||
|
||||
func ResolveModelSpec(modelID string) modelSpec {
|
||||
switch modelID {
|
||||
case "firefly-gpt-image", "firefly-gpt-image-2":
|
||||
return modelSpec{UpstreamModelID: "gpt-image", UpstreamModelVersion: "2"}
|
||||
case "flux-kontext-max":
|
||||
return modelSpec{UpstreamModelID: "flux", UpstreamModelVersion: "fluxKontextMax"}
|
||||
default:
|
||||
return modelSpec{UpstreamModelID: "gemini-flash", UpstreamModelVersion: "nano-banana-3"}
|
||||
}
|
||||
}
|
||||
|
||||
// buildImage5Payload builds the Adobe Firefly Image 5 request. It uses a distinct
|
||||
// schema from the firefly-3p models: NO modelId/size, a top-level aspectRatio
|
||||
// string label and a resolutionLevel (1K→1MP, 2K→4MP). Mirrors a captured
|
||||
// working image-v5.ff.adobe.io request.
|
||||
func buildImage5Payload(prompt, aspectRatio, resolution string, blobIDs []string) map[string]any {
|
||||
p := map[string]any{
|
||||
"n": 1,
|
||||
"seeds": []int{int(time.Now().Unix()) % 999999},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"prompt": prompt,
|
||||
"referenceBlobs": []any{},
|
||||
"modelSpecificPayload": map[string]any{"locale": "en-US", "prompt_reasoner": "quality"},
|
||||
"modelVersion": "image5",
|
||||
"resolutionLevel": image5ResolutionLevel(resolution),
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
// Instruct-edit: aspect ratio is derived from the reference image; sending
|
||||
// aspectRatio is rejected with a validation_error.
|
||||
p["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
} else {
|
||||
p["aspectRatio"] = defaultString(aspectRatio, "1:1")
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// image5ResolutionLevel maps the UI resolution tier to Image 5's megapixel level.
|
||||
func image5ResolutionLevel(resolution string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(resolution)) {
|
||||
case "1K":
|
||||
return "1MP"
|
||||
case "2K":
|
||||
return "4MP"
|
||||
default:
|
||||
return "4MP"
|
||||
}
|
||||
}
|
||||
|
||||
func BuildImagePayloadCandidates(modelID, prompt, aspectRatio, outputResolution string, blobIDs []string) []map[string]any {
|
||||
spec := ResolveModelSpec(modelID)
|
||||
ratio := defaultString(aspectRatio, "1:1")
|
||||
resolution := defaultString(outputResolution, "2K")
|
||||
|
||||
switch spec.UpstreamModelID {
|
||||
case "gpt-image":
|
||||
return buildGPTImagePayloads(spec, prompt, ratio, resolution, blobIDs)
|
||||
case "flux":
|
||||
return buildFluxPayloads(spec, prompt, ratio, blobIDs)
|
||||
default:
|
||||
return buildDefaultPayloads(spec, prompt, ratio, resolution, blobIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func buildGPTImagePayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
||||
size := getSize(gptImageSize, resolution, ratio, "1:1")
|
||||
// Mirrors the captured working gpt-image request shape: modelSpecificPayload.size,
|
||||
// generationSettings.detailLevel 3, and NO top-level size / outputResolution
|
||||
// (sending those got 403). Keeps the chosen size via modelSpecificPayload.size
|
||||
// ("WxH") rather than "auto".
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"seeds": []int{int(time.Now().Unix()) % 999999},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
"modelSpecificPayload": map[string]any{"size": sizeString(size)},
|
||||
"generationSettings": map[string]any{"detailLevel": 3},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
return []map[string]any{base}
|
||||
}
|
||||
subject := cloneMap(base)
|
||||
subject["referenceBlobs"] = blobRefs(blobIDs, "subject")
|
||||
return []map[string]any{subject}
|
||||
}
|
||||
|
||||
func buildFluxPayloads(spec modelSpec, prompt, ratio string, blobIDs []string) []map[string]any {
|
||||
size := fluxSize[ratio]
|
||||
if size == [2]int{} {
|
||||
size = fluxSize["1:1"]
|
||||
}
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
||||
"seeds": []int{int(time.Now().Unix()) % 999999},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"prompt_upsampling": true,
|
||||
"safety_tolerance": 2,
|
||||
"aspect_ratio": ratio,
|
||||
},
|
||||
"generationMetadata": map[string]any{"module": "text2image", "submodule": "ff-image-generate"},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
return []map[string]any{base}
|
||||
}
|
||||
edited := cloneMap(base)
|
||||
edited["generationMetadata"] = map[string]any{"module": "image2image", "submodule": "ff-image-generate"}
|
||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
return []map[string]any{edited}
|
||||
}
|
||||
|
||||
func buildDefaultPayloads(spec modelSpec, prompt, ratio, resolution string, blobIDs []string) []map[string]any {
|
||||
size := getSize(defaultSize, resolution, ratio, "16:9")
|
||||
// Shape mirrors a captured working firefly.adobe.com request exactly: top-level
|
||||
// size object, modelSpecificPayload only {parameters:{addWatermark:false}},
|
||||
// groundSearch:false, module "text2image" (even with a reference blob). NO
|
||||
// skipCai and NO modelSpecificPayload.aspectRatio — sending those got 403.
|
||||
base := map[string]any{
|
||||
"modelId": spec.UpstreamModelID,
|
||||
"modelVersion": spec.UpstreamModelVersion,
|
||||
"n": 1,
|
||||
"prompt": prompt,
|
||||
"size": map[string]any{"width": size[0], "height": size[1]},
|
||||
"seeds": []int{int(time.Now().Unix()) % 999999},
|
||||
"groundSearch": false,
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2image",
|
||||
"submodule": "ff-image-generate",
|
||||
},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"parameters": map[string]any{"addWatermark": false},
|
||||
},
|
||||
}
|
||||
if len(blobIDs) == 0 {
|
||||
base["referenceBlobs"] = []any{}
|
||||
return []map[string]any{base}
|
||||
}
|
||||
edited := cloneMap(base)
|
||||
edited["referenceBlobs"] = blobRefs(blobIDs, "general")
|
||||
return []map[string]any{edited}
|
||||
}
|
||||
|
||||
func getSize(table map[string]map[string][2]int, resolution, ratio, fallbackRatio string) [2]int {
|
||||
level := defaultString(resolution, "2K")
|
||||
levelTable, ok := table[level]
|
||||
if !ok {
|
||||
levelTable = table["2K"]
|
||||
}
|
||||
size, ok := levelTable[ratio]
|
||||
if !ok {
|
||||
size = levelTable[fallbackRatio]
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func sizeString(size [2]int) string {
|
||||
return itoa(size[0]) + "x" + itoa(size[1])
|
||||
}
|
||||
|
||||
func blobRefs(ids []string, usage string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"id": id, "usage": usage})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func referenceImagesByID(ids []string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"id": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func referenceImagesByLocal(ids []string) []any {
|
||||
out := make([]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
out = append(out, map[string]any{"localBlobRef": id})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMap(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func BuildVideoPayload(engine, prompt, aspectRatio string, durationSeconds int, resolution, referenceMode, upstreamModel string, blobIDs []string) map[string]any {
|
||||
seedVal := int(time.Now().Unix()) % 999999
|
||||
engine = defaultString(engine, "sora2")
|
||||
resolution = defaultString(resolution, "720p")
|
||||
aspectRatio = defaultString(aspectRatio, "16:9")
|
||||
if durationSeconds <= 0 {
|
||||
durationSeconds = 5
|
||||
}
|
||||
|
||||
switch engine {
|
||||
case "firefly-video":
|
||||
// Firefly-native video model — a distinct schema (mirrors a captured
|
||||
// working video-v1.ff.adobe.io request): sizes[] carries width/height +
|
||||
// numFrames (numFrames encodes duration, ~25.6fps so 5s = 128), and
|
||||
// reference frames go under image.conditions with placement.start
|
||||
// (0 = first frame / 首帧, 1 = last frame / 末帧). NO modelId / version /
|
||||
// engine / duration / referenceBlobs fields.
|
||||
w, h, frames := fireflyVideoSize(aspectRatio, resolution, durationSeconds)
|
||||
payload := map[string]any{
|
||||
"addOnTransparentBackground": false,
|
||||
"prompt": prompt,
|
||||
"seeds": []int{seedVal},
|
||||
"sizes": []any{map[string]any{"width": w, "height": h, "numFrames": frames}},
|
||||
"videoSettings": map[string]any{},
|
||||
"locale": "en-US",
|
||||
"generationMetadata": map[string]any{"module": "text2video", "submodule": "ff-video-generate"},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
conds := make([]any, 0, 2)
|
||||
conds = append(conds, map[string]any{
|
||||
"source": map[string]any{"id": blobIDs[0]},
|
||||
"placement": map[string]any{"start": 0},
|
||||
})
|
||||
if len(blobIDs) > 1 {
|
||||
conds = append(conds, map[string]any{
|
||||
"source": map[string]any{"id": blobIDs[1]},
|
||||
"placement": map[string]any{"start": 1},
|
||||
})
|
||||
}
|
||||
payload["image"] = map[string]any{"conditions": conds}
|
||||
}
|
||||
return payload
|
||||
case "veo31-fast", "veo31-standard":
|
||||
modelVersion := "3.1-fast-generate"
|
||||
if engine == "veo31-standard" {
|
||||
modelVersion = "3.1-generate"
|
||||
}
|
||||
// Shape mirrors a captured working firefly.adobe.com video request: flat
|
||||
// top-level duration / negativePrompt / generateAudio, submodule set, and
|
||||
// NO `n` / NO modelSpecificPayload (sending those got 403).
|
||||
payload := map[string]any{
|
||||
"modelId": "veo",
|
||||
"modelVersion": modelVersion,
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"seeds": []int{seedVal},
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generateAudio": false,
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2video",
|
||||
"submodule": "ff-video-generate",
|
||||
},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
"referenceBlobs": []any{},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
payload["generationMetadata"] = map[string]any{"module": "image2video", "submodule": "ff-video-generate"}
|
||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "general", "promptReference": idx + 1})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
return payload
|
||||
case "luma":
|
||||
payload := map[string]any{
|
||||
"modelId": "luma",
|
||||
"modelVersion": "3.14-ray",
|
||||
"size": lumaVideoSize(aspectRatio, resolution),
|
||||
"mode": "flex_2",
|
||||
"prompt": prompt,
|
||||
"negativePrompt": "",
|
||||
"duration": durationSeconds,
|
||||
"generationMetadata": map[string]any{
|
||||
"module": "text2video",
|
||||
"submodule": "ff-video-generate",
|
||||
},
|
||||
"modelSpecificPayload": map[string]any{
|
||||
"resolution": strings.ToLower(resolution),
|
||||
"aspect_ratio": aspectRatio,
|
||||
},
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
payload["generationMetadata"] = map[string]any{
|
||||
"module": "image2video",
|
||||
"submodule": "ff-video-generate",
|
||||
}
|
||||
refs := make([]any, 0, min(len(blobIDs), 2))
|
||||
for idx, id := range blobIDs[:min(len(blobIDs), 2)] {
|
||||
refs = append(refs, map[string]any{"id": id, "usage": "frame", "order": idx + 1})
|
||||
}
|
||||
payload["referenceBlobs"] = refs
|
||||
}
|
||||
return payload
|
||||
default:
|
||||
upstream := defaultString(upstreamModel, "openai:firefly:colligo:sora2")
|
||||
payload := map[string]any{
|
||||
"n": 1,
|
||||
"seeds": []int{seedVal},
|
||||
"modelId": "sora",
|
||||
"modelVersion": "sora-2",
|
||||
"size": videoSize(aspectRatio, resolution),
|
||||
"duration": durationSeconds,
|
||||
"fps": 24,
|
||||
"prompt": buildVideoPromptJSON(prompt, durationSeconds),
|
||||
"generationMetadata": map[string]any{"module": "text2video"},
|
||||
"model": upstream,
|
||||
"generateAudio": true,
|
||||
"generateLoop": false,
|
||||
"transparentBackground": false,
|
||||
"seed": itoa(seedVal),
|
||||
"locale": "en-US",
|
||||
"camera": map[string]any{"angle": "none", "shotSize": "none", "motion": nil, "promptStyle": nil},
|
||||
"negativePrompt": "",
|
||||
"jobMode": "standard",
|
||||
"debugGenerationEndpoint": "",
|
||||
"referenceBlobs": []any{},
|
||||
"referenceFrames": []any{},
|
||||
"referenceVideo": nil,
|
||||
"cameraMotionReferenceVideo": nil,
|
||||
"characterReference": nil,
|
||||
"editReferenceVideo": nil,
|
||||
"output": map[string]any{"storeInputs": true},
|
||||
}
|
||||
if len(blobIDs) > 0 {
|
||||
firstID := blobIDs[0]
|
||||
payload["generationMetadata"] = map[string]any{"module": "image2video"}
|
||||
payload["referenceBlobs"] = []any{
|
||||
map[string]any{"id": firstID, "usage": "general", "promptReference": 1},
|
||||
}
|
||||
payload["referenceFrames"] = []any{map[string]any{"localBlobRef": firstID}, nil}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// fireflyVideoSizeTable maps the firefly-video resolution tier + aspect ratio to
|
||||
// pixel dimensions. Only 1080p 9:16 (1080x1920) is HAR-confirmed; the rest follow
|
||||
// the standard 540p/720p/1080p grid for each ratio.
|
||||
var fireflyVideoSizeTable = map[string]map[string][2]int{
|
||||
"540p": {"16:9": {960, 540}, "1:1": {540, 540}, "9:16": {540, 960}},
|
||||
"720p": {"16:9": {1280, 720}, "1:1": {720, 720}, "9:16": {720, 1280}},
|
||||
"1080p": {"16:9": {1920, 1080}, "1:1": {1080, 1080}, "9:16": {1080, 1920}},
|
||||
}
|
||||
|
||||
// fireflyVideoSize returns width, height and numFrames. numFrames encodes the
|
||||
// clip length (~25.6fps; 5s = 128 frames, HAR-confirmed).
|
||||
func fireflyVideoSize(aspectRatio, resolution string, durationSeconds int) (int, int, int) {
|
||||
table, ok := fireflyVideoSizeTable[strings.ToLower(defaultString(resolution, "1080p"))]
|
||||
if !ok {
|
||||
table = fireflyVideoSizeTable["1080p"]
|
||||
}
|
||||
wh, ok := table[defaultString(aspectRatio, "9:16")]
|
||||
if !ok {
|
||||
wh = table["9:16"]
|
||||
}
|
||||
frames := durationSeconds * 128 / 5
|
||||
if frames <= 0 {
|
||||
frames = 128
|
||||
}
|
||||
return wh[0], wh[1], frames
|
||||
}
|
||||
|
||||
func videoSize(aspectRatio, resolution string) map[string]any {
|
||||
if strings.EqualFold(resolution, "1080p") {
|
||||
if aspectRatio == "16:9" {
|
||||
return map[string]any{"width": 1920, "height": 1080}
|
||||
}
|
||||
return map[string]any{"width": 1080, "height": 1920}
|
||||
}
|
||||
if aspectRatio == "16:9" {
|
||||
return map[string]any{"width": 1280, "height": 720}
|
||||
}
|
||||
return map[string]any{"width": 720, "height": 1280}
|
||||
}
|
||||
|
||||
func lumaVideoSize(aspectRatio, resolution string) map[string]any {
|
||||
table, ok := lumaSize[strings.ToLower(defaultString(resolution, "720p"))]
|
||||
if !ok {
|
||||
table = lumaSize["720p"]
|
||||
}
|
||||
size, ok := table[defaultString(aspectRatio, "16:9")]
|
||||
if !ok {
|
||||
size = table["16:9"]
|
||||
}
|
||||
return map[string]any{"width": size[0], "height": size[1]}
|
||||
}
|
||||
|
||||
func buildVideoPromptJSON(prompt string, durationSeconds int) string {
|
||||
payload := map[string]any{
|
||||
"id": 1,
|
||||
"duration_sec": durationSeconds,
|
||||
"prompt_text": prompt,
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package adobe
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
||||
defaultSecCHUA = `"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"`
|
||||
)
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return strings.TrimSpace(strings.ReplaceAll(toJSONScalar(x), "\n", " "))
|
||||
}
|
||||
}
|
||||
|
||||
func toJSONScalar(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func defaultString(v, fallback string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func itoa(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildARPSessionID() string {
|
||||
raw := map[string]any{
|
||||
"sid": uuid.NewString(),
|
||||
"ftr": randomHex(16) + "_" + strconv.FormatInt(time.Now().UnixMilli(), 10) + "_" + strconv.Itoa(os.Getpid()) + "_dUAL43-mnts-ants-d4_31ck__tt",
|
||||
}
|
||||
b, _ := json.Marshal(raw)
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
now := time.Now().UnixNano()
|
||||
for i := range buf {
|
||||
buf[i] = byte(now >> ((i % 8) * 8))
|
||||
}
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func intOrNil(v any) any {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return int(n)
|
||||
case string:
|
||||
n, err := strconv.Atoi(strings.TrimSpace(x))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
var (
|
||||
cores = []int{8, 16, 24, 32}
|
||||
documentKeys = []string{"__reactContainer$fzelfjyxej8", "_reactListening5dehydibo78", "location"}
|
||||
screenResolutions = [][2]int{{1920, 1080}, {1440, 900}, {2560, 1440}, {3840, 2160}}
|
||||
navKeys = []string{
|
||||
"registerProtocolHandler−function registerProtocolHandler() { [native code] }",
|
||||
"storage−[object StorageManager]",
|
||||
"locks−[object LockManager]",
|
||||
"appCodeName−Mozilla",
|
||||
"permissions−[object Permissions]",
|
||||
"share−function share() { [native code] }",
|
||||
"webdriver−false",
|
||||
"vendor−Google Inc.",
|
||||
"mediaDevices−[object MediaDevices]",
|
||||
"cookieEnabled−true",
|
||||
"onLine−true",
|
||||
"mimeTypes−[object MimeTypeArray]",
|
||||
"credentials−[object CredentialsContainer]",
|
||||
"serviceWorker−[object ServiceWorkerContainer]",
|
||||
"keyboard−[object Keyboard]",
|
||||
"gpu−[object GPU]",
|
||||
"doNotTrack",
|
||||
"language−zh-CN",
|
||||
"geolocation−[object Geolocation]",
|
||||
"hardwareConcurrency−32",
|
||||
}
|
||||
winKeys = []string{
|
||||
"0", "window", "self", "document", "name", "location", "history",
|
||||
"navigation", "innerWidth", "innerHeight", "screen", "chrome",
|
||||
"navigator", "performance", "crypto", "indexedDB", "sessionStorage",
|
||||
"localStorage", "fetch", "matchMedia", "postMessage", "setTimeout",
|
||||
"caches", "__NEXT_DATA__",
|
||||
}
|
||||
)
|
||||
|
||||
func buildLegacyRequirementsToken(userAgent string, scriptSources []string, dataBuild string) string {
|
||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
||||
body, _ := json.Marshal(cfg)
|
||||
return "gAAAAAC" + base64.StdEncoding.EncodeToString(body)
|
||||
}
|
||||
|
||||
func buildProofToken(seed, difficulty, userAgent string, scriptSources []string, dataBuild string) (string, error) {
|
||||
cfg := buildPOWConfig(userAgent, scriptSources, dataBuild)
|
||||
answer, solved := powGenerate(seed, difficulty, cfg, 500000)
|
||||
if !solved {
|
||||
return "", errors.New("failed to solve proof token")
|
||||
}
|
||||
return "gAAAAAB" + answer, nil
|
||||
}
|
||||
|
||||
func buildPOWConfig(userAgent string, scriptSources []string, dataBuild string) []any {
|
||||
// scriptSources/dataBuild are no longer part of the sentinel config array
|
||||
// (the current chatgpt.com client dropped them); kept in the signature for
|
||||
// call-site compatibility.
|
||||
_ = scriptSources
|
||||
_ = dataBuild
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
screen := screenResolutions[rng.Intn(len(screenResolutions))]
|
||||
loc := time.FixedZone("GMT+0800", 8*3600)
|
||||
nowLocal := time.Now().In(loc).Format("Mon Jan 02 2006 15:04:05") + " GMT+0800 (中国标准时间)"
|
||||
perf := float64(time.Now().UnixNano()%1_000_000_000) / 1_000_000
|
||||
return []any{
|
||||
screen[0] + screen[1], // [0]
|
||||
nowLocal, // [1] local time, JS Date.toString() shape
|
||||
4395630592, // [2]
|
||||
1, // [3] overwritten by powGenerate counter
|
||||
userAgent, // [4]
|
||||
nil, // [5] (was script source; now null)
|
||||
defaultClientVersion, // [6] oai-client-version, must match header
|
||||
"zh-CN", // [7] matches oai-language
|
||||
"zh-CN,en,en-GB,en-US", // [8]
|
||||
rng.Float64(), // [9] overwritten by powGenerate counter
|
||||
navKeys[rng.Intn(len(navKeys))],
|
||||
documentKeys[rng.Intn(len(documentKeys))],
|
||||
winKeys[rng.Intn(len(winKeys))],
|
||||
perf, // [13]
|
||||
newUUID(), // [14]
|
||||
"", // [15]
|
||||
cores[rng.Intn(len(cores))], // [16]
|
||||
float64(timeMillis()) - perf, // [17]
|
||||
0, 0, 0, 0, 0, 0,
|
||||
0,
|
||||
}
|
||||
}
|
||||
|
||||
func powGenerate(seed, difficulty string, cfg []any, limit int) (string, bool) {
|
||||
target, err := hex.DecodeString(strings.TrimSpace(difficulty))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
diffLen := len(strings.TrimSpace(difficulty)) / 2
|
||||
seedBytes := []byte(seed)
|
||||
head1, _ := json.Marshal(cfg[:3])
|
||||
head2, _ := json.Marshal(cfg[4:9])
|
||||
head3, _ := json.Marshal(cfg[10:])
|
||||
static1 := []byte(string(head1[:len(head1)-1]) + ",")
|
||||
static2 := []byte("," + string(head2[1:len(head2)-1]) + ",")
|
||||
static3 := []byte("," + string(head3[1:]))
|
||||
|
||||
for i := 0; i < limit; i++ {
|
||||
finalJSON := append([]byte{}, static1...)
|
||||
finalJSON = append(finalJSON, []byte(strconvItoa(i))...)
|
||||
finalJSON = append(finalJSON, static2...)
|
||||
finalJSON = append(finalJSON, []byte(strconvItoa(i>>1))...)
|
||||
finalJSON = append(finalJSON, static3...)
|
||||
encoded := base64.StdEncoding.EncodeToString(finalJSON)
|
||||
sum := sha3.Sum512(append(seedBytes, []byte(encoded)...))
|
||||
if bytesCompare(sum[:diffLen], target) <= 0 {
|
||||
return encoded, true
|
||||
}
|
||||
}
|
||||
fallback := "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" + base64.StdEncoding.EncodeToString([]byte(`"`+seed+`"`))
|
||||
return fallback, false
|
||||
}
|
||||
|
||||
func bytesCompare(a, b []byte) int {
|
||||
for i := 0; i < len(a) && i < len(b); i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if len(a) < len(b) {
|
||||
return -1
|
||||
}
|
||||
if len(a) > len(b) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func strconvItoa(v int) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type orderedMap struct {
|
||||
keys []string
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
func newOrderedMap() *orderedMap {
|
||||
return &orderedMap{values: map[string]any{}}
|
||||
}
|
||||
|
||||
func (m *orderedMap) add(key string, value any) {
|
||||
if _, ok := m.values[key]; !ok {
|
||||
m.keys = append(m.keys, key)
|
||||
}
|
||||
m.values[key] = value
|
||||
}
|
||||
|
||||
func solveTurnstileToken(dx, p string) string {
|
||||
decoded, err := base64.StdEncoding.DecodeString(dx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var tokenList [][]any
|
||||
if err := json.Unmarshal([]byte(xorString(string(decoded), p)), &tokenList); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
processMap := map[int]any{16: p}
|
||||
start := time.Now()
|
||||
result := ""
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
toStr := func(value any) string {
|
||||
if value == nil {
|
||||
return "undefined"
|
||||
}
|
||||
if s, ok := value.(string); ok {
|
||||
special := map[string]string{
|
||||
"window.Math": "[object Math]",
|
||||
"window.Reflect": "[object Reflect]",
|
||||
"window.performance": "[object Performance]",
|
||||
"window.localStorage": "[object Storage]",
|
||||
"window.Object": "function Object() { [native code] }",
|
||||
"window.Reflect.set": "function set() { [native code] }",
|
||||
"window.performance.now": "function () { [native code] }",
|
||||
"window.Object.create": "function create() { [native code] }",
|
||||
"window.Object.keys": "function keys() { [native code] }",
|
||||
"window.Math.random": "function random() { [native code] }",
|
||||
}
|
||||
if specialValue, ok := special[s]; ok {
|
||||
return specialValue
|
||||
}
|
||||
return s
|
||||
}
|
||||
if list, ok := value.([]string); ok {
|
||||
return strings.Join(list, ",")
|
||||
}
|
||||
return stringValue(value)
|
||||
}
|
||||
|
||||
for _, token := range tokenList {
|
||||
if len(token) == 0 {
|
||||
continue
|
||||
}
|
||||
op := intValue(token[0])
|
||||
switch op {
|
||||
case 2:
|
||||
if len(token) >= 3 {
|
||||
processMap[intValue(token[1])] = token[2]
|
||||
}
|
||||
case 3:
|
||||
if len(token) >= 2 {
|
||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
||||
}
|
||||
case 5:
|
||||
if len(token) >= 3 {
|
||||
e := intValue(token[1])
|
||||
t := intValue(token[2])
|
||||
cur := processMap[e]
|
||||
inc := processMap[t]
|
||||
if list, ok := cur.([]any); ok {
|
||||
processMap[e] = append(list, inc)
|
||||
} else if _, ok := cur.(string); ok {
|
||||
processMap[e] = toStr(cur) + toStr(inc)
|
||||
} else {
|
||||
processMap[e] = "NaN"
|
||||
}
|
||||
}
|
||||
case 6, 24:
|
||||
if len(token) >= 4 {
|
||||
e := intValue(token[1])
|
||||
t := toStr(processMap[intValue(token[2])])
|
||||
n := toStr(processMap[intValue(token[3])])
|
||||
v := t + "." + n
|
||||
if op == 6 && v == "window.document.location" {
|
||||
v = "https://chatgpt.com/"
|
||||
}
|
||||
processMap[e] = v
|
||||
}
|
||||
case 8:
|
||||
if len(token) >= 3 {
|
||||
processMap[intValue(token[1])] = processMap[intValue(token[2])]
|
||||
}
|
||||
case 14:
|
||||
if len(token) >= 3 {
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(toStr(processMap[intValue(token[2])])), &parsed); err == nil {
|
||||
processMap[intValue(token[1])] = parsed
|
||||
}
|
||||
}
|
||||
case 15:
|
||||
if len(token) >= 3 {
|
||||
b, _ := json.Marshal(processMap[intValue(token[2])])
|
||||
processMap[intValue(token[1])] = string(b)
|
||||
}
|
||||
case 17:
|
||||
if len(token) >= 3 {
|
||||
e := intValue(token[1])
|
||||
target := toStr(processMap[intValue(token[2])])
|
||||
switch target {
|
||||
case "window.performance.now":
|
||||
processMap[e] = float64(time.Since(start).Nanoseconds())/1e6 + rng.Float64()
|
||||
case "window.Object.create":
|
||||
processMap[e] = newOrderedMap()
|
||||
case "window.Object.keys":
|
||||
processMap[e] = []string{
|
||||
"STATSIG_LOCAL_STORAGE_INTERNAL_STORE_V4",
|
||||
"STATSIG_LOCAL_STORAGE_STABLE_ID",
|
||||
"client-correlated-secret",
|
||||
"oai/apps/capExpiresAt",
|
||||
"oai-did",
|
||||
"STATSIG_LOCAL_STORAGE_LOGGING_REQUEST",
|
||||
"UiState.isNavigationCollapsed.1",
|
||||
}
|
||||
case "window.Math.random":
|
||||
processMap[e] = rng.Float64()
|
||||
}
|
||||
}
|
||||
case 18:
|
||||
if len(token) >= 2 {
|
||||
raw, err := base64.StdEncoding.DecodeString(toStr(processMap[intValue(token[1])]))
|
||||
if err == nil {
|
||||
processMap[intValue(token[1])] = string(raw)
|
||||
}
|
||||
}
|
||||
case 19:
|
||||
if len(token) >= 2 {
|
||||
processMap[intValue(token[1])] = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[1])])))
|
||||
}
|
||||
case 20:
|
||||
if len(token) >= 4 {
|
||||
if toStr(processMap[intValue(token[1])]) == toStr(processMap[intValue(token[2])]) {
|
||||
if intValue(token[3]) == 3 && len(token) >= 5 {
|
||||
result = base64.StdEncoding.EncodeToString([]byte(toStr(processMap[intValue(token[4])])))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func xorString(text, key string) string {
|
||||
if key == "" {
|
||||
return text
|
||||
}
|
||||
out := make([]rune, 0, len(text))
|
||||
keyRunes := []rune(key)
|
||||
for i, ch := range text {
|
||||
out = append(out, ch^keyRunes[i%len(keyRunes)])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package chatgpt
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://chatgpt.com"
|
||||
defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0"
|
||||
defaultClientVersion = "prod-ab8a6348980a3e1d771c463b9f4f3e4e584f2769"
|
||||
defaultClientBuildNumber = "7624276"
|
||||
defaultPOWScript = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
||||
)
|
||||
|
||||
var (
|
||||
fileServiceIDPattern = regexp.MustCompile(`file-service://([A-Za-z0-9_-]+)`)
|
||||
sedimentIDPattern = regexp.MustCompile(`sediment://([A-Za-z0-9_-]+)`)
|
||||
realImageIDPattern = regexp.MustCompile(`\bfile_00000000[a-f0-9]{24}\b`)
|
||||
conversationIDRE = regexp.MustCompile(`"conversation_id"\s*:\s*"([^"]+)"`)
|
||||
scriptSrcRE = regexp.MustCompile(`<script[^>]+src="([^"]+)"`)
|
||||
dataBuildPathRE = regexp.MustCompile(`c/[^/]*/_`)
|
||||
htmlDataBuildRE = regexp.MustCompile(`<html[^>]*data-build="([^"]*)"`)
|
||||
)
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case float32:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newUUID() string {
|
||||
return uuid.NewString()
|
||||
}
|
||||
|
||||
func clip(v []byte, n int) string {
|
||||
s := strings.TrimSpace(string(v))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func parsePOWResources(html string) ([]string, string) {
|
||||
matches := scriptSrcRE.FindAllStringSubmatch(html, -1)
|
||||
sources := make([]string, 0, len(matches))
|
||||
dataBuild := ""
|
||||
for _, match := range matches {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
src := strings.TrimSpace(match[1])
|
||||
if src == "" {
|
||||
continue
|
||||
}
|
||||
sources = append(sources, src)
|
||||
if dataBuild == "" {
|
||||
if path := dataBuildPathRE.FindString(src); path != "" {
|
||||
dataBuild = path
|
||||
}
|
||||
}
|
||||
}
|
||||
if dataBuild == "" {
|
||||
if match := htmlDataBuildRE.FindStringSubmatch(html); len(match) >= 2 {
|
||||
dataBuild = strings.TrimSpace(match[1])
|
||||
}
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
sources = []string{defaultPOWScript}
|
||||
}
|
||||
return sources, dataBuild
|
||||
}
|
||||
|
||||
func timeMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
// Package imagine implements the Imagine.art (vyro.ai) provider client. The
|
||||
// durable credential is a JSON blob {"token","refreshToken"}: `token` is a ~6h
|
||||
// access JWT used as Authorization: Bearer for the API, and `refreshToken` is a
|
||||
// ~7d JWT that mints a fresh pair via /apis/v1/auth/other/refresh/web when the
|
||||
// access token expires. Both rotate on refresh, so the new pair MUST be saved.
|
||||
// tls-client gives a Chrome JA3/JA4 so vyro's edge doesn't flag the requests.
|
||||
package imagine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://imagine.vyro.ai"
|
||||
teamsBase = "https://teams-imagine.vyro.ai"
|
||||
authBase = "https://auth.vyro.ai"
|
||||
webOrigin = "https://www.imagine.art"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("imagine auth failed")
|
||||
ErrQuotaExhausted = errors.New("imagine quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("imagine upstream temporary error")
|
||||
)
|
||||
|
||||
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
||||
// (proactive, not lazy at expiry) — the maintenance sweep keeps tokens fresh so a
|
||||
// dormant account's rotating refreshToken never lapses.
|
||||
const refreshLeadSeconds = 600 // 10 minutes
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// freshest credential per account (key: user id) + a per-account refresh lock,
|
||||
// so concurrent callers don't each spend the rotating refresh_token — the first
|
||||
// refreshes, the rest reuse the cached fresh credential.
|
||||
mu sync.Mutex
|
||||
creds map[string]string
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), creds: map[string]string{}, locks: map[string]*sync.Mutex{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
func (c *Client) userLock(userID string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.locks[userID]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.locks[userID] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type credential struct {
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
// Email is the real account email — supplied at import, used for display and
|
||||
// (pool,email) dedup. It is NOT in the JWT (which only carries userId), so it
|
||||
// must be carried across refreshes (the refresh response omits it).
|
||||
Email string `json:"email,omitempty"`
|
||||
// ParentID is a canvas node the account OWNS, used as the generation's
|
||||
// parent_id. Imagine rejects any parent the account doesn't own ("user does
|
||||
// not have access to parent asset") and silently orphans a parent-less
|
||||
// generation (charged but never produced) — so it's supplied at import and
|
||||
// carried across refreshes.
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
}
|
||||
|
||||
func parseCred(s string) (credential, bool) {
|
||||
var cr credential
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(s)), &cr) != nil {
|
||||
return cr, false
|
||||
}
|
||||
if strings.TrimSpace(cr.Token) == "" || strings.TrimSpace(cr.RefreshToken) == "" {
|
||||
return cr, false
|
||||
}
|
||||
return cr, true
|
||||
}
|
||||
|
||||
func buildCred(token, refresh, email, parentID string) string {
|
||||
b, _ := json.Marshal(credential{
|
||||
Token: strings.TrimSpace(token),
|
||||
RefreshToken: strings.TrimSpace(refresh),
|
||||
Email: strings.TrimSpace(email),
|
||||
ParentID: strings.TrimSpace(parentID),
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ParentIDFromCred returns the canvas parent node id supplied at import.
|
||||
func ParentIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(cr.ParentID)
|
||||
}
|
||||
|
||||
func looksLikeJWT(s string) bool {
|
||||
return len(strings.Split(strings.TrimSpace(s), ".")) == 3
|
||||
}
|
||||
|
||||
// IsImagineToken reports whether a pasted credential is an Imagine.art account:
|
||||
// a JSON object carrying a non-empty token + refreshToken that both look like
|
||||
// JWTs. Distinguishes it from adobe/leonardo/krea cookies.
|
||||
func IsImagineToken(value string) bool {
|
||||
cr, ok := parseCred(value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return looksLikeJWT(cr.Token) && looksLikeJWT(cr.RefreshToken)
|
||||
}
|
||||
|
||||
// jwtClaims base64url-decodes the JWT payload (segment 1) into a claims map.
|
||||
func jwtClaims(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
// tolerate padded variants
|
||||
if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func userIDFromToken(token string) string {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(stringValue(claims["userId"])); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(stringValue(claims["sub"]))
|
||||
}
|
||||
|
||||
func tokenExp(token string) int64 {
|
||||
claims := jwtClaims(token)
|
||||
if claims == nil {
|
||||
return 0
|
||||
}
|
||||
return toInt64(claims["exp"])
|
||||
}
|
||||
|
||||
// EmailFromCred returns the real account email supplied at import; if absent it
|
||||
// falls back to the JWT userId so (pool,email) dedup still has a stable key.
|
||||
func EmailFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if e := strings.TrimSpace(cr.Email); e != "" {
|
||||
return e
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// UserIDFromCred returns the JWT userId (== org_id used for credit/generation).
|
||||
func UserIDFromCred(cred string) string {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return userIDFromToken(cr.Token)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RefreshIfNeeded returns a credential whose access token is still valid: if the
|
||||
// stored one is (near) expired it spends the refreshToken to mint a fresh pair
|
||||
// and rebuilds the credential. Returns (cred, changed, err); changed=true means
|
||||
// the caller must persist the new credential (both tokens rotate). ErrAuth means
|
||||
// the refreshToken is dead → the account is gone.
|
||||
func (c *Client) RefreshIfNeeded(ctx context.Context, cred string) (string, bool, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return cred, false, nil // unparseable — let the downstream call surface the error
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
now := time.Now().Unix()
|
||||
|
||||
lk := c.userLock(userID)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// A concurrent caller may already have refreshed this account.
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
cached := c.creds[userID]
|
||||
c.mu.Unlock()
|
||||
if cc, ok := parseCred(cached); ok && tokenExp(cc.Token)-refreshLeadSeconds > now {
|
||||
return cached, cached != cred, nil
|
||||
}
|
||||
}
|
||||
if tokenExp(cr.Token)-refreshLeadSeconds > now {
|
||||
return cred, false, nil // still valid
|
||||
}
|
||||
|
||||
respBody, status, err := c.refreshPost(ctx, cr.RefreshToken)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 400 || status == 401 || status == 403 {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
|
||||
}
|
||||
var rb struct {
|
||||
Result struct {
|
||||
SessionToken string `json:"sessionToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
} `json:"result"`
|
||||
}
|
||||
if json.Unmarshal(respBody, &rb) != nil || strings.TrimSpace(rb.Result.SessionToken) == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
newRefresh := rb.Result.RefreshToken
|
||||
if strings.TrimSpace(newRefresh) == "" {
|
||||
newRefresh = cr.RefreshToken // some responses may omit it — keep the old one
|
||||
}
|
||||
newCred := buildCred(rb.Result.SessionToken, newRefresh, cr.Email, cr.ParentID)
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
c.creds[userID] = newCred
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return newCred, true, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, authBase+"/apis/v1/auth/other/refresh/web", nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + refreshToken},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// FetchCreditsBalance reads the account's credit balance via /v1/credit.
|
||||
// remaining is the `total` field. 401/403 → ErrAuth (token dead). Returns the
|
||||
// normalized map shared by all providers.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cred string) (map[string]any, error) {
|
||||
cr, ok := parseCred(cred)
|
||||
if !ok {
|
||||
return unknownBalance("bad credential"), nil
|
||||
}
|
||||
userID := userIDFromToken(cr.Token)
|
||||
body, status, err := c.apiGet(ctx, cr.Token, apiBase+"/v1/credit?org_id="+userID)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
}
|
||||
var cb struct {
|
||||
Status string `json:"status"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &cb); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": cb.Total,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"email": emptyStringNil(EmailFromCred(cred)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (c *Client) apiGet(ctx context.Context, token, url string) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json, text/plain, */*"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"origin": {webOrigin},
|
||||
"referer": {webOrigin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "authorization", "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
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small util
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func uuid4() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Package krea implements the Krea.ai (krea.ai) provider client. The durable
|
||||
// credential is the browser cookie (Supabase "sb-superb-auth-token"); Krea's own
|
||||
// Next.js backend reads it directly, so quota and generation just forward the
|
||||
// cookie — there's no separate token-exchange step. tls-client gives a Chrome
|
||||
// JA3/JA4 so Krea's Cloudflare edge doesn't flag the requests.
|
||||
package krea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
// kreaAnonKey is Krea's public Supabase anon key (fixed, embedded in their
|
||||
// frontend) — required as the apikey/bearer when refreshing a session token.
|
||||
const kreaAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc1Mjc4ODU3LCJleHAiOjE5MzI5NTg4NTd9.NUiqEOd__QsCCMjo3D1zrCAda5dLV2F5p6Kf584sZKc"
|
||||
|
||||
const (
|
||||
apiBase = "https://www.krea.ai"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("krea auth failed")
|
||||
ErrQuotaExhausted = errors.New("krea quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("krea upstream temporary error")
|
||||
)
|
||||
|
||||
// refreshLeadSeconds renews the access token this many seconds BEFORE it expires
|
||||
// (not lazily at expiry). The maintenance sweep refreshes proactively, so a
|
||||
// dormant account's token never lapses — once the rotating refresh_token is gone
|
||||
// (expired/consumed) the account can't recover, so we keep it perpetually fresh.
|
||||
const refreshLeadSeconds = 600 // 10 minutes
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// freshest cookie per account (key: user id) + a per-account refresh lock, so
|
||||
// concurrent callers don't each spend the single-use (rotating) refresh_token —
|
||||
// the first refreshes, the rest reuse the cached fresh cookie.
|
||||
mu sync.Mutex
|
||||
cookies map[string]string
|
||||
locks map[string]*sync.Mutex
|
||||
// actAt = last /app activation time per account (key: user id); actLocks gives
|
||||
// a per-account lock so concurrent generations wait for the first to finish the
|
||||
// (once-per-daily-reset) activation instead of each loading /app.
|
||||
actAt map[string]int64
|
||||
actLocks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{
|
||||
proxy: strings.TrimSpace(proxy),
|
||||
cookies: map[string]string{},
|
||||
locks: map[string]*sync.Mutex{},
|
||||
actAt: map[string]int64{},
|
||||
actLocks: map[string]*sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) userLock(userID string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.locks[userID]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.locks[userID] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// RefreshIfNeeded returns a cookie whose access_token is still valid: if the
|
||||
// stored one is (near) expired it spends the refresh_token to mint a new session
|
||||
// and rebuilds the cookie. Returns (cookie, changed, err); changed=true means the
|
||||
// caller must persist the new cookie (the refresh_token rotated). ErrAuth means
|
||||
// the refresh_token is dead → the account is gone.
|
||||
func (c *Client) RefreshIfNeeded(ctx context.Context, cookie string) (string, bool, error) {
|
||||
authVal := authCookieValue(cookie)
|
||||
sess, ok := decodeSession(authVal)
|
||||
if !ok {
|
||||
return cookie, false, nil // unparseable — let the downstream call surface the error
|
||||
}
|
||||
userID := nestedStr(sess, "user", "id")
|
||||
now := time.Now().Unix()
|
||||
|
||||
lk := c.userLock(userID)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// A concurrent caller may already have refreshed this account.
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
cached := c.cookies[userID]
|
||||
c.mu.Unlock()
|
||||
if cs, ok := decodeSession(authCookieValue(cached)); ok && toInt64(cs["expires_at"])-refreshLeadSeconds > now {
|
||||
return cached, cached != cookie, nil
|
||||
}
|
||||
}
|
||||
if toInt64(sess["expires_at"])-refreshLeadSeconds > now {
|
||||
return cookie, false, nil // still valid
|
||||
}
|
||||
refreshTok := strings.TrimSpace(stringValue(sess["refresh_token"]))
|
||||
if refreshTok == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
|
||||
respBody, status, err := c.refreshPost(ctx, refreshTok)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("%w: refresh: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 400 || status == 401 || status == 403 {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", false, fmt.Errorf("%w: refresh http %d: %s", ErrTemporaryUpstream, status, clip(respBody, 120))
|
||||
}
|
||||
var ns map[string]any
|
||||
if json.Unmarshal(respBody, &ns) != nil || strings.TrimSpace(stringValue(ns["access_token"])) == "" {
|
||||
return "", false, ErrAuth
|
||||
}
|
||||
newCookie := replaceAuthCookie(cookie, "base64-"+base64.StdEncoding.EncodeToString(respBody))
|
||||
if userID != "" {
|
||||
c.mu.Lock()
|
||||
c.cookies[userID] = newCookie
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return newCookie, true, nil
|
||||
}
|
||||
|
||||
func (c *Client) refreshPost(ctx context.Context, refreshToken string) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+"/auth/v1/token?grant_type=refresh_token", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"content-type": {"application/json;charset=UTF-8"},
|
||||
"apikey": {kreaAnonKey},
|
||||
"authorization": {"Bearer " + kreaAnonKey},
|
||||
"x-client-info": {"supabase-ssr/0.6.1 createBrowserClient"},
|
||||
"x-supabase-api-version": {"2024-01-01"},
|
||||
"origin": {apiBase},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "content-type", "apikey", "authorization", "x-client-info",
|
||||
"x-supabase-api-version", "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
|
||||
}
|
||||
|
||||
// chunkSize is supabase-ssr's per-cookie chunk limit; larger sessions (e.g.
|
||||
// Google-OAuth accounts) are split into sb-superb-auth-token.0/.1/...
|
||||
const chunkSize = 3600
|
||||
|
||||
func cookieVal(cookie, name string) string {
|
||||
for _, p := range strings.Split(cookie, ";") {
|
||||
p = strings.TrimSpace(p)
|
||||
if v, ok := strings.CutPrefix(p, name+"="); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// authCookieValue returns the full auth value, transparently reassembling a
|
||||
// chunked cookie (sb-superb-auth-token.0 + .1 + ...) or returning the single one.
|
||||
func authCookieValue(cookie string) string {
|
||||
if v := cookieVal(cookie, "sb-superb-auth-token"); v != "" {
|
||||
return v
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; ; i++ {
|
||||
v := cookieVal(cookie, fmt.Sprintf("sb-superb-auth-token.%d", i))
|
||||
if v == "" {
|
||||
break
|
||||
}
|
||||
b.WriteString(v)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// replaceAuthCookie drops every sb-superb-auth-token[.N] cookie and re-adds the
|
||||
// new value (chunked the same way supabase-ssr would if it's large), preserving
|
||||
// all other cookies (krea-workspace-id, etc.).
|
||||
func replaceAuthCookie(cookie, newValue string) string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(cookie, ";") {
|
||||
t := strings.TrimSpace(p)
|
||||
if t == "" || strings.HasPrefix(t, "sb-superb-auth-token=") || strings.HasPrefix(t, "sb-superb-auth-token.") {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if len(newValue) <= chunkSize {
|
||||
out = append(out, "sb-superb-auth-token="+newValue)
|
||||
} else {
|
||||
for i, off := 0, 0; off < len(newValue); i++ {
|
||||
end := off + chunkSize
|
||||
if end > len(newValue) {
|
||||
end = len(newValue)
|
||||
}
|
||||
out = append(out, fmt.Sprintf("sb-superb-auth-token.%d=%s", i, newValue[off:end]))
|
||||
off = end
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// decodeSession base64-decodes the auth cookie value into the session JSON map.
|
||||
func decodeSession(authValue string) (map[string]any, bool) {
|
||||
v := strings.TrimPrefix(strings.TrimSpace(authValue), "base64-")
|
||||
if v == "" {
|
||||
return nil, false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
raw, err = base64.RawURLEncoding.DecodeString(v)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
func nestedStr(m map[string]any, k1, k2 string) string {
|
||||
if sub, ok := m[k1].(map[string]any); ok {
|
||||
return strings.TrimSpace(stringValue(sub[k2]))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int64(x)
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return n
|
||||
case string:
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsKreaCookie reports whether a pasted credential is a Krea cookie: it carries
|
||||
// the Supabase auth cookie. Distinguishes it from adobe/leonardo cookies.
|
||||
func IsKreaCookie(value string) bool {
|
||||
return strings.Contains(value, "sb-superb-auth-token")
|
||||
}
|
||||
|
||||
// EmailFromCookie decodes the account email straight out of the cookie's embedded
|
||||
// Supabase session (no network), handling chunked cookies too.
|
||||
func EmailFromCookie(cookie string) string {
|
||||
sess, ok := decodeSession(authCookieValue(cookie))
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return nestedStr(sess, "user", "email")
|
||||
}
|
||||
|
||||
// FetchCreditsBalance reads the account's free-credit balance via /api/billing-data.
|
||||
// remaining is the integer floor of balance.free (per spec: 17.94 → 17). 401 →
|
||||
// ErrAuth (cookie dead). Returns the normalized map shared by all providers.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
||||
// Detach from the request ctx so a page refresh can't cancel the probe
|
||||
// mid-flight (which left accounts stuck at "—").
|
||||
probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
// NOTE: no /app here — the heavy SSR activation is done separately (on recovery
|
||||
// and at generation via Activate). This probe just reads the current balance.
|
||||
body, status, err := c.apiGet(probeCtx, cookie, "/api/billing-data")
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
}
|
||||
// 余额在 balance.free(真实剩余,小数,随用量递减)。krea 的这个字段一直都在,
|
||||
// 只是排在很长的 entitlements 之后 —— 只读它,不用套餐配额兜底。
|
||||
var bd struct {
|
||||
Balance struct {
|
||||
Free float64 `json:"free"`
|
||||
Total float64 `json:"total"`
|
||||
} `json:"balance"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &bd); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
remaining := int(bd.Balance.Free) // floor
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": nil,
|
||||
"total": int(bd.Balance.Total),
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"email": emptyStringNil(EmailFromCookie(cookie)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Activate loads the authenticated SSR app page (/app), which is what makes krea
|
||||
// grant the account's DAILY free balance — a cold API-only call (billing-data /
|
||||
// generate) otherwise sees balance.free=0 and 402s. Done at most ONCE per account
|
||||
// per daily reset, under a per-account lock: the first caller loads /app while
|
||||
// concurrent callers wait, then everyone proceeds (no redundant /app). Called
|
||||
// before each generation and by the daily activation sweep. Best-effort.
|
||||
func (c *Client) Activate(ctx context.Context, cookie string) {
|
||||
key := accountKey(cookie)
|
||||
lastReset := (time.Now().Unix() / 86400) * 86400
|
||||
c.mu.Lock()
|
||||
doneToday := key != "" && c.actAt[key] >= lastReset
|
||||
c.mu.Unlock()
|
||||
if doneToday {
|
||||
return
|
||||
}
|
||||
lk := c.actLock(key)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
// Re-check after acquiring the lock — another caller may have just activated.
|
||||
c.mu.Lock()
|
||||
doneToday = key != "" && c.actAt[key] >= lastReset
|
||||
c.mu.Unlock()
|
||||
if doneToday {
|
||||
return
|
||||
}
|
||||
actCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 60*time.Second)
|
||||
defer cancel()
|
||||
_, _, _ = c.apiGet(actCtx, cookie, "/app")
|
||||
c.mu.Lock()
|
||||
c.actAt[key] = time.Now().Unix()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Client) actLock(key string) *sync.Mutex {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m, ok := c.actLocks[key]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
c.actLocks[key] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// accountKey is the stable per-account id (the Supabase user id from the session
|
||||
// cookie) used to key activation state — survives cookie rotation.
|
||||
func accountKey(cookie string) string {
|
||||
if sess, ok := decodeSession(authCookieValue(cookie)); ok {
|
||||
return nestedStr(sess, "user", "id")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// apiGet issues a GET to a krea.ai API path carrying the account cookie.
|
||||
func (c *Client) apiGet(ctx context.Context, cookie, path string) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+path, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cookie": {cookie},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "cookie", "referer", "user-agent",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil, "used": nil, "total": nil, "unknown": true, "error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package krea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
const (
|
||||
genModel = "flux2-klein4b" // the single exposed Krea model
|
||||
genEndpoint = "/api/jobs/v2/new/fluxKlein4b"
|
||||
refStrength = 0.4
|
||||
)
|
||||
|
||||
// ensureProject returns a flux project id for the account: the first existing
|
||||
// project, or a freshly created one. Generation requires a project.
|
||||
func (c *Client) ensureProject(ctx context.Context, cookie string) (string, error) {
|
||||
body, status, err := c.apiGet(ctx, cookie, "/api/flux-projects")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: list projects: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status == 200 {
|
||||
var projs []struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(body, &projs) == nil {
|
||||
for _, p := range projs {
|
||||
if strings.TrimSpace(p.ID) != "" {
|
||||
return p.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No project on this account (e.g. brand-new). Try to create one; if that
|
||||
// fails, fall back to generating WITHOUT a project (Krea assigns a default).
|
||||
cb, cs, cerr := c.apiPostJSON(ctx, cookie, "/api/flux-projects", map[string]any{"title": "vivid"})
|
||||
if cerr == nil && (cs == 200 || cs == 201) {
|
||||
var pr struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(cb, &pr) == nil && pr.ID != "" {
|
||||
return pr.ID, nil
|
||||
}
|
||||
}
|
||||
return "", nil // generate without an explicit project
|
||||
}
|
||||
|
||||
// uploadImage uploads a reference image (i2i) and returns its app-uploads URL.
|
||||
func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", "image.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.Close()
|
||||
body, status, err := c.apiPost(ctx, cookie, "/api/upload?", w.FormDataContentType(), buf.Bytes())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", fmt.Errorf("%w: upload http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
}
|
||||
var ur struct {
|
||||
ImageURL string `json:"imageUrl"`
|
||||
}
|
||||
if json.Unmarshal(body, &ur) != nil || ur.ImageURL == "" {
|
||||
return "", fmt.Errorf("%w: no imageUrl", ErrTemporaryUpstream)
|
||||
}
|
||||
return ur.ImageURL, nil
|
||||
}
|
||||
|
||||
// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
|
||||
// upload reference images, submit the job, poll until done, then resolve and
|
||||
// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted.
|
||||
func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte) ([]byte, map[string]any, error) {
|
||||
// Ensure the daily free balance is granted (load /app) before generating, so a
|
||||
// not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and
|
||||
// once-per-daily-reset — concurrent gens wait for the first activation, already
|
||||
// activated ones skip straight through.
|
||||
c.Activate(ctx, cookie)
|
||||
|
||||
projectID, err := c.ensureProject(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var styleImages []map[string]any
|
||||
for _, img := range refImages {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
url, upErr := c.uploadImage(ctx, cookie, img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
styleImages = append(styleImages, map[string]any{"url": url, "strength": refStrength, "source": "upload"})
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"provider": genModel,
|
||||
"prompt": prompt,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"strength": 1,
|
||||
"steps": 28,
|
||||
"guidance_scale_flux": 3.5,
|
||||
"presetStyles": []any{},
|
||||
"batchSize": 2,
|
||||
"guidance": 3.5,
|
||||
}
|
||||
if projectID != "" {
|
||||
payload["project"] = projectID
|
||||
}
|
||||
if len(styleImages) > 0 {
|
||||
payload["styleImages"] = styleImages
|
||||
}
|
||||
payloadJSON, _ := json.Marshal(payload)
|
||||
|
||||
// Submit (multipart with a single "payload" field).
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("payload", string(payloadJSON))
|
||||
_ = w.Close()
|
||||
body, status, err := c.apiPost(ctx, cookie, genEndpoint, 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 || strings.Contains(string(body), "INSUFFICIENT_BALANCE") {
|
||||
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 {
|
||||
JobID string `json:"job_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &jobs); err != nil || len(jobs) == 0 || jobs[0].JobID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no job id: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
// batchSize=2 returns two jobs; keep the SECOND image and discard the first
|
||||
// (per spec). Fall back to the first if only one came back.
|
||||
jobID := jobs[0].JobID
|
||||
if len(jobs) >= 2 && jobs[1].JobID != "" {
|
||||
jobID = jobs[1].JobID
|
||||
}
|
||||
|
||||
// Poll until terminal, then resolve the produced image.
|
||||
imageURL, err := c.pollImage(ctx, cookie, jobID)
|
||||
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{"job_id": jobID, "image_url": imageURL, "project": projectID}, nil
|
||||
}
|
||||
|
||||
// pollImage polls job-status until the job leaves the queue, then matches the
|
||||
// produced asset by generation_job_id and returns its image URL.
|
||||
func (c *Client) pollImage(ctx context.Context, cookie, jobID string) (string, error) {
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
|
||||
for {
|
||||
body, status, err := c.apiGet(ctx, cookie, "/api/job-status?id="+jobID)
|
||||
if err == nil && status == 200 {
|
||||
var js struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if json.Unmarshal(body, &js) == nil {
|
||||
switch strings.ToLower(js.Status) {
|
||||
case "complete", "completed", "succeeded", "success", "done", "finished":
|
||||
if url, e := c.assetForJob(ctx, cookie, jobID); e == nil && url != "" {
|
||||
return url, nil
|
||||
}
|
||||
case "failed", "error", "cancelled", "canceled":
|
||||
return "", fmt.Errorf("%w: job %s", ErrTemporaryUpstream, js.Status)
|
||||
}
|
||||
}
|
||||
} 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assetForJob finds the generated asset produced by a job and returns its URL.
|
||||
func (c *Client) assetForJob(ctx context.Context, cookie, jobID string) (string, error) {
|
||||
body, status, err := c.apiGet(ctx, cookie, "/api/assets?filter=generated&offset=0")
|
||||
if err != nil || status != 200 {
|
||||
return "", fmt.Errorf("assets http %d", status)
|
||||
}
|
||||
var assets []struct {
|
||||
ImageURL string `json:"image_url"`
|
||||
Metadata struct {
|
||||
GenerationJobID string `json:"generation_job_id"`
|
||||
} `json:"metadata"`
|
||||
}
|
||||
if json.Unmarshal(body, &assets) != nil {
|
||||
return "", fmt.Errorf("assets non-json")
|
||||
}
|
||||
for _, a := range assets {
|
||||
if a.Metadata.GenerationJobID == jobID && strings.TrimSpace(a.ImageURL) != "" {
|
||||
return a.ImageURL, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("asset not found yet")
|
||||
}
|
||||
|
||||
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": {apiBase + "/"},
|
||||
}
|
||||
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 cookie.
|
||||
func (c *Client) apiPost(ctx context.Context, cookie, path, contentType string, body []byte) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, apiBase+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {contentType},
|
||||
"cookie": {cookie},
|
||||
"origin": {apiBase},
|
||||
"referer": {apiBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "content-type", "cookie", "origin",
|
||||
"referer", "user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) apiPostJSON(ctx context.Context, cookie, path string, payload any) ([]byte, int, error) {
|
||||
b, _ := json.Marshal(payload)
|
||||
return c.apiPost(ctx, cookie, path, "application/json", b)
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
// Package leonardo implements the Leonardo.ai (app.leonardo.ai) provider client.
|
||||
// Unlike chatgpt/runway (whose JWT IS the stored credential), Leonardo's durable
|
||||
// credential is the browser COOKIE (better-auth session): the bearer access token
|
||||
// it mints lives only ~1h. So every call here takes the cookie and derives a
|
||||
// fresh JWT on the fly via /api/auth/get-session — there is no long-lived token to
|
||||
// store or a separate refresh profile to maintain. tls-client gives a Chrome
|
||||
// JA3/JA4 fingerprint so the requests aren't flagged.
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
const (
|
||||
appBase = "https://app.leonardo.ai"
|
||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
||||
schemaVersion = "1.187.0"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("leonardo auth failed")
|
||||
ErrQuotaExhausted = errors.New("leonardo quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("leonardo upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
// sessions caches the short-lived access token per cookie so we don't hit
|
||||
// /api/auth/get-session on every call — Leonardo rate-limits that endpoint
|
||||
// (429) hard, so re-using the ~1h JWT is essential.
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsLeonardoCookie reports whether a pasted credential is a Leonardo cookie: it
|
||||
// carries the better-auth session cookie name. This is what disambiguates it from
|
||||
// an Adobe cookie at import time.
|
||||
func IsLeonardoCookie(value string) bool {
|
||||
return strings.Contains(value, "__Secure-better-auth.session_token") ||
|
||||
strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// Session is the result of /api/auth/get-session: the short-lived bearer plus the
|
||||
// ids the GraphQL API needs (cognitoSub for the quota query, userId for the feed
|
||||
// and the CDN image path) and the human-facing account fields.
|
||||
type Session struct {
|
||||
AccessToken string
|
||||
CognitoSub string
|
||||
UserID string
|
||||
Email string
|
||||
Name string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
// GetSession exchanges the cookie for a fresh access token + account ids. A 401/403
|
||||
// (or a response with no access token) means the cookie/session is dead → ErrAuth.
|
||||
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
cookie = strings.TrimSpace(cookie)
|
||||
if cookie == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
// Re-use a cached, still-valid access token (keep a 60s safety margin) instead
|
||||
// of hitting the heavily rate-limited get-session endpoint again.
|
||||
c.mu.Lock()
|
||||
if cs, ok := c.sessions[cookie]; ok && cs.ExpiresAt-60 > time.Now().Unix() {
|
||||
c.mu.Unlock()
|
||||
return cs, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, appBase+"/api/auth/get-session", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cookie": {cookie},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "accept-language", "cookie", "origin", "referer",
|
||||
"user-agent", "sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 160))
|
||||
}
|
||||
var raw struct {
|
||||
Session struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
CognitoSub string `json:"cognitoSub"`
|
||||
UserID string `json:"userId"`
|
||||
HasuraUserID string `json:"hasuraUserId"`
|
||||
TokenExpiry int64 `json:"accessTokenExpiry"`
|
||||
} `json:"session"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
} `json:"user"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("%w: get-session non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
if strings.TrimSpace(raw.Session.AccessToken) == "" {
|
||||
// No bearer despite 200 → the cookie no longer authenticates.
|
||||
return nil, ErrAuth
|
||||
}
|
||||
uid := raw.Session.UserID
|
||||
if uid == "" {
|
||||
uid = raw.Session.HasuraUserID
|
||||
}
|
||||
if uid == "" {
|
||||
uid = raw.User.ID
|
||||
}
|
||||
sess := &Session{
|
||||
AccessToken: raw.Session.AccessToken,
|
||||
CognitoSub: raw.Session.CognitoSub,
|
||||
UserID: uid,
|
||||
Email: strings.TrimSpace(raw.User.Email),
|
||||
Name: strings.TrimSpace(raw.User.Name),
|
||||
ExpiresAt: raw.Session.TokenExpiry,
|
||||
}
|
||||
if sess.ExpiresAt > time.Now().Unix() {
|
||||
c.mu.Lock()
|
||||
c.sessions[cookie] = sess
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
|
||||
user_details(where: {cognitoId: {_eq: $sub}}) {
|
||||
id
|
||||
plan
|
||||
subscriptionTokens
|
||||
paidTokens
|
||||
rolloverTokens
|
||||
tokenRenewalDate
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// FetchCreditsBalance derives a JWT from the cookie then reads the account's image
|
||||
// token balance. Returns a normalized map mirroring the other providers so the
|
||||
// TokenService quota plumbing is uniform. remaining = subscription+paid+rollover
|
||||
// (the spendable image tokens); available_until carries the daily renewal time so
|
||||
// the maintenance sweep can auto-recover a 限额 account.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
return unknownBalance(err.Error()), nil
|
||||
}
|
||||
if sess.CognitoSub == "" {
|
||||
return unknownBalance("no cognitoSub"), nil
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GetUserTokensFromSub",
|
||||
"variables": map[string]any{"sub": sess.CognitoSub},
|
||||
"query": qGetTokens,
|
||||
})
|
||||
body, status, err := c.graphql(ctx, sess.AccessToken, payload)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return unknownBalance(fmt.Sprintf("http %d: %s", status, clip(body, 160))), nil
|
||||
}
|
||||
var result struct {
|
||||
Data struct {
|
||||
UserDetails []struct {
|
||||
Plan string `json:"plan"`
|
||||
SubscriptionTokens int `json:"subscriptionTokens"`
|
||||
PaidTokens int `json:"paidTokens"`
|
||||
RolloverTokens int `json:"rolloverTokens"`
|
||||
TokenRenewalDate string `json:"tokenRenewalDate"`
|
||||
} `json:"user_details"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return unknownBalance("non-json"), nil
|
||||
}
|
||||
if len(result.Data.UserDetails) == 0 {
|
||||
return unknownBalance("no user_details"), nil
|
||||
}
|
||||
ud := result.Data.UserDetails[0]
|
||||
remaining := ud.SubscriptionTokens + ud.PaidTokens + ud.RolloverTokens
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
"plan": ud.Plan,
|
||||
"available_until": strings.TrimSpace(ud.TokenRenewalDate),
|
||||
"email": emptyStringNil(sess.Email),
|
||||
"display_name": emptyStringNil(sess.Name),
|
||||
"user_id": emptyStringNil(sess.UserID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// graphql POSTs a GraphQL body to the Leonardo API with the bearer + schema header,
|
||||
// returning the raw response body and status.
|
||||
func (c *Client) graphql(ctx context.Context, accessToken string, payload []byte) ([]byte, int, error) {
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, graphqlURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"authorization": {"Bearer " + accessToken},
|
||||
"x-leo-schema-version": {schemaVersion},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-site"},
|
||||
http.HeaderOrderKey: {
|
||||
"content-type", "accept", "accept-language", "origin", "referer",
|
||||
"user-agent", "authorization", "x-leo-schema-version",
|
||||
"sec-fetch-dest", "sec-fetch-mode", "sec-fetch-site",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": true,
|
||||
"error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
|
||||
// Match the fingerprint proven to work against Leonardo's Cloudflare edge:
|
||||
// Chrome_120, fixed extension order. A randomized JA3 (Chrome_133 +
|
||||
// WithRandomTLSExtensionOrder) gets flagged and 429'd at get-session.
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_120),
|
||||
}
|
||||
if c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
// downloadImage fetches a generated image (cdn.leonardo.ai) and returns the bytes.
|
||||
func (c *Client) downloadImage(ctx context.Context, imageURL string) ([]byte, error) {
|
||||
if _, err := url.Parse(imageURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, imageURL, 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": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, 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 body, nil
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
)
|
||||
|
||||
// defaultStyleID is the "Dynamic" style applied when the caller doesn't specify
|
||||
// one — Leonardo's Generate mutation expects a style_ids entry.
|
||||
const defaultStyleID = "111dc692-d470-4eec-b791-3475abac4c46"
|
||||
|
||||
const mGenerate = `mutation Generate($request: CreateGenerationRequest!) {
|
||||
generate(request: $request) {
|
||||
apiCreditCost
|
||||
generationId
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// qGenerationImages polls one generation's status AND its produced images in a
|
||||
// single round-trip (where: id _in [genId]).
|
||||
const qGenerationImages = `query GenerationImages($where: generations_bool_exp = {}) {
|
||||
generations(where: $where) {
|
||||
id
|
||||
status
|
||||
generated_images {
|
||||
id
|
||||
url
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!) {
|
||||
uploadImage(arg1: $uploadImageInput) {
|
||||
uploadId
|
||||
url
|
||||
fields
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// uploadInitImage uploads a reference (init) image for image-to-image: it asks
|
||||
// Leonardo for a presigned S3 POST, uploads the bytes, and returns the upload id
|
||||
// to reference in the Generate request's image_reference guidance.
|
||||
func (c *Client) uploadInitImage(ctx context.Context, accessToken string, img []byte) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadImage",
|
||||
"query": mUploadImage,
|
||||
"variables": map[string]any{"uploadImageInput": map[string]any{"uploadType": "INIT", "extension": "png"}},
|
||||
})
|
||||
body, status, err := c.graphql(ctx, accessToken, payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: upload-init: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return "", fmt.Errorf("%w: upload-init http %d: %s", ErrTemporaryUpstream, status, clip(body, 160))
|
||||
}
|
||||
if e := graphqlError(body); e != nil {
|
||||
return "", e
|
||||
}
|
||||
var ur struct {
|
||||
Data struct {
|
||||
UploadImage struct {
|
||||
UploadID string `json:"uploadId"`
|
||||
URL string `json:"url"`
|
||||
Fields string `json:"fields"`
|
||||
} `json:"uploadImage"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &ur); err != nil {
|
||||
return "", fmt.Errorf("%w: upload-init non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
up := ur.Data.UploadImage
|
||||
if up.UploadID == "" || up.URL == "" {
|
||||
return "", fmt.Errorf("%w: no upload url", ErrTemporaryUpstream)
|
||||
}
|
||||
var fields map[string]string
|
||||
if err := json.Unmarshal([]byte(up.Fields), &fields); err != nil {
|
||||
return "", fmt.Errorf("%w: bad upload fields", ErrTemporaryUpstream)
|
||||
}
|
||||
|
||||
// Presigned S3 POST: all policy fields first, the file part LAST.
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", "image.png")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, up.URL, &buf)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"content-type": {w.FormDataContentType()},
|
||||
"user-agent": {userAgent},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: s3 upload: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 204 && resp.StatusCode != 200 && resp.StatusCode != 201 {
|
||||
return "", fmt.Errorf("%w: s3 upload http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return up.UploadID, nil
|
||||
}
|
||||
|
||||
// GenerateImage runs the full Leonardo image pipeline against one account cookie:
|
||||
// mint a JWT, (for image-to-image) upload each reference image, submit the
|
||||
// Generate mutation, poll until COMPLETE, then download the first produced image.
|
||||
// Returns the image bytes, an info map, and a classified error.
|
||||
func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte) ([]byte, map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(styleIDs) == 0 {
|
||||
styleIDs = []string{defaultStyleID}
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "seedream-4.5"
|
||||
}
|
||||
|
||||
// Image-to-image: upload each reference and collect its guidance entry.
|
||||
var imageRefs []map[string]any
|
||||
for _, img := range refImages {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadInitImage(ctx, sess.AccessToken, img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, map[string]any{
|
||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
||||
"strength": "MID",
|
||||
})
|
||||
}
|
||||
|
||||
promptEnhance := "AUTO"
|
||||
parameters := map[string]any{
|
||||
"height": height,
|
||||
"width": width,
|
||||
"prompt_enhance": promptEnhance,
|
||||
"quantity": 1,
|
||||
"style_ids": styleIDs,
|
||||
"prompt": prompt,
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
// Preserve the reference when image-guided (matches the web app).
|
||||
parameters["prompt_enhance"] = "OFF"
|
||||
parameters["guidances"] = map[string]any{"image_reference": imageRefs}
|
||||
}
|
||||
|
||||
// 1. submit
|
||||
genReq := map[string]any{
|
||||
"operationName": "Generate",
|
||||
"query": mGenerate,
|
||||
"variables": map[string]any{
|
||||
"request": map[string]any{
|
||||
"model": model,
|
||||
"public": true,
|
||||
"parameters": parameters,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, _ := json.Marshal(genReq)
|
||||
body, status, err := c.graphql(ctx, sess.AccessToken, payload)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if status != 200 {
|
||||
return nil, nil, fmt.Errorf("%w: generate http %d: %s", ErrTemporaryUpstream, status, clip(body, 200))
|
||||
}
|
||||
if e := graphqlError(body); e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
Generate struct {
|
||||
GenerationID string `json:"generationId"`
|
||||
} `json:"generate"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &genResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("%w: generate non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
genID := strings.TrimSpace(genResp.Data.Generate.GenerationID)
|
||||
if genID == "" {
|
||||
return nil, nil, fmt.Errorf("%w: no generationId: %s", ErrTemporaryUpstream, clip(body, 200))
|
||||
}
|
||||
|
||||
// 2. poll until COMPLETE, then read the image url.
|
||||
imageURL, err := c.pollImage(ctx, sess.AccessToken, genID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 3. download bytes
|
||||
data, err := c.downloadImage(ctx, imageURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info := map[string]any{
|
||||
"generation_id": genID,
|
||||
"image_url": imageURL,
|
||||
"user_id": sess.UserID,
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
// pollImage polls one generation until it reports COMPLETE (returning the first
|
||||
// image url) or FAILED (error). Honors ctx cancellation / deadline.
|
||||
func (c *Client) pollImage(ctx context.Context, accessToken, genID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GenerationImages",
|
||||
"query": qGenerationImages,
|
||||
"variables": map[string]any{
|
||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
||||
},
|
||||
})
|
||||
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
// Cap the wait independent of the parent deadline so a stuck job can't hang.
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
|
||||
for {
|
||||
body, status, err := c.graphql(ctx, accessToken, payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: poll: %s", ErrTemporaryUpstream, err.Error())
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status == 200 {
|
||||
var pr struct {
|
||||
Data struct {
|
||||
Generations []struct {
|
||||
Status string `json:"status"`
|
||||
GeneratedImages []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"generated_images"`
|
||||
} `json:"generations"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &pr); err == nil && len(pr.Data.Generations) > 0 {
|
||||
g := pr.Data.Generations[0]
|
||||
switch strings.ToUpper(g.Status) {
|
||||
case "COMPLETE":
|
||||
for _, img := range g.GeneratedImages {
|
||||
if u := strings.TrimSpace(img.URL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: complete but no image url", ErrTemporaryUpstream)
|
||||
case "FAILED":
|
||||
return "", fmt.Errorf("%w: generation failed", ErrTemporaryUpstream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("%w: generation timed out", ErrTemporaryUpstream)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// graphqlError inspects a GraphQL response body for an "errors" array and maps the
|
||||
// first message to a classified sentinel (auth / quota / temporary). Returns nil
|
||||
// when there are no errors.
|
||||
func graphqlError(body []byte) error {
|
||||
var env struct {
|
||||
Errors []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err != nil || len(env.Errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
msg := strings.TrimSpace(env.Errors[0].Message)
|
||||
low := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(low, "unauthor") || strings.Contains(low, "jwt") || strings.Contains(low, "token is") || strings.Contains(low, "forbidden"):
|
||||
return ErrAuth
|
||||
case strings.Contains(low, "token") || strings.Contains(low, "credit") || strings.Contains(low, "quota") || strings.Contains(low, "insufficient") || strings.Contains(low, "not enough"):
|
||||
return ErrQuotaExhausted
|
||||
default:
|
||||
return fmt.Errorf("leonardo: %s", clip([]byte(msg), 200))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// Package runway implements the Runway (runwayml.com) provider client. For now
|
||||
// it only covers account management — JWT detection, workspace/team id
|
||||
// extraction and credit-balance probing — mirroring the curl_cffi reference in
|
||||
// query_credits.py with tls-client so the JA3/JA4 fingerprint matches Chrome.
|
||||
package runway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBase = "https://api.runwayml.com"
|
||||
origin = "https://app.runwayml.com"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("runway auth failed")
|
||||
ErrQuotaExhausted = errors.New("runway quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("runway upstream temporary error")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
proxy string
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy)}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
c.proxy = strings.TrimSpace(proxy)
|
||||
}
|
||||
|
||||
// IsRunwayToken reports whether a JWT looks like a Runway access token: a
|
||||
// top-level numeric "id" plus an "sso" claim, and crucially NO OpenAI
|
||||
// (https://api.openai.com/*) claims — that's what disambiguates it from a
|
||||
// ChatGPT token, which is otherwise also an opaque three-part JWT.
|
||||
func IsRunwayToken(token string) bool {
|
||||
claims := decodeJWTPayload(token)
|
||||
if len(claims) == 0 {
|
||||
return false
|
||||
}
|
||||
for k := range claims {
|
||||
if strings.HasPrefix(k, "https://api.openai.com/") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
_, hasSSO := claims["sso"]
|
||||
return hasSSO && claims["id"] != nil
|
||||
}
|
||||
|
||||
// TeamIDFromToken returns the Runway workspace/team id, which equals the JWT
|
||||
// "id" claim (query_credits.py / gen_video.py both derive teamId this way).
|
||||
func TeamIDFromToken(token string) string {
|
||||
claims := decodeJWTPayload(token)
|
||||
switch v := claims["id"].(type) {
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case json.Number:
|
||||
return v.String()
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractAccountInfo decodes the free (no-network) JWT claims for the accounts
|
||||
// view: email, team id and expiry.
|
||||
func ExtractAccountInfo(token string) map[string]any {
|
||||
claims := decodeJWTPayload(token)
|
||||
return map[string]any{
|
||||
"email": emptyStringNil(strings.TrimSpace(stringValue(claims["email"]))),
|
||||
"team_id": emptyStringNil(TeamIDFromToken(token)),
|
||||
"expires_at": claims["exp"],
|
||||
}
|
||||
}
|
||||
|
||||
// FetchCreditsBalance probes the account's plan credits via /v1/profile/features
|
||||
// (query_credits.py). Returns a normalized map mirroring the Adobe client so the
|
||||
// TokenService quota plumbing can treat all providers uniformly. A 401/403 maps
|
||||
// to ErrAuth (token dead); any other failure is reported as unknown without
|
||||
// killing the account.
|
||||
func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[string]any, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return unknownBalance("empty token"), nil
|
||||
}
|
||||
teamID := TeamIDFromToken(token)
|
||||
if teamID == "" {
|
||||
return unknownBalance("no team id"), nil
|
||||
}
|
||||
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := apiBase + "/v1/profile/features?asTeamId=" + teamID
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json"},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"x-runway-workspace": {teamID},
|
||||
http.HeaderOrderKey: {
|
||||
"accept",
|
||||
"content-type",
|
||||
"origin",
|
||||
"referer",
|
||||
"authorization",
|
||||
"x-runway-workspace",
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return unknownBalance("network: " + err.Error()), nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return unknownBalance(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 unknownBalance("non-json"), nil
|
||||
}
|
||||
features, _ := payload["features"].(map[string]any)
|
||||
permitted, _ := features["permitted"].(map[string]any)
|
||||
used, _ := features["used"].(map[string]any)
|
||||
total := intValue(permitted["numPlanCredits"])
|
||||
spent := intValue(used["numPlanCredits"])
|
||||
remaining := total - spent
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": spent,
|
||||
"total": total,
|
||||
"unknown": false,
|
||||
"error": nil,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func unknownBalance(reason string) map[string]any {
|
||||
return map[string]any{
|
||||
"remaining": nil,
|
||||
"used": nil,
|
||||
"total": nil,
|
||||
"unknown": true,
|
||||
"error": reason,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient() (tlsclient.HttpClient, error) {
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(30),
|
||||
tlsclient.WithClientProfile(profiles.Chrome_133),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
return tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
}
|
||||
|
||||
func decodeJWTPayload(token string) map[string]any {
|
||||
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(token, "Bearer ")), ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
b, _ := json.Marshal(x)
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
case json.Number:
|
||||
n, _ := x.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(x))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func emptyStringNil(v string) any {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clip(b []byte, n int) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > n {
|
||||
return s[:n]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package runway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
mrand "math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
http "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ratioDimensions maps an aspect ratio to the Gen-4 Turbo native output size.
|
||||
// These are the only dimensions gen4_turbo accepts; "2K" is a UI label over this
|
||||
// native tier (see runway-video-gen-spec). Unknown ratios fall back to 16:9.
|
||||
func ratioDimensions(aspectRatio string) (int, int) {
|
||||
switch strings.TrimSpace(strings.ReplaceAll(aspectRatio, "x", ":")) {
|
||||
case "16:9":
|
||||
return 1280, 720
|
||||
case "9:16":
|
||||
return 720, 1280
|
||||
case "1:1":
|
||||
return 960, 960
|
||||
case "4:3":
|
||||
return 1104, 832
|
||||
case "3:4":
|
||||
return 832, 1104
|
||||
case "21:9":
|
||||
return 1584, 672
|
||||
default:
|
||||
return 1280, 720
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateVideo runs the full i2v pipeline (gen_video.py): upload the first-frame
|
||||
// image (preview + dataset), create a dataset, create a gen4_turbo task and poll
|
||||
// it to completion, then download the rendered MP4. teamID is the workspace id
|
||||
// (meta["team_id"]); if empty it's derived from the token. seconds must be 5 or
|
||||
// 10; aspectRatio picks the native output size.
|
||||
// GenerateVideo renders the clip and (when downloadResult) downloads the MP4.
|
||||
// With downloadResult=false it returns nil bytes and the upstream artifact 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, teamID, prompt, aspectRatio string, seconds int, frame []byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, nil, ErrAuth
|
||||
}
|
||||
if teamID == "" {
|
||||
teamID = TeamIDFromToken(token)
|
||||
}
|
||||
if teamID == "" {
|
||||
return nil, nil, errors.New("runway: no team id")
|
||||
}
|
||||
if len(frame) == 0 {
|
||||
return nil, nil, errors.New("runway: first-frame image required")
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("runway: failed to decode first-frame image")
|
||||
}
|
||||
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
filename := "frame_" + time.Now().UTC().Format("20060102_150405") + ".png"
|
||||
previewUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET_PREVIEW", frame)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
datasetUploadID, _, err := c.uploadFile(ctx, client, token, teamID, filename, "DATASET", frame)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
assetID, imageURL, err := c.createDataset(ctx, client, token, teamID, filename, datasetUploadID, previewUploadID, cfg.Width, cfg.Height)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
assetGroupID, _ := c.assetGroupID(ctx, client, token, teamID) // best-effort
|
||||
|
||||
taskID, err := c.createTask(ctx, client, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio, seconds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
artifactURL, err := c.pollTask(ctx, client, token, teamID, taskID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"provider": "runway",
|
||||
"task_id": taskID,
|
||||
"team_id": teamID,
|
||||
"video_url": artifactURL,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, meta, nil
|
||||
}
|
||||
data, err := c.download(ctx, client, artifactURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, meta, nil
|
||||
}
|
||||
|
||||
// uploadFile mirrors gen_video.upload_file: register the upload, PUT the bytes to
|
||||
// the returned S3 URL, then complete. Returns the upload id and final url.
|
||||
func (c *Client) uploadFile(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, uploadType string, data []byte) (string, string, error) {
|
||||
info, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads", map[string]any{
|
||||
"filename": filename,
|
||||
"numberOfParts": 1,
|
||||
"type": uploadType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
uploadID := strings.TrimSpace(stringValue(info["id"]))
|
||||
urls, _ := info["uploadUrls"].([]any)
|
||||
if uploadID == "" || len(urls) == 0 {
|
||||
return "", "", fmt.Errorf("%w: upload register missing fields", ErrTemporaryUpstream)
|
||||
}
|
||||
putURL := strings.TrimSpace(stringValue(urls[0]))
|
||||
contentType := "application/octet-stream"
|
||||
if hdrs, ok := info["uploadHeaders"].(map[string]any); ok {
|
||||
if ct := strings.TrimSpace(stringValue(hdrs["Content-Type"])); ct != "" {
|
||||
contentType = ct
|
||||
}
|
||||
}
|
||||
|
||||
etag, err := c.putBytes(ctx, client, putURL, contentType, data)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/uploads/"+uploadID+"/complete", map[string]any{
|
||||
"parts": []map[string]any{{"PartNumber": 1, "ETag": etag}},
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return uploadID, strings.TrimSpace(stringValue(res["url"])), nil
|
||||
}
|
||||
|
||||
func (c *Client) createDataset(ctx context.Context, client tlsclient.HttpClient, token, teamID, filename, datasetUploadID, previewUploadID string, w, h int) (string, string, error) {
|
||||
teamIDNum := jsonNumberOrString(teamID)
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/datasets", map[string]any{
|
||||
"fileCount": 1,
|
||||
"name": filename,
|
||||
"uploadId": datasetUploadID,
|
||||
"previewUploadIds": []string{previewUploadID},
|
||||
"metadata": map[string]any{"size": map[string]any{"width": w, "height": h}},
|
||||
"type": map[string]any{"name": "image", "type": "image", "isDirectory": false},
|
||||
"asTeamId": teamIDNum,
|
||||
"privateInTeam": true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
ds, _ := res["dataset"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(ds["id"]))
|
||||
url := strings.TrimSpace(stringValue(ds["url"]))
|
||||
if id == "" || url == "" {
|
||||
return "", "", fmt.Errorf("%w: dataset missing fields", ErrTemporaryUpstream)
|
||||
}
|
||||
return id, url, nil
|
||||
}
|
||||
|
||||
func (c *Client) assetGroupID(ctx context.Context, client tlsclient.HttpClient, token, teamID string) (string, error) {
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet,
|
||||
"/v1/asset_groups/by_name?name=Generations&asTeamId="+teamID+"&privateInTeam=true", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ag, _ := res["assetGroup"].(map[string]any)
|
||||
return strings.TrimSpace(stringValue(ag["id"])), nil
|
||||
}
|
||||
|
||||
func (c *Client) createTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, prompt, imageURL, assetID, assetGroupID, aspectRatio string, seconds int) (string, error) {
|
||||
w, h := ratioDimensions(aspectRatio)
|
||||
opts := map[string]any{
|
||||
"route": "i2v",
|
||||
"name": "Gen-4 Turbo - " + prompt,
|
||||
"text_prompt": prompt,
|
||||
"seconds": seconds,
|
||||
"width": w,
|
||||
"height": h,
|
||||
"init_image": imageURL,
|
||||
"imageAssetId": assetID,
|
||||
"exploreMode": false,
|
||||
"creationSource": "tool-mode",
|
||||
"seed": mrand.IntN(999999999) + 1,
|
||||
"watermark": true,
|
||||
}
|
||||
if assetGroupID != "" {
|
||||
opts["assetGroupId"] = assetGroupID
|
||||
}
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodPost, "/v1/tasks", map[string]any{
|
||||
"taskType": "gen4_turbo",
|
||||
"options": opts,
|
||||
"asTeamId": jsonNumberOrString(teamID),
|
||||
"sessionId": uuid.NewString(),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
id := strings.TrimSpace(stringValue(task["id"]))
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("%w: task missing id", ErrTemporaryUpstream)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (c *Client) pollTask(ctx context.Context, client tlsclient.HttpClient, token, teamID, taskID string) (string, error) {
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := c.apiJSON(ctx, client, token, teamID, http.MethodGet, "/v1/tasks/"+taskID+"?asTeamId="+teamID, nil)
|
||||
if err != nil {
|
||||
// A transient blip shouldn't kill a render that may still succeed.
|
||||
if errors.Is(err, ErrTemporaryUpstream) {
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
continue
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
task, _ := res["task"].(map[string]any)
|
||||
status := strings.ToUpper(strings.TrimSpace(stringValue(task["status"])))
|
||||
switch status {
|
||||
case "SUCCEEDED":
|
||||
arts, _ := task["artifacts"].([]any)
|
||||
for _, raw := range arts {
|
||||
art, _ := raw.(map[string]any)
|
||||
if url := strings.TrimSpace(stringValue(art["url"])); url != "" {
|
||||
return url, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("runway: task succeeded with no artifact url")
|
||||
case "FAILED", "CANCELED":
|
||||
reason := strings.TrimSpace(stringValue(task["error"]))
|
||||
if isCreditError(reason) {
|
||||
return "", fmt.Errorf("%w: %s", ErrQuotaExhausted, reason)
|
||||
}
|
||||
return "", fmt.Errorf("runway: task %s: %s", status, reason)
|
||||
}
|
||||
if sleepCtx(ctx, 5*time.Second) != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apiJSON performs an authed JSON request against the Runway API and returns the
|
||||
// parsed body, mapping status codes to the shared provider error sentinels.
|
||||
func (c *Client) apiJSON(ctx context.Context, client tlsclient.HttpClient, token, teamID, method, path string, body any) (map[string]any, error) {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
reader = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequest(method, apiBase+path, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{
|
||||
"accept": {"application/json"},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"authorization": {"Bearer " + token},
|
||||
"x-runway-workspace": {teamID},
|
||||
http.HeaderOrderKey: {
|
||||
"accept", "content-type", "origin", "referer", "authorization", "x-runway-workspace",
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return nil, fmt.Errorf("%w: %s %d %s", ErrAuth, path, resp.StatusCode, clip(raw, 200))
|
||||
case resp.StatusCode == 429:
|
||||
return nil, fmt.Errorf("%w: %s 429 %s", ErrQuotaExhausted, path, clip(raw, 200))
|
||||
case resp.StatusCode >= 500:
|
||||
return nil, fmt.Errorf("%w: %s %d %s", ErrTemporaryUpstream, path, resp.StatusCode, clip(raw, 200))
|
||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
||||
if isCreditError(string(raw)) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrQuotaExhausted, clip(raw, 200))
|
||||
}
|
||||
return nil, fmt.Errorf("runway: %s %d %s", path, resp.StatusCode, clip(raw, 200))
|
||||
}
|
||||
var out map[string]any
|
||||
if len(raw) == 0 {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s non-json: %s", ErrTemporaryUpstream, path, clip(raw, 120))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// putBytes uploads raw bytes to a presigned S3 URL (no auth) and returns the
|
||||
// ETag, mirroring the plain requests.Session().put in gen_video.py.
|
||||
func (c *Client) putBytes(ctx context.Context, client tlsclient.HttpClient, url, contentType string, data []byte) (string, error) {
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = http.Header{"content-type": {contentType}}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("%w: s3 put %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
return strings.Trim(resp.Header.Get("ETag"), `"`), nil
|
||||
}
|
||||
|
||||
func (c *Client) download(ctx context.Context, client tlsclient.HttpClient, url string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%w: download %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("runway: empty artifact download")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// jsonNumberOrString returns the team id as a JSON number when it's purely
|
||||
// numeric (Runway's asTeamId is an integer in the reference payloads), else the
|
||||
// raw string.
|
||||
func jsonNumberOrString(teamID string) any {
|
||||
return json.Number(strings.TrimSpace(teamID))
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
||||
}
|
||||
|
||||
// sleepCtx sleeps for d or until ctx is done; returns ctx.Err() if cancelled.
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user