支持远程url吗
This commit is contained in:
@@ -210,6 +210,25 @@ func (h *V1Handler) GetVideoContent(c *gin.Context) {
|
|||||||
_, _ = io.Copy(c.Writer, body)
|
_, _ = io.Copy(c.Writer, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetImageContent — GET /v1/images/{id}/content. Streams a no-store image by
|
||||||
|
// proxying its stored (possibly auth-gated) upstream URL. Never persisted.
|
||||||
|
func (h *V1Handler) GetImageContent(c *gin.Context) {
|
||||||
|
principal, err := h.v1.Authenticate(c.Request.Context(), c.GetHeader("Authorization"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeAuthError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, contentType, err := h.v1.OpenImageContent(c.Request.Context(), principal, c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
h.writeV1Error(c, err, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer body.Close()
|
||||||
|
c.Header("Content-Type", contentType)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
_, _ = io.Copy(c.Writer, body)
|
||||||
|
}
|
||||||
|
|
||||||
// readMultipartImages reads the given file fields and returns each as base64.
|
// readMultipartImages reads the given file fields and returns each as base64.
|
||||||
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
func readMultipartImages(c *gin.Context, keys ...string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ func New(cfg *config.Config, auth *service.AuthService, handlers Handlers) *gin.
|
|||||||
engine.POST("/v1/videos", handlers.V1.CreateVideo)
|
engine.POST("/v1/videos", handlers.V1.CreateVideo)
|
||||||
engine.GET("/v1/videos/:id", handlers.V1.GetVideo)
|
engine.GET("/v1/videos/:id", handlers.V1.GetVideo)
|
||||||
engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent)
|
engine.GET("/v1/videos/:id/content", handlers.V1.GetVideoContent)
|
||||||
|
// No-store image content proxy (auth-gated upstream URLs, e.g. chatgpt).
|
||||||
|
engine.GET("/v1/images/:id/content", handlers.V1.GetImageContent)
|
||||||
|
|
||||||
publicAdmin := engine.Group("/admin/api")
|
publicAdmin := engine.Group("/admin/api")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ func (c *Client) uploadImageOnce(ctx context.Context, token string, content []by
|
|||||||
return body, nil, true
|
return body, nil, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string) ([]byte, map[string]any, error) {
|
func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspectRatio, resolution string, blobIDs []string, downloadResult bool) ([]byte, map[string]any, error) {
|
||||||
// Only the generate submit goes through the proxy; polling + download run on
|
// Only the generate submit goes through the proxy; polling + download run on
|
||||||
// the local IP.
|
// the local IP.
|
||||||
submitSess, err := c.newTLSClient()
|
submitSess, err := c.newTLSClient()
|
||||||
@@ -201,7 +201,7 @@ func (c *Client) GenerateImage(ctx context.Context, token, modelID, prompt, aspe
|
|||||||
for _, payload := range candidates {
|
for _, payload := range candidates {
|
||||||
respBody, pollURL, err := c.submitImage(ctx, submitSess, token, prompt, endpoint, payload)
|
respBody, pollURL, err := c.submitImage(ctx, submitSess, token, prompt, endpoint, payload)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
meta, data, pollErr := c.pollImage(ctx, pollSess, token, pollURL)
|
meta, data, pollErr := c.pollImage(ctx, pollSess, token, pollURL, downloadResult)
|
||||||
if pollErr != nil {
|
if pollErr != nil {
|
||||||
return nil, nil, pollErr
|
return nil, nil, pollErr
|
||||||
}
|
}
|
||||||
@@ -531,7 +531,7 @@ func (c *Client) submitImage(ctx context.Context, sess *tlsSession, token, promp
|
|||||||
return respBody, "", errors.New("submit ok but no poll url")
|
return respBody, "", errors.New("submit ok but no poll url")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL string) (map[string]any, []byte, error) {
|
func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL string, downloadResult bool) (map[string]any, []byte, error) {
|
||||||
for {
|
for {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return nil, nil, fmt.Errorf("adobe generation timed out: %w", err)
|
return nil, nil, fmt.Errorf("adobe generation timed out: %w", err)
|
||||||
@@ -587,6 +587,11 @@ func (c *Client) pollImage(ctx context.Context, sess *tlsSession, token, pollURL
|
|||||||
if first, ok := outputs[0].(map[string]any); ok {
|
if first, ok := outputs[0].(map[string]any); ok {
|
||||||
if image, ok := first["image"].(map[string]any); ok {
|
if image, ok := first["image"].(map[string]any); ok {
|
||||||
if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" {
|
if url := strings.TrimSpace(stringValue(image["presignedUrl"])); url != "" {
|
||||||
|
// Expose the presigned URL for callers that want it directly.
|
||||||
|
payload["image_url"] = url
|
||||||
|
if !downloadResult {
|
||||||
|
return payload, nil, nil
|
||||||
|
}
|
||||||
data, err := c.download(ctx, sess, url)
|
data, err := c.download(ctx, sess, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (c *Client) SetProxy(proxy string) {
|
|||||||
c.proxy = strings.TrimSpace(proxy)
|
c.proxy = strings.TrimSpace(proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, aspectRatio, resolution string, refs [][]byte) ([]byte, map[string]any, error) {
|
func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model, aspectRatio, resolution string, refs [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||||
// Everything except the generation submit egresses on the local IP. Only
|
// Everything except the generation submit egresses on the local IP. Only
|
||||||
// startImageGeneration (the /backend-api/f/conversation POST) goes through
|
// startImageGeneration (the /backend-api/f/conversation POST) goes through
|
||||||
// the proxy; the bootstrap / chat-requirements / reference upload / prepare
|
// the proxy; the bootstrap / chat-requirements / reference upload / prepare
|
||||||
@@ -130,6 +130,17 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model,
|
|||||||
if len(urls) == 0 {
|
if len(urls) == 0 {
|
||||||
return nil, nil, errors.New("no image urls resolved")
|
return nil, nil, errors.New("no image urls resolved")
|
||||||
}
|
}
|
||||||
|
meta := map[string]any{
|
||||||
|
"provider": "chatgpt",
|
||||||
|
"model": model,
|
||||||
|
"conversation_id": conversationID,
|
||||||
|
"image_url": urls[0], // auth-gated (files.oaiusercontent.com) — needs the account token to fetch
|
||||||
|
}
|
||||||
|
// downloadResult=false: skip the (auth-gated) download and return just the URL;
|
||||||
|
// the caller proxies it via OpenAsset with the account token.
|
||||||
|
if !downloadResult {
|
||||||
|
return nil, meta, nil
|
||||||
|
}
|
||||||
images, err := c.downloadBytes(ctx, session, accessToken, urls)
|
images, err := c.downloadBytes(ctx, session, accessToken, urls)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -137,11 +148,36 @@ func (c *Client) GenerateImage(ctx context.Context, accessToken, prompt, model,
|
|||||||
if len(images) == 0 {
|
if len(images) == 0 {
|
||||||
return nil, nil, errors.New("download produced no bytes")
|
return nil, nil, errors.New("download produced no bytes")
|
||||||
}
|
}
|
||||||
return images[0], map[string]any{
|
return images[0], meta, nil
|
||||||
"provider": "chatgpt",
|
}
|
||||||
"model": model,
|
|
||||||
"conversation_id": conversationID,
|
// OpenAsset streams an auth-gated ChatGPT image URL (files.oaiusercontent.com)
|
||||||
}, nil
|
// using the generating account's token — a plain GET 403s. Mirrors downloadBytes
|
||||||
|
// but returns a live stream instead of buffering.
|
||||||
|
func (c *Client) OpenAsset(ctx context.Context, accessToken, rawURL string) (io.ReadCloser, string, error) {
|
||||||
|
session, err := c.newDirectSession(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
req.Header = c.baseHeaders(accessToken)
|
||||||
|
req.Header.Set("accept", "*/*")
|
||||||
|
resp, err := session.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, "", fmt.Errorf("%w: chatgpt asset status %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||||
|
}
|
||||||
|
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||||
|
if ct == "" {
|
||||||
|
ct = "image/png"
|
||||||
|
}
|
||||||
|
return resp.Body, ct, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExtractAccountInfo(token string) map[string]any {
|
func ExtractAccountInfo(token string) map[string]any {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ package custom
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -61,10 +60,10 @@ func httpClient() *http.Client { return &http.Client{Timeout: 10 * time.Minute}
|
|||||||
// GenerateImage calls the upstream OpenAI image API. With reference images it
|
// GenerateImage calls the upstream OpenAI image API. With reference images it
|
||||||
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
|
// uses /v1/images/edits (multipart); otherwise /v1/images/generations. Returns
|
||||||
// the raw image bytes (decoded from b64_json, or downloaded from url).
|
// the raw image bytes (decoded from b64_json, or downloaded from url).
|
||||||
func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte) ([]byte, error) {
|
func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prompt, size, quality string, refs [][]byte, downloadResult bool) ([]byte, string, error) {
|
||||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||||
if baseURL == "" || apiKey == "" {
|
if baseURL == "" || apiKey == "" {
|
||||||
return nil, ErrAuth
|
return nil, "", ErrAuth
|
||||||
}
|
}
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
var err error
|
var err error
|
||||||
@@ -73,24 +72,26 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom
|
|||||||
w := multipart.NewWriter(body)
|
w := multipart.NewWriter(body)
|
||||||
_ = w.WriteField("model", model)
|
_ = w.WriteField("model", model)
|
||||||
_ = w.WriteField("prompt", prompt)
|
_ = w.WriteField("prompt", prompt)
|
||||||
|
// Ask the upstream for a URL (not base64) so we can pass it through directly.
|
||||||
|
_ = w.WriteField("response_format", "url")
|
||||||
if size != "" {
|
if size != "" {
|
||||||
_ = w.WriteField("size", size)
|
_ = w.WriteField("size", size)
|
||||||
}
|
}
|
||||||
for i, r := range refs {
|
for i, r := range refs {
|
||||||
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
|
fw, e := w.CreateFormFile("image[]", fmt.Sprintf("ref_%d.png", i+1))
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, e
|
return nil, "", e
|
||||||
}
|
}
|
||||||
_, _ = fw.Write(r)
|
_, _ = fw.Write(r)
|
||||||
}
|
}
|
||||||
_ = w.Close()
|
_ = w.Close()
|
||||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
|
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/edits", body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||||
} else {
|
} else {
|
||||||
payload := map[string]any{"model": model, "prompt": prompt, "n": 1}
|
payload := map[string]any{"model": model, "prompt": prompt, "n": 1, "response_format": "url"}
|
||||||
if size != "" {
|
if size != "" {
|
||||||
payload["size"] = size
|
payload["size"] = size
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,7 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom
|
|||||||
raw, _ := json.Marshal(payload)
|
raw, _ := json.Marshal(payload)
|
||||||
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
|
req, err = http.NewRequest(http.MethodPost, baseURL+"/v1/images/generations", bytes.NewReader(raw))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
@@ -109,14 +110,14 @@ func (c *Client) GenerateImage(ctx context.Context, baseURL, apiKey, model, prom
|
|||||||
|
|
||||||
resp, err := httpClient().Do(req)
|
resp, err := httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if e := mapStatus(resp.StatusCode, body); e != nil {
|
if e := mapStatus(resp.StatusCode, body); e != nil {
|
||||||
return nil, e
|
return nil, "", e
|
||||||
}
|
}
|
||||||
return imageBytesFromResponse(ctx, body)
|
return imageFromResponse(ctx, body, downloadResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateVideo drives the upstream Sora-style async video API:
|
// GenerateVideo drives the upstream Sora-style async video API:
|
||||||
@@ -239,34 +240,34 @@ func (c *Client) download(ctx context.Context, url, apiKey string) ([]byte, erro
|
|||||||
|
|
||||||
// imageBytesFromResponse extracts image bytes from an OpenAI images response:
|
// imageBytesFromResponse extracts image bytes from an OpenAI images response:
|
||||||
// data[0].b64_json (preferred) or data[0].url (downloaded).
|
// data[0].b64_json (preferred) or data[0].url (downloaded).
|
||||||
func imageBytesFromResponse(ctx context.Context, body []byte) ([]byte, error) {
|
// imageFromResponse parses an OpenAI image response and returns the upstream URL.
|
||||||
|
// We always request response_format=url, so the response must carry a URL — a
|
||||||
|
// base64-only response is treated as an error (no base64 pass-through). With
|
||||||
|
// downloadResult=false the URL is returned directly (no download).
|
||||||
|
func imageFromResponse(ctx context.Context, body []byte, downloadResult bool) ([]byte, string, error) {
|
||||||
var out struct {
|
var out struct {
|
||||||
Data []struct {
|
Data []struct {
|
||||||
B64JSON string `json:"b64_json"`
|
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 {
|
if err := json.Unmarshal(body, &out); err != nil || len(out.Data) == 0 {
|
||||||
return nil, fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160))
|
return nil, "", fmt.Errorf("%w: bad image response: %s", ErrTemporaryUpstream, clip(body, 160))
|
||||||
}
|
}
|
||||||
d := out.Data[0]
|
url := strings.TrimSpace(out.Data[0].URL)
|
||||||
if d.B64JSON != "" {
|
if url == "" {
|
||||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(d.B64JSON))
|
return nil, "", fmt.Errorf("%w: image response had no url (upstream ignored response_format=url)", ErrTemporaryUpstream)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: bad b64: %v", ErrTemporaryUpstream, err)
|
|
||||||
}
|
}
|
||||||
return raw, nil
|
if !downloadResult {
|
||||||
|
return nil, url, nil
|
||||||
}
|
}
|
||||||
if d.URL != "" {
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, d.URL, nil)
|
|
||||||
resp, err := httpClient().Do(req)
|
resp, err := httpClient().Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
return nil, "", fmt.Errorf("%w: %s", ErrTemporaryUpstream, sanitizeErr(err))
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return io.ReadAll(resp.Body)
|
raw, err := io.ReadAll(resp.Body)
|
||||||
}
|
return raw, url, err
|
||||||
return nil, fmt.Errorf("%w: image response had no b64/url", ErrTemporaryUpstream)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapStatus(status int, body []byte) error {
|
func mapStatus(status int, body []byte) error {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
// the org objects feed until the batch finishes, then download the produced
|
// 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).
|
// image. styleID picks the model (41001 = 1.5 / 2K, 41004 = 1.5pro / 4K).
|
||||||
// HTTP 402 → ErrQuotaExhausted. These models are pure text2img (no refs).
|
// 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) {
|
func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, resolution, aspectRatio, prompt string, downloadResult bool) ([]byte, map[string]any, error) {
|
||||||
cr, ok := parseCred(cred)
|
cr, ok := parseCred(cred)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, nil, ErrAuth
|
return nil, nil, ErrAuth
|
||||||
@@ -85,11 +85,15 @@ func (c *Client) GenerateImage(ctx context.Context, cred string, styleID int, re
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
meta := map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}
|
||||||
|
if !downloadResult {
|
||||||
|
return nil, meta, nil
|
||||||
|
}
|
||||||
data, err := c.download(ctx, imageURL)
|
data, err := c.download(ctx, imageURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return data, map[string]any{"batch_id": batchID, "image_url": imageURL, "org_id": userID}, nil
|
return data, meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pollImage polls the org objects feed until the entry for our batch finishes,
|
// pollImage polls the org objects feed until the entry for our batch finishes,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func (c *Client) uploadImage(ctx context.Context, cookie string, img []byte) (st
|
|||||||
// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
|
// GenerateImage runs the full Krea image pipeline: ensure a project, (for i2i)
|
||||||
// upload reference images, submit the job, poll until done, then resolve and
|
// upload reference images, submit the job, poll until done, then resolve and
|
||||||
// download the produced image. 402 INSUFFICIENT_BALANCE → ErrQuotaExhausted.
|
// 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) {
|
func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width, height int, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||||
// Ensure the daily free balance is granted (load /app) before generating, so a
|
// 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
|
// not-yet-activated account doesn't 402 INSUFFICIENT_BALANCE. Lock-guarded and
|
||||||
// once-per-daily-reset — concurrent gens wait for the first activation, already
|
// once-per-daily-reset — concurrent gens wait for the first activation, already
|
||||||
@@ -169,11 +169,15 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, prompt string, width
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
meta := map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}
|
||||||
|
if !downloadResult {
|
||||||
|
return nil, meta, nil
|
||||||
|
}
|
||||||
data, err := c.download(ctx, imageURL)
|
data, err := c.download(ctx, imageURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return data, map[string]any{"job_id": jobID, "image_url": imageURL, "project": projectID}, nil
|
return data, meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// pollImage polls job-status until the job leaves the queue, then matches the
|
// pollImage polls job-status until the job leaves the queue, then matches the
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (c *Client) uploadInitImage(ctx context.Context, accessToken string, img []
|
|||||||
// mint a JWT, (for image-to-image) upload each reference image, submit the
|
// mint a JWT, (for image-to-image) upload each reference image, submit the
|
||||||
// Generate mutation, poll until COMPLETE, then download the first produced image.
|
// Generate mutation, poll until COMPLETE, then download the first produced image.
|
||||||
// Returns the image bytes, an info map, and a classified error.
|
// 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) {
|
func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string, width, height int, styleIDs []string, refImages [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||||
sess, err := c.GetSession(ctx, cookie)
|
sess, err := c.GetSession(ctx, cookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -226,16 +226,19 @@ func (c *Client) GenerateImage(ctx context.Context, cookie, model, prompt string
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. download bytes
|
|
||||||
data, err := c.downloadImage(ctx, imageURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
info := map[string]any{
|
info := map[string]any{
|
||||||
"generation_id": genID,
|
"generation_id": genID,
|
||||||
"image_url": imageURL,
|
"image_url": imageURL,
|
||||||
"user_id": sess.UserID,
|
"user_id": sess.UserID,
|
||||||
}
|
}
|
||||||
|
if !downloadResult {
|
||||||
|
return nil, info, nil
|
||||||
|
}
|
||||||
|
// 3. download bytes
|
||||||
|
data, err := c.downloadImage(ctx, imageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
return data, info, nil
|
return data, info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
// anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize
|
// anything else → workflow_gemini_image / gemini-3-pro-image-preview. imageSize
|
||||||
// is the "1K"/"2K"/"4K" tier. teamID is the workspace id; if empty it's derived
|
// is the "1K"/"2K"/"4K" tier. teamID is the workspace id; if empty it's derived
|
||||||
// from the token. refs may be empty (pure text-to-image).
|
// from the token. refs may be empty (pure text-to-image).
|
||||||
func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte) ([]byte, map[string]any, error) {
|
func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prompt, aspectRatio, imageSize string, refs [][]byte, downloadResult bool) ([]byte, map[string]any, error) {
|
||||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
return nil, nil, ErrAuth
|
return nil, nil, ErrAuth
|
||||||
@@ -86,16 +86,21 @@ func (c *Client) GenerateImage(ctx context.Context, token, teamID, modelID, prom
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
data, err := c.download(ctx, directClient, artifactURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
meta := map[string]any{
|
meta := map[string]any{
|
||||||
"provider": "runway",
|
"provider": "runway",
|
||||||
"task_id": taskID,
|
"task_id": taskID,
|
||||||
"team_id": teamID,
|
"team_id": teamID,
|
||||||
"image_url": artifactURL,
|
"image_url": artifactURL,
|
||||||
}
|
}
|
||||||
|
// downloadResult=false (API-key url-only mode): return the upstream artifact
|
||||||
|
// URL without downloading the PNG.
|
||||||
|
if !downloadResult {
|
||||||
|
return nil, meta, nil
|
||||||
|
}
|
||||||
|
data, err := c.download(ctx, directClient, artifactURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
return data, meta, nil
|
return data, meta, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -552,6 +552,16 @@ func (r *EventRepository) MarkVideoReady(ctx context.Context, eventID, fileURL s
|
|||||||
return res.Error
|
return res.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetFile stores an arbitrary file reference (relative path OR upstream URL) on
|
||||||
|
// an event WITHOUT touching status/counters — used for already-succeeded no-store
|
||||||
|
// events whose auth-gated upstream URL is proxied on demand.
|
||||||
|
func (r *EventRepository) SetFile(ctx context.Context, eventID, fileURL string) error {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Model(&model.EventLog{}).
|
||||||
|
Where("id = ?", eventID).
|
||||||
|
Update("file", fileURL).Error
|
||||||
|
}
|
||||||
|
|
||||||
func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error {
|
func (r *EventRepository) UpdateStatus(ctx context.Context, eventID, status, errMsg string, elapsedMS int) error {
|
||||||
patch := map[string]any{
|
patch := map[string]any{
|
||||||
"status": status,
|
"status": status,
|
||||||
|
|||||||
+229
-69
@@ -453,6 +453,13 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
if !noStore {
|
if !noStore {
|
||||||
fileURL, relativePath = s.allocateOutput(principal, "png", in.BaseURL)
|
fileURL, relativePath = s.allocateOutput(principal, "png", in.BaseURL)
|
||||||
}
|
}
|
||||||
|
// upstreamURL is the provider's original artifact URL. For API-key (source
|
||||||
|
// "v1") requests we return it instead of base64. When gatedURL is true the URL
|
||||||
|
// is auth-gated (chatgpt files.oaiusercontent.com — a plain GET 403s), so we
|
||||||
|
// store it on the event and hand the caller a proxy URL
|
||||||
|
// ({base}/v1/images/{eventID}/content) that re-fetches with the account token.
|
||||||
|
var upstreamURL string
|
||||||
|
var gatedURL bool
|
||||||
eventID, err := s.logPendingEvent(ctx, "image", modelItem, principal, in.Prompt, aspectRatio, resolution, "", refCount, price, relativePath, source, refFiles, in.DeAI)
|
eventID, err := s.logPendingEvent(ctx, "image", modelItem, principal, in.Prompt, aspectRatio, resolution, "", refCount, price, relativePath, source, refFiles, in.DeAI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.cleanupReferenceImages(ctx, "", refFiles)
|
s.cleanupReferenceImages(ctx, "", refFiles)
|
||||||
@@ -470,7 +477,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
var imageBytes []byte
|
var imageBytes []byte
|
||||||
switch s.effectiveProvider(genCtx, modelItem) {
|
switch s.effectiveProvider(genCtx, modelItem) {
|
||||||
case "adobe":
|
case "adobe":
|
||||||
b, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, u, execErr := s.generateAdobeImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||||
@@ -486,8 +493,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
imageBytes = b
|
||||||
|
upstreamURL = u
|
||||||
case "chatgpt":
|
case "chatgpt":
|
||||||
b, execErr := s.generateChatGPTImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, u, execErr := s.generateChatGPTImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||||
@@ -503,8 +511,10 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
imageBytes = b
|
||||||
|
upstreamURL = u
|
||||||
|
gatedURL = true // chatgpt URL needs the account token → proxy it
|
||||||
case "leonardo":
|
case "leonardo":
|
||||||
b, execErr := s.generateLeonardoImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, u, execErr := s.generateLeonardoImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||||
@@ -520,8 +530,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
imageBytes = b
|
||||||
|
upstreamURL = u
|
||||||
case "krea":
|
case "krea":
|
||||||
b, execErr := s.generateKreaImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, u, execErr := s.generateKreaImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||||
@@ -537,8 +548,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
imageBytes = b
|
||||||
|
upstreamURL = u
|
||||||
case "imagine":
|
case "imagine":
|
||||||
b, execErr := s.generateImagineImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, u, execErr := s.generateImagineImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||||
@@ -554,8 +566,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
imageBytes = b
|
||||||
|
upstreamURL = u
|
||||||
case "runway":
|
case "runway":
|
||||||
b, execErr := s.generateRunwayImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, u, execErr := s.generateRunwayImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||||
@@ -571,8 +584,9 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
imageBytes = b
|
||||||
|
upstreamURL = u
|
||||||
case "custom":
|
case "custom":
|
||||||
b, execErr := s.generateCustomImage(genCtx, eventID, modelItem, in, aspectRatio, resolution)
|
b, u, execErr := s.generateCustomImage(genCtx, eventID, modelItem, in, aspectRatio, resolution, noStore)
|
||||||
if execErr != nil {
|
if execErr != nil {
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", execErr.Error(), 0)
|
||||||
@@ -588,6 +602,7 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
imageBytes = b
|
imageBytes = b
|
||||||
|
upstreamURL = u
|
||||||
default:
|
default:
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
||||||
@@ -626,6 +641,32 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
|||||||
_ = s.maybeGrantInviteReward(ctx, principal)
|
_ = s.maybeGrantInviteReward(ctx, principal)
|
||||||
}
|
}
|
||||||
if noStore {
|
if noStore {
|
||||||
|
// Prefer the provider's original URL — return it directly, no base64.
|
||||||
|
// (API-key requests don't support DeAI, so there's no post-processing that
|
||||||
|
// would invalidate the upstream URL.)
|
||||||
|
if strings.TrimSpace(upstreamURL) != "" {
|
||||||
|
outURL := upstreamURL
|
||||||
|
if gatedURL {
|
||||||
|
// Auth-gated URL (chatgpt): store it on the event and return a proxy
|
||||||
|
// URL that re-fetches with the account token (see OpenImageContent).
|
||||||
|
_ = s.events.SetFile(ctx, eventID, upstreamURL)
|
||||||
|
if base := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/"); base != "" {
|
||||||
|
outURL = base + "/v1/images/" + eventID + "/content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"created": time.Now().Unix(),
|
||||||
|
"data": []map[string]any{{"url": outURL}},
|
||||||
|
"model": modelItem.EffectiveName(),
|
||||||
|
"provider": modelItem.Provider,
|
||||||
|
"kind": "image",
|
||||||
|
"url": outURL,
|
||||||
|
"elapsed_ms": elapsedMS,
|
||||||
|
"charged": price,
|
||||||
|
"credits": principalCredits(principal),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
// Fallback: providers without an upstream URL still return base64.
|
||||||
b64 := base64.StdEncoding.EncodeToString(imageBytes)
|
b64 := base64.StdEncoding.EncodeToString(imageBytes)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"created": time.Now().Unix(),
|
"created": time.Now().Unix(),
|
||||||
@@ -716,17 +757,24 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
|||||||
defer s.cleanupReferenceImages(ctx, eventID, refFiles)
|
defer s.cleanupReferenceImages(ctx, eventID, refFiles)
|
||||||
startedAt := time.Now()
|
startedAt := time.Now()
|
||||||
|
|
||||||
|
// API-key (noStore) requests return the upstream video URL directly.
|
||||||
|
// downloadResult=false skips the download. grok asset URLs are auth-gated
|
||||||
|
// (a plain GET 403s) → gatedVideoURL routes them through the /content proxy.
|
||||||
|
prov := s.effectiveProvider(genCtx, modelItem)
|
||||||
|
urlOnly := noStore
|
||||||
|
gatedVideoURL := prov == "grok"
|
||||||
var videoBytes []byte
|
var videoBytes []byte
|
||||||
|
var videoURL string
|
||||||
var execErr error
|
var execErr error
|
||||||
switch s.effectiveProvider(genCtx, modelItem) {
|
switch prov {
|
||||||
case "adobe":
|
case "adobe":
|
||||||
videoBytes, _, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
|
videoBytes, videoURL, execErr = s.generateAdobeVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly)
|
||||||
case "runway":
|
case "runway":
|
||||||
videoBytes, _, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), true)
|
videoBytes, videoURL, execErr = s.generateRunwayVideo(genCtx, eventID, modelItem, in, aspectRatio, parseDurationSeconds(duration), !urlOnly)
|
||||||
case "grok":
|
case "grok":
|
||||||
videoBytes, _, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
|
videoBytes, videoURL, execErr = s.generateGrokVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly)
|
||||||
case "custom":
|
case "custom":
|
||||||
videoBytes, _, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), true)
|
videoBytes, videoURL, execErr = s.generateCustomVideo(genCtx, eventID, modelItem, in, aspectRatio, resolution, parseDurationSeconds(duration), !urlOnly)
|
||||||
default:
|
default:
|
||||||
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
_ = s.refundIfNeeded(ctx, principal, eventID, price)
|
||||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", "provider not implemented", 0)
|
||||||
@@ -777,6 +825,28 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
|||||||
if charge {
|
if charge {
|
||||||
_ = s.maybeGrantInviteReward(ctx, principal)
|
_ = s.maybeGrantInviteReward(ctx, principal)
|
||||||
}
|
}
|
||||||
|
if noStore && strings.TrimSpace(videoURL) != "" {
|
||||||
|
// Return the upstream video URL. grok URLs are auth-gated → store on the
|
||||||
|
// event and hand back the /content proxy (re-fetches with the account token).
|
||||||
|
outURL := videoURL
|
||||||
|
if gatedVideoURL {
|
||||||
|
_ = s.events.SetFile(ctx, eventID, videoURL)
|
||||||
|
if base := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/"); base != "" {
|
||||||
|
outURL = base + "/v1/videos/" + eventID + "/content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"created": time.Now().Unix(),
|
||||||
|
"data": []map[string]any{{"url": outURL}},
|
||||||
|
"model": modelItem.EffectiveName(),
|
||||||
|
"provider": modelItem.Provider,
|
||||||
|
"kind": "video",
|
||||||
|
"url": outURL,
|
||||||
|
"elapsed_ms": elapsedMS,
|
||||||
|
"charged": price,
|
||||||
|
"credits": principalCredits(principal),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
if noStore {
|
if noStore {
|
||||||
b64 := base64.StdEncoding.EncodeToString(videoBytes)
|
b64 := base64.StdEncoding.EncodeToString(videoBytes)
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
@@ -952,6 +1022,55 @@ func (s *V1Service) OpenVideoContent(ctx context.Context, principal *APIPrincipa
|
|||||||
return resp.Body, ct, nil
|
return resp.Body, ct, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenImageContent streams a no-store image by proxying the stored upstream URL.
|
||||||
|
// chatgpt URLs are auth-gated (files.oaiusercontent.com — a plain GET 403s), so
|
||||||
|
// they're fetched through the generating account's token; other providers'
|
||||||
|
// URLs are public and proxied directly. Never persisted.
|
||||||
|
func (s *V1Service) OpenImageContent(ctx context.Context, principal *APIPrincipal, id string) (io.ReadCloser, string, error) {
|
||||||
|
ev, err := s.events.GetByID(ctx, strings.TrimSpace(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if ev == nil || ev.Kind != "image" {
|
||||||
|
return nil, "", ErrVideoJobNotFound
|
||||||
|
}
|
||||||
|
if principal != nil && principal.User != nil && ev.UserID != principal.User.ID {
|
||||||
|
return nil, "", ErrVideoJobNotFound
|
||||||
|
}
|
||||||
|
if ev.Status != "success" || strings.TrimSpace(ev.File) == "" {
|
||||||
|
return nil, "", ErrVideoNotReady
|
||||||
|
}
|
||||||
|
if ev.Provider == "chatgpt" && s.chatgpt != nil {
|
||||||
|
if s.settings != nil {
|
||||||
|
if proxy, perr := s.settings.GetValue(ctx, "proxy.url"); perr == nil {
|
||||||
|
s.chatgpt.SetProxy(proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acct, _ := s.tokens.Get(ctx, "chatgpt", ev.AccountID)
|
||||||
|
if acct == nil || strings.TrimSpace(acct.Value) == "" {
|
||||||
|
return nil, "", fmt.Errorf("%w: chatgpt account no longer available for this image", ErrProviderTemporary)
|
||||||
|
}
|
||||||
|
return s.chatgpt.OpenAsset(ctx, acct.Value, ev.File)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ev.File, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("%w: fetch upstream image: %v", ErrProviderTemporary, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil, "", fmt.Errorf("%w: upstream image status %d", ErrProviderTemporary, resp.StatusCode)
|
||||||
|
}
|
||||||
|
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
||||||
|
if ct == "" {
|
||||||
|
ct = "image/png"
|
||||||
|
}
|
||||||
|
return resp.Body, ct, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *V1Service) videoEventForUser(ctx context.Context, principal *APIPrincipal, id string) (*model.EventLog, error) {
|
func (s *V1Service) videoEventForUser(ctx context.Context, principal *APIPrincipal, id string) (*model.EventLog, error) {
|
||||||
ev, err := s.events.GetByID(ctx, strings.TrimSpace(id))
|
ev, err := s.events.GetByID(ctx, strings.TrimSpace(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1532,9 +1651,12 @@ func adobeErrClass(e error) (bool, bool, bool, bool) {
|
|||||||
return errors.Is(e, adobe.ErrAuth), errors.Is(e, adobe.ErrQuotaExhausted), errors.Is(e, adobe.ErrTemporaryUpstream), errors.Is(e, adobe.ErrDeadUpstream)
|
return errors.Is(e, adobe.ErrAuth), errors.Is(e, adobe.ErrQuotaExhausted), errors.Is(e, adobe.ErrTemporaryUpstream), errors.Is(e, adobe.ErrDeadUpstream)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
|
// noStore url-only mode: adobe returns a presigned image URL (meta["image_url"]);
|
||||||
|
// skip the download and return it directly.
|
||||||
|
func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||||
|
urlOnly := noStore
|
||||||
if s.adobe == nil {
|
if s.adobe == nil {
|
||||||
return nil, errors.New("adobe client not configured")
|
return nil, "", errors.New("adobe client not configured")
|
||||||
}
|
}
|
||||||
if s.settings != nil {
|
if s.settings != nil {
|
||||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
||||||
@@ -1544,7 +1666,7 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
|
|||||||
|
|
||||||
items, err := s.tokens.ListByPool(ctx, "adobe")
|
items, err := s.tokens.ListByPool(ctx, "adobe")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
var active []model.TokenAccount
|
var active []model.TokenAccount
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -1558,20 +1680,21 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
|
|||||||
}
|
}
|
||||||
active = pinTestAccount(items, active, in.AccountID)
|
active = pinTestAccount(items, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return nil, ErrNoProviderAccount
|
return nil, "", ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
s.rotateRoundRobin("adobe", active)
|
s.rotateRoundRobin("adobe", active)
|
||||||
|
|
||||||
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round-robin order. Adobe uses tempFailover=true: a temporary upstream error
|
// Round-robin order. Adobe uses tempFailover=true: a temporary upstream error
|
||||||
// ("system under load") fails over to the next account without penalizing the
|
// ("system under load") fails over to the next account without penalizing the
|
||||||
// current one, capped at maxTempDeadAccounts; auth/quota also fail over
|
// current one, capped at maxTempDeadAccounts; auth/quota also fail over
|
||||||
// (see runPoolWithFailover).
|
// (see runPoolWithFailover). imageURL is captured from the successful attempt.
|
||||||
return s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
var imageURL string
|
||||||
|
data, err := s.runPoolWithFailover(ctx, eventID, "adobe", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
||||||
var blobIDs []string
|
var blobIDs []string
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "")
|
id, upErr := s.adobe.UploadImage(ctx, token.Value, ref, "image/png", "")
|
||||||
@@ -1580,11 +1703,15 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode
|
|||||||
}
|
}
|
||||||
blobIDs = append(blobIDs, id)
|
blobIDs = append(blobIDs, id)
|
||||||
}
|
}
|
||||||
data, _, genErr := s.adobe.GenerateImage(ctx, token.Value, modelItem.ID, in.Prompt, aspectRatio, resolution, blobIDs)
|
d, meta, genErr := s.adobe.GenerateImage(ctx, token.Value, modelItem.ID, in.Prompt, aspectRatio, resolution, blobIDs, !urlOnly)
|
||||||
return data, genErr
|
if genErr == nil {
|
||||||
|
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
|
}
|
||||||
|
return d, genErr
|
||||||
}, adobeErrClass, func(id string) (model.TokenAccount, bool) {
|
}, adobeErrClass, func(id string) (model.TokenAccount, bool) {
|
||||||
return s.refreshAdobeToken(ctx, id)
|
return s.refreshAdobeToken(ctx, id)
|
||||||
}, true)
|
}, true)
|
||||||
|
return data, imageURL, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) {
|
func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1VideoRequest, aspectRatio, resolution string, durationSeconds int, downloadResult bool) ([]byte, string, error) {
|
||||||
@@ -1827,21 +1954,22 @@ func (s *V1Service) effectiveProvider(ctx context.Context, modelItem *model.Mode
|
|||||||
// generateCustomImage forwards an image generation to an OpenAI-compatible
|
// generateCustomImage forwards an image generation to an OpenAI-compatible
|
||||||
// upstream. The upstream (custom account) is matched by model id; calls go direct
|
// upstream. The upstream (custom account) is matched by model id; calls go direct
|
||||||
// (no proxy). Billing uses the local model price.
|
// (no proxy). Billing uses the local model price.
|
||||||
func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
|
func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||||
|
urlOnly := noStore
|
||||||
if s.custom == nil {
|
if s.custom == nil {
|
||||||
return nil, errors.New("custom client not configured")
|
return nil, "", errors.New("custom client not configured")
|
||||||
}
|
}
|
||||||
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
active, err := s.customActive(ctx, modelItem.ID)
|
active, err := s.customActive(ctx, modelItem.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
active = pinTestAccount(active, active, in.AccountID)
|
active = pinTestAccount(active, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return nil, ErrNoProviderAccount
|
return nil, "", ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
size := upstreamSize(aspectRatio, resolution)
|
size := upstreamSize(aspectRatio, resolution)
|
||||||
quality := upstreamQuality(resolution)
|
quality := upstreamQuality(resolution)
|
||||||
@@ -1853,17 +1981,19 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var data []byte
|
var data []byte
|
||||||
|
var imgURL string
|
||||||
done, failover := func() (bool, bool) {
|
done, failover := func() (bool, bool) {
|
||||||
defer s.acctRelease(ctx, token.ID, eventID)
|
defer s.acctRelease(ctx, token.ID, eventID)
|
||||||
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
||||||
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
_ = s.tokens.TouchLastUsed(ctx, token.ID)
|
||||||
baseURL := stringValue(token.Meta["base_url"])
|
baseURL := stringValue(token.Meta["base_url"])
|
||||||
d, genErr := s.custom.GenerateImage(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, quality, refs)
|
d, u, genErr := s.custom.GenerateImage(ctx, baseURL, token.Value, modelItem.ID, in.Prompt, size, quality, refs, !urlOnly)
|
||||||
if genErr == nil {
|
if genErr == nil {
|
||||||
_, _ = s.tokens.Update(ctx, "custom", token.ID, map[string]any{
|
_, _ = s.tokens.Update(ctx, "custom", token.ID, map[string]any{
|
||||||
"last_used_at": time.Now(), "success_total": gorm.Expr("success_total + 1"), "fails": 0,
|
"last_used_at": time.Now(), "success_total": gorm.Expr("success_total + 1"), "fails": 0,
|
||||||
})
|
})
|
||||||
data = d
|
data = d
|
||||||
|
imgURL = u
|
||||||
return true, false
|
return true, false
|
||||||
}
|
}
|
||||||
lastErr = genErr
|
lastErr = genErr
|
||||||
@@ -1881,20 +2011,20 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if done {
|
if done {
|
||||||
return data, nil
|
return data, imgURL, nil
|
||||||
}
|
}
|
||||||
if failover {
|
if failover {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
if busy > 0 {
|
if busy > 0 {
|
||||||
return nil, ErrConcurrencyFull
|
return nil, "", ErrConcurrencyFull
|
||||||
}
|
}
|
||||||
lastErr = ErrProviderExecution
|
lastErr = ErrProviderExecution
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateCustomVideo forwards a video generation to an OpenAI-compatible
|
// generateCustomVideo forwards a video generation to an OpenAI-compatible
|
||||||
@@ -2144,9 +2274,14 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model
|
|||||||
// 401 — marked dead (status=disabled) and skipped — because Runway credits don't
|
// 401 — marked dead (status=disabled) and skipped — because Runway credits don't
|
||||||
// refill daily, so a "quota" mark (which the maintenance loop would revive) is
|
// refill daily, so a "quota" mark (which the maintenance loop would revive) is
|
||||||
// wrong. Reference images (up to the model's max) are uploaded per attempt.
|
// wrong. Reference images (up to the model's max) are uploaded per attempt.
|
||||||
func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
|
// noStore url-only mode (API-key requests without DeAI): skip the artifact
|
||||||
|
// download and return the upstream image URL directly, no bytes.
|
||||||
|
func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||||
|
// API-key (noStore) requests don't support DeAI (only the web drawing board
|
||||||
|
// does), so url-only mode == noStore — skip the download, return the URL.
|
||||||
|
urlOnly := noStore
|
||||||
if s.runway == nil {
|
if s.runway == nil {
|
||||||
return nil, errors.New("runway client not configured")
|
return nil, "", errors.New("runway client not configured")
|
||||||
}
|
}
|
||||||
if s.settings != nil {
|
if s.settings != nil {
|
||||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
||||||
@@ -2156,12 +2291,12 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
|||||||
|
|
||||||
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
refs, err := decodeReferenceImages(in.ReferenceImages, max(1, modelItem.MaxReferenceImages))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := s.tokens.ListByPool(ctx, "runway")
|
items, err := s.tokens.ListByPool(ctx, "runway")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
var active []model.TokenAccount
|
var active []model.TokenAccount
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -2178,7 +2313,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
|||||||
}
|
}
|
||||||
active = pinTestAccount(items, active, in.AccountID)
|
active = pinTestAccount(items, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return nil, ErrNoProviderAccount
|
return nil, "", ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
s.rotateRoundRobin("runway", active)
|
s.rotateRoundRobin("runway", active)
|
||||||
|
|
||||||
@@ -2195,6 +2330,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var data []byte
|
var data []byte
|
||||||
|
var artURL string
|
||||||
done, failover := func() (bool, bool) {
|
done, failover := func() (bool, bool) {
|
||||||
defer s.acctRelease(ctx, token.ID, eventID)
|
defer s.acctRelease(ctx, token.ID, eventID)
|
||||||
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
_ = s.events.SetAccount(ctx, eventID, token.ID, token.AccountEmail)
|
||||||
@@ -2203,7 +2339,9 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
|||||||
if token.Meta != nil {
|
if token.Meta != nil {
|
||||||
teamID = strings.TrimSpace(stringValue(token.Meta["team_id"]))
|
teamID = strings.TrimSpace(stringValue(token.Meta["team_id"]))
|
||||||
}
|
}
|
||||||
d, _, genErr := s.runway.GenerateImage(ctx, token.Value, teamID, modelItem.ID, in.Prompt, aspectRatio, imageSize, refs)
|
// downloadResult=false in url-only mode → skip the artifact download and
|
||||||
|
// just return meta["image_url"].
|
||||||
|
d, meta, genErr := s.runway.GenerateImage(ctx, token.Value, teamID, modelItem.ID, in.Prompt, aspectRatio, imageSize, refs, !urlOnly)
|
||||||
if genErr == nil {
|
if genErr == nil {
|
||||||
_, _ = s.tokens.Update(ctx, "runway", token.ID, map[string]any{
|
_, _ = s.tokens.Update(ctx, "runway", token.ID, map[string]any{
|
||||||
"last_used_at": time.Now(),
|
"last_used_at": time.Now(),
|
||||||
@@ -2211,6 +2349,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
|||||||
"fails": 0,
|
"fails": 0,
|
||||||
})
|
})
|
||||||
data = d
|
data = d
|
||||||
|
artURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
return true, false
|
return true, false
|
||||||
}
|
}
|
||||||
lastErr = genErr
|
lastErr = genErr
|
||||||
@@ -2228,20 +2367,20 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
if done {
|
if done {
|
||||||
return data, nil
|
return data, artURL, nil
|
||||||
}
|
}
|
||||||
if failover {
|
if failover {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
if lastErr == nil {
|
if lastErr == nil {
|
||||||
if busy > 0 {
|
if busy > 0 {
|
||||||
return nil, ErrConcurrencyFull
|
return nil, "", ErrConcurrencyFull
|
||||||
}
|
}
|
||||||
lastErr = ErrProviderExecution
|
lastErr = ErrProviderExecution
|
||||||
}
|
}
|
||||||
return nil, lastErr
|
return nil, "", lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileChatGPTQuota re-reads OpenAI's image_gen remaining right after a
|
// reconcileChatGPTQuota re-reads OpenAI's image_gen remaining right after a
|
||||||
@@ -2277,9 +2416,13 @@ func (s *V1Service) reconcileChatGPTQuota(ctx context.Context, tokenID, accessTo
|
|||||||
_, _ = s.tokens.Update(ctx, "chatgpt", tokenID, patch)
|
_, _ = s.tokens.Update(ctx, "chatgpt", tokenID, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
|
// chatgpt image URLs are auth-gated (files.oaiusercontent.com — a plain GET
|
||||||
|
// 403s), so url-only mode returns the URL for the caller to proxy via
|
||||||
|
// OpenImageContent using the generating account's token.
|
||||||
|
func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||||
|
urlOnly := noStore
|
||||||
if s.chatgpt == nil {
|
if s.chatgpt == nil {
|
||||||
return nil, errors.New("chatgpt client not configured")
|
return nil, "", errors.New("chatgpt client not configured")
|
||||||
}
|
}
|
||||||
if s.settings != nil {
|
if s.settings != nil {
|
||||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
||||||
@@ -2289,7 +2432,7 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo
|
|||||||
|
|
||||||
items, err := s.tokens.ListByPool(ctx, "chatgpt")
|
items, err := s.tokens.ListByPool(ctx, "chatgpt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
var active []model.TokenAccount
|
var active []model.TokenAccount
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -2299,7 +2442,7 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo
|
|||||||
}
|
}
|
||||||
active = pinTestAccount(items, active, in.AccountID)
|
active = pinTestAccount(items, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return nil, ErrNoProviderAccount
|
return nil, "", ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
s.rotateRoundRobin("chatgpt", active)
|
s.rotateRoundRobin("chatgpt", active)
|
||||||
|
|
||||||
@@ -2309,24 +2452,27 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo
|
|||||||
}
|
}
|
||||||
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
|
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round-robin order; on a transient upstream error (e.g. "image generation
|
// Round-robin order; on a transient upstream error (e.g. "image generation
|
||||||
// did not start (no async marker)") FAIL OVER to the next account
|
// did not start (no async marker)") FAIL OVER to the next account
|
||||||
// (tempFailover=true, capped at maxTempDeadAccounts) — never mark the
|
// (tempFailover=true, capped at maxTempDeadAccounts) — never mark the
|
||||||
// account dead. Auth/quota fail over immediately (see runPoolWithFailover).
|
// account dead. Auth/quota fail over immediately (see runPoolWithFailover).
|
||||||
return s.runPoolWithFailover(ctx, eventID, "chatgpt", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
var imageURL string
|
||||||
data, _, genErr := s.chatgpt.GenerateImage(ctx, token.Value, in.Prompt, modelItem.ID, aspectRatio, resolution, refs)
|
data, err := s.runPoolWithFailover(ctx, eventID, "chatgpt", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
||||||
|
d, meta, genErr := s.chatgpt.GenerateImage(ctx, token.Value, in.Prompt, modelItem.ID, aspectRatio, resolution, refs, !urlOnly)
|
||||||
if genErr == nil {
|
if genErr == nil {
|
||||||
|
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
// Sync the real OpenAI quota BEFORE the concurrency gate releases, so the
|
// Sync the real OpenAI quota BEFORE the concurrency gate releases, so the
|
||||||
// freshly-decremented remaining (and 限额 flip at 0) gates the next pick.
|
// freshly-decremented remaining (and 限额 flip at 0) gates the next pick.
|
||||||
s.reconcileChatGPTQuota(ctx, token.ID, token.Value)
|
s.reconcileChatGPTQuota(ctx, token.ID, token.Value)
|
||||||
}
|
}
|
||||||
return data, genErr
|
return d, genErr
|
||||||
}, func(e error) (bool, bool, bool, bool) {
|
}, func(e error) (bool, bool, bool, bool) {
|
||||||
return errors.Is(e, chatgpt.ErrAuth), errors.Is(e, chatgpt.ErrQuotaExhausted), errors.Is(e, chatgpt.ErrTemporaryUpstream), false
|
return errors.Is(e, chatgpt.ErrAuth), errors.Is(e, chatgpt.ErrQuotaExhausted), errors.Is(e, chatgpt.ErrTemporaryUpstream), false
|
||||||
}, nil, true) // chatgpt token IS the credential — no cookie to refresh; switch accounts on transient errors
|
}, nil, true) // chatgpt token IS the credential — no cookie to refresh; switch accounts on transient errors
|
||||||
|
return data, imageURL, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// leonardoResetAfter returns when a Leonardo account's daily free tokens renew.
|
// leonardoResetAfter returns when a Leonardo account's daily free tokens renew.
|
||||||
@@ -2380,9 +2526,10 @@ func leonardoDimensions(resolution, aspectRatio string) (int, int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
|
func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||||
|
urlOnly := noStore
|
||||||
if s.leonardo == nil {
|
if s.leonardo == nil {
|
||||||
return nil, errors.New("leonardo client not configured")
|
return nil, "", errors.New("leonardo client not configured")
|
||||||
}
|
}
|
||||||
if s.settings != nil {
|
if s.settings != nil {
|
||||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
||||||
@@ -2392,7 +2539,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
|
|||||||
|
|
||||||
items, err := s.tokens.ListByPool(ctx, "leonardo")
|
items, err := s.tokens.ListByPool(ctx, "leonardo")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
var active []model.TokenAccount
|
var active []model.TokenAccount
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -2408,7 +2555,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
|
|||||||
}
|
}
|
||||||
active = pinTestAccount(items, active, in.AccountID)
|
active = pinTestAccount(items, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return nil, ErrNoProviderAccount
|
return nil, "", ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
s.rotateRoundRobin("leonardo", active)
|
s.rotateRoundRobin("leonardo", active)
|
||||||
|
|
||||||
@@ -2424,12 +2571,13 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
|
|||||||
}
|
}
|
||||||
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
|
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// token.Value is the cookie; GenerateImage mints a fresh JWT each attempt, so an
|
// token.Value is the cookie; GenerateImage mints a fresh JWT each attempt, so an
|
||||||
// auth failure means the cookie itself is dead — no refresher (nil).
|
// auth failure means the cookie itself is dead — no refresher (nil).
|
||||||
return s.runPoolWithFailover(ctx, eventID, "leonardo", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
var imageURL string
|
||||||
|
data, err := s.runPoolWithFailover(ctx, eventID, "leonardo", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
||||||
// Atomically pre-deduct the per-generation cost so concurrent picks of the
|
// Atomically pre-deduct the per-generation cost so concurrent picks of the
|
||||||
// same near-empty account can't over-commit it. A known-insufficient
|
// same near-empty account can't over-commit it. A known-insufficient
|
||||||
// balance surfaces as quota → the driver fails over to the next account.
|
// balance surfaces as quota → the driver fails over to the next account.
|
||||||
@@ -2440,7 +2588,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
|
|||||||
if !allowed {
|
if !allowed {
|
||||||
return nil, leonardo.ErrQuotaExhausted
|
return nil, leonardo.ErrQuotaExhausted
|
||||||
}
|
}
|
||||||
data, _, genErr := s.leonardo.GenerateImage(ctx, token.Value, upstreamModel, in.Prompt, width, height, nil, refs)
|
data, meta, genErr := s.leonardo.GenerateImage(ctx, token.Value, upstreamModel, in.Prompt, width, height, nil, refs, !urlOnly)
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
// Release the hold so a failed render doesn't burn credits.
|
// Release the hold so a failed render doesn't burn credits.
|
||||||
if deducted {
|
if deducted {
|
||||||
@@ -2448,6 +2596,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
|
|||||||
}
|
}
|
||||||
return nil, genErr
|
return nil, genErr
|
||||||
}
|
}
|
||||||
|
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
// Success → overwrite the held value with the REAL upstream balance and
|
// Success → overwrite the held value with the REAL upstream balance and
|
||||||
// sink to 限额 if below the floor (best-effort; never fails a done render).
|
// sink to 限额 if below the floor (best-effort; never fails a done render).
|
||||||
s.reconcileLeonardoCredits(ctx, token.ID, token.Value)
|
s.reconcileLeonardoCredits(ctx, token.ID, token.Value)
|
||||||
@@ -2455,6 +2604,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m
|
|||||||
}, func(e error) (bool, bool, bool, bool) {
|
}, func(e error) (bool, bool, bool, bool) {
|
||||||
return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false
|
return errors.Is(e, leonardo.ErrAuth), errors.Is(e, leonardo.ErrQuotaExhausted), errors.Is(e, leonardo.ErrTemporaryUpstream), false
|
||||||
}, nil, true)
|
}, nil, true)
|
||||||
|
return data, imageURL, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconcileLeonardoCredits re-fetches an account's real token balance after a
|
// reconcileLeonardoCredits re-fetches an account's real token balance after a
|
||||||
@@ -2536,9 +2686,10 @@ func kreaDimensions(resolution, aspectRatio string) (int, int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
|
func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||||
|
urlOnly := noStore
|
||||||
if s.krea == nil {
|
if s.krea == nil {
|
||||||
return nil, errors.New("krea client not configured")
|
return nil, "", errors.New("krea client not configured")
|
||||||
}
|
}
|
||||||
if s.settings != nil {
|
if s.settings != nil {
|
||||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
||||||
@@ -2548,7 +2699,7 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model
|
|||||||
|
|
||||||
items, err := s.tokens.ListByPool(ctx, "krea")
|
items, err := s.tokens.ListByPool(ctx, "krea")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
var active []model.TokenAccount
|
var active []model.TokenAccount
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -2560,7 +2711,7 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model
|
|||||||
}
|
}
|
||||||
active = pinTestAccount(items, active, in.AccountID)
|
active = pinTestAccount(items, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return nil, ErrNoProviderAccount
|
return nil, "", ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
s.rotateRoundRobin("krea", active)
|
s.rotateRoundRobin("krea", active)
|
||||||
|
|
||||||
@@ -2571,21 +2722,26 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model
|
|||||||
}
|
}
|
||||||
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
|
refs, err := decodeReferenceImages(in.ReferenceImages, refLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.runPoolWithFailover(ctx, eventID, "krea", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
var imageURL string
|
||||||
|
data, err := s.runPoolWithFailover(ctx, eventID, "krea", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
||||||
// Refresh the (rotating) Supabase token if expired and persist the new
|
// Refresh the (rotating) Supabase token if expired and persist the new
|
||||||
// cookie, then generate with the fresh cookie.
|
// cookie, then generate with the fresh cookie.
|
||||||
cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, token.ID, token.Value)
|
cookie, rerr := kreaRefreshAndPersist(ctx, s.krea, s.tokens, token.ID, token.Value)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
return nil, rerr
|
return nil, rerr
|
||||||
}
|
}
|
||||||
data, _, genErr := s.krea.GenerateImage(ctx, cookie, in.Prompt, width, height, refs)
|
data, meta, genErr := s.krea.GenerateImage(ctx, cookie, in.Prompt, width, height, refs, !urlOnly)
|
||||||
|
if genErr == nil {
|
||||||
|
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
|
}
|
||||||
return data, genErr
|
return data, genErr
|
||||||
}, func(e error) (bool, bool, bool, bool) {
|
}, func(e error) (bool, bool, bool, bool) {
|
||||||
return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream), false
|
return errors.Is(e, krea.ErrAuth), errors.Is(e, krea.ErrQuotaExhausted), errors.Is(e, krea.ErrTemporaryUpstream), false
|
||||||
}, nil, true)
|
}, nil, true)
|
||||||
|
return data, imageURL, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// imagineRefreshAndPersist ensures the account's Imagine credential has a valid
|
// imagineRefreshAndPersist ensures the account's Imagine credential has a valid
|
||||||
@@ -2613,9 +2769,10 @@ func imagineStyle(modelID string) (int, string) {
|
|||||||
return 41001, "2K"
|
return 41001, "2K"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string) ([]byte, error) {
|
func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, modelItem *model.ModelConfig, in V1ImageRequest, aspectRatio, resolution string, noStore bool) ([]byte, string, error) {
|
||||||
|
urlOnly := noStore
|
||||||
if s.imagine == nil {
|
if s.imagine == nil {
|
||||||
return nil, errors.New("imagine client not configured")
|
return nil, "", errors.New("imagine client not configured")
|
||||||
}
|
}
|
||||||
if s.settings != nil {
|
if s.settings != nil {
|
||||||
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
if proxy, err := s.settings.GetValue(ctx, "proxy.url"); err == nil {
|
||||||
@@ -2625,7 +2782,7 @@ func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, mo
|
|||||||
|
|
||||||
items, err := s.tokens.ListByPool(ctx, "imagine")
|
items, err := s.tokens.ListByPool(ctx, "imagine")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
var active []model.TokenAccount
|
var active []model.TokenAccount
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
@@ -2637,28 +2794,31 @@ func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, mo
|
|||||||
}
|
}
|
||||||
active = pinTestAccount(items, active, in.AccountID)
|
active = pinTestAccount(items, active, in.AccountID)
|
||||||
if len(active) == 0 {
|
if len(active) == 0 {
|
||||||
return nil, ErrNoProviderAccount
|
return nil, "", ErrNoProviderAccount
|
||||||
}
|
}
|
||||||
s.rotateRoundRobin("imagine", active)
|
s.rotateRoundRobin("imagine", active)
|
||||||
|
|
||||||
// Each model supports exactly one resolution (2K / 4K) — force it per model.
|
// Each model supports exactly one resolution (2K / 4K) — force it per model.
|
||||||
styleID, res := imagineStyle(modelItem.ID)
|
styleID, res := imagineStyle(modelItem.ID)
|
||||||
|
|
||||||
return s.runPoolWithFailover(ctx, eventID, "imagine", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
var imageURL string
|
||||||
|
data, err := s.runPoolWithFailover(ctx, eventID, "imagine", active, "image", func(token model.TokenAccount) ([]byte, error) {
|
||||||
// Refresh the (rotating) access token if expired and persist the new
|
// Refresh the (rotating) access token if expired and persist the new
|
||||||
// credential, then generate with the fresh token.
|
// credential, then generate with the fresh token.
|
||||||
cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, token.ID, token.Value)
|
cred, rerr := imagineRefreshAndPersist(ctx, s.imagine, s.tokens, token.ID, token.Value)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
return nil, rerr
|
return nil, rerr
|
||||||
}
|
}
|
||||||
data, _, genErr := s.imagine.GenerateImage(ctx, cred, styleID, res, aspectRatio, in.Prompt)
|
data, meta, genErr := s.imagine.GenerateImage(ctx, cred, styleID, res, aspectRatio, in.Prompt, !urlOnly)
|
||||||
if genErr != nil {
|
if genErr != nil {
|
||||||
return nil, genErr
|
return nil, genErr
|
||||||
}
|
}
|
||||||
|
imageURL = strings.TrimSpace(stringValue(meta["image_url"]))
|
||||||
return data, nil
|
return data, nil
|
||||||
}, func(e error) (bool, bool, bool, bool) {
|
}, func(e error) (bool, bool, bool, bool) {
|
||||||
return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream), false
|
return errors.Is(e, imagine.ErrAuth), errors.Is(e, imagine.ErrQuotaExhausted), errors.Is(e, imagine.ErrTemporaryUpstream), false
|
||||||
}, nil, true)
|
}, nil, true)
|
||||||
|
return data, imageURL, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error {
|
func (s *V1Service) refundIfNeeded(ctx context.Context, principal *APIPrincipal, eventID string, price float64) error {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const examples = computed(() => [
|
|||||||
{
|
{
|
||||||
title: '文生图 · Python (openai SDK)',
|
title: '文生图 · Python (openai SDK)',
|
||||||
code:
|
code:
|
||||||
`import base64
|
`import urllib.request
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||||
@@ -123,9 +123,8 @@ resp = client.images.generate(
|
|||||||
prompt="a corgi running in a golden wheat field, cinematic",
|
prompt="a corgi running in a golden wheat field, cinematic",
|
||||||
size="2048x2048", # 2K · 1:1,见下方对照表
|
size="2048x2048", # 2K · 1:1,见下方对照表
|
||||||
)
|
)
|
||||||
# 结果是 base64(无 URL)
|
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
|
||||||
with open("out.png", "wb") as f:
|
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
|
||||||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '图生图 / 参考图 · curl (multipart)',
|
title: '图生图 / 参考图 · curl (multipart)',
|
||||||
@@ -141,7 +140,7 @@ with open("out.png", "wb") as f:
|
|||||||
{
|
{
|
||||||
title: '图生图 · Python (openai SDK)',
|
title: '图生图 · Python (openai SDK)',
|
||||||
code:
|
code:
|
||||||
`import base64
|
`import urllib.request
|
||||||
from openai import OpenAI
|
from openai import OpenAI
|
||||||
|
|
||||||
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
client = OpenAI(api_key="${keyHint.value}", base_url="${base.value}/v1")
|
||||||
@@ -151,8 +150,8 @@ resp = client.images.edit(
|
|||||||
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
|
image=open("input.png", "rb"), # 多张:image=[open("a.png","rb"), open("b.png","rb")]
|
||||||
prompt="把这张图改成赛博朋克风格",
|
prompt="把这张图改成赛博朋克风格",
|
||||||
)
|
)
|
||||||
with open("out.png", "wb") as f:
|
# 结果是图片 URL(上游原始直链,会过期 → 尽快下载/转存)
|
||||||
f.write(base64.b64decode(resp.data[0].b64_json))`,
|
urllib.request.urlretrieve(resp.data[0].url, "out.png")`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '视频 · curl(创建 → 轮询 → 下载)',
|
title: '视频 · curl(创建 → 轮询 → 下载)',
|
||||||
@@ -424,7 +423,7 @@ async function copy(text) {
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
<h2 class="text-lg font-semibold mb-3">响应 & 计费</h2>
|
||||||
<div class="card p-6 space-y-3 text-sm text-white/70">
|
<div class="card p-6 space-y-3 text-sm text-white/70">
|
||||||
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "b64_json": "..." }] }' }}</code> —— 产物以 <strong class="text-white/90">base64</strong> 直接放在 <code class="text-white/85 font-mono">data[0].b64_json</code>(原始 base64、无 <code class="text-white/70">data:</code> 前缀),自行解码保存为图片。<strong class="text-white/90">不返回 URL、服务端不留存</strong>。</p>
|
<p><strong class="text-white/90">图像</strong>(generations / edits)返回 OpenAI 图片格式:<code class="text-white/85 font-mono">{{ '{ "created": ..., "data": [{ "url": "..." }] }' }}</code> —— <code class="text-white/85 font-mono">data[0].url</code> 是产物 URL,服务端不留存(<strong class="text-white/90">不返回 base64</strong>)。多数模型返回上游<strong class="text-white/90">原始直链</strong>;少数上游需鉴权的(如 gpt-image)会返回一个本站转发链 <code class="text-white/85 font-mono">/v1/images/{id}/content</code>,由服务端带账号凭据取回。<strong class="text-white/90">两种链接都会过期</strong>,请<strong class="text-white/90">尽快下载或转存到你自己的存储</strong>。</p>
|
||||||
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
<p><strong class="text-white/90">视频</strong>(异步,Sora 风格三步):</p>
|
||||||
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
<ol class="list-decimal list-inside space-y-1 text-white/65 pl-1">
|
||||||
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
<li><code class="text-white/85 font-mono">POST /v1/videos</code> 立即返回任务对象 <code class="text-white/85 font-mono">{{ '{ "id": "...", "object": "video", "status": "queued", ... }' }}</code></li>
|
||||||
|
|||||||
Reference in New Issue
Block a user