orca-gallery-panel 0.0.0-stage → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +224 -2
- package/gallery.config.example.json +6 -0
- package/gallery.config.schema.json +127 -0
- package/lib/build.js +131 -0
- package/lib/cli.js +136 -0
- package/lib/config-file.js +38 -0
- package/lib/encode.js +20 -0
- package/lib/html.js +40 -0
- package/lib/images.js +26 -0
- package/lib/manifest.js +72 -0
- package/lib/options.js +286 -0
- package/lib/package-info.js +7 -0
- package/lib/panel/config.js +48 -0
- package/lib/panel/i18n.js +65 -0
- package/lib/panel/main.js +378 -0
- package/lib/panel/panel.css +460 -0
- package/lib/panel/playlist.js +56 -0
- package/lib/panel/template.html +129 -0
- package/lib/preview.js +62 -0
- package/lib/watch.js +68 -0
- package/package.json +69 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaime Errázuriz Werth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,225 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Orca Gallery Panel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/jerrazuw/orca-gallery-panel/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
**Background images for [Orca](https://orca.dev), the agent IDE.** Orca has no setting for a
|
|
7
|
+
wallpaper or background image, so this plugin adds a side panel that shows a rotating gallery of
|
|
8
|
+
your own pictures while your agents work: wallpapers, photos, art, mood boards.
|
|
9
|
+
|
|
10
|
+
You point it at a folder, it packs the images into an Orca panel plugin, and the panel shuffles
|
|
11
|
+
through them with a slideshow, a blurred background, an optional Ken Burns zoom and controls you
|
|
12
|
+
can change without leaving Orca.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
## Features
|
|
17
|
+
|
|
18
|
+
- **Slideshow**: shuffles your images and changes them on an interval you choose. Every image
|
|
19
|
+
shows once before any repeats, and the same one never appears twice in a row.
|
|
20
|
+
- **Controls** on hover: previous, pause/resume, next, a position counter, interval, display
|
|
21
|
+
settings and fit/fill.
|
|
22
|
+
- **Settings in the panel**: click the clock to type the seconds between images (`0` stops the
|
|
23
|
+
automatic change), or the sliders to turn on a blurred background, a slow Ken Burns zoom or
|
|
24
|
+
the image's name. Changes last until Orca reloads the panel, because Orca gives panels no
|
|
25
|
+
storage; the defaults come from your build settings.
|
|
26
|
+
- **Blurred background**: an image that doesn't fill the panel sits on a blurred copy of itself
|
|
27
|
+
instead of empty bars.
|
|
28
|
+
- **Several galleries**, each in its own panel, from one config file.
|
|
29
|
+
- **Watch mode**: `--watch` rebuilds whenever you add or remove images, and Orca reloads the
|
|
30
|
+
panel, so a new photo shows up without touching the terminal.
|
|
31
|
+
- **Keyboard**: `←` / `→` to navigate, `Space` to pause, `F` to switch between fit and fill.
|
|
32
|
+
- **Matches Orca's theme**, light or dark, using the design tokens Orca gives every panel.
|
|
33
|
+
- **Any common format**: PNG, JPEG, GIF, WebP, AVIF and TIFF. Animated GIF and WebP stay
|
|
34
|
+
animated, and EXIF rotation is applied.
|
|
35
|
+
- **English and Spanish**, picked from the system language.
|
|
36
|
+
- **Accessible**: labeled buttons, visible focus, and no fade if you have reduced motion turned on.
|
|
37
|
+
- **Private**: the plugin asks Orca for no permissions and makes no network requests.
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
### 1. Try it from Orca
|
|
42
|
+
|
|
43
|
+
[Open in Orca](https://onorca.dev/plugins/jerrazuw.gallery-panel-demo?catalog=community) takes you
|
|
44
|
+
to **Gallery Panel (demo)** in Orca's plugin directory; the first time, Orca asks to add its
|
|
45
|
+
community catalog. Click **Install**, then open the panel from the right sidebar. It rotates
|
|
46
|
+
through a few sample images and explains, inside the panel, how to show your own.
|
|
47
|
+
|
|
48
|
+
Orca installs plugins from the directory as they are, so the listed one can only carry sample
|
|
49
|
+
images. Your own photos take one command, below.
|
|
50
|
+
|
|
51
|
+
### 2. Show your own photos
|
|
52
|
+
|
|
53
|
+
You need [Node.js](https://nodejs.org) 22.18 or later and Orca 1.4.160 or later. In a terminal,
|
|
54
|
+
run this with the path to your folder instead of `~/Pictures/wallpapers`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx orca-gallery-panel ~/Pictures/wallpapers --out ~/orca-gallery --watch
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
It packs every image in the folder into an Orca plugin in `~/orca-gallery`, then keeps watching
|
|
61
|
+
the folder. In Orca, go to **Settings → Plugins → Development**, click **Add path** and enter the
|
|
62
|
+
full path of that folder (`/Users/<you>/orca-gallery` on a Mac). Open the panel from the right
|
|
63
|
+
sidebar.
|
|
64
|
+
|
|
65
|
+
If you installed the demo, uninstall it from **Settings → Plugins** so you don't have two
|
|
66
|
+
galleries.
|
|
67
|
+
|
|
68
|
+
### 3. Add photos whenever you like
|
|
69
|
+
|
|
70
|
+
While the command runs, copy photos into your folder or delete them, and the panel updates by
|
|
71
|
+
itself within a few seconds: the plugin is rebuilt and Orca reloads it. Copying 20 photos at once
|
|
72
|
+
triggers a single rebuild. A good place to leave it running is a terminal tab inside Orca.
|
|
73
|
+
|
|
74
|
+
`Ctrl+C` stops it. Next time, run the same command again: it picks up whatever changed in the
|
|
75
|
+
meantime and goes back to watching. Without `--watch`, it builds once and exits, so run it again
|
|
76
|
+
after changing your photos.
|
|
77
|
+
|
|
78
|
+
### Saving your settings
|
|
79
|
+
|
|
80
|
+
Instead of typing the options every time, put them in a `gallery.config.json` in the folder you
|
|
81
|
+
run the command from (your home folder, for example):
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"$schema": "https://unpkg.com/orca-gallery-panel/gallery.config.schema.json",
|
|
86
|
+
"folder": "~/Pictures/wallpapers",
|
|
87
|
+
"out": "~/orca-gallery",
|
|
88
|
+
"title": "Wallpapers",
|
|
89
|
+
"interval": 60
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
From then on, `npx orca-gallery-panel --watch` is enough, and editing the file while it runs
|
|
94
|
+
rebuilds the panel too. The `$schema` line gives you autocomplete and validation in editors that
|
|
95
|
+
support JSON Schema. Options passed on the command line still override the file for that run,
|
|
96
|
+
and `--config <file>` reads a different one.
|
|
97
|
+
|
|
98
|
+
If you installed the plugin with **Install plugin → Local folder** instead of **Development**,
|
|
99
|
+
Orca keeps a copy, so you need to install it again after each build.
|
|
100
|
+
|
|
101
|
+
### Working from a clone
|
|
102
|
+
|
|
103
|
+
To change the code, or to try the bundled samples with `npm run build -- examples/images`:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
git clone https://github.com/jerrazuw/orca-gallery-panel.git
|
|
107
|
+
cd orca-gallery-panel
|
|
108
|
+
npm install
|
|
109
|
+
npm run build -- ~/Pictures/wallpapers --watch
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Here `npm run build --` takes the place of `npx orca-gallery-panel`, and the plugin goes to
|
|
113
|
+
`dist/` unless you pass `--out`. `gallery.config.json` at the root of the clone is ignored by
|
|
114
|
+
git, so your paths stay on your machine; `gallery.config.example.json` is a starting point.
|
|
115
|
+
|
|
116
|
+
## Options
|
|
117
|
+
|
|
118
|
+
| Option | Config key | Default | Description |
|
|
119
|
+
| ----------------------- | ----------- | --------- | ------------------------------------------------- |
|
|
120
|
+
| `[folder]` | `folder` | `images` | Folder with your images. |
|
|
121
|
+
| `-o, --out <dir>` | `out` | `dist` | Output folder for the plugin. |
|
|
122
|
+
| `-t, --title <text>` | `title` | `Gallery` | Panel title shown in Orca. |
|
|
123
|
+
| `-i, --interval <sec>` | `interval` | `180` | Seconds between images. `0` disables rotation. |
|
|
124
|
+
| `-s, --max-size <px>` | `maxSize` | `800` | Longest side of each image, in pixels. |
|
|
125
|
+
| `-q, --quality <1-100>` | `quality` | `80` | WebP quality. |
|
|
126
|
+
| `--fit <mode>` | `fit` | `contain` | Initial mode: `contain` (whole image) or `cover`. |
|
|
127
|
+
| `--effect <name>` | `effect` | `none` | `none`, or `kenburns` for a slow zoom. |
|
|
128
|
+
| `--no-blur` | `blur` | `true` | Blurred background behind images. |
|
|
129
|
+
| `--caption` | `caption` | `false` | Show the image's file name. |
|
|
130
|
+
| `-r, --recursive` | `recursive` | `false` | Include images in subfolders. |
|
|
131
|
+
| | `galleries` | | Several galleries; see above. |
|
|
132
|
+
| `-w, --watch` | | | Rebuild when images or the config file change. |
|
|
133
|
+
| `-c, --config <file>` | | | Config file to read instead of the default. |
|
|
134
|
+
|
|
135
|
+
Precedence: command line, then the gallery's own settings, then the top level of
|
|
136
|
+
`gallery.config.json`, then the defaults. Paths in the config file are relative to the file, and
|
|
137
|
+
`~` means your home folder. The `fit`, `interval`, `blur`, `effect` and `caption` settings are
|
|
138
|
+
only where the panel starts: you can change them from the panel itself.
|
|
139
|
+
|
|
140
|
+
### Several galleries
|
|
141
|
+
|
|
142
|
+
List them under `galleries`. Each one becomes its own panel in Orca. Settings at the top level
|
|
143
|
+
apply to every gallery unless it sets its own; the top-level `title` names the plugin, and a
|
|
144
|
+
gallery without a `title` is named after its folder.
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"$schema": "./gallery.config.schema.json",
|
|
149
|
+
"title": "My galleries",
|
|
150
|
+
"interval": 120,
|
|
151
|
+
"galleries": [
|
|
152
|
+
{ "folder": "~/Pictures/wallpapers", "title": "Wallpapers", "effect": "kenburns" },
|
|
153
|
+
{ "folder": "~/Pictures/art", "interval": 30, "caption": true }
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each panel has to stay under Orca's 10 MiB limit for a panel, and all of them together under
|
|
159
|
+
its 50 MiB limit for a plugin.
|
|
160
|
+
|
|
161
|
+
## How it works
|
|
162
|
+
|
|
163
|
+
Orca renders a plugin panel as a sandboxed `srcdoc` iframe whose Content Security Policy only
|
|
164
|
+
allows inline scripts and styles and `data:` images. A panel cannot read files from disk, and
|
|
165
|
+
Orca's plugin API gives it no file or folder access either.
|
|
166
|
+
|
|
167
|
+
So the images are packed into the panel when you build it:
|
|
168
|
+
|
|
169
|
+
1. `src/images.ts` finds the supported images in your folder, in natural order.
|
|
170
|
+
2. `src/encode.ts` uses [sharp](https://sharp.pixelplumbing.com) to apply EXIF rotation, shrink
|
|
171
|
+
each image to `--max-size` and re-encode it as WebP. That keeps transparency and animation,
|
|
172
|
+
and is far smaller than base64-encoded PNGs or GIFs.
|
|
173
|
+
3. `src/html.ts` bundles the panel script (`src/panel/`, TypeScript) with
|
|
174
|
+
[esbuild](https://esbuild.github.io) and inlines it, the CSS and the images into one
|
|
175
|
+
`panel.html`.
|
|
176
|
+
4. `src/build.ts` checks the result against Orca's 10 MiB limit for a panel. If it's over, it
|
|
177
|
+
names the largest images and suggests what to change.
|
|
178
|
+
5. `src/manifest.ts` writes `orca-plugin.json`, taking the version from `package.json`, with
|
|
179
|
+
one panel per gallery.
|
|
180
|
+
|
|
181
|
+
At the default settings a typical photo ends up around 40 KB, so the limit fits a couple of
|
|
182
|
+
hundred images. Animated GIFs are much heavier.
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
npm run check # typecheck, lint, format check and unit tests
|
|
188
|
+
npm run test:e2e # the panel in headless Chromium (first run: npx playwright install chromium)
|
|
189
|
+
npm run preview # writes dist/preview.html: the built panel inside an Orca-like frame
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
| Path | What it is |
|
|
193
|
+
| ------------------------------ | ----------------------------------------------------------------------------- |
|
|
194
|
+
| `src/cli.ts`, `src/options.ts` | Command-line entry point and argument parsing. |
|
|
195
|
+
| `src/build.ts` | Builds the plugin: find, encode, render, check size, write. |
|
|
196
|
+
| `src/panel/` | The code that runs inside Orca: slideshow, controls, translations and styles. |
|
|
197
|
+
| `src/watch.ts` | Watch mode: reruns the build when the watched folders change. |
|
|
198
|
+
| `src/preview.ts` | Reproduces Orca's panel shell so the panel can be checked in a browser. |
|
|
199
|
+
| `test/` | Unit tests (`node:test`) and browser tests (`test/e2e/`, Playwright). |
|
|
200
|
+
| `examples/images/` | Sample images, made by `scripts/generate-examples.ts`. |
|
|
201
|
+
| `plugin/` | The demo build listed in Orca's plugin directory, by `npm run build:demo`. |
|
|
202
|
+
|
|
203
|
+
TypeScript runs directly on Node through
|
|
204
|
+
[type stripping](https://nodejs.org/api/typescript.html#type-stripping), so there is no compile
|
|
205
|
+
step while you work on it. Node won't strip types inside `node_modules`, so the npm package is
|
|
206
|
+
compiled to `lib/` by `npm run build:lib`, which `npm pack` runs on its own. CI runs on Linux,
|
|
207
|
+
macOS and Windows with Node 22.18, 22 and 24, and installs the packed package in an empty
|
|
208
|
+
project to check that the command works.
|
|
209
|
+
|
|
210
|
+
## In Orca's plugin directory
|
|
211
|
+
|
|
212
|
+
Orca lists a plugin from a tagged commit of its repository and installs it as it is, with no build
|
|
213
|
+
step, so it cannot pack your photos. The listed plugin is the build in `plugin/`: the sample images
|
|
214
|
+
with a note on how to load your own, under its own id (`gallery-panel-demo`) so it can sit next to
|
|
215
|
+
the plugin you build. `npm run build:demo` writes it, and `npm version` reruns that for every
|
|
216
|
+
release.
|
|
217
|
+
|
|
218
|
+
## Contributing
|
|
219
|
+
|
|
220
|
+
Issues and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
[MIT](LICENSE). The sample images in `examples/images/` are generated by code in this repository
|
|
225
|
+
and are covered by the same license.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "Orca Gallery Panel settings",
|
|
4
|
+
"description": "Saved options for the gallery build. Command-line options override these.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"$schema": {
|
|
9
|
+
"type": "string"
|
|
10
|
+
},
|
|
11
|
+
"folder": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"minLength": 1,
|
|
14
|
+
"description": "Folder with your images. Relative paths start from this file; ~ is your home folder.",
|
|
15
|
+
"default": "images"
|
|
16
|
+
},
|
|
17
|
+
"title": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"minLength": 1,
|
|
20
|
+
"maxLength": 256,
|
|
21
|
+
"description": "Panel title shown in Orca. With \"galleries\", it names the plugin instead.",
|
|
22
|
+
"default": "Gallery"
|
|
23
|
+
},
|
|
24
|
+
"interval": {
|
|
25
|
+
"type": "integer",
|
|
26
|
+
"minimum": 0,
|
|
27
|
+
"maximum": 86400,
|
|
28
|
+
"description": "Seconds between images. 0 disables automatic rotation.",
|
|
29
|
+
"default": 180
|
|
30
|
+
},
|
|
31
|
+
"maxSize": {
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"minimum": 16,
|
|
34
|
+
"maximum": 8192,
|
|
35
|
+
"description": "Longest side of each image, in pixels.",
|
|
36
|
+
"default": 800
|
|
37
|
+
},
|
|
38
|
+
"quality": {
|
|
39
|
+
"type": "integer",
|
|
40
|
+
"minimum": 1,
|
|
41
|
+
"maximum": 100,
|
|
42
|
+
"description": "WebP quality.",
|
|
43
|
+
"default": 80
|
|
44
|
+
},
|
|
45
|
+
"fit": {
|
|
46
|
+
"enum": ["contain", "cover"],
|
|
47
|
+
"description": "Initial mode: contain shows the whole image, cover fills the panel.",
|
|
48
|
+
"default": "contain"
|
|
49
|
+
},
|
|
50
|
+
"blur": {
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"description": "Show a blurred copy of the image behind it when it doesn't fill the panel.",
|
|
53
|
+
"default": true
|
|
54
|
+
},
|
|
55
|
+
"effect": {
|
|
56
|
+
"enum": ["none", "kenburns"],
|
|
57
|
+
"description": "none, or kenburns for a slow zoom while each image is shown.",
|
|
58
|
+
"default": "none"
|
|
59
|
+
},
|
|
60
|
+
"caption": {
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"description": "Show the image's file name at the top of the panel.",
|
|
63
|
+
"default": false
|
|
64
|
+
},
|
|
65
|
+
"recursive": {
|
|
66
|
+
"type": "boolean",
|
|
67
|
+
"description": "Include images in subfolders.",
|
|
68
|
+
"default": false
|
|
69
|
+
},
|
|
70
|
+
"out": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"minLength": 1,
|
|
73
|
+
"description": "Output folder for the plugin.",
|
|
74
|
+
"default": "dist"
|
|
75
|
+
},
|
|
76
|
+
"galleries": {
|
|
77
|
+
"type": "array",
|
|
78
|
+
"minItems": 1,
|
|
79
|
+
"description": "Several galleries, each in its own panel. Settings at the top level apply to all of them unless a gallery sets its own.",
|
|
80
|
+
"items": {
|
|
81
|
+
"$ref": "#/$defs/gallery"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"$defs": {
|
|
86
|
+
"gallery": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"additionalProperties": false,
|
|
89
|
+
"required": ["folder"],
|
|
90
|
+
"properties": {
|
|
91
|
+
"folder": {
|
|
92
|
+
"$ref": "#/properties/folder"
|
|
93
|
+
},
|
|
94
|
+
"title": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"minLength": 1,
|
|
97
|
+
"maxLength": 256,
|
|
98
|
+
"description": "Panel title. Defaults to the folder's name."
|
|
99
|
+
},
|
|
100
|
+
"interval": {
|
|
101
|
+
"$ref": "#/properties/interval"
|
|
102
|
+
},
|
|
103
|
+
"maxSize": {
|
|
104
|
+
"$ref": "#/properties/maxSize"
|
|
105
|
+
},
|
|
106
|
+
"quality": {
|
|
107
|
+
"$ref": "#/properties/quality"
|
|
108
|
+
},
|
|
109
|
+
"fit": {
|
|
110
|
+
"$ref": "#/properties/fit"
|
|
111
|
+
},
|
|
112
|
+
"blur": {
|
|
113
|
+
"$ref": "#/properties/blur"
|
|
114
|
+
},
|
|
115
|
+
"effect": {
|
|
116
|
+
"$ref": "#/properties/effect"
|
|
117
|
+
},
|
|
118
|
+
"caption": {
|
|
119
|
+
"$ref": "#/properties/caption"
|
|
120
|
+
},
|
|
121
|
+
"recursive": {
|
|
122
|
+
"$ref": "#/properties/recursive"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
package/lib/build.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
import { encodeImage } from "./encode.js";
|
|
4
|
+
import { renderPanelHtml } from "./html.js";
|
|
5
|
+
import { findImages, labelFor } from "./images.js";
|
|
6
|
+
import { createManifest, isGeneratedPanelFile, MANIFEST_FILE, panelEntries } from "./manifest.js";
|
|
7
|
+
/** Orca rejects a panel entry above this size (PLUGIN_PANEL_ENTRY_MAX_BYTES). */
|
|
8
|
+
export const PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
/** Orca rejects a whole plugin above this size. */
|
|
10
|
+
export const PLUGIN_MAX_BYTES = 50 * 1024 * 1024;
|
|
11
|
+
const ENCODE_CONCURRENCY = 4;
|
|
12
|
+
export class BuildError extends Error {
|
|
13
|
+
name = 'BuildError';
|
|
14
|
+
}
|
|
15
|
+
async function mapConcurrent(items, limit, task) {
|
|
16
|
+
const results = new Array(items.length);
|
|
17
|
+
let cursor = 0;
|
|
18
|
+
const worker = async () => {
|
|
19
|
+
while (cursor < items.length) {
|
|
20
|
+
const index = cursor++;
|
|
21
|
+
results[index] = await task(items[index]);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
25
|
+
return results;
|
|
26
|
+
}
|
|
27
|
+
export function formatBytes(bytes) {
|
|
28
|
+
if (bytes < 1024)
|
|
29
|
+
return `${String(bytes)} B`;
|
|
30
|
+
if (bytes < 1024 * 1024)
|
|
31
|
+
return `${(bytes / 1024).toFixed(0)} KB`;
|
|
32
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
33
|
+
}
|
|
34
|
+
export function oversizeMessage(title, panelBytes, images) {
|
|
35
|
+
const largest = [...images]
|
|
36
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
37
|
+
.slice(0, 5)
|
|
38
|
+
.map((image) => ` ${formatBytes(image.bytes).padStart(8)} ${basename(image.file)}${image.animated ? ' (animated)' : ''}`);
|
|
39
|
+
return [
|
|
40
|
+
`"${title}" is ${formatBytes(panelBytes)}, over Orca's ${formatBytes(PANEL_ENTRY_MAX_BYTES)} limit for a panel.`,
|
|
41
|
+
'Largest images:',
|
|
42
|
+
...largest,
|
|
43
|
+
'Try a smaller --max-size or --quality, or remove some images.',
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
46
|
+
async function assertFolder(folder) {
|
|
47
|
+
const info = await stat(folder).catch(() => undefined);
|
|
48
|
+
if (!info?.isDirectory())
|
|
49
|
+
throw new BuildError(`Image folder not found: ${folder}`);
|
|
50
|
+
}
|
|
51
|
+
async function buildGallery(gallery, logger, demo) {
|
|
52
|
+
await assertFolder(gallery.input);
|
|
53
|
+
const files = await findImages(gallery.input, gallery.recursive);
|
|
54
|
+
const skipped = [];
|
|
55
|
+
const encoded = await mapConcurrent(files, ENCODE_CONCURRENCY, async (file) => {
|
|
56
|
+
try {
|
|
57
|
+
return { file, image: await encodeImage(file, gallery) };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
61
|
+
skipped.push({ file, reason });
|
|
62
|
+
logger.warn(`Skipping ${basename(file)}: ${reason}`);
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
const ok = encoded.filter((entry) => entry !== undefined);
|
|
67
|
+
if (ok.length === 0) {
|
|
68
|
+
throw new BuildError(files.length === 0
|
|
69
|
+
? `No supported images in ${gallery.input}${gallery.recursive ? '' : ' (use --recursive to include subfolders)'}`
|
|
70
|
+
: `None of the ${String(files.length)} images in ${gallery.input} could be read`);
|
|
71
|
+
}
|
|
72
|
+
const images = ok.map(({ file, image }) => ({
|
|
73
|
+
src: image.dataUrl,
|
|
74
|
+
label: labelFor(file),
|
|
75
|
+
}));
|
|
76
|
+
const reports = ok.map(({ file, image }) => ({
|
|
77
|
+
file,
|
|
78
|
+
bytes: image.bytes,
|
|
79
|
+
width: image.width,
|
|
80
|
+
height: image.height,
|
|
81
|
+
animated: image.animated,
|
|
82
|
+
}));
|
|
83
|
+
const html = await renderPanelHtml({
|
|
84
|
+
images,
|
|
85
|
+
intervalMs: gallery.intervalSeconds * 1000,
|
|
86
|
+
fit: gallery.fit,
|
|
87
|
+
blur: gallery.blur,
|
|
88
|
+
effect: gallery.effect,
|
|
89
|
+
caption: gallery.caption,
|
|
90
|
+
demo,
|
|
91
|
+
});
|
|
92
|
+
const panelBytes = Buffer.byteLength(html);
|
|
93
|
+
if (panelBytes > PANEL_ENTRY_MAX_BYTES) {
|
|
94
|
+
throw new BuildError(oversizeMessage(gallery.title, panelBytes, reports));
|
|
95
|
+
}
|
|
96
|
+
return { html, images: reports, skipped };
|
|
97
|
+
}
|
|
98
|
+
/** Removes panel files an earlier build wrote for galleries that no longer exist. */
|
|
99
|
+
async function removeStalePanels(outDir, keep) {
|
|
100
|
+
const files = await readdir(outDir);
|
|
101
|
+
await Promise.all(files
|
|
102
|
+
.filter((file) => isGeneratedPanelFile(file) && !keep.has(file))
|
|
103
|
+
.map((file) => rm(join(outDir, file))));
|
|
104
|
+
}
|
|
105
|
+
export async function buildPlugin(options, logger = console) {
|
|
106
|
+
const entries = panelEntries(options.galleries.map((gallery) => gallery.title));
|
|
107
|
+
const built = [];
|
|
108
|
+
const demo = options.demo ?? false;
|
|
109
|
+
for (const gallery of options.galleries)
|
|
110
|
+
built.push(await buildGallery(gallery, logger, demo));
|
|
111
|
+
const totalBytes = built.reduce((sum, { html }) => sum + Buffer.byteLength(html), 0);
|
|
112
|
+
if (totalBytes > PLUGIN_MAX_BYTES) {
|
|
113
|
+
throw new BuildError(`The galleries add up to ${formatBytes(totalBytes)}, over Orca's ${formatBytes(PLUGIN_MAX_BYTES)} limit for a plugin.`);
|
|
114
|
+
}
|
|
115
|
+
await mkdir(options.outDir, { recursive: true });
|
|
116
|
+
await Promise.all([
|
|
117
|
+
...built.map(({ html }, i) => writeFile(join(options.outDir, entries[i]?.entry ?? ''), html)),
|
|
118
|
+
writeFile(join(options.outDir, MANIFEST_FILE), `${JSON.stringify(createManifest(options.name, entries, { demo }), null, 2)}\n`),
|
|
119
|
+
]);
|
|
120
|
+
await removeStalePanels(options.outDir, new Set(entries.map(({ entry }) => entry)));
|
|
121
|
+
return {
|
|
122
|
+
outDir: options.outDir,
|
|
123
|
+
galleries: built.map(({ html, images, skipped }, i) => ({
|
|
124
|
+
title: entries[i]?.title ?? '',
|
|
125
|
+
entry: entries[i]?.entry ?? '',
|
|
126
|
+
panelBytes: Buffer.byteLength(html),
|
|
127
|
+
images,
|
|
128
|
+
skipped,
|
|
129
|
+
})),
|
|
130
|
+
};
|
|
131
|
+
}
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { basename, dirname, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { pkg } from "./package-info.js";
|
|
4
|
+
import { buildPlugin, BuildError, formatBytes, PANEL_ENTRY_MAX_BYTES, } from "./build.js";
|
|
5
|
+
import { loadConfigFile } from "./config-file.js";
|
|
6
|
+
import { isSupportedImage } from "./images.js";
|
|
7
|
+
import { CONFIG_FILE, HELP, parseCommand, resolveOptions, UsageError, } from "./options.js";
|
|
8
|
+
import { watchLoop } from "./watch.js";
|
|
9
|
+
function configFilePath({ configPath, cwd }) {
|
|
10
|
+
return resolve(cwd, configPath ?? CONFIG_FILE);
|
|
11
|
+
}
|
|
12
|
+
async function loadOptions(invocation) {
|
|
13
|
+
const { settings, configPath, cwd } = invocation;
|
|
14
|
+
const configFile = await loadConfigFile(configFilePath(invocation), {
|
|
15
|
+
required: configPath !== undefined,
|
|
16
|
+
});
|
|
17
|
+
if (configFile)
|
|
18
|
+
console.log(`Using settings from ${configPath ?? CONFIG_FILE}`);
|
|
19
|
+
return resolveOptions([...(configFile ? [configFile] : []), { settings, baseDir: cwd }], cwd);
|
|
20
|
+
}
|
|
21
|
+
function displayPath(path, cwd) {
|
|
22
|
+
const relativePath = relative(cwd, path);
|
|
23
|
+
if (relativePath === '')
|
|
24
|
+
return '.';
|
|
25
|
+
return relativePath.startsWith('..') ? path : relativePath;
|
|
26
|
+
}
|
|
27
|
+
function plural(count, noun) {
|
|
28
|
+
return `${String(count)} ${noun}${count === 1 ? '' : 's'}`;
|
|
29
|
+
}
|
|
30
|
+
function report(options, result, cwd) {
|
|
31
|
+
const where = displayPath(result.outDir, cwd);
|
|
32
|
+
const usage = (bytes) => `${((bytes / PANEL_ENTRY_MAX_BYTES) * 100).toFixed(0)}%`;
|
|
33
|
+
const [only] = result.galleries;
|
|
34
|
+
if (result.galleries.length === 1 && only) {
|
|
35
|
+
console.log(`Built "${only.title}" with ${plural(only.images.length, 'image')}: ` +
|
|
36
|
+
`${formatBytes(only.panelBytes)} (${usage(only.panelBytes)} of Orca's limit) in ${where}`);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.log(`Built "${options.name}" with ${String(result.galleries.length)} panels in ${where}:`);
|
|
40
|
+
const width = Math.max(...result.galleries.map((gallery) => gallery.title.length));
|
|
41
|
+
for (const gallery of result.galleries) {
|
|
42
|
+
console.log(` ${gallery.title.padEnd(width)} ${plural(gallery.images.length, 'image').padStart(10)} ` +
|
|
43
|
+
`${formatBytes(gallery.panelBytes).padStart(8)} (${usage(gallery.panelBytes)} of the limit)`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const skipped = result.galleries.reduce((sum, gallery) => sum + gallery.skipped.length, 0);
|
|
47
|
+
if (skipped > 0)
|
|
48
|
+
console.warn(`${String(skipped)} file(s) skipped.`);
|
|
49
|
+
}
|
|
50
|
+
function watchTargets(invocation, options) {
|
|
51
|
+
const config = configFilePath(invocation);
|
|
52
|
+
const targets = [
|
|
53
|
+
{
|
|
54
|
+
path: dirname(config),
|
|
55
|
+
recursive: false,
|
|
56
|
+
accept: (file) => basename(file) === basename(config),
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
for (const gallery of options?.galleries ?? []) {
|
|
60
|
+
targets.push({
|
|
61
|
+
path: gallery.input,
|
|
62
|
+
recursive: true,
|
|
63
|
+
accept: (file) => isSupportedImage(file) && (gallery.recursive || !file.includes(sep)),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return targets;
|
|
67
|
+
}
|
|
68
|
+
function describe(error) {
|
|
69
|
+
return error instanceof Error ? error.message : String(error);
|
|
70
|
+
}
|
|
71
|
+
async function watchAndBuild(invocation) {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
process.once('SIGINT', () => {
|
|
74
|
+
controller.abort();
|
|
75
|
+
});
|
|
76
|
+
await watchLoop(async () => {
|
|
77
|
+
let options;
|
|
78
|
+
try {
|
|
79
|
+
options = await loadOptions(invocation);
|
|
80
|
+
report(options, await buildPlugin(options), invocation.cwd);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (!(error instanceof UsageError || error instanceof BuildError))
|
|
84
|
+
throw error;
|
|
85
|
+
console.error(describe(error));
|
|
86
|
+
}
|
|
87
|
+
console.log(`[${new Date().toLocaleTimeString()}] Watching for changes. Ctrl+C to stop.`);
|
|
88
|
+
return watchTargets(invocation, options);
|
|
89
|
+
}, {
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
onSkip: (target, error) => {
|
|
92
|
+
console.warn(`Not watching ${target.path}: ${describe(error)}`);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function main(argv) {
|
|
97
|
+
const command = parseCommand(argv);
|
|
98
|
+
if (command.kind === 'help') {
|
|
99
|
+
process.stdout.write(HELP);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
if (command.kind === 'version') {
|
|
103
|
+
process.stdout.write(`${pkg.version}\n`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
const invocation = {
|
|
107
|
+
settings: command.settings,
|
|
108
|
+
configPath: command.configPath,
|
|
109
|
+
cwd: process.cwd(),
|
|
110
|
+
};
|
|
111
|
+
if (command.watch) {
|
|
112
|
+
await watchAndBuild(invocation);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
const options = await loadOptions(invocation);
|
|
116
|
+
report(options, await buildPlugin(options), invocation.cwd);
|
|
117
|
+
console.log('Load it in Orca from Settings > Plugins > Development > Add path; it reloads on every build.\n' +
|
|
118
|
+
'Add --watch to rebuild on its own whenever your images change.');
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
main(process.argv.slice(2)).then((code) => {
|
|
122
|
+
process.exitCode = code;
|
|
123
|
+
}, (error) => {
|
|
124
|
+
if (error instanceof UsageError) {
|
|
125
|
+
console.error(`${error.message}\n\nRun with --help for usage.`);
|
|
126
|
+
process.exitCode = 2;
|
|
127
|
+
}
|
|
128
|
+
else if (error instanceof BuildError) {
|
|
129
|
+
console.error(error.message);
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
console.error(error);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
}
|
|
136
|
+
});
|