From 273892f98f2eb261dc171b7436516923edb67f0f Mon Sep 17 00:00:00 2001 From: chiyi Date: Fri, 3 Jul 2026 00:15:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/go.mod | 15 ++-- backend/go.sum | 16 ++++ backend/internal/http/handler/images.go | 19 +++- backend/internal/provider/grok/client.go | 108 ++++++++++++++++++++--- backend/internal/provider/grok/video.go | 19 +++- backend/internal/service/admin_read.go | 37 ++++---- backend/internal/service/thumbnail.go | 71 +++++++++++++++ backend/internal/service/v1.go | 16 ++++ backend/internal/service/video_frames.go | 76 ++++++++++++++++ frontend/src/api.js | 6 ++ frontend/src/views/ImagesView.vue | 17 +++- frontend/src/views/LogsView.vue | 13 ++- frontend/src/views/PlaygroundView.vue | 37 +++++++- frontend/src/views/UserLogsTableView.vue | 10 ++- frontend/src/views/UserLogsView.vue | 17 +++- 15 files changed, 416 insertions(+), 61 deletions(-) create mode 100644 backend/internal/service/thumbnail.go create mode 100644 backend/internal/service/video_frames.go diff --git a/backend/go.mod b/backend/go.mod index 21e71dd..beed649 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,7 +12,7 @@ require ( github.com/google/uuid v1.6.0 github.com/matoous/go-nanoid/v2 v2.1.0 github.com/redis/go-redis/v9 v9.16.0 - golang.org/x/crypto v0.46.0 + golang.org/x/crypto v0.51.0 gorm.io/datatypes v1.2.7 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.0 @@ -60,12 +60,13 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.2 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/image v0.43.0 // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.9 // indirect gorm.io/driver/mysql v1.5.6 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 2b759f4..bb49e05 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -138,25 +138,41 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20211104170005-ce137452f963/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/http/handler/images.go b/backend/internal/http/handler/images.go index 59b2c17..d901fc9 100644 --- a/backend/internal/http/handler/images.go +++ b/backend/internal/http/handler/images.go @@ -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"}) diff --git a/backend/internal/provider/grok/client.go b/backend/internal/provider/grok/client.go index 7a83080..70bd0c4 100644 --- a/backend/internal/provider/grok/client.go +++ b/backend/internal/provider/grok/client.go @@ -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} diff --git a/backend/internal/provider/grok/video.go b/backend/internal/provider/grok/video.go index 73e8550..edf3069 100644 --- a/backend/internal/provider/grok/video.go +++ b/backend/internal/provider/grok/video.go @@ -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") diff --git a/backend/internal/service/admin_read.go b/backend/internal/service/admin_read.go index 413a1bb..d99e2c9 100644 --- a/backend/internal/service/admin_read.go +++ b/backend/internal/service/admin_read.go @@ -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 diff --git a/backend/internal/service/thumbnail.go b/backend/internal/service/thumbnail.go new file mode 100644 index 0000000..1c0a6a5 --- /dev/null +++ b/backend/internal/service/thumbnail.go @@ -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 +} diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index dea0209..de204f9 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -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 { diff --git a/backend/internal/service/video_frames.go b/backend/internal/service/video_frames.go new file mode 100644 index 0000000..0844251 --- /dev/null +++ b/backend/internal/service/video_frames.go @@ -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 +} diff --git a/frontend/src/api.js b/frontend/src/api.js index 3ea7285..1aef72d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -51,3 +51,9 @@ export async function fetchHealth() { export function generatedUrl(name) { return `${BASE}/images/${name}` } + +/** Small thumbnail URL for list views. The server falls back to the original + * when no thumbnail object exists (old images), so this is always safe. */ +export function thumbUrl(name) { + return `${BASE}/images/${name}.thumb.jpg` +} diff --git a/frontend/src/views/ImagesView.vue b/frontend/src/views/ImagesView.vue index 7fc824c..ca371f5 100644 --- a/frontend/src/views/ImagesView.vue +++ b/frontend/src/views/ImagesView.vue @@ -1,6 +1,6 @@