feat(leonardo): 接入视频通道并新增 seedance-2.0/minimax-h3 模型
This commit is contained in:
@@ -52,8 +52,15 @@ var (
|
||||
// applies, but callers can single it out — unlike an expired access token this
|
||||
// cannot be fixed by refreshing from the cookie, so the account is done.
|
||||
ErrNotEntitled = fmt.Errorf("%w: user not entitled", ErrAuth)
|
||||
// errTransport marks a request that never got a response back (EOF / 连接被切 /
|
||||
// 超时)。上游没收到就没开始生成,所以原地换条连接重试是安全的。
|
||||
errTransport = errors.New("transport failure")
|
||||
)
|
||||
|
||||
// videoSubmitMaxRetries is how many extra in-place attempts a video submit gets
|
||||
// when the connection dies before any response arrives (EOF/reset/timeout).
|
||||
const videoSubmitMaxRetries = 3
|
||||
|
||||
// isContentRejection reports whether an Adobe response (status + body) is a
|
||||
// content-safety refusal rather than a genuine upstream/account failure. Adobe
|
||||
// returns HTTP 451 with an "*_unsafe" error_code when moderation blocks the
|
||||
@@ -314,7 +321,22 @@ func (c *Client) GenerateVideo(ctx context.Context, token, engine, prompt, aspec
|
||||
if engine == "firefly-video" {
|
||||
endpoint = fireflyVideoSubmitURL
|
||||
}
|
||||
// 连接在拿到响应前就断掉(EOF / reset / 超时)说明上游根本没收到这单,
|
||||
// 换一条新连接原地重试,最多 videoSubmitMaxRetries 次。
|
||||
respBody, pollURL, err := c.submitVideo(ctx, submitSess, token, endpoint, payload)
|
||||
for attempt := 0; err != nil && errors.Is(err, errTransport) && attempt < videoSubmitMaxRetries && ctx.Err() == nil; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt+1) * 2 * time.Second):
|
||||
}
|
||||
retrySess, sessErr := c.newTLSClient()
|
||||
if sessErr != nil {
|
||||
break
|
||||
}
|
||||
submitSess = retrySess
|
||||
respBody, pollURL, err = c.submitVideo(ctx, submitSess, token, endpoint, payload)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -743,7 +765,7 @@ func (c *Client) submitVideo(ctx context.Context, sess *tlsSession, token, endpo
|
||||
|
||||
resp, err := sess.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
return nil, "", fmt.Errorf("%w: %w: %v", ErrTemporaryUpstream, errTransport, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import (
|
||||
const (
|
||||
appBase = "https://app.leonardo.ai"
|
||||
graphqlURL = "https://api.leonardo.ai/v1/graphql"
|
||||
schemaVersion = "1.187.0"
|
||||
schemaVersion = "1.255.2"
|
||||
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
@@ -45,10 +45,15 @@ type Client struct {
|
||||
// (429) hard, so re-using the ~1h JWT is essential.
|
||||
mu sync.Mutex
|
||||
sessions map[string]*Session
|
||||
// rotated maps a stored cookie to the freshest value Leonardo handed back via
|
||||
// Set-Cookie (better-auth rotates its session_data cookie cache). The service
|
||||
// persists it; keeping it here means an unpersisted rotation still works for
|
||||
// the rest of the process's life.
|
||||
rotated map[string]string
|
||||
}
|
||||
|
||||
func NewClient(proxy string) *Client {
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}}
|
||||
return &Client{proxy: strings.TrimSpace(proxy), sessions: map[string]*Session{}, rotated: map[string]string{}}
|
||||
}
|
||||
|
||||
func (c *Client) SetProxy(proxy string) {
|
||||
@@ -63,6 +68,75 @@ func IsLeonardoCookie(value string) bool {
|
||||
strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// HasSessionData reports whether the cookie carries better-auth's session_data
|
||||
// cache. Leonardo authenticates get-session off THAT cookie: session_token alone
|
||||
// answers 200 null (no bearer), which looks exactly like a dead account — so a
|
||||
// cookie without it must be rejected at import instead of dying later.
|
||||
func HasSessionData(value string) bool {
|
||||
return strings.Contains(value, "better-auth.session_data")
|
||||
}
|
||||
|
||||
// RotatedCookie returns the freshest value for a stored cookie when Leonardo
|
||||
// rotated its session_data cache, so the caller can persist it.
|
||||
func (c *Client) RotatedCookie(cookie string) (string, bool) {
|
||||
key := strings.TrimSpace(cookie)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
fresh, ok := c.rotated[key]
|
||||
return fresh, ok && fresh != key
|
||||
}
|
||||
|
||||
// mergeCookies applies a response's Set-Cookie pairs onto a request cookie
|
||||
// string, keeping the original order and appending new names.
|
||||
func mergeCookies(cookie string, setCookies []string) string {
|
||||
if len(setCookies) == 0 {
|
||||
return cookie
|
||||
}
|
||||
updates := map[string]string{}
|
||||
order := []string{}
|
||||
for _, sc := range setCookies {
|
||||
pair := strings.TrimSpace(strings.Split(sc, ";")[0])
|
||||
name, value, ok := strings.Cut(pair, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if !ok || name == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := updates[name]; !seen {
|
||||
order = append(order, name)
|
||||
}
|
||||
updates[name] = value
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return cookie
|
||||
}
|
||||
var out []string
|
||||
used := map[string]bool{}
|
||||
for _, part := range strings.Split(cookie, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name, _, _ := strings.Cut(part, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if v, ok := updates[name]; ok {
|
||||
used[name] = true
|
||||
if v == "" { // a cleared cookie drops out
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+v)
|
||||
continue
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
for _, name := range order {
|
||||
if used[name] || updates[name] == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, name+"="+updates[name])
|
||||
}
|
||||
return strings.Join(out, "; ")
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -73,10 +147,14 @@ type Session struct {
|
||||
Email string
|
||||
Name string
|
||||
ExpiresAt int64
|
||||
// Cookie is the cookie that produced this session, with any Set-Cookie
|
||||
// rotation applied — persist it so the account keeps authenticating.
|
||||
Cookie string
|
||||
}
|
||||
|
||||
// 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.
|
||||
// GetSession exchanges the cookie for a fresh access token + account ids. Only a
|
||||
// 401 or a 200 carrying no access token means the session is dead → ErrAuth;
|
||||
// everything else (notably the 403/429 人机校验 page) is a temporary error.
|
||||
func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error) {
|
||||
cookie = strings.TrimSpace(cookie)
|
||||
if cookie == "" {
|
||||
@@ -91,6 +169,15 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Use the freshest known value (an earlier response may have rotated the
|
||||
// better-auth cookie cache) rather than the possibly stale stored cookie.
|
||||
send := cookie
|
||||
c.mu.Lock()
|
||||
if fresh, ok := c.rotated[cookie]; ok && fresh != "" {
|
||||
send = fresh
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
client, err := c.newDirectTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -103,7 +190,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
req.Header = http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"cookie": {cookie},
|
||||
"cookie": {send},
|
||||
"origin": {appBase},
|
||||
"referer": {appBase + "/"},
|
||||
"user-agent": {userAgent},
|
||||
@@ -121,10 +208,18 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
if merged := mergeCookies(send, resp.Header["Set-Cookie"]); merged != send {
|
||||
c.mu.Lock()
|
||||
c.rotated[cookie] = merged
|
||||
c.mu.Unlock()
|
||||
send = merged
|
||||
}
|
||||
if resp.StatusCode == 401 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
// 403 / 429 here is the Vercel / Cloudflare 人机校验 页,不是 cookie 失效 —
|
||||
// 当成临时错误,否则健康的号会被误判死。
|
||||
return nil, fmt.Errorf("%w: get-session http %d: %s", ErrTemporaryUpstream, resp.StatusCode, clip(body, 160))
|
||||
}
|
||||
var raw struct {
|
||||
@@ -162,6 +257,7 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
Email: strings.TrimSpace(raw.User.Email),
|
||||
Name: strings.TrimSpace(raw.User.Name),
|
||||
ExpiresAt: raw.Session.TokenExpiry,
|
||||
Cookie: send,
|
||||
}
|
||||
if sess.ExpiresAt > time.Now().Unix() {
|
||||
c.mu.Lock()
|
||||
@@ -171,6 +267,59 @@ func (c *Client) GetSession(ctx context.Context, cookie string) (*Session, error
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// session returns the cookie's access token, optionally forcing a fresh mint
|
||||
// (dropping the cache) — used when the upstream rejected the current bearer.
|
||||
func (c *Client) session(ctx context.Context, cookie string, force bool) (*Session, error) {
|
||||
if force {
|
||||
c.mu.Lock()
|
||||
delete(c.sessions, strings.TrimSpace(cookie))
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return c.GetSession(ctx, cookie)
|
||||
}
|
||||
|
||||
// callGraphQL runs one GraphQL call for an account cookie. The bearer only lives
|
||||
// ~1h, so a rejected token (401/403 or a JWTExpired GraphQL error) is re-minted
|
||||
// from the cookie and the call retried once. Only a cookie that itself stops
|
||||
// authenticating yields ErrAuth — an upstream bearer rejection stays temporary so
|
||||
// the account is never killed for it.
|
||||
func (c *Client) callGraphQL(ctx context.Context, cookie string, payload []byte, useProxy bool, label string) ([]byte, error) {
|
||||
var lastStatus int
|
||||
var lastBody []byte
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
sess, err := c.session(ctx, cookie, attempt > 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, useProxy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s: %s", ErrTemporaryUpstream, label, err.Error())
|
||||
}
|
||||
lastStatus, lastBody = status, body
|
||||
stale := status == 401 || status == 403
|
||||
var gqlErr error
|
||||
if !stale && status == 200 {
|
||||
gqlErr = graphqlError(body)
|
||||
stale = errors.Is(gqlErr, ErrAuth)
|
||||
}
|
||||
if stale {
|
||||
if attempt == 0 {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if status != 200 {
|
||||
return nil, fmt.Errorf("%w: %s http %d: %s", ErrTemporaryUpstream, label, status, clip(body, 160))
|
||||
}
|
||||
if gqlErr != nil {
|
||||
return nil, gqlErr
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s rejected a freshly minted bearer (http %d): %s",
|
||||
ErrTemporaryUpstream, label, lastStatus, clip(lastBody, 160))
|
||||
}
|
||||
|
||||
const qGetTokens = `query GetUserTokensFromSub($sub: String) {
|
||||
user_details(where: {cognitoId: {_eq: $sub}}) {
|
||||
id
|
||||
@@ -205,15 +354,12 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st
|
||||
"variables": map[string]any{"sub": sess.CognitoSub},
|
||||
"query": qGetTokens,
|
||||
})
|
||||
body, status, err := c.graphqlP(ctx, sess.AccessToken, payload, false)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "credits")
|
||||
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
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
return unknownBalance(err.Error()), nil
|
||||
}
|
||||
var result struct {
|
||||
Data struct {
|
||||
@@ -248,13 +394,8 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, cookie string) (map[st
|
||||
}, nil
|
||||
}
|
||||
|
||||
// graphql runs a GraphQL call through the proxy. graphqlP lets callers pick the
|
||||
// egress: only the generate submit uses the proxy; reference-image upload and
|
||||
// polling run direct (local IP).
|
||||
func (c *Client) graphql(ctx context.Context, accessToken string, payload []byte) ([]byte, int, error) {
|
||||
return c.graphqlP(ctx, accessToken, payload, true)
|
||||
}
|
||||
|
||||
// graphqlP runs a GraphQL call; callers pick the egress: only the generate submit
|
||||
// uses the proxy; reference-image upload and polling run direct (local IP).
|
||||
func (c *Client) graphqlP(ctx context.Context, accessToken string, payload []byte, useProxy bool) ([]byte, int, error) {
|
||||
client, err := c.newTLSClientP(useProxy)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
@@ -51,24 +52,25 @@ const mUploadImage = `mutation UploadImage($uploadImageInput: UploadImageInput!)
|
||||
// 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) {
|
||||
func (c *Client) uploadInitImage(ctx context.Context, cookie string, img []byte) (string, error) {
|
||||
return c.uploadAsset(ctx, cookie, "png", img)
|
||||
}
|
||||
|
||||
// uploadAsset uploads one reference asset (extension png / mp3 / mp4 …) through
|
||||
// the same UploadImage presigned-S3 flow images use, and returns its upload id.
|
||||
func (c *Client) uploadAsset(ctx context.Context, cookie, extension string, asset []byte) (string, error) {
|
||||
extension = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
|
||||
if extension == "" {
|
||||
extension = "png"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "UploadImage",
|
||||
"query": mUploadImage,
|
||||
"variables": map[string]any{"uploadImageInput": map[string]any{"uploadType": "INIT", "extension": "png"}},
|
||||
"variables": map[string]any{"uploadImageInput": map[string]any{"uploadType": "INIT", "extension": extension}},
|
||||
})
|
||||
body, status, err := c.graphqlP(ctx, accessToken, payload, false)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "upload-init")
|
||||
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
|
||||
return "", err
|
||||
}
|
||||
var ur struct {
|
||||
Data struct {
|
||||
@@ -97,11 +99,11 @@ func (c *Client) uploadInitImage(ctx context.Context, accessToken string, img []
|
||||
for k, v := range fields {
|
||||
_ = w.WriteField(k, v)
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", "image.png")
|
||||
fw, err := w.CreateFormFile("file", "asset."+extension)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := fw.Write(img); err != nil {
|
||||
if _, err := fw.Write(asset); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = w.Close()
|
||||
@@ -154,7 +156,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadInitImage(ctx, sess.AccessToken, img)
|
||||
uploadID, upErr := c.uploadInitImage(ctx, cookie, img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
@@ -192,18 +194,9 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
||||
},
|
||||
}
|
||||
payload, _ := json.Marshal(genReq)
|
||||
body, status, err := c.graphql(ctx, sess.AccessToken, payload)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate")
|
||||
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
|
||||
return nil, nil, err
|
||||
}
|
||||
var genResp struct {
|
||||
Data struct {
|
||||
@@ -221,7 +214,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
||||
}
|
||||
|
||||
// 2. poll until COMPLETE, then read the image url.
|
||||
imageURL, err := c.pollImage(ctx, sess.AccessToken, genID)
|
||||
imageURL, err := c.pollImage(ctx, cookie, genID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -244,7 +237,7 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
||||
|
||||
// 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) {
|
||||
func (c *Client) pollImage(ctx context.Context, cookie, genID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GenerationImages",
|
||||
"query": qGenerationImages,
|
||||
@@ -264,14 +257,12 @@ func (c *Client) pollImage(ctx context.Context, accessToken, genID string) (stri
|
||||
}
|
||||
|
||||
for {
|
||||
body, status, err := c.graphqlP(ctx, accessToken, payload, false)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: poll: %s", ErrTemporaryUpstream, err.Error())
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll")
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return "", err
|
||||
}
|
||||
if status == 401 || status == 403 {
|
||||
return "", ErrAuth
|
||||
}
|
||||
if status == 200 {
|
||||
// 其它错误(含上游临时抖动)不中断轮询,等 deadline 再判超时。
|
||||
if err == nil {
|
||||
var pr struct {
|
||||
Data struct {
|
||||
Generations []struct {
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
package leonardo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// qGenerationVideos polls one video generation's status AND its produced clip in
|
||||
// a single round-trip (motionMP4URL carries the mp4).
|
||||
const qGenerationVideos = `query GenerationVideos($where: generations_bool_exp = {}) {
|
||||
generations(where: $where) {
|
||||
id
|
||||
status
|
||||
generated_images {
|
||||
id
|
||||
motionMP4URL
|
||||
__typename
|
||||
}
|
||||
__typename
|
||||
}
|
||||
}`
|
||||
|
||||
// VideoAssets are the decoded reference assets a video request can carry:
|
||||
// image references (strength MID), one audio track and video references. The
|
||||
// caller enforces the per-type caps; durations are derived from the bytes here
|
||||
// because Leonardo requires them for audio/video guidances.
|
||||
type VideoAssets struct {
|
||||
Images [][]byte
|
||||
Audios [][]byte
|
||||
Videos [][]byte
|
||||
}
|
||||
|
||||
// GenerateVideo runs the Leonardo video pipeline (seedance-2.0 / -fast, hailuo) against
|
||||
// one account cookie: upload every reference asset, submit the Generate mutation
|
||||
// as a PRIVATE generation, poll until COMPLETE, then optionally download the mp4.
|
||||
func (c *Client) GenerateVideo(ctx context.Context, cookie, model, prompt string, width, height, durationSeconds int, refs VideoAssets, downloadResult bool) ([]byte, map[string]any, error) {
|
||||
sess, err := c.GetSession(ctx, cookie)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = "seedance-2.0-fast"
|
||||
}
|
||||
|
||||
guidances := map[string]any{}
|
||||
var imageRefs []map[string]any
|
||||
for _, img := range refs.Images {
|
||||
if len(img) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(img, "png"), img)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
imageRefs = append(imageRefs, map[string]any{
|
||||
"image": map[string]any{"id": uploadID, "type": "UPLOADED"},
|
||||
"strength": "MID",
|
||||
})
|
||||
}
|
||||
if len(imageRefs) > 0 {
|
||||
guidances["image_reference"] = imageRefs
|
||||
}
|
||||
var audioRefs []map[string]any
|
||||
for _, aud := range refs.Audios {
|
||||
if len(aud) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(aud, "mp3"), aud)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
audio := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(aud); secs > 0 {
|
||||
audio["duration"] = secs
|
||||
}
|
||||
audioRefs = append(audioRefs, map[string]any{"audio": audio})
|
||||
}
|
||||
if len(audioRefs) > 0 {
|
||||
guidances["audio_reference"] = audioRefs
|
||||
}
|
||||
var videoRefs []map[string]any
|
||||
for _, vid := range refs.Videos {
|
||||
if len(vid) == 0 {
|
||||
continue
|
||||
}
|
||||
uploadID, upErr := c.uploadAsset(ctx, cookie, assetExtension(vid, "mp4"), vid)
|
||||
if upErr != nil {
|
||||
return nil, nil, upErr
|
||||
}
|
||||
video := map[string]any{"id": uploadID, "type": "UPLOADED"}
|
||||
if secs := MediaDurationSeconds(vid); secs > 0 {
|
||||
video["duration"] = secs
|
||||
}
|
||||
videoRefs = append(videoRefs, map[string]any{"video": video})
|
||||
}
|
||||
if len(videoRefs) > 0 {
|
||||
guidances["video_reference_base"] = videoRefs
|
||||
}
|
||||
|
||||
parameters := map[string]any{
|
||||
"height": height,
|
||||
"width": width,
|
||||
"duration": durationSeconds,
|
||||
"motion_has_audio": true,
|
||||
"quantity": 1,
|
||||
"prompt": prompt,
|
||||
"guidances": guidances,
|
||||
}
|
||||
// seedance 走随机种子;hailuo 的生成页 seedEnabled=false,请求里不带 seed。
|
||||
if !strings.HasPrefix(model, "hailuo") {
|
||||
parameters["seed"] = -1
|
||||
}
|
||||
genReq := map[string]any{
|
||||
"operationName": "Generate",
|
||||
"query": mGenerate,
|
||||
"variables": map[string]any{
|
||||
"request": map[string]any{
|
||||
"model": model,
|
||||
// 私有生成:不进公开 feed。
|
||||
"public": false,
|
||||
"parameters": parameters,
|
||||
},
|
||||
},
|
||||
}
|
||||
payload, _ := json.Marshal(genReq)
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, true, "generate-video")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
videoURL, err := c.pollVideo(ctx, cookie, genID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info := map[string]any{
|
||||
"generation_id": genID,
|
||||
"video_url": videoURL,
|
||||
"user_id": sess.UserID,
|
||||
}
|
||||
if !downloadResult {
|
||||
return nil, info, nil
|
||||
}
|
||||
data, err := c.downloadImage(ctx, videoURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return data, info, nil
|
||||
}
|
||||
|
||||
// pollVideo polls one generation until COMPLETE (returning the mp4 url) or
|
||||
// FAILED. Temporary upstream hiccups don't abort the wait — only ctx/deadline do.
|
||||
func (c *Client) pollVideo(ctx context.Context, cookie, genID string) (string, error) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"operationName": "GenerationVideos",
|
||||
"query": qGenerationVideos,
|
||||
"variables": map[string]any{
|
||||
"where": map[string]any{"id": map[string]any{"_in": []string{genID}}},
|
||||
},
|
||||
})
|
||||
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
deadline = dl.Add(-60 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
body, err := c.callGraphQL(ctx, cookie, payload, false, "poll-video")
|
||||
if errors.Is(err, ErrAuth) {
|
||||
return "", err
|
||||
}
|
||||
if err == nil {
|
||||
var pr struct {
|
||||
Data struct {
|
||||
Generations []struct {
|
||||
Status string `json:"status"`
|
||||
GeneratedImages []struct {
|
||||
MotionMP4URL string `json:"motionMP4URL"`
|
||||
} `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.MotionMP4URL); u != "" {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: complete but no video 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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assetExtension sniffs the upload extension Leonardo expects for a reference
|
||||
// asset; fallback is used when the bytes aren't recognized.
|
||||
func assetExtension(data []byte, fallback string) string {
|
||||
n := len(data)
|
||||
switch {
|
||||
case n >= 8 && string(data[1:4]) == "PNG":
|
||||
return "png"
|
||||
case n >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
|
||||
return "jpg"
|
||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP":
|
||||
return "webp"
|
||||
case n >= 12 && string(data[4:8]) == "ftyp":
|
||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "qt") {
|
||||
return "mov"
|
||||
}
|
||||
if n >= 12 && strings.HasPrefix(string(data[8:12]), "M4A") {
|
||||
return "m4a"
|
||||
}
|
||||
return "mp4"
|
||||
case n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3:
|
||||
return "webm"
|
||||
case n >= 3 && string(data[0:3]) == "ID3":
|
||||
return "mp3"
|
||||
case n >= 2 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0:
|
||||
return "mp3"
|
||||
case n >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE":
|
||||
return "wav"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// MediaDurationSeconds returns a media asset's duration in seconds (0 when it
|
||||
// can't be determined). Leonardo's audio_reference / video_reference_base
|
||||
// guidances carry the duration, and the caller validates video lengths with it,
|
||||
// so it's derived from the uploaded bytes: mp4/mov via the mvhd box, wav via the
|
||||
// fmt byte rate, mp3 from the first frame's bitrate.
|
||||
func MediaDurationSeconds(data []byte) float64 {
|
||||
if secs := mp4Duration(data); secs > 0 {
|
||||
return secs
|
||||
}
|
||||
if secs := wavDuration(data); secs > 0 {
|
||||
return secs
|
||||
}
|
||||
return mp3Duration(data)
|
||||
}
|
||||
|
||||
// mp4Duration walks the ISOBMFF box tree to moov/mvhd and reads timescale+duration.
|
||||
func mp4Duration(data []byte) float64 {
|
||||
if len(data) < 12 || string(data[4:8]) != "ftyp" {
|
||||
return 0
|
||||
}
|
||||
moov := findBox(data, "moov")
|
||||
if moov == nil {
|
||||
return 0
|
||||
}
|
||||
mvhd := findBox(moov, "mvhd")
|
||||
if len(mvhd) < 20 {
|
||||
return 0
|
||||
}
|
||||
version := mvhd[0]
|
||||
if version == 1 {
|
||||
if len(mvhd) < 32 {
|
||||
return 0
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(mvhd[20:24])
|
||||
duration := binary.BigEndian.Uint64(mvhd[24:32])
|
||||
if timescale == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(duration) / float64(timescale)
|
||||
}
|
||||
timescale := binary.BigEndian.Uint32(mvhd[12:16])
|
||||
duration := binary.BigEndian.Uint32(mvhd[16:20])
|
||||
if timescale == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(duration) / float64(timescale)
|
||||
}
|
||||
|
||||
// findBox returns the payload of the first box named typ among data's boxes
|
||||
// (callers descend one level at a time by passing a parent's payload back in).
|
||||
func findBox(data []byte, typ string) []byte {
|
||||
for off := 0; off+8 <= len(data); {
|
||||
size := int(binary.BigEndian.Uint32(data[off : off+4]))
|
||||
name := string(data[off+4 : off+8])
|
||||
header := 8
|
||||
if size == 1 { // 64-bit size
|
||||
if off+16 > len(data) {
|
||||
return nil
|
||||
}
|
||||
size = int(binary.BigEndian.Uint64(data[off+8 : off+16]))
|
||||
header = 16
|
||||
} else if size == 0 {
|
||||
size = len(data) - off
|
||||
}
|
||||
if size < header || off+size > len(data) {
|
||||
return nil
|
||||
}
|
||||
if name == typ {
|
||||
return data[off+header : off+size]
|
||||
}
|
||||
off += size
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wavDuration reads the RIFF fmt chunk's byte rate and the data chunk size.
|
||||
func wavDuration(data []byte) float64 {
|
||||
if len(data) < 44 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
|
||||
return 0
|
||||
}
|
||||
byteRate := 0
|
||||
dataSize := 0
|
||||
for off := 12; off+8 <= len(data); {
|
||||
name := string(data[off : off+4])
|
||||
size := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
|
||||
body := off + 8
|
||||
if size < 0 || body > len(data) {
|
||||
break
|
||||
}
|
||||
switch name {
|
||||
case "fmt ":
|
||||
if body+16 <= len(data) {
|
||||
byteRate = int(binary.LittleEndian.Uint32(data[body+8 : body+12]))
|
||||
}
|
||||
case "data":
|
||||
dataSize = size
|
||||
if body+size > len(data) {
|
||||
dataSize = len(data) - body
|
||||
}
|
||||
}
|
||||
off = body + size + size%2
|
||||
}
|
||||
if byteRate <= 0 || dataSize <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(dataSize) / float64(byteRate)
|
||||
}
|
||||
|
||||
// mp3Bitrates are the Layer III bitrate tables (kbps) indexed by the frame
|
||||
// header's bitrate index: 1 = MPEG1, 2 = MPEG2/2.5.
|
||||
var mp3Bitrates = map[int][]int{
|
||||
1: {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
|
||||
2: {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
|
||||
}
|
||||
|
||||
// mp3Duration estimates the duration from the first frame header's bitrate
|
||||
// (constant-bitrate assumption — good enough for the guidance duration field).
|
||||
func mp3Duration(data []byte) float64 {
|
||||
off := 0
|
||||
if len(data) >= 10 && string(data[0:3]) == "ID3" {
|
||||
// syncsafe int: 7 bits per byte
|
||||
tagSize := int(data[6]&0x7F)<<21 | int(data[7]&0x7F)<<14 | int(data[8]&0x7F)<<7 | int(data[9]&0x7F)
|
||||
off = 10 + tagSize
|
||||
}
|
||||
for ; off+4 <= len(data); off++ {
|
||||
if data[off] != 0xFF || data[off+1]&0xE0 != 0xE0 {
|
||||
continue
|
||||
}
|
||||
versionBits := (data[off+1] >> 3) & 0x03
|
||||
layerBits := (data[off+1] >> 1) & 0x03
|
||||
if layerBits != 0x01 { // Layer III only
|
||||
continue
|
||||
}
|
||||
table := 1
|
||||
if versionBits != 0x03 { // MPEG2 / 2.5
|
||||
table = 2
|
||||
}
|
||||
idx := int((data[off+2] >> 4) & 0x0F)
|
||||
rates := mp3Bitrates[table]
|
||||
if idx <= 0 || idx >= len(rates) || rates[idx] == 0 {
|
||||
continue
|
||||
}
|
||||
kbps := rates[idx]
|
||||
return float64(len(data)-off) * 8 / float64(kbps*1000)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user