更新缩略图

This commit is contained in:
2026-07-03 00:15:41 +08:00
parent 7bb0531c4b
commit 273892f98f
15 changed files with 416 additions and 61 deletions
+20 -17
View File
@@ -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
+71
View File
@@ -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
}
+16
View File
@@ -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 {
+76
View File
@@ -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
}