feat: sync all features to image2api
This commit is contained in:
@@ -0,0 +1,629 @@
|
||||
// Package creativefabrica implements the Creative Fabrica Studio
|
||||
// (studio.creativefabrica.com) video-generation upstream.
|
||||
//
|
||||
// One-shot accounts: every account's coins are just enough for exactly one
|
||||
// generation, so a successful render kills the account. The credential is a
|
||||
// .creativefabrica.com session cookie; a short-lived JWT is minted from it on
|
||||
// demand via GraphQL /query/userAuth, and every model request authenticates
|
||||
// with that JWT (the cookie is sent along as a fallback).
|
||||
package creativefabrica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fhttp "github.com/bogdanfinn/fhttp"
|
||||
tlsclient "github.com/bogdanfinn/tls-client"
|
||||
"github.com/bogdanfinn/tls-client/profiles"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuth = errors.New("creativefabrica auth failed")
|
||||
ErrQuotaExhausted = errors.New("creativefabrica quota exhausted")
|
||||
ErrTemporaryUpstream = errors.New("creativefabrica upstream temporary error")
|
||||
ErrDeadUpstream = errors.New("creativefabrica upstream fatal error")
|
||||
ErrRateLimited = errors.New("creativefabrica rate limited")
|
||||
// ErrPaymentRequired marks an account whose payment intent is in a failed
|
||||
// state — it can never generate (the studio answers 400 failed_precondition
|
||||
// "payment required ... COIN_PAYMENT_INTENT_STATUS_FAILED"). It wraps ErrAuth
|
||||
// so the pool kills the account and fails over instead of burning retries.
|
||||
ErrPaymentRequired = fmt.Errorf("%w: payment required", ErrAuth)
|
||||
)
|
||||
|
||||
// isPaymentRequired reports whether a non-200 body is the account-level
|
||||
// "payment required" rejection rather than a request-level parameter error.
|
||||
//
|
||||
// Connect unary errors don't always carry the marker in plaintext: the studio
|
||||
// answers failed_precondition with the real detail base64-protobuf-encoded in
|
||||
// details[].value ("payment required. payment status: COIN_PAYMENT_INTENT_...").
|
||||
// Decode those values and scan the decoded bytes, so a failed coin intent still
|
||||
// kills the account instead of being misread as a request-level 400.
|
||||
func isPaymentRequired(status int, body string) bool {
|
||||
if status != 400 {
|
||||
return false
|
||||
}
|
||||
b := strings.ToLower(body)
|
||||
if strings.Contains(b, "payment required") || strings.Contains(b, "coin_payment_intent") {
|
||||
return true
|
||||
}
|
||||
var env struct {
|
||||
Details []struct {
|
||||
Value string `json:"value"`
|
||||
} `json:"details"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &env); err != nil {
|
||||
return false
|
||||
}
|
||||
for _, d := range env.Details {
|
||||
raw, err := base64.StdEncoding.DecodeString(d.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
low := strings.ToLower(string(raw))
|
||||
if strings.Contains(low, "payment required") ||
|
||||
strings.Contains(low, "coin_payment_intent") ||
|
||||
strings.Contains(low, "coin_error_code_payment_required") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
graphQLHost = "https://graphql-gw.creativefabrica.com"
|
||||
mediaMatrixHost = "https://studio-media-matrix.creativefabrica.com"
|
||||
userAuthPath = "/query/userAuth"
|
||||
userBalancePath = "/query/userBalance"
|
||||
userPath = "/query/user"
|
||||
initiatePath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/InitiateSession"
|
||||
listSessionsPath = "/creativefabrica.studiomediamatrix.v1.StudioMediaMatrixService/ListSessions"
|
||||
origin = "https://studio.creativefabrica.com"
|
||||
pollInterval = 5 * time.Second
|
||||
pollTimeout = 16 * time.Minute
|
||||
downloadTimeout = 3 * time.Minute
|
||||
videoServiceType = "SERVICE_TYPE_VIDEO_GENERATOR"
|
||||
videoFrameRef = "VIDEO_FRAME_TYPE_REFERENCE"
|
||||
visibilityPrivate = "SESSION_VISIBILITY_PRIVATE"
|
||||
)
|
||||
|
||||
// Model is one Creative Fabrica video model: the local catalog id, the upstream
|
||||
// enum, the fixed duration in seconds, and the upstream resolution label.
|
||||
type Model struct {
|
||||
ID string // local model_configs id, e.g. "seedance-2.0"
|
||||
Enum string // upstream enum, e.g. VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2
|
||||
Duration int // fixed seconds (account plan is fixed-length)
|
||||
Resolution string // upstream resolution label, e.g. 720p
|
||||
}
|
||||
|
||||
// Models returns the two Creative Fabrica seedance models. The upstream enum
|
||||
// differs between the two (SEEDANCE_2_FAST vs SEEDDREAM_2), matching the
|
||||
// studio frontend's InitiateSession payloads.
|
||||
func Models() map[string]Model {
|
||||
return map[string]Model{
|
||||
"seedance-2.0-fast": {
|
||||
ID: "seedance-2.0-fast",
|
||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDANCE_2_FAST",
|
||||
Duration: 14,
|
||||
Resolution: "720p",
|
||||
},
|
||||
"seedance-2.0": {
|
||||
ID: "seedance-2.0",
|
||||
Enum: "VIDEO_GENERATOR_MODEL_BYTEDANCE_SEEDDREAM_2",
|
||||
Duration: 10,
|
||||
Resolution: "720p",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupModel resolves a local model id to its upstream config. Returns ok=false
|
||||
// when the id isn't a Creative Fabrica model.
|
||||
func LookupModel(modelID string) (Model, bool) {
|
||||
m, ok := Models()[modelID]
|
||||
return m, ok
|
||||
}
|
||||
|
||||
// Client talks to the Creative Fabrica Studio API through a Chrome-fingerprinted
|
||||
// TLS client so the Cloudflare-protected Connect endpoints don't reject us.
|
||||
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)
|
||||
}
|
||||
|
||||
// ExchangeToken mints the short-lived JWT from the account cookie via GraphQL
|
||||
// /query/userAuth. Returns the token and the user id. A null / missing me means
|
||||
// the cookie no longer authenticates → ErrAuth.
|
||||
func (c *Client) ExchangeToken(ctx context.Context, cookie string) (token, userID string, err error) {
|
||||
query := `{"query":"\n query userAuth {\n me {\n token\n user {\n id\n isTemporary\n }\n }\n}\n "}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Me *struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"user"`
|
||||
} `json:"me"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userAuthPath, query)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", "", fmt.Errorf("%w: bad token response: %s", ErrAuth, clip(body, 300))
|
||||
}
|
||||
if payload.Data.Me == nil || strings.TrimSpace(payload.Data.Me.Token) == "" {
|
||||
return "", "", fmt.Errorf("%w: cookie did not authenticate", ErrAuth)
|
||||
}
|
||||
return payload.Data.Me.Token, payload.Data.Me.User.ID, nil
|
||||
}
|
||||
|
||||
// FetchBalance reads the coin balance via GraphQL /query/userBalance (the
|
||||
// request authenticates with the cookie alone). Negative value on error.
|
||||
func (c *Client) FetchBalance(ctx context.Context, cookie string) (int64, error) {
|
||||
query := `{"query":"\nquery userBalance {\n userBalance {\n balance\n }\n}\n\n"}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
UserBalance *struct {
|
||||
Balance json.Number `json:"balance"`
|
||||
} `json:"userBalance"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userBalancePath, query)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return -1, fmt.Errorf("bad balance response: %s", clip(body, 300))
|
||||
}
|
||||
if payload.Data.UserBalance == nil {
|
||||
return -1, ErrAuth
|
||||
}
|
||||
b, _ := payload.Data.UserBalance.Balance.Int64()
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// FetchUser reads the profile (email, name) via GraphQL /query/user, which the
|
||||
// studio browser hits on every page load. The request authenticates with the
|
||||
// cookie alone. Returns the email (empty on error); a null me means the cookie
|
||||
// no longer authenticates → ErrAuth.
|
||||
func (c *Client) FetchUser(ctx context.Context, cookie string) (string, error) {
|
||||
query := `{"query":"\n query user {\n me {\n token\n user {\n id\n email\n }\n }\n}\n "}`
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Me *struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
} `json:"user"`
|
||||
} `json:"me"`
|
||||
} `json:"data"`
|
||||
}
|
||||
body, err := c.postGraphQL(ctx, cookie, "", userPath, query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return "", fmt.Errorf("%w: bad user response: %s", ErrAuth, clip(body, 300))
|
||||
}
|
||||
if payload.Data.Me == nil || payload.Data.Me.User.ID == "" {
|
||||
return "", ErrAuth
|
||||
}
|
||||
return strings.TrimSpace(payload.Data.Me.User.Email), nil
|
||||
}
|
||||
|
||||
// GenerateVideo runs the full generation: InitiateSession → PUT reference
|
||||
// images to the presigned S3 URLs → poll ListSessions until COMPLETED → (when
|
||||
// downloadResult) fetch the MP4. Returns the bytes (nil when url-only) and the
|
||||
// previewMediaUrl. durationSeconds is ignored: the plan fixes the length per
|
||||
// model (LookupModel.Duration).
|
||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, token, modelID, prompt, aspectRatio string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||
m, ok := LookupModel(modelID)
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("creativefabrica: unknown model %q", modelID)
|
||||
}
|
||||
sessionID, uploads, err := c.initiateSession(ctx, cookie, token, m, prompt, aspectRatio, refs)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
// Each ref uploads to the presigned URL the session returned for it.
|
||||
for i, u := range uploads {
|
||||
if err := c.putS3(ctx, u, refs[i]); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
videoURL, err := c.pollSession(ctx, cookie, token, sessionID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, videoURL, nil
|
||||
}
|
||||
data, err := c.download(ctx, videoURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return data, videoURL, nil
|
||||
}
|
||||
|
||||
// initiateSession submits the generation and returns the session id plus the
|
||||
// presigned upload URLs (one per reference image).
|
||||
func (c *Client) initiateSession(ctx context.Context, cookie, token string, m Model, prompt, aspectRatio string, refs [][]byte) (string, []string, error) {
|
||||
frames := make([]any, 0, len(refs))
|
||||
refPrompt := strings.TrimSpace(prompt)
|
||||
for i := range refs {
|
||||
ref := fmt.Sprintf("img%d", i+1)
|
||||
frames = append(frames, map[string]any{
|
||||
"type": videoFrameRef,
|
||||
"fileSize": fmt.Sprintf("%d", len(refs[i])),
|
||||
"fileName": randomFileName(i + 1),
|
||||
"ref": ref,
|
||||
})
|
||||
if !strings.Contains(refPrompt, "["+ref+"]") {
|
||||
refPrompt += " Use [" + ref + "]"
|
||||
}
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"visibility": visibilityPrivate,
|
||||
"sessionRequestPromptToVideoGeneratorContent": map[string]any{
|
||||
"serviceType": videoServiceType,
|
||||
"promptContent": map[string]any{"prompt": refPrompt},
|
||||
"resolution": resolutionEnum(m.Resolution),
|
||||
"model": m.Enum,
|
||||
"frames": frames,
|
||||
"aspectRatio": aspectRatioEnum(aspectRatio),
|
||||
"directorConfig": map[string]any{
|
||||
"filmStock": map[string]any{"color": "DIRECTOR_FILM_STOCK_COLOR_FULL_COLOR"},
|
||||
},
|
||||
"videoDuration": map[string]any{"inSeconds": m.Duration},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
resp, err := c.postConnect(ctx, cookie, token, initiatePath, body, true)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var payload struct {
|
||||
Session struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
// The presigned upload URLs are nested under promptToVideoGeneratorContent.frames
|
||||
// (each frame echoes the request frame + the S3 presigned `url`), not under a
|
||||
// top-level session.frames. Reading the wrong level yields 0 uploads.
|
||||
PromptToVideoGeneratorContent struct {
|
||||
Frames []struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"frames"`
|
||||
} `json:"promptToVideoGeneratorContent"`
|
||||
} `json:"session"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
||||
return "", nil, fmt.Errorf("creativefabrica bad initiate response: %s", clip(resp, 300))
|
||||
}
|
||||
if strings.TrimSpace(payload.Session.ID) == "" {
|
||||
return "", nil, fmt.Errorf("creativefabrica initiate missing session: %s", clip(resp, 300))
|
||||
}
|
||||
respFrames := payload.Session.PromptToVideoGeneratorContent.Frames
|
||||
uploads := make([]string, 0, len(respFrames))
|
||||
for _, f := range respFrames {
|
||||
if u := strings.TrimSpace(f.URL); u != "" {
|
||||
uploads = append(uploads, u)
|
||||
}
|
||||
}
|
||||
if len(uploads) != len(refs) {
|
||||
return "", nil, fmt.Errorf("creativefabrica initiate returned %d upload urls for %d refs", len(uploads), len(refs))
|
||||
}
|
||||
return payload.Session.ID, uploads, nil
|
||||
}
|
||||
|
||||
// pollSession polls ListSessions until the session is COMPLETED / FAILED and
|
||||
// returns previewMediaUrl on success.
|
||||
func (c *Client) pollSession(ctx context.Context, cookie, token, sessionID string) (string, error) {
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"serviceType": videoServiceType,
|
||||
"pagination": map[string]any{"take": 100},
|
||||
"surface": "SURFACE_STUDIO",
|
||||
})
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return "", fmt.Errorf("creativefabrica generation timed out after %v", pollTimeout)
|
||||
}
|
||||
resp, err := c.postConnect(ctx, cookie, token, listSessionsPath, reqBody, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var payload struct {
|
||||
Sessions []map[string]any `json:"sessions"`
|
||||
}
|
||||
if err := json.Unmarshal(resp, &payload); err != nil {
|
||||
return "", fmt.Errorf("creativefabrica bad list response: %s", clip(resp, 300))
|
||||
}
|
||||
for _, s := range payload.Sessions {
|
||||
id, _ := s["id"].(string)
|
||||
if id != sessionID {
|
||||
continue
|
||||
}
|
||||
status, _ := s["status"].(string)
|
||||
switch status {
|
||||
case "SESSION_STATUS_COMPLETED":
|
||||
if u := strings.TrimSpace(stringValue(s["previewMediaUrl"])); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
return "", fmt.Errorf("creativefabrica session completed without preview url")
|
||||
case "SESSION_STATUS_FAILED", "SESSION_STATUS_CANCELLED", "SESSION_STATUS_ERROR":
|
||||
detail := ""
|
||||
for _, k := range []string{"errorMessage", "failureReason", "error", "message"} {
|
||||
if v, ok := s[k]; ok {
|
||||
if d := strings.TrimSpace(stringValue(v)); d != "" {
|
||||
detail = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("creativefabrica session %s%s", status, withDetail(detail))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(pollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// putS3 uploads a reference image to the presigned URL. S3 doesn't care about
|
||||
// TLS fingerprinting, so a plain client is fine here.
|
||||
func (c *Client) putS3(ctx context.Context, presigned string, data []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, presigned, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "image/png")
|
||||
req.Header.Set("Origin", origin)
|
||||
req.Header.Set("Referer", origin+"/")
|
||||
req.Header.Set("User-Agent", defaultUA())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creativefabrica s3 upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("creativefabrica s3 upload failed: %d %s", resp.StatusCode, clip(body, 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches the finished MP4 from the public video-v2 URL.
|
||||
func (c *Client) download(parent context.Context, videoURL string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(parent), downloadTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, videoURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("User-Agent", defaultUA())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: download: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("creativefabrica download failed: %d", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: read body: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// postGraphQL calls a graphql-gw endpoint with the raw JSON body. Authenticated
|
||||
// by cookie (balance) and optionally the JWT too. Direct connection (no proxy).
|
||||
func (c *Client) postGraphQL(ctx context.Context, cookie, token, path, body string) ([]byte, error) {
|
||||
return c.postJSON(ctx, graphQLHost+path, token, cookie, []byte(body), false, true, false)
|
||||
}
|
||||
|
||||
// postConnect calls a Connect unary endpoint on the media-matrix service. The
|
||||
// Connect-Protocol-Version header marks the POST as a Connect RPC (distinct from
|
||||
// a plain JSON REST POST) — the studio browser always sends it. Only the
|
||||
// InitiateSession call (下单) egresses through the proxy; polling runs direct.
|
||||
func (c *Client) postConnect(ctx context.Context, cookie, token, path string, body []byte, useProxy bool) ([]byte, error) {
|
||||
return c.postJSON(ctx, mediaMatrixHost+path, token, cookie, body, true, false, useProxy)
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, url, token, cookie string, body []byte, connect, graphql, useProxy bool) ([]byte, error) {
|
||||
sess, err := c.newTLSClient(useProxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := fhttp.NewRequest(fhttp.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
req.Header = fhttp.Header{
|
||||
"content-type": {"application/json"},
|
||||
"accept": {"*/*"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"user-agent": {defaultUA()},
|
||||
}
|
||||
if graphql {
|
||||
req.Header.Set("accept", "application/json, multipart/mixed")
|
||||
}
|
||||
if connect {
|
||||
req.Header.Set("connect-protocol-version", "1")
|
||||
}
|
||||
if cookie != "" {
|
||||
req.Header.Set("cookie", cookie)
|
||||
}
|
||||
if token != "" {
|
||||
req.Header.Set("authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := sess.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creativefabrica request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == 401 || resp.StatusCode == 403:
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrAuth, resp.StatusCode, clip(data, 300))
|
||||
case isPaymentRequired(resp.StatusCode, string(data)):
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrPaymentRequired, resp.StatusCode, clip(data, 300))
|
||||
case resp.StatusCode == 429:
|
||||
return nil, fmt.Errorf("%w (429)", ErrRateLimited)
|
||||
case resp.StatusCode >= 500:
|
||||
return nil, fmt.Errorf("%w (%d: %s)", ErrDeadUpstream, resp.StatusCode, clip(data, 300))
|
||||
case resp.StatusCode != 200:
|
||||
return nil, fmt.Errorf("creativefabrica %d: %s", resp.StatusCode, clip(data, 300))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func resolutionEnum(res string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(res)) {
|
||||
case "720p":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
||||
case "1080p":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_1080P"
|
||||
default:
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_RESOLUTION_720P"
|
||||
}
|
||||
}
|
||||
|
||||
func aspectRatioEnum(ratio string) string {
|
||||
switch strings.ReplaceAll(strings.TrimSpace(ratio), " ", "") {
|
||||
case "16:9":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
||||
case "9:16":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_9_16"
|
||||
case "1:1":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_1_1"
|
||||
case "4:3":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_4_3"
|
||||
case "3:4":
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_3_4"
|
||||
default:
|
||||
return "PROMPT_TO_VIDEO_GENERATOR_CONTENT_ASPECT_RATIO_16_9"
|
||||
}
|
||||
}
|
||||
|
||||
// randomFileName mimics the studio's client-generated reference name
|
||||
// ("_<base36-ish id>_<n>.png"). The value only needs to be unique per upload.
|
||||
func randomFileName(n int) string {
|
||||
return "_" + randomID() + "_" + fmt.Sprintf("%d", n) + ".png"
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
b := make([]byte, 26)
|
||||
for i := range b {
|
||||
v, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
b[i] = alphabet[v.Int64()]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TLS client plumbing: a Chrome-fingerprinted client so the Cloudflare front of
|
||||
// the Connect endpoints sees a plausible browser handshake.
|
||||
var fingerprints = []profiles.ClientProfile{
|
||||
profiles.Chrome_146,
|
||||
profiles.Chrome_144,
|
||||
profiles.Chrome_133,
|
||||
profiles.Chrome_131,
|
||||
}
|
||||
|
||||
type tlsSession struct {
|
||||
client tlsclient.HttpClient
|
||||
}
|
||||
|
||||
func (c *Client) newTLSClient(useProxy bool) (*tlsSession, error) {
|
||||
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(fingerprints))))
|
||||
options := []tlsclient.HttpClientOption{
|
||||
tlsclient.WithTimeoutSeconds(60),
|
||||
tlsclient.WithClientProfile(fingerprints[idx.Int64()]),
|
||||
tlsclient.WithNotFollowRedirects(),
|
||||
tlsclient.WithRandomTLSExtensionOrder(),
|
||||
}
|
||||
if useProxy && c.proxy != "" {
|
||||
options = append(options, tlsclient.WithProxyUrl(c.proxy))
|
||||
}
|
||||
client, err := tlsclient.NewHttpClient(tlsclient.NewNoopLogger(), options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tlsSession{client: client}, nil
|
||||
}
|
||||
|
||||
func defaultUA() string {
|
||||
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
func clip(v []byte, n int) string {
|
||||
s := strings.TrimSpace(string(v))
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
// stringValue coerces a decoded JSON value to its string form.
|
||||
func stringValue(v any) string {
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
return x
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", x)
|
||||
case json.Number:
|
||||
return x.String()
|
||||
case bool:
|
||||
if x {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
// withDetail appends a failure detail to an error message when present.
|
||||
func withDetail(detail string) string {
|
||||
if detail == "" {
|
||||
return ""
|
||||
}
|
||||
return " (" + detail + ")"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package creativefabrica
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBalance(t *testing.T) {
|
||||
cookie := os.Getenv("CF_COOKIE")
|
||||
if cookie == "" {
|
||||
t.Skip("CF_COOKIE not set")
|
||||
}
|
||||
c := NewClient("")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
tok, uid, err := c.ExchangeToken(ctx, cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("exchange: %v", err)
|
||||
}
|
||||
bal, err := c.FetchBalance(ctx, cookie)
|
||||
if err != nil {
|
||||
t.Fatalf("balance: %v", err)
|
||||
}
|
||||
t.Logf("user=%s token=%d bal=%d", uid, len(tok), bal)
|
||||
}
|
||||
Reference in New Issue
Block a user