-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy paththumbnails.go
218 lines (187 loc) · 7.57 KB
/
thumbnails.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
package ortfodb
import (
"bytes"
"fmt"
ll "github.com/ewen-lbh/label-logger-go"
"io"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
)
var ThumbnailableContentTypes = []string{"image/*", "video/*", "application/pdf"}
func (m Media) Thumbnailable() bool {
// TODO
if m.Online {
return false
}
for _, contentTypePattern := range ThumbnailableContentTypes {
match, err := filepath.Match(contentTypePattern, m.ContentType)
if err != nil {
panic(err)
}
if match {
return true
}
}
return false
}
// MakeThumbnail creates a thumbnail on disk of the given media (it is assumed that the given media is an image).
// It returns the path where the thumbnail has been written to.
// saveTo should be relative to cwd.
func (ctx *RunContext) MakeThumbnail(media Media, targetSize int, saveTo string) error {
ll.Debug("Making thumbnail for %s at size %d to %s", media.DistSource.Absolute(ctx), targetSize, saveTo)
if media.ContentType == "image/gif" {
return ctx.makeGifThumbnail(media, targetSize, saveTo)
}
if media.ContentType == "image/svg+xml" {
return ctx.makeSvgThumbnail(media, targetSize, saveTo)
}
if strings.HasPrefix(media.ContentType, "image/") {
return run("magick", media.DistSource.Absolute(ctx), "-resize", fmt.Sprint(targetSize), saveTo)
}
if strings.HasPrefix(media.ContentType, "video/") {
return run("ffmpegthumbnailer", "-i"+media.DistSource.Absolute(ctx), "-o"+saveTo, fmt.Sprintf("-s%d", targetSize))
}
if media.ContentType == "application/pdf" {
return ctx.makePdfThumbnail(media, targetSize, saveTo)
}
return fmt.Errorf("cannot make a thumbnail for %s: unsupported content type %s", media.DistSource.Absolute(ctx), media.ContentType)
}
func (ctx *RunContext) makeSvgThumbnail(media Media, targetSize int, saveTo string) error {
// Use resvg instead of magick, because magick delegates to inkscape which is not reliable in parallel (see https://gitlab.com/inkscape/inkscape/-/issues/4716)
return run("resvg", "--width", fmt.Sprint(targetSize), "--height", fmt.Sprint(targetSize), media.DistSource.Absolute(ctx), saveTo)
}
func (ctx *RunContext) makePdfThumbnail(media Media, targetSize int, saveTo string) error {
// If the target extension was not supported, convert from png to the actual target extension
temporaryPng, err := ioutil.TempFile("", "*.png")
defer os.Remove(temporaryPng.Name())
if err != nil {
return err
}
// TODO: (maybe) update media.Dimensions now that we have an image of the PDF though this will only be representative when all pages of the PDF have the same dimensions.
// pdftoppm *adds* the extension to the end of the filename even if it already has it... smh.
err = run("pdftoppm", "-singlefile", "-png", media.DistSource.Absolute(ctx), strings.TrimSuffix(temporaryPng.Name(), ".png"))
if err != nil {
return err
}
return run("magick", temporaryPng.Name(), "-thumbnail", fmt.Sprint(targetSize), saveTo)
}
func (ctx *RunContext) makeGifThumbnail(media Media, targetSize int, saveTo string) error {
var dimensionToResize string
if media.Dimensions.AspectRatio > 1 {
dimensionToResize = "width"
} else {
dimensionToResize = "height"
}
source, err := os.Open(media.DistSource.Absolute(ctx))
if err != nil {
return fmt.Errorf("while opening source media: %w", err)
}
defer source.Close()
tempGif, err := ioutil.TempFile("", "*.gif")
if err != nil {
return fmt.Errorf("while creating temporary processed GIF file: %w", err)
}
defer tempGif.Close()
err = runWithStdoutStdin("gifsicle", source, tempGif, "--resize-"+dimensionToResize, fmt.Sprint(targetSize))
if err != nil {
return fmt.Errorf("while resizing source GIF: %w", err)
}
if strings.HasSuffix(saveTo, ".webp") {
err = convertGifToWebp(tempGif.Name(), saveTo)
if err != nil {
return fmt.Errorf("while converting temporary processed GIF file to webp: %w", err)
}
} else {
dest, err := os.Create(saveTo)
if err != nil {
return fmt.Errorf("while creating thumbnail file: %w", err)
}
defer dest.Close()
content, err := os.ReadFile(tempGif.Name())
if err != nil {
return fmt.Errorf("while reading temporary processed GIF file: %w", err)
}
_, err = dest.Write(content)
if err != nil {
return fmt.Errorf("while writing to thumbnail file: %w", err)
}
}
return nil
}
// runWithStdoutStdin runs the given command with the given arguments, setting stdin and stdout to the given readers.
// The returned error contains stdout if the exit code was nonzero.
func runWithStdoutStdin(command string, stdin io.Reader, stdout io.Writer, args ...string) error {
// Create the proc
proc := exec.Command(command, args...)
// Hook up stderr/out to a writer so that we can capture the output
var stdBuffer bytes.Buffer
stdWriter := io.MultiWriter(os.Stdout, &stdBuffer)
proc.Stdout = stdout
proc.Stderr = stdWriter
proc.Stdin = stdin
// Run the proc
err := proc.Run()
// Handle errors
if err != nil {
switch e := err.(type) {
case *exec.ExitError:
return fmt.Errorf("while running %s: exited with %d: %s", strings.Join(proc.Args, " "), e.ExitCode(), stdBuffer.String())
default:
return fmt.Errorf("while running %s: %s", strings.Join(proc.Args, " "), err.Error())
}
}
return nil
}
func convertGifToWebp(source string, destination string) error {
return run("gif2webp", "-quiet", source, "-o", destination)
}
// run is like exec.Command(...).Run(...) but the error's message is actually useful (it's not just "exit status n", it has the stdout+stderr)
func run(command string, args ...string) error {
// Create the proc
proc := exec.Command(command, args...)
// Hook up stderr/out to a writer so that we can capture the output
var stdBuffer bytes.Buffer
// stdWriter := io.MultiWriter(os.Stdout, &stdBuffer)
proc.Stdout = &stdBuffer
proc.Stderr = &stdBuffer
// Run the proc
err := proc.Run()
// Handle errors
if err != nil {
switch e := err.(type) {
case *exec.ExitError:
return fmt.Errorf("while running %s: exited with %d: %s", strings.Join(proc.Args, " "), e.ExitCode(), stdBuffer.String())
default:
return fmt.Errorf("while running %s: %s", strings.Join(proc.Args, " "), err.Error())
}
}
return nil
}
// ComputeOutputThumbnailFilename returns the filename where to save a thumbnail,
// using to the configuration and the given information.
// file name templates are relative to the output media directory.
// Placeholders that will be replaced in the file name template:
//
// <project id> the project’s id
// <media directory> the value of media.at in the configuration
// <basename> the media’s basename (with the extension)
// <block id> the media’s id
// <size> the current thumbnail size
// <extension> the media’s extension
// <lang> the current language.
func (ctx *RunContext) ComputeOutputThumbnailFilename(media Media, blockID string, projectID string, targetSize int, lang string) FilePathInsideMediaRoot {
computed := ctx.Config.MakeThumbnails.FileNameTemplate
computed = strings.ReplaceAll(computed, "<project id>", projectID)
computed = strings.ReplaceAll(computed, "<work id>", projectID)
computed = strings.ReplaceAll(computed, "<basename>", path.Base(media.DistSource.Absolute(ctx)))
computed = strings.ReplaceAll(computed, "<block id>", blockID)
computed = strings.ReplaceAll(computed, "<size>", fmt.Sprint(targetSize))
computed = strings.ReplaceAll(computed, "<extension>", strings.Replace(filepath.Ext(media.DistSource.Absolute(ctx)), ".", "", 1))
computed = strings.ReplaceAll(computed, "<lang>", lang)
computed = strings.ReplaceAll(computed, "<media directory>", ctx.Config.Media.At)
return FilePathInsideMediaRoot(computed)
}