修复grok视频参考图

This commit is contained in:
2026-07-03 23:10:04 +08:00
parent f9e72c0168
commit c6a8ca440a
2 changed files with 71 additions and 14 deletions
+60 -13
View File
@@ -1,12 +1,14 @@
package grok package grok
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime/multipart"
"net/textproto"
"os" "os"
"regexp" "regexp"
"strings" "strings"
@@ -14,6 +16,7 @@ import (
http "github.com/bogdanfinn/fhttp" http "github.com/bogdanfinn/fhttp"
tlsclient "github.com/bogdanfinn/tls-client" tlsclient "github.com/bogdanfinn/tls-client"
"github.com/google/uuid"
) )
// assetBase is where generated media artifacts live (the stream returns a // assetBase is where generated media artifacts live (the stream returns a
@@ -166,17 +169,11 @@ func (c *Client) GenerateVideo(ctx context.Context, token, prompt, aspectRatio,
return data, meta, nil return data, meta, nil
} }
// uploadImage uploads one reference frame via /rest/app-chat/upload-file (JSON // uploadImage uploads one reference frame via /http/upload-file-v2/direct and
// with base64 content) and returns its asset content URL for imageReferences. // returns its asset content URL for imageReferences. Cloudflare's bot score is
// Cloudflare's bot score is per-request, so a big base64 upload can hit a // per-request, so retry transient failures with backoff instead of failing the
// "Just a moment…" 403 intermittently while identical requests pass — retry // whole task.
// transient failures with backoff instead of failing the whole task.
func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) { func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (string, error) {
body := map[string]any{
"fileName": "ref.png",
"fileMimeType": "image/png",
"content": base64.StdEncoding.EncodeToString(img),
}
var res map[string]any var res map[string]any
var err error var err error
backoffs := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second} backoffs := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second}
@@ -188,7 +185,7 @@ func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, t
case <-time.After(wait): case <-time.After(wait):
} }
} }
res, err = c.postJSON(ctx, client, token, "/rest/app-chat/upload-file", body) res, err = c.uploadFileV2(ctx, client, token, img)
if err == nil || !errors.Is(err, ErrTemporaryUpstream) { if err == nil || !errors.Is(err, ErrTemporaryUpstream) {
break break
} }
@@ -196,7 +193,11 @@ func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, t
if err != nil { if err != nil {
return "", err return "", err
} }
fileURI := strings.TrimSpace(stringValue(res["fileUri"])) meta, _ := res["fileMetadata"].(map[string]any)
fileURI := ""
if meta != nil {
fileURI = strings.TrimSpace(stringValue(meta["fileUri"]))
}
if fileURI == "" { if fileURI == "" {
return "", fmt.Errorf("%w: upload missing fileUri", ErrTemporaryUpstream) return "", fmt.Errorf("%w: upload missing fileUri", ErrTemporaryUpstream)
} }
@@ -206,6 +207,52 @@ func (c *Client) uploadImage(ctx context.Context, client tlsclient.HttpClient, t
return assetBase + strings.TrimPrefix(fileURI, "/"), nil return assetBase + strings.TrimPrefix(fileURI, "/"), nil
} }
func (c *Client) uploadFileV2(ctx context.Context, client tlsclient.HttpClient, token string, img []byte) (map[string]any, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
partHeader := textproto.MIMEHeader{}
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, uuid.NewString()+".png"))
partHeader.Set("Content-Type", "image/png")
part, err := mw.CreatePart(partHeader)
if err != nil {
return nil, err
}
if _, err := part.Write(img); err != nil {
return nil, err
}
if err := mw.WriteField("file_source", "IMAGINE_SELF_UPLOAD_FILE_SOURCE"); err != nil {
return nil, err
}
if err := mw.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, apiBase+"/http/upload-file-v2/direct", bytes.NewReader(buf.Bytes()))
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
c.applyHeaders(req, token, map[string]string{"content-type": mw.FormDataContentType()})
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
}
if e := mapStatus("/http/upload-file-v2/direct", resp.StatusCode, raw); e != nil {
return nil, e
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("%w: upload non-json: %s", ErrTemporaryUpstream, clip(raw, 120))
}
return out, nil
}
// createPost registers a video media post and returns its id (parentPostId). // createPost registers a video media post and returns its id (parentPostId).
func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, token, prompt string) (string, error) { func (c *Client) createPost(ctx context.Context, client tlsclient.HttpClient, token, prompt string) (string, error) {
res, err := c.postJSON(ctx, client, token, "/rest/media/post/create", map[string]any{ res, err := c.postJSON(ctx, client, token, "/rest/media/post/create", map[string]any{
+11 -1
View File
@@ -9,6 +9,7 @@ import SelectMenu from '../components/SelectMenu.vue'
import MediaLightbox from '../components/MediaLightbox.vue' import MediaLightbox from '../components/MediaLightbox.vue'
import { pointsLabel } from '../credits' import { pointsLabel } from '../credits'
import { sortResolutions } from '../utils/format' import { sortResolutions } from '../utils/format'
import { copyText } from '../utils/clipboard'
const route = useRoute() const route = useRoute()
@@ -265,6 +266,12 @@ function flash(msg) {
toastTimer = setTimeout(() => (toast.value = ''), 1800) toastTimer = setTimeout(() => (toast.value = ''), 1800)
} }
async function copyPrompt(item) {
const text = (item && item.prompt) || ''
if (!text.trim()) return
flash(await copyText(text) ? '指令已复制' : '复制失败')
}
// ---- generate (concurrent — no lock) ---- // ---- generate (concurrent — no lock) ----
// 生图 can request 14 images at once: each is an independent task/charge. // 生图 can request 14 images at once: each is an independent task/charge.
const count = ref(1) const count = ref(1)
@@ -691,7 +698,9 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<div class="absolute inset-x-0 bottom-0 p-2.5 pointer-events-none"> <div class="absolute inset-x-0 bottom-0 p-2.5 pointer-events-none">
<div class="pg-cap text-[11px] leading-tight font-medium line-clamp-2" :title="item.prompt">{{ item.prompt }}</div> <div class="pg-cap text-[11px] leading-tight font-medium line-clamp-2 transition-colors"
:class="item.prompt ? 'pointer-events-auto cursor-pointer' : ''"
:title="item.prompt ? '点击复制提示词' : ''" @click.stop="copyPrompt(item)">{{ item.prompt }}</div>
<div class="pg-cap-sub text-[9px] mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div> <div class="pg-cap-sub text-[9px] mt-0.5 font-mono truncate">{{ item.model }}<span v-if="item.elapsed_ms"> · {{ (item.elapsed_ms / 1000).toFixed(1) }}s</span></div>
</div> </div>
</template> </template>
@@ -744,5 +753,6 @@ onUnmounted(() => {
The global `.theme-text` remap would otherwise darken them (it turns The global `.theme-text` remap would otherwise darken them (it turns
over-image whites dark for the marketing pages), making them unreadable here. */ over-image whites dark for the marketing pages), making them unreadable here. */
.pg-cap { color: #fff !important; } .pg-cap { color: #fff !important; }
.pg-cap.cursor-pointer:hover { color: rgb(255 255 255 / 0.75) !important; }
.pg-cap-sub { color: rgb(255 255 255 / 0.62) !important; } .pg-cap-sub { color: rgb(255 255 255 / 0.62) !important; }
</style> </style>