更新缩略图
This commit is contained in:
+8
-7
@@ -12,7 +12,7 @@ require (
|
|||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0
|
github.com/matoous/go-nanoid/v2 v2.1.0
|
||||||
github.com/redis/go-redis/v9 v9.16.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/datatypes v1.2.7
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.0
|
gorm.io/gorm v1.31.0
|
||||||
@@ -60,12 +60,13 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.2 // indirect
|
go.uber.org/mock v0.5.2 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/image v0.43.0 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/mod v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/tools v0.39.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
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
gorm.io/driver/mysql v1.5.6 // indirect
|
gorm.io/driver/mysql v1.5.6 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
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 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
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.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 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
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.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/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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
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.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.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 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@@ -38,7 +38,16 @@ func (h *ImageHandler) Serve(c *gin.Context) {
|
|||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to authorize image"})
|
c.JSON(http.StatusInternalServerError, gin.H{"detail": "failed to authorize image"})
|
||||||
return
|
return
|
||||||
@@ -65,6 +74,14 @@ func (h *ImageHandler) Serve(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadGateway, gin.H{"detail": "failed to fetch object"})
|
c.JSON(http.StatusBadGateway, gin.H{"detail": "failed to fetch object"})
|
||||||
return
|
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()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode == http.StatusNotFound {
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"detail": "not found"})
|
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
|
used = fullCredits
|
||||||
}
|
}
|
||||||
remaining := fullCredits - used
|
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{
|
return map[string]any{
|
||||||
"remaining": remaining,
|
"remaining": remaining,
|
||||||
"used": used,
|
"used": used,
|
||||||
@@ -163,6 +171,78 @@ func (c *Client) FetchCreditsBalance(ctx context.Context, token string) (map[str
|
|||||||
}, nil
|
}, 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
|
// FetchSession reads the account profile via GET /api/auth/session and returns
|
||||||
// (email, userID). A 401/403 means the sso session is dead → ErrAuth.
|
// (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) {
|
func (c *Client) FetchSession(ctx context.Context, token string) (email, userID string, err error) {
|
||||||
|
|||||||
@@ -331,9 +331,10 @@ func mapStatus(path string, status int, raw []byte) error {
|
|||||||
switch {
|
switch {
|
||||||
case status == 200:
|
case status == 200:
|
||||||
return nil
|
return nil
|
||||||
case status == 403 && strings.Contains(strings.ToLower(string(raw)), "anti-bot"):
|
case status == 403 && isBotChallenge(string(raw)):
|
||||||
// grok bot-detection (proxy/TLS fingerprint), NOT a dead token — transient,
|
// grok bot-detection or a Cloudflare challenge page ("Just a moment…"),
|
||||||
// so a good account isn't killed by an IP/anti-bot hiccup.
|
// 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))
|
return fmt.Errorf("%w: %s 403 %s", ErrTemporaryUpstream, path, clip(raw, 160))
|
||||||
case status == 401 || status == 403:
|
case status == 401 || status == 403:
|
||||||
return fmt.Errorf("%w: %s %d %s", ErrAuth, path, status, clip(raw, 160))
|
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 {
|
func isCreditError(s string) bool {
|
||||||
s = strings.ToLower(s)
|
s = strings.ToLower(s)
|
||||||
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
return strings.Contains(s, "usagepoolexhausted") || strings.Contains(s, "credit") || strings.Contains(s, "insufficient") || strings.Contains(s, "quota")
|
||||||
|
|||||||
@@ -574,6 +574,9 @@ func (s *AdminReadService) scanGeneratedFiles(ctx context.Context) ([]generatedF
|
|||||||
if isReferenceFile(o.Key) {
|
if isReferenceFile(o.Key) {
|
||||||
continue // reference uploads are not generated outputs — hide from gallery
|
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)
|
kind := mediaKind(o.Key)
|
||||||
if kind == "" {
|
if kind == "" {
|
||||||
continue
|
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)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0)
|
||||||
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err)
|
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())
|
elapsedMS := int(time.Since(startedAt).Milliseconds())
|
||||||
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
|
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)
|
_ = s.events.UpdateStatus(ctx, eventID, "failed", "storage upload failed: "+err.Error(), 0)
|
||||||
return nil, fmt.Errorf("%w: %v", ErrProviderExecution, err)
|
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())
|
elapsedMS := int(time.Since(startedAt).Milliseconds())
|
||||||
if err := s.events.UpdateStatus(ctx, eventID, "success", "", elapsedMS); err != nil {
|
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
|
||||||
|
}
|
||||||
@@ -51,3 +51,9 @@ export async function fetchHealth() {
|
|||||||
export function generatedUrl(name) {
|
export function generatedUrl(name) {
|
||||||
return `${BASE}/images/${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`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { api, generatedUrl } from '../api'
|
import { api, generatedUrl, thumbUrl } from '../api'
|
||||||
import { fmtTs, fmtSize } from '../utils/format'
|
import { fmtTs, fmtSize } from '../utils/format'
|
||||||
import { copyText } from '../utils/clipboard'
|
import { copyText } from '../utils/clipboard'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
@@ -12,6 +12,9 @@ const stats = ref({ total: 0, image: 0, video: 0, size_bytes: 0 })
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const kind = ref('') // '' | 'image' | 'video'
|
const kind = ref('') // '' | 'image' | 'video'
|
||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
|
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||||||
|
// the muted <video> preview for those cards.
|
||||||
|
const thumbFail = reactive({})
|
||||||
const toast = ref('')
|
const toast = ref('')
|
||||||
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -139,13 +142,19 @@ onUnmounted(() => window.removeEventListener('keydown', onKey))
|
|||||||
@click="selected = f">
|
@click="selected = f">
|
||||||
<!-- media -->
|
<!-- media -->
|
||||||
<template v-if="f.kind === 'video'">
|
<template v-if="f.kind === 'video'">
|
||||||
<video :src="generatedUrl(f.name)" muted loop preload="metadata"
|
<!-- first-frame still as background-image (same as image cards); the
|
||||||
|
hidden probe img flips to the <video> fallback for old videos. -->
|
||||||
|
<div v-if="!thumbFail[f.name]" :style="{ backgroundImage: `url(${thumbUrl(f.name)})` }"
|
||||||
|
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105">
|
||||||
|
<img :src="thumbUrl(f.name)" class="hidden" @error="thumbFail[f.name] = true" />
|
||||||
|
</div>
|
||||||
|
<video v-else :src="generatedUrl(f.name)" muted loop preload="metadata"
|
||||||
class="absolute inset-0 w-full h-full object-cover"
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
@mouseenter="$event.target.play && $event.target.play()"
|
@mouseenter="$event.target.play && $event.target.play()"
|
||||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||||
</template>
|
</template>
|
||||||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||||||
<div v-else :style="{ backgroundImage: `url(${generatedUrl(f.name)})` }"
|
<div v-else :style="{ backgroundImage: `url(${thumbUrl(f.name)})` }"
|
||||||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105"></div>
|
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105"></div>
|
||||||
|
|
||||||
<!-- gradient veil (always visible so the prompt overlay reads) -->
|
<!-- gradient veil (always visible so the prompt overlay reads) -->
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
import { fmtTs, fmtDate, fmtClock } from '../utils/format'
|
||||||
import { copyText } from '../utils/clipboard'
|
import { copyText } from '../utils/clipboard'
|
||||||
import { generatedUrl } from '../api'
|
import { generatedUrl, thumbUrl } from '../api'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
import MediaLightbox from '../components/MediaLightbox.vue'
|
import MediaLightbox from '../components/MediaLightbox.vue'
|
||||||
|
|
||||||
@@ -107,6 +107,10 @@ function fmtWhen(ts) {
|
|||||||
return fmtTs(ts)
|
return fmtTs(ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video rows whose first-frame thumbnail is missing (old videos) — fall back
|
||||||
|
// to the muted <video> preview for those.
|
||||||
|
const thumbFail = reactive({})
|
||||||
|
|
||||||
const previewing = ref(null) // entry whose generated file is open in the lightbox
|
const previewing = ref(null) // entry whose generated file is open in the lightbox
|
||||||
function openPreview(e) {
|
function openPreview(e) {
|
||||||
// API (v1) outputs aren't persisted/served by us (image=b64 inline, video=an
|
// API (v1) outputs aren't persisted/served by us (image=b64 inline, video=an
|
||||||
@@ -251,8 +255,9 @@ const sourcePill = (s) => ({
|
|||||||
<button v-if="e.status === 'success' && e.file && e.source !== 'v1'"
|
<button v-if="e.status === 'success' && e.file && e.source !== 'v1'"
|
||||||
@click="openPreview(e)"
|
@click="openPreview(e)"
|
||||||
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
|
class="block w-12 h-12 mx-auto rounded-lg overflow-hidden ring-1 ring-white/10 hover:ring-fuchsia-400/60 transition-all">
|
||||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy"
|
<img v-if="e.kind !== 'video' || !thumbFail[e.id]" :src="thumbUrl(e.file)" loading="lazy"
|
||||||
class="w-full h-full object-cover" />
|
class="w-full h-full object-cover"
|
||||||
|
@error="e.kind === 'video' && (thumbFail[e.id] = true)" />
|
||||||
<video v-else :src="generatedUrl(e.file)" muted loop preload="metadata" playsinline
|
<video v-else :src="generatedUrl(e.file)" muted loop preload="metadata" playsinline
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
@mouseenter="$event.target.play && $event.target.play()"
|
@mouseenter="$event.target.play && $event.target.play()"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { api, jsonBody, generatedUrl } from '../api'
|
import { api, jsonBody, generatedUrl } from '../api'
|
||||||
import { auth, refreshMe } from '../auth'
|
import { auth, refreshMe } from '../auth'
|
||||||
@@ -50,6 +50,9 @@ const history = ref([])
|
|||||||
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
const GATEWAY_TIMEOUT = new Set([0, 408, 504, 520, 521, 522, 523, 524, 525])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const lightbox = ref(null)
|
const lightbox = ref(null)
|
||||||
|
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||||||
|
// the muted <video> preview for those cards.
|
||||||
|
const thumbFail = reactive({})
|
||||||
const toast = ref('')
|
const toast = ref('')
|
||||||
let pollTimer = null
|
let pollTimer = null
|
||||||
|
|
||||||
@@ -436,7 +439,9 @@ function lastFrameDataUrl(url) {
|
|||||||
// (frame) models only. Triggered by the small button; clicking the video zooms.
|
// (frame) models only. Triggered by the small button; clicking the video zooms.
|
||||||
async function useVideoFrame(item) {
|
async function useVideoFrame(item) {
|
||||||
if (!item || !item.url) return
|
if (!item || !item.url) return
|
||||||
const dataUrl = await lastFrameDataUrl(item.url)
|
// Prefer the server-stored FULL-RES last-frame still; old videos without one
|
||||||
|
// fall back to grabbing the frame from the video via canvas.
|
||||||
|
const dataUrl = (await storedLastFrameDataUrl(item.url)) || (await lastFrameDataUrl(item.url))
|
||||||
if (!dataUrl) { flash('截取末帧失败'); return }
|
if (!dataUrl) { flash('截取末帧失败'); return }
|
||||||
const ref = { name: 'frame', dataUrl }
|
const ref = { name: 'frame', dataUrl }
|
||||||
if (refImages.value.length === 0) refImages.value = [ref]
|
if (refImages.value.length === 0) refImages.value = [ref]
|
||||||
@@ -444,6 +449,23 @@ async function useVideoFrame(item) {
|
|||||||
flash('已把视频末帧设为首帧')
|
flash('已把视频末帧设为首帧')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch the server-stored last-frame still (url + '.last.jpg'). The server
|
||||||
|
// falls back to the ORIGINAL VIDEO when the still doesn't exist (old videos),
|
||||||
|
// so verify the response really is an image before using it.
|
||||||
|
async function storedLastFrameDataUrl(url) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url + '.last.jpg')
|
||||||
|
if (!r.ok || !(r.headers.get('content-type') || '').startsWith('image/')) return ''
|
||||||
|
const blob = await r.blob()
|
||||||
|
return await new Promise((res) => {
|
||||||
|
const fr = new FileReader()
|
||||||
|
fr.onload = () => res(fr.result)
|
||||||
|
fr.onerror = () => res('')
|
||||||
|
fr.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -637,14 +659,21 @@ onUnmounted(() => {
|
|||||||
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]">
|
class="group relative rounded-xl overflow-hidden ring-1 ring-slate-200 bg-slate-100 aspect-[4/5]">
|
||||||
<!-- done: media + caption -->
|
<!-- done: media + caption -->
|
||||||
<template v-if="item.status === 'done' && item.url">
|
<template v-if="item.status === 'done' && item.url">
|
||||||
<video v-if="item.kind === 'video'" :src="item.url" muted loop preload="metadata"
|
<!-- first-frame still as background-image (same as image cards); the
|
||||||
|
hidden probe img flips to the <video> fallback for old videos. -->
|
||||||
|
<div v-if="item.kind === 'video' && !thumbFail[item.id]" @click="lightbox = item" title="点击播放"
|
||||||
|
:style="{ backgroundImage: `url(${item.url}.thumb.jpg)` }"
|
||||||
|
class="absolute inset-0 w-full h-full bg-cover bg-center cursor-zoom-in transition-transform duration-300 group-hover:scale-105">
|
||||||
|
<img :src="item.url + '.thumb.jpg'" class="hidden" @error="thumbFail[item.id] = true" />
|
||||||
|
</div>
|
||||||
|
<video v-else-if="item.kind === 'video'" :src="item.url" muted loop preload="metadata"
|
||||||
@click="lightbox = item" title="点击放大"
|
@click="lightbox = item" title="点击放大"
|
||||||
class="absolute inset-0 w-full h-full object-cover cursor-zoom-in"
|
class="absolute inset-0 w-full h-full object-cover cursor-zoom-in"
|
||||||
@mouseenter="$event.target.play && $event.target.play()"
|
@mouseenter="$event.target.play && $event.target.play()"
|
||||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||||||
<div v-else @click="lightbox = item" title="点击放大"
|
<div v-else @click="lightbox = item" title="点击放大"
|
||||||
:style="{ backgroundImage: `url(${item.url})` }"
|
:style="{ backgroundImage: `url(${item.url}.thumb.jpg)` }"
|
||||||
class="absolute inset-0 w-full h-full bg-cover bg-center cursor-zoom-in transition-transform duration-300 group-hover:scale-105"></div>
|
class="absolute inset-0 w-full h-full bg-cover bg-center cursor-zoom-in transition-transform duration-300 group-hover:scale-105"></div>
|
||||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none"></div>
|
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/30 to-transparent pointer-events-none"></div>
|
||||||
<!-- hover action: 上参考图. Image → use as reference; video → 末帧设为首帧
|
<!-- hover action: 上参考图. Image → use as reference; video → 末帧设为首帧
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
// generations (success / failed / pending), surfacing failure reasons that the
|
// generations (success / failed / pending), surfacing failure reasons that the
|
||||||
// image-only 记录 gallery hides. Uses the same /logs endpoint (auto-scoped to
|
// image-only 记录 gallery hides. Uses the same /logs endpoint (auto-scoped to
|
||||||
// the caller), just without the success-only filter.
|
// the caller), just without the success-only filter.
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api, generatedUrl } from '../api'
|
import { api, generatedUrl, thumbUrl } from '../api'
|
||||||
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
|
import { fmtDate, fmtClock, fmtTs } from '../utils/format'
|
||||||
import { copyText } from '../utils/clipboard'
|
import { copyText } from '../utils/clipboard'
|
||||||
import { points } from '../credits'
|
import { points } from '../credits'
|
||||||
@@ -23,6 +23,9 @@ const search = ref('')
|
|||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const lightbox = ref(null)
|
const lightbox = ref(null)
|
||||||
|
// Video rows whose first-frame thumbnail is missing (old videos) — fall back
|
||||||
|
// to the muted <video> preview for those.
|
||||||
|
const thumbFail = reactive({})
|
||||||
|
|
||||||
const toast = ref('')
|
const toast = ref('')
|
||||||
let toastTimer = null
|
let toastTimer = null
|
||||||
@@ -221,7 +224,8 @@ const params = (e) => {
|
|||||||
(not a RustFS path), so it can't be previewed in-browser — show —. -->
|
(not a RustFS path), so it can't be previewed in-browser — show —. -->
|
||||||
<button v-if="e.status === 'success' && e.file && !e.file.startsWith('http')" @click="lightbox = e"
|
<button v-if="e.status === 'success' && e.file && !e.file.startsWith('http')" @click="lightbox = e"
|
||||||
class="block w-11 h-11 mx-auto rounded-lg overflow-hidden ring-1 ring-slate-200 hover:ring-fuchsia-300 transition-all">
|
class="block w-11 h-11 mx-auto rounded-lg overflow-hidden ring-1 ring-slate-200 hover:ring-fuchsia-300 transition-all">
|
||||||
<img v-if="e.kind !== 'video'" :src="generatedUrl(e.file)" loading="lazy" class="w-full h-full object-cover" />
|
<img v-if="e.kind !== 'video' || !thumbFail[e.id]" :src="thumbUrl(e.file)" loading="lazy" class="w-full h-full object-cover"
|
||||||
|
@error="e.kind === 'video' && (thumbFail[e.id] = true)" />
|
||||||
<video v-else :src="generatedUrl(e.file)" muted preload="metadata" class="w-full h-full object-cover" />
|
<video v-else :src="generatedUrl(e.file)" muted preload="metadata" class="w-full h-full object-cover" />
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="text-slate-300">—</span>
|
<span v-else class="text-slate-300">—</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { api, generatedUrl } from '../api'
|
import { api, generatedUrl, thumbUrl } from '../api'
|
||||||
import { fmtTs } from '../utils/format'
|
import { fmtTs } from '../utils/format'
|
||||||
import { copyText } from '../utils/clipboard'
|
import { copyText } from '../utils/clipboard'
|
||||||
import Icon from '../components/Icon.vue'
|
import Icon from '../components/Icon.vue'
|
||||||
@@ -97,6 +97,9 @@ async function copyPrompt(e) {
|
|||||||
|
|
||||||
const toast = ref('')
|
const toast = ref('')
|
||||||
const lightbox = ref(null)
|
const lightbox = ref(null)
|
||||||
|
// Videos whose first-frame thumbnail is missing (old videos) — fall back to
|
||||||
|
// the muted <video> preview for those cards.
|
||||||
|
const thumbFail = reactive({})
|
||||||
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
function onKey(e) { if (e.key === 'Escape') lightbox.value = null }
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -157,12 +160,18 @@ onUnmounted(() => {
|
|||||||
@click="(e.status === 'success' && e.file) && (lightbox = e)">
|
@click="(e.status === 'success' && e.file) && (lightbox = e)">
|
||||||
<!-- media -->
|
<!-- media -->
|
||||||
<template v-if="e.status === 'success' && e.file">
|
<template v-if="e.status === 'success' && e.file">
|
||||||
<video v-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata"
|
<!-- first-frame still as background-image (same as image cards); the
|
||||||
|
hidden probe img flips to the <video> fallback for old videos. -->
|
||||||
|
<div v-if="e.kind === 'video' && !thumbFail[e.id]" :style="{ backgroundImage: `url(${thumbUrl(e.file)})` }"
|
||||||
|
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105">
|
||||||
|
<img :src="thumbUrl(e.file)" class="hidden" @error="thumbFail[e.id] = true" />
|
||||||
|
</div>
|
||||||
|
<video v-else-if="e.kind === 'video'" :src="generatedUrl(e.file)" muted loop preload="metadata"
|
||||||
class="absolute inset-0 w-full h-full object-cover"
|
class="absolute inset-0 w-full h-full object-cover"
|
||||||
@mouseenter="$event.target.play && $event.target.play()"
|
@mouseenter="$event.target.play && $event.target.play()"
|
||||||
@mouseleave="$event.target.pause && $event.target.pause()" />
|
@mouseleave="$event.target.pause && $event.target.pause()" />
|
||||||
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
<!-- background-image (not <img>) so Edge shows no 视觉搜索 overlay icon. -->
|
||||||
<div v-else :style="{ backgroundImage: `url(${generatedUrl(e.file)})` }"
|
<div v-else :style="{ backgroundImage: `url(${thumbUrl(e.file)})` }"
|
||||||
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105"></div>
|
class="absolute inset-0 w-full h-full bg-cover bg-center transition-transform duration-300 group-hover:scale-105"></div>
|
||||||
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
<div class="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/85 via-black/40 to-transparent pointer-events-none"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user