summaryrefslogtreecommitdiff
path: root/src/utils/thumbnailer.ts
blob: d23dab173b4b91cf3347a02ba4d85bee6d6dc6a3 (plain)
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
import { execAsync, GLib, type Variable } from "astal";
import { thumbnailer as config } from "config";

export interface ThumbOpts {
    width?: number;
    height?: number;
    exact?: boolean;
}

export default class Thumbnailer {
    static readonly thumbnailDir = `${CACHE}/thumbnails`;

    static readonly #running = new Set<string>();

    static getOpt<T extends keyof ThumbOpts>(opt: T, opts: ThumbOpts) {
        return opts[opt] ?? (config.defaults[opt] as Variable<NonNullable<ThumbOpts[T]>>).get();
    }

    static async getThumbPath(path: string, opts: ThumbOpts) {
        const hash = (await execAsync(`sha1sum ${path}`)).split(" ")[0];
        const size = `${this.getOpt("width", opts)}x${this.getOpt("height", opts)}`;
        const exact = this.getOpt("exact", opts) ? "-exact" : "";
        return `${this.thumbnailDir}/${hash}@${size}${exact}.png`;
    }

    static async shouldThumbnail(path: string, opts: ThumbOpts) {
        const [w, h] = (await execAsync(`identify -ping -format "%w %h" ${path}`)).split(" ").map(parseInt);
        return w > this.getOpt("width", opts) || h > this.getOpt("height", opts);
    }

    static async #thumbnail(path: string, opts: ThumbOpts, attempts: number): Promise<string> {
        const thumbPath = await this.getThumbPath(path, opts);

        try {
            const width = this.getOpt("width", opts);
            const height = this.getOpt("height", opts);
            const cropCmd = this.getOpt("exact", opts)
                ? `-background none -gravity center -extent ${width}x${height}`
                : "";
            await execAsync(`magick ${path} -thumbnail ${width}x${height}^ ${cropCmd} -unsharp 0x.5 ${thumbPath}`);
        } catch {
            if (attempts >= config.maxAttempts.get()) {
                console.error(`Failed to generate thumbnail for ${path}`);
                return path;
            }

            await new Promise(r => setTimeout(r, config.timeBetweenAttempts.get()));
            return this.#thumbnail(path, opts, attempts + 1);
        }

        return thumbPath;
    }

    static async thumbnail(path: string, opts: ThumbOpts = {}): Promise<string> {
        if (!(await this.shouldThumbnail(path, opts))) return path;

        let thumbPath = await this.getThumbPath(path, opts);

        // Wait for existing thumbnail for path to finish
        while (this.#running.has(path)) await new Promise(r => setTimeout(r, 100));

        // If no thumbnail, generate
        if (!GLib.file_test(thumbPath, GLib.FileTest.EXISTS)) {
            this.#running.add(path);

            thumbPath = await this.#thumbnail(path, opts, 0);

            this.#running.delete(path);
        }

        return thumbPath;
    }

    // Static class
    private constructor() {}

    static {
        GLib.mkdir_with_parents(this.thumbnailDir, 0o755);
    }
}