更新缩略图
This commit is contained in:
@@ -38,7 +38,16 @@ func (h *ImageHandler) Serve(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
public, err := h.imageAccess.IsPublic(c.Request.Context(), rel)
|
||||
// A thumbnail shares its original's visibility, and old images without a
|
||||
// stored thumb fall back to the original object.
|
||||
origRel := rel
|
||||
if service.IsThumbKey(rel) {
|
||||
origRel = service.OrigKey(rel)
|
||||
} else if service.IsLastFrameKey(rel) {
|
||||
origRel = service.LastFrameOrigKey(rel)
|
||||
}
|
||||
|
||||
public, err := h.imageAccess.IsPublic(c.Request.Context(), origRel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to authorize image"})
|
||||
return
|
||||
@@ -65,6 +74,14 @@ func (h *ImageHandler) Serve(c *gin.Context) {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": "failed to fetch object"})
|
||||
return
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound && origRel != rel {
|
||||
resp.Body.Close()
|
||||
resp, err = h.store.Get(c.Request.Context(), origRel, c.GetHeader("Range"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"detail": "failed to fetch object"})
|
||||
return
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"detail": "not found"})
|
||||
|
||||
@@ -153,6 +153,14 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
||||
used = fullCredits
|
||||
}
|
||||
remaining := fullCredits - used
|
||||
|
||||
// 恢复时间: prefer the subscription's billing-period end (when the plan renews
|
||||
// and credits reset) over the credits-config timestamp. Free accounts have no
|
||||
// subscription, so this falls back to the credits-config reset above.
|
||||
sub, _ := c.FetchSubscription(ctx, token)
|
||||
if sub != nil && strings.TrimSpace(sub.BillingPeriodEnd) != "" {
|
||||
reset = strings.TrimSpace(sub.BillingPeriodEnd)
|
||||
}
|
||||
return map[string]any{
|
||||
"remaining": remaining,
|
||||
"used": used,
|
||||
@@ -163,6 +171,78 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Subscription is the membership view parsed from GET /rest/subscriptions.
|
||||
type Subscription struct {
|
||||
Member bool // an active subscription exists
|
||||
Tier string // e.g. SUBSCRIPTION_TIER_GROK_PRO ("" for free)
|
||||
Status string // e.g. SUBSCRIPTION_STATUS_ACTIVE
|
||||
BillingPeriodEnd string // RFC3339; when the plan renews / credits reset
|
||||
FreeTrial bool // currently in a free-trial offer
|
||||
}
|
||||
|
||||
// FetchSubscription reads GET /rest/subscriptions and reports the account's
|
||||
// membership. An empty subscriptions array means a free account (Member=false).
|
||||
// A 401/403 maps to ErrAuth; other transport/HTTP errors are returned so callers
|
||||
// can treat them as best-effort (they already have the credit balance).
|
||||
func (c *Client) FetchSubscription(ctx context.Context, token string) (*Subscription, error) {
|
||||
token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
|
||||
if token == "" {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
client, err := c.newTLSClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, apiBase+"/rest/subscriptions", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
c.applyHeaders(req, token, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrTemporaryUpstream, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return nil, ErrAuth
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%w: subscriptions http %d", ErrTemporaryUpstream, resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
Subscriptions []struct {
|
||||
Tier string `json:"tier"`
|
||||
Status string `json:"status"`
|
||||
BillingPeriodEnd string `json:"billingPeriodEnd"`
|
||||
ActiveOffer struct {
|
||||
FreeTrial *struct {
|
||||
TrialDays int `json:"trialDays"`
|
||||
} `json:"freeTrial"`
|
||||
} `json:"activeOffer"`
|
||||
} `json:"subscriptions"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
return nil, fmt.Errorf("%w: subscriptions non-json", ErrTemporaryUpstream)
|
||||
}
|
||||
out := &Subscription{}
|
||||
// Pick the active subscription (fall back to the first entry) as the membership.
|
||||
for i, s := range body.Subscriptions {
|
||||
if i == 0 || strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE") {
|
||||
out.Member = true
|
||||
out.Tier = strings.TrimSpace(s.Tier)
|
||||
out.Status = strings.TrimSpace(s.Status)
|
||||
out.BillingPeriodEnd = strings.TrimSpace(s.BillingPeriodEnd)
|
||||
out.FreeTrial = s.ActiveOffer.FreeTrial != nil
|
||||
if strings.EqualFold(s.Status, "SUBSCRIPTION_STATUS_ACTIVE") {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FetchSession reads the account profile via GET /api/auth/session and returns
|
||||
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
|
||||
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
|
||||
@@ -271,21 +351,21 @@ func statsigID(path, method string) string {
|
||||
// extra overrides/adds per-request headers (e.g. content-type).
|
||||
func (c *Client) applyHeaders(req *http.Request, token string, extra map[string]string) {
|
||||
h := http.Header{
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"x-statsig-id": {statsigID(req.URL.Path, req.Method)},
|
||||
"x-xai-request-id": {uuid.NewString()},
|
||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"accept": {"*/*"},
|
||||
"accept-language": {"en-US,en;q=0.9"},
|
||||
"content-type": {"application/json"},
|
||||
"origin": {origin},
|
||||
"referer": {origin + "/"},
|
||||
"user-agent": {userAgent},
|
||||
"x-statsig-id": {statsigID(req.URL.Path, req.Method)},
|
||||
"x-xai-request-id": {uuid.NewString()},
|
||||
"sec-ch-ua": {`"Chromium";v="133", "Not(A:Brand";v="99"`},
|
||||
"sec-ch-ua-mobile": {"?0"},
|
||||
"sec-ch-ua-platform": {`"Windows"`},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
"sec-fetch-dest": {"empty"},
|
||||
"sec-fetch-mode": {"cors"},
|
||||
"sec-fetch-site": {"same-origin"},
|
||||
"cookie": {"sso=" + token + "; sso-rw=" + token},
|
||||
}
|
||||
for k, v := range extra {
|
||||
h[k] = []string{v}
|
||||
|
||||
@@ -331,9 +331,10 @@ func mapStatus(path string, status int, raw []byte) error {
|
||||
switch {
|
||||
case status == 200:
|
||||
return nil
|
||||
case status == 403 && strings.Contains(strings.ToLower(string(raw)), "anti-bot"):
|
||||
// grok bot-detection (proxy/TLS fingerprint), NOT a dead token — transient,
|
||||
// so a good account isn't killed by an IP/anti-bot hiccup.
|
||||
case status == 403 && isBotChallenge(string(raw)):
|
||||
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
|
||||
// NOT a dead token — transient, so a good account isn't killed by an
|
||||
// IP/anti-bot hiccup.
|
||||
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||
case status == 401 || status == 403:
|
||||
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
||||
@@ -355,6 +356,18 @@ func mapStatus(path string, status int, raw []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// isBotChallenge reports whether a 403 body is an anti-bot interstitial rather
|
||||
// than a real auth rejection: grok's own "anti-bot" marker or a Cloudflare
|
||||
// challenge page ("Just a moment…" / cf-chl / challenge-platform).
|
||||
func isBotChallenge(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "anti-bot") ||
|
||||
strings.Contains(s, "just a moment") ||
|
||||
strings.Contains(s, "cf-chl") ||
|
||||
strings.Contains(s, "challenge-platform") ||
|
||||
strings.Contains(s, "cf_chl")
|
||||
}
|
||||
|
||||
func isCreditError(s string) bool {
|
||||
s = strings.ToLower(s)
|
||||
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
||||
|
||||
@@ -62,25 +62,25 @@ func (s *AdminReadService) ModelsView(ctx context.Context) ([]map[string]any, er
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, map[string]any{
|
||||
"id": item.ID,
|
||||
"type": item.Type,
|
||||
"name": item.Name,
|
||||
"provider": item.Provider,
|
||||
"enabled": item.Enabled,
|
||||
"ratios": repo.JSONStrings(item.Ratios),
|
||||
"prices": map[string]any(item.Prices),
|
||||
"resolutions": repo.JSONStrings(item.Resolutions),
|
||||
"image_to_image": item.ImageToImage,
|
||||
"duration_prices": map[string]any(item.DurationPrices),
|
||||
"id": item.ID,
|
||||
"type": item.Type,
|
||||
"name": item.Name,
|
||||
"provider": item.Provider,
|
||||
"enabled": item.Enabled,
|
||||
"ratios": repo.JSONStrings(item.Ratios),
|
||||
"prices": map[string]any(item.Prices),
|
||||
"resolutions": repo.JSONStrings(item.Resolutions),
|
||||
"image_to_image": item.ImageToImage,
|
||||
"duration_prices": map[string]any(item.DurationPrices),
|
||||
"prices_agent": map[string]any(item.PricesAgent),
|
||||
"duration_prices_agent": map[string]any(item.DurationPricesAgent),
|
||||
"durations": repo.JSONStrings(item.Durations),
|
||||
"max_reference_images": item.MaxReferenceImages,
|
||||
"reference_mode": item.ReferenceMode,
|
||||
"weight": item.Weight,
|
||||
"generation_count": item.GenerationCount,
|
||||
"created_at": item.CreatedAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
"durations": repo.JSONStrings(item.Durations),
|
||||
"max_reference_images": item.MaxReferenceImages,
|
||||
"reference_mode": item.ReferenceMode,
|
||||
"weight": item.Weight,
|
||||
"generation_count": item.GenerationCount,
|
||||
"created_at": item.CreatedAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
@@ -574,6 +574,9 @@ func (s *AdminReadService) scanGeneratedFiles(ctx context.Context) ([]generatedF
|
||||
if isReferenceFile(o.Key) {
|
||||
continue // reference uploads are not generated outputs — hide from gallery
|
||||
}
|
||||
if IsThumbKey(o.Key) || IsLastFrameKey(o.Key) {
|
||||
continue // thumbnails / last-frame stills are derived — only originals are listed
|
||||
}
|
||||
kind := mediaKind(o.Key)
|
||||
if kind == "" {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// thumbSuffix is appended to an image's object key to form its thumbnail key
|
||||
// ("u/x.png" → "u/x.png.thumb.jpg"). List views load the thumbnail; preview and
|
||||
// download always use the original.
|
||||
const thumbSuffix = ".thumb.jpg"
|
||||
|
||||
// thumbMaxDim bounds the thumbnail's longest side. 512px is crisp for grid
|
||||
// cards / table rows while staying ~20-50 KB as JPEG.
|
||||
const thumbMaxDim = 512
|
||||
|
||||
// ThumbKey returns the thumbnail object key for an image key.
|
||||
func ThumbKey(rel string) string { return rel + thumbSuffix }
|
||||
|
||||
// IsThumbKey reports whether name refers to a thumbnail object, and OrigKey
|
||||
// maps a thumbnail key back to its original image key.
|
||||
func IsThumbKey(name string) bool { return strings.HasSuffix(name, thumbSuffix) }
|
||||
func OrigKey(name string) string { return strings.TrimSuffix(name, thumbSuffix) }
|
||||
|
||||
// makeThumbnail downscales an image to thumbMaxDim (longest side) and encodes
|
||||
// it as JPEG. Images already small enough are re-encoded as-is (so the thumb
|
||||
// object always exists once generated). Returns an error for undecodable input
|
||||
// (e.g. video bytes) — callers treat thumbnailing as best-effort.
|
||||
func makeThumbnail(b []byte) ([]byte, error) {
|
||||
src, _, err := image.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bounds := src.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
tw, th := w, h
|
||||
if w > thumbMaxDim || h > thumbMaxDim {
|
||||
if w >= h {
|
||||
tw = thumbMaxDim
|
||||
th = h * thumbMaxDim / w
|
||||
} else {
|
||||
th = thumbMaxDim
|
||||
tw = w * thumbMaxDim / h
|
||||
}
|
||||
if tw < 1 {
|
||||
tw = 1
|
||||
}
|
||||
if th < 1 {
|
||||
th = 1
|
||||
}
|
||||
}
|
||||
// JPEG has no alpha — composite onto white so transparent PNGs don't go black.
|
||||
dst := image.NewRGBA(image.Rect(0, 0, tw, th))
|
||||
draw.Draw(dst, dst.Bounds(), image.White, image.Point{}, draw.Src)
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil)
|
||||
var out bytes.Buffer
|
||||
if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 78}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -515,6 +515,11 @@ func (s *V1Service) prepareImageExecution(ctx context.Context, principal *APIPri
|
||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0)
|
||||
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err)
|
||||
}
|
||||
// Best-effort thumbnail for list views; the image serving route falls
|
||||
// back to the original when the thumb object is missing.
|
||||
if thumb, terr := makeThumbnail(imageBytes); terr == nil {
|
||||
_ = s.store.Put(genCtx, ThumbKey(relativePath), thumb, "image/jpeg")
|
||||
}
|
||||
}
|
||||
elapsedMS := int(time.Since(startedAt).Milliseconds())
|
||||
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
|
||||
@@ -648,6 +653,17 @@ func (s *V1Service) prepareVideoExecution(ctx context.Context, principal *APIPri
|
||||
_ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0)
|
||||
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err)
|
||||
}
|
||||
// Best-effort stills: first frame (downscaled) for list thumbnails and
|
||||
// the full-res last frame for 首尾帧 continuation. Missing objects fall
|
||||
// back to the video itself at serve time.
|
||||
if thumb, last, terr := extractVideoFrames(genCtx, videoBytes); terr == nil {
|
||||
if len(thumb) > 0 {
|
||||
_ = s.store.Put(genCtx, ThumbKey(relativePath), thumb, "image/jpeg")
|
||||
}
|
||||
if len(last) > 0 {
|
||||
_ = s.store.Put(genCtx, LastFrameKey(relativePath), last, "image/jpeg")
|
||||
}
|
||||
}
|
||||
}
|
||||
elapsedMS := int(time.Since(startedAt).Milliseconds())
|
||||
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// lastFrameSuffix marks a video's FULL-RESOLUTION last-frame still
|
||||
// ("u/x.mp4" → "u/x.mp4.last.jpg"). The 画图台 uses it as the 首帧 reference
|
||||
// when continuing a video (首尾帧 models); the first-frame THUMBNAIL reuses
|
||||
// thumbSuffix so list views load videos and images the same way.
|
||||
const lastFrameSuffix = ".last.jpg"
|
||||
|
||||
// LastFrameKey returns the last-frame object key for a video key, and
|
||||
// IsLastFrameKey reports whether name refers to such a derived object.
|
||||
func LastFrameKey(rel string) string { return rel + lastFrameSuffix }
|
||||
func IsLastFrameKey(name string) bool { return strings.HasSuffix(name, lastFrameSuffix) }
|
||||
func LastFrameOrigKey(name string) string { return strings.TrimSuffix(name, lastFrameSuffix) }
|
||||
|
||||
// extractVideoFrames pulls two stills from an mp4 via ffmpeg: the FIRST frame
|
||||
// downscaled for list thumbnails (≤thumbMaxDim) and the LAST frame at full
|
||||
// resolution. Callers treat this as best-effort — any missing ffmpeg or decode
|
||||
// failure just means the derived objects aren't stored.
|
||||
func extractVideoFrames(ctx context.Context, video []byte) (thumb, last []byte, err error) {
|
||||
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return nil, nil, errors.New("ffmpeg not installed")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "vidframes-*")
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
in := filepath.Join(dir, "in.mp4")
|
||||
if err := os.WriteFile(in, video, 0o600); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
thumbPath := filepath.Join(dir, "thumb.jpg")
|
||||
if out, err := exec.CommandContext(ctx, ffmpeg, "-y", "-i", in,
|
||||
"-vf", fmt.Sprintf("scale='min(%d,iw)':-2", thumbMaxDim),
|
||||
"-frames:v", "1", "-q:v", "4", thumbPath).CombinedOutput(); err != nil {
|
||||
return nil, nil, fmt.Errorf("ffmpeg first frame: %v: %s", err, clipTail(out))
|
||||
}
|
||||
thumb, err = os.ReadFile(thumbPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// -sseof seeks from the end; a tiny negative offset lands on the final
|
||||
// frame(s). Some encodes have sparse keyframes near EOF, so fall back to a
|
||||
// wider window before giving up (thumb alone is still useful).
|
||||
lastPath := filepath.Join(dir, "last.jpg")
|
||||
for _, off := range []string{"-0.1", "-1"} {
|
||||
_ = exec.CommandContext(ctx, ffmpeg, "-y", "-sseof", off, "-i", in,
|
||||
"-frames:v", "1", "-q:v", "2", "-update", "1", lastPath).Run()
|
||||
if b, rerr := os.ReadFile(lastPath); rerr == nil && len(b) > 0 {
|
||||
last = b
|
||||
break
|
||||
}
|
||||
}
|
||||
return thumb, last, nil
|
||||
}
|
||||
|
||||
func clipTail(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if len(s) > 300 {
|
||||
s = s[len(s)-300:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user