11 Commits
Author SHA1 Message Date
kawaandClaude Haiku 4.5 7eaa3cd072 refactor: extract release body generation into Get-ReleaseBody function
Sync Gitea releases to GitHub / sync-releases (push) Waiting to run
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-14 13:44:21 +02:00
kawa 00bd5d8b13 Now build everything under WSL (so I can work with unsigned exe's on my work computer :P )
Sync Gitea releases to GitHub / sync-releases (push) Waiting to run
2026-08-14 13:20:30 +02:00
kawa 298ca15b97 Bump version
Sync Gitea releases to GitHub / sync-releases (push) Waiting to run
2026-08-14 10:26:02 +02:00
kawa 1996647dfe Merge pull request 'Fix/upstream open bugs' (#2) from fix/upstream-open-bugs into main
Sync Gitea releases to GitHub / sync-releases (push) Waiting to run
Reviewed-on: #2
2026-08-14 10:22:45 +02:00
kawaandClaude Haiku 4.5 45c37a5e73 feat: resizable side panels, audio layer fixes
- Add horizontal resize handles for browser and properties panels with localStorage persistence
- Support hide/show toggles for each side panel
- Fix audio layers not being interactive on canvas (hasControls, hasBorders, evented flags)
- Convert hardcoded panel widths to CSS variables for responsive layout
- Support narrower windows by shrinking panels while preserving minimum canvas width
- Ensure old projects with audio layers don't show phantom control boxes at 0,0

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-14 09:19:36 +02:00
kawaandClaude Opus 5 fe57429603 fix: crop rotated images, radius in px, modals block timeline
Replays the 13 open bug reports on alyssaxuu/motionity against this fork.
Nine were already fixed by the earlier audit and one (#8, video from the
search tab) needs a Pixabay API key to reproduce; these are the three that
were still broken.

#23 - border radius behaved like a percentage. fabric applies rx/ry before
the object's scale and shapes are resized by scaling, so a radius typed as
20 drew at 60px on a rect scaled 3x while the panel still read 20. A new
cornerRadius property keeps the pixel value the user asked for, rx/ry are
derived from it and re-derived whenever the scale changes, and old projects
fall back to rx * scaleX.

#27 - cropping a rotated image cropped the wrong region. crop() compared
canvas-space edges and covered only three of the four quadrants, so it did
nothing at all when the crop window was centred on the image. It now works
in the image's own frame, clamps the region to the bitmap and re-centres the
object on what it kept; the crop window carries the image's angle and the
expand-to-full-bitmap shift in cropImage is rotated the same way.

#1 - modals are painted over the timeline but never took its pointer events,
so the resize handle, seekbar, keyframes and layer bars all reacted to a drag
behind the dialog. All four now bail while a modal is open.

Verified in Chromium: 20px draws a 20px corner at scale 1 and scale 3 and
survives save, JSON round-trip and reload; at 0, 30 and 45 degrees the pixels
under the crop window are unchanged by the crop and everything outside it is
dropped; a real drag on the timeline handle with the export modal open leaves
the timeline height untouched. WEBM export and a non-rotated crop still work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:28:50 +02:00
kawa 094bfbdc60 Updated links pointing to motionity.app > motionity.kawa.zip
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 16:00:46 +02:00
kawa 414e0ee30c Updated the credits with links to the contributor's git pages
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 15:33:51 +02:00
kawa cc439c1152 Modified publish pipeline for WSL (flatpak, appimage builds)
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 15:13:42 +02:00
kawa e775ea478f Made the README.md nicer
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 14:05:41 +02:00
kawa dcb804e48e Bumped package.json version
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 13:02:26 +02:00
12 changed files with 1895 additions and 213 deletions
+204 -24
View File
@@ -4,7 +4,7 @@ Three distribution targets share one source tree (`src/`, a plain static app):
| Target | Output | Build host | | Target | Output | Build host |
| --- | --- | --- | | --- | --- | --- |
| Desktop | `Motionity Setup 1.0.0.exe`, `Motionity-1.0.0-x64.AppImage`, `.flatpak` | Windows for `.exe`, Linux (or WSL2) for AppImage/Flatpak | | Desktop | `Motionity Setup 1.0.0.exe`, `Motionity-1.0.0-x64.AppImage`, `.flatpak` | Linux or WSL2 builds all of them; Windows builds only the `.exe` targets |
| Container | `motionity:latest`, ~86 MB on disk / ~57 MB pulled | any Docker host | | Container | `motionity:latest`, ~86 MB on disk / ~57 MB pulled | any Docker host |
| Bare metal | `src/` behind Node, nginx or Caddy | any | | Bare metal | `src/` behind Node, nginx or Caddy | any |
@@ -63,12 +63,38 @@ npm run dist:win # NSIS installer + portable .exe -> dist/
npm run dist:appimage # .AppImage -> dist/ npm run dist:appimage # .AppImage -> dist/
npm run dist:flatpak # .flatpak -> dist/ npm run dist:flatpak # .flatpak -> dist/
npm run dist:linux # both Linux targets npm run dist:linux # both Linux targets
npm run dist:linux:wsl # from Windows: Linux bundles, built in WSL
npm run dist:win:wsl # from Windows: .exe targets, built and left in WSL
npm run dist:win:wsl:portable # same, portable only (needs no Wine in the distro)
``` ```
Each `dist:*` script re-runs `vendor` and regenerates `build/icon.png` Each `dist:*` script re-runs `vendor` and regenerates `build/icon.png`
(`scripts/make-icon.cjs` rasterises the logo geometry with zlib only — no (`scripts/make-icon.cjs` rasterises the logo geometry with zlib only — no
ImageMagick, no sharp). ImageMagick, no sharp).
### Why `desktopName` is `app.motionity.desktop.desktop`
The doubled suffix is correct, do not trim it. Electron reads the **root-level**
`desktopName` from `package.json` and derives the Wayland `app_id` / X11
`WM_CLASS` from it with the `.desktop` suffix stripped, so the value has to be
the desktop *file name*, not the app id. Flatpak in turn installs the entry as
`<appId>.desktop` and nothing can rename it — with `appId`
`app.motionity.desktop`, the file is `app.motionity.desktop.desktop`.
`linux.syncDesktopName: true` makes electron-builder use the same base name for
the AppImage's embedded entry and write a matching `StartupWMClass`. Without the
pair, the build warns
```
electron uses desktopName as app_id / WM_CLASS for window association.
reason=desktopName is not set in package.json
```
and the running window is not linked to its launcher entry: GNOME shows a
generic icon and a second, unpinnable dock item instead of the installed app.
Changing `appId` means changing `desktopName` in step with it.
### If the NSIS step fails with "Access denied" on `Motionity.exe` ### If the NSIS step fails with "Access denied" on `Motionity.exe`
On a locked-down Windows machine the security agent can take an exclusive lock On a locked-down Windows machine the security agent can take an exclusive lock
@@ -85,9 +111,17 @@ WARNING: Cannot open 2 files
The ACL is intact (the owner still has FullControl), so this is a filter The ACL is intact (the owner still has FullControl), so this is a filter
driver, not a permissions problem. Fixes, in order of preference: driver, not a permissions problem. Fixes, in order of preference:
1. Exclude the repository's `dist/` directory in the endpoint protection agent. 1. **Build the Windows targets in WSL**, where the agent cannot see the files at
2. Build on a machine or CI runner without that agent. all — `npm run dist:win:wsl`, covered in [its own section](#can-the-exe-be-built-in-wsl-too)
3. Ship `dist/win-unpacked/``electron-builder --win dir` is unaffected. below. Needs no admin rights on the Windows side.
2. Exclude the repository's `dist/` directory in the endpoint protection agent.
3. Build on a machine or CI runner without that agent.
4. Ship `dist/win-unpacked/``electron-builder --win dir` is unaffected.
The same agent can also quarantine the *finished* unsigned `.exe` on write, not
just lock it during the build. That is what `-KeepInWsl` is for: the artifact
stays in the distro and is uploaded to the release from there, so it never
crosses onto NTFS.
### Running `npm run dev` from a VS Code terminal ### Running `npm run dev` from a VS Code terminal
@@ -102,22 +136,61 @@ $env:ELECTRON_RUN_AS_NODE=$null; npm run dev # PowerShell
### Is WSL enough for AppImage and Flatpak? ### Is WSL enough for AppImage and Flatpak?
**AppImage: yes.** electron-builder produces the squashfs itself, so no FUSE is Yes, and `build-release.ps1 -UseWsl` does it for you from Windows (`-WinInWsl`
needed at build time. To *run* the result inside WSL you need `libfuse2` moves the `.exe` targets there too — see [below](#can-the-exe-be-built-in-wsl-too)):
(or `./Motionity-1.0.0-x64.AppImage --appimage-extract-and-run`), and WSLg on
Windows 11 gives you the GUI.
**Flatpak: technically yes, practically annoying.** `flatpak-builder` runs under ```powershell
WSL2 (the kernel has the user namespaces and `/dev/fuse` that bubblewrap needs), ./scripts/build-release.ps1 -UseWsl # .exe on Windows, Linux bundles in WSL
but you must install the runtimes by hand first and there is no ./scripts/build-release.ps1 -Targets linux -UseWsl # Linux bundles only
`xdg-desktop-portal` to fall back on. If it fights you, build it in a Linux ./scripts/publish.ps1 -Tag v2.0.1 -PublishRelease -UseWsl # same, then attach to the Gitea release
container instead — it is the same command with fewer moving parts. ```
**`.exe`: no.** Build it on the Windows side. Cross-building NSIS from Linux Both bundles have been built this way on this machine (`Ubuntu`, WSL2 kernel
needs Wine and rules out signing. 6.18): a 172 MB AppImage and a 133 MB Flatpak, checksums verified with
`sha256sum -c`. Under the hood:
This machine currently has no WSL distro other than `docker-desktop`, so the - the `npm ci`, `vendor` and icon steps run **once on the Windows side**, and the
Linux targets have not been run here. Setup, from PowerShell: WSL build reads them back through `/mnt/c` — one worktree, no second checkout;
- `-UseWsl` delegates only `linux-appimage` and `linux-flatpak`; add `-WinInWsl`
to send `win` / `win-nsis` / `win-portable` as well. On a Linux host, or with no
target selected for WSL, both warn and change nothing;
- the distro is the first installed one that is not `docker-desktop`, override
with `-WslDistro`. `docker-desktop` is skipped deliberately: it is Docker's own
LinuxKit VM, with no apt and no home to install the flatpak runtimes into, and
it is usually the *default* distro, so a blind `wsl --` lands there;
- missing tooling fails **before** the build with the apt or `flatpak install`
line to run, because electron-builder's own error for an absent flatpak ref is
a bare exit code naming neither the ref nor the remote.
**Why the build stages in `~/.cache/motionity-build` and not in `dist/`.**
electron-builder chmods every file it unpacks from the Electron zip, and `/mnt/c`
is mounted without the `metadata` option, so chmod is refused:
```
EPERM: operation not permitted, chmod '.../dist/linux-unpacked.tmp/locales/de.pak'
```
The alternative fix is `options = "metadata"` under `[automount]` in
`/etc/wsl.conf` plus a `wsl --shutdown` — a global, sudo-and-reboot change to the
distro. Building into ext4 and copying the two finished bundles back into `dist/`
needs neither, and is faster anyway. Reading `src/` over the mount is fine;
nothing chmods the input. The copy back is `cp -f`, never `cp -p` — preserving
modes means chmod, which is the EPERM being avoided.
**AppImage** needs no extra tooling: electron-builder downloads its own appimage
bundle and writes the squashfs itself, no FUSE at build time. To *run* the result
inside WSL you need `libfuse2` (or
`./motionity-*.AppImage --appimage-extract-and-run`); WSLg gives you the GUI.
**Flatpak** needs `flatpak-builder` and the runtimes installed by hand — the
WSL2 kernel has the user namespaces and `/dev/fuse` that bubblewrap wants, but
there is no `xdg-desktop-portal` to fall back on.
**`.exe`: yes** — see the next section. It is opt-in (`-WinInWsl`) because Windows
builds those targets natively too; the reason to move them is the endpoint agent
described above, not portability.
One-time distro setup, from PowerShell:
```powershell ```powershell
wsl --install -d Ubuntu wsl --install -d Ubuntu
@@ -127,22 +200,129 @@ Then inside Ubuntu:
```bash ```bash
sudo apt update sudo apt update
sudo apt install -y nodejs npm libfuse2 # AppImage sudo apt install -y nodejs npm libfuse2 # AppImage (libfuse2 only to run it)
sudo apt install -y flatpak flatpak-builder elfutils # Flatpak sudo apt install -y flatpak flatpak-builder elfutils # Flatpak
sudo dpkg --add-architecture i386 && sudo apt update # only for the NSIS .exe (see below)
sudo apt install -y wine # " "
flatpak remote-add --if-not-exists --user flathub \ flatpak remote-add --if-not-exists --user flathub \
https://dl.flathub.org/repo/flathub.flatpakrepo https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub \ flatpak install --user -y flathub \
org.freedesktop.Platform//23.08 \ org.freedesktop.Platform//23.08 \
org.freedesktop.Sdk//23.08 \ org.freedesktop.Sdk//23.08 \
org.electronjs.Electron2.BaseApp//23.08 org.electronjs.Electron2.BaseApp//23.08
cd /mnt/c/Users/<you>/git\ azuze/motionity-2
npm install
npm run dist:linux
``` ```
Note that building on `/mnt/c` is slow. Copying the tree into the WSL ### Can the `.exe` be built in WSL too?
filesystem (`~/motionity`) is several times faster.
Yes, and it is the way out of the "Access denied" failure above, since nothing
unsigned is written to a Windows filesystem.
```powershell
npm run dist:win:wsl # NSIS + portable, built and left in WSL — build only
npm run dist:win:wsl:portable # portable only — no Wine, no sudo needed
npm run release:binaries:wsl # same .exe targets, then uploaded to the Gitea release
npm run release:wsl # everything incl. Linux + the container image push
./scripts/build-release.ps1 -WinInWsl # copy the .exe back into dist/
./scripts/build-release.ps1 -WinInWsl -KeepInWsl # leave it in the distro
```
Verified on this machine (`Ubuntu`, WSL2 kernel 6.18): a 138 MB portable
`motionity-v2.0.2-win-x64-portable.exe`, `PE32 executable for MS Windows (GUI),
Nullsoft Installer self-extracting archive`, with the icon and version resources
applied, byte-identical whether read in the distro or after the copy back into
`dist/`. The NSIS installer needs the wine setup below and has not been built this
way yet.
**What each Windows target needs on the Linux side.**
| Target | Wine? | Why |
| --- | --- | --- |
| `-Targets win-portable` | no | electron-builder's NSIS bundle ships a native Linux `makensis`, and the exe's icon and version strings are written by the `resedit` JS package, not by `rcedit.exe`. |
| `-Targets win-nsis` | **yes, 32-bit capable** | NSIS builds its uninstaller by *executing* the installer stub it has just linked, so a Windows PE has to run. |
| `-Targets win` | yes | Both of the above in one packaging pass. |
**Why the NSIS target cannot avoid Wine.** electron-builder links the installer
once with `BUILD_UNINSTALLER` defined, runs it to produce `uninstaller.exe`, then
links the real installer, which *embeds that file*:
`templates/nsis/include/installer.nsh` does
`File "/oname=${UNINSTALL_FILENAME}" "${UNINSTALLER_OUT_FILE}"`. There is no
option to skip the first pass. The stub it executes is **PE32/i386** even for an
x64 app, so a 64-bit-only Wine is not enough either:
```bash
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install -y wine
```
`build-release.ps1` probes for a usable wine before packaging (missing or broken
wine is an error; a wine with no `i386-windows` directory is a warning), because
the failure otherwise arrives ~200 MB into the build naming ntdll rather than the
missing package.
**Do not use `toolsets.wine=1.0.1` for this.** electron-builder can download its
own Wine 11 bundle instead of using the distro's, which looks like it would avoid
the apt install, and it does download and verify cleanly. Its Linux build is
unusable: `lib/wine/x86_64-unix/` only, with no `*-windows` PE builtin directory
and no `syswow64`, so it fails after the app is already packaged with
```
wine: failed to load .../wine-11.0-linux-x86_64-*/lib/wine/x86_64-unix/ntdll.dll error c0000135
0024:err:environ:run_wineboot failed to start wineboot 1
```
`c0000135` is `STATUS_DLL_NOT_FOUND`. Leaving `toolsets.wine` unset is what makes
electron-builder use the distro's `wine` on Linux, which is the working path. If
that bundle was already downloaded, `rm -rf ~/.cache/electron-builder/wine@1.0.1`
reclaims it.
If you cannot install anything in the distro, `-Targets win-portable` is a
complete answer: a single self-contained `.exe`, no installer, no Wine, no root.
**Why the build also passes `win.signExecutable=false`.** With no certificate
configured, electron-builder still walks the signing path, and on Linux that
path shells out to `signtool.exe` under Wine *before* discovering there is
nothing to sign — `spawn wine ENOENT`, build over. `signExecutable: false` skips
signing while still applying the icon and version metadata.
(`signAndEditExecutable: false` would drop those too, which is not wanted.) Both
overrides are passed on the command line for the WSL build only, so a native
Windows build behaves exactly as before. These releases are unsigned either way.
**Uploading straight from the distro.** With `-KeepInWsl`, `build-release.ps1`
writes `dist/wsl-artifacts.json` naming the distro, the staging directory and the
files it deliberately did not copy back. `publish.ps1` reads it and runs the
`curl` upload *inside* the distro for those files. The Gitea token reaches WSL
through `WSLENV` and is written to a `mktemp` config file by bash — it is in
neither `wsl.exe`'s arguments nor the distro's process table. `SHA256SUMS.txt`
still covers every artifact, hashes for the staged ones coming from `sha256sum`
in the distro; it is text, so nothing objects to it landing in `dist/`.
Two things to know when writing more of this plumbing:
- These `.ps1` files are stored with **CRLF**, so a multi-line here-string handed
to `bash -lc` arrives with a `\r` on every line (`set: - : invalid option`,
`cd: $'/path\r': No such file or directory`). `ConvertTo-BashScript` strips it.
- Never combine `set -e` with an explicit `exit 0` under `bash -lc`. A login
shell sources `~/.bash_logout`, Ubuntu's ends in a `clear_console` test that
fails with no tty, and errexit promotes that to the shell's exit status:
`wsl -e bash -lc 'set -e; exit 0'` returns **1**. `-l` has to stay, because
node from nvm or fnm is only on the login `PATH`.
Those three refs must match `build.flatpak.runtimeVersion` / `baseVersion` in
`package.json`; `build-release.ps1` reads them from there when it checks.
To build inside the distro directly instead — no `-UseWsl`, and faster still,
since `src/` is read locally too:
```bash
git clone <repo> ~/motionity && cd ~/motionity
npm ci && npm run dist:linux
```
`wsl.exe` writes its own listings as UTF-16LE, which PowerShell 5.1 renders as
NUL-interleaved text — `wsl -l -v` can look like it has one distro when it has
two. `$env:WSL_UTF8 = "1"` fixes it.
## 2. Docker ## 2. Docker
+145 -17
View File
@@ -1,34 +1,162 @@
<div align="center">
<img src="src/assets/logo.svg" alt="Motionity" width="72">
# Motionity # Motionity
### Demo available here : [motionity.kawa.zip](https://motionity.kawa.zip/)
Web-based motion graphics editor with keyframing, masking, filters and text animations.
This is a fork of the original [Motionity](https://github.com/alyssaxuu/motionity) by [@alyssaxuu](https://github.com/alyssaxuu), with bug fixes and enhancements. **Web-based motion graphics editor** — keyframing, masking, filters, text animations.
A free alternative to After Effects and Canva, running entirely in the browser.
## Quick Start ### ▶ [Try the live demo — motionity.kawa.zip](https://motionity.kawa.zip/)
[![Demo](https://img.shields.io/badge/demo-motionity.kawa.zip-6c5ce7?style=flat-square)](https://motionity.kawa.zip/)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
[![Downloads](https://img.shields.io/badge/downloads-releases-2ea44f?style=flat-square)](https://git.azuze.fr/kawa/Motionity/releases)
---
Created by **[Alyssa X](https://github.com/alyssaxuu)** · maintained by **Kawa**
Fork of the original [Motionity](https://github.com/alyssaxuu/motionity), with bug fixes,
vendored (offline-capable) assets, desktop builds and a hardened container image.
</div>
---
## How to run
### Docker (recommended for self-hosting)
```bash
docker compose up -d # http://localhost:8080
```
Or without compose:
```bash
docker run --rm -p 8080:8080 git.azuze.fr/kawa/motionity:latest
```
Build the image yourself:
```bash
docker build -t motionity:latest . # ~86 MB on disk
docker build --build-arg WITH_FFMPEG=0 . # -23 MB, no MP4/GIF export
```
Runtime is [static-web-server](https://github.com/static-web-server/static-web-server) on Alpine — no Node, no shell, runs as UID 65534, `read_only` filesystem.
### Windows app
Grab the installer or the portable build from **[Releases](https://git.azuze.fr/kawa/Motionity/releases)**:
| File | What it is |
| --- | --- |
| `Motionity Setup <ver>.exe` | NSIS installer, per-user, choosable install dir |
| `Motionity-<ver>-x64.exe` | Portable, no install |
Build from source (on Windows):
**Web (localhost only):**
```bash ```bash
npm install npm install
npm run vendor npm run dist:win # -> dist/
```
### Linux app
From **[Releases](https://git.azuze.fr/kawa/Motionity/releases)**: `Motionity-<ver>-x86_64.AppImage` or the `.flatpak`.
```bash
chmod +x Motionity-*.AppImage && ./Motionity-*.AppImage # needs libfuse2
flatpak install ./Motionity-*.flatpak # or this
```
Build from source (on Linux or WSL2):
```bash
npm install
npm run dist:appimage # AppImage only
npm run dist:flatpak # Flatpak only
npm run dist:linux # both
```
### From source — web
```bash
npm install
npm run vendor # downloads third-party assets into src/vendor/ (~24 MB)
npm start # http://127.0.0.1:8080 npm start # http://127.0.0.1:8080
``` ```
**Desktop:** ```bash
HOST=0.0.0.0 PORT=3000 npm start # bind elsewhere
```
### From source — desktop
```bash ```bash
npm install npm install
npm run vendor npm run vendor
npm run dev # Electron app npm run dev # Electron
``` ```
**Docker:** > In a VS Code terminal, `ELECTRON_RUN_AS_NODE=1` breaks this. Unset it:
```bash > `$env:ELECTRON_RUN_AS_NODE=$null; npm run dev`
docker-compose up # http://localhost:8080
```
Full build instructions (Windows installers, Linux AppImage/Flatpak) in [PACKAGING.md](PACKAGING.md). ### Two things to know
## Notes - **`npm run vendor` is mandatory** for every non-Docker target. Without it the page loads but every script 404s. The Docker build runs it inside the image.
- **Secure context required.** WebCodecs (fast export) and IndexedDB (project saving) only exist on `http://localhost` or HTTPS. Over plain HTTP on a LAN address they silently disappear: export falls back to slow real-time capture, projects stop saving. Anything beyond localhost needs TLS — `deploy/Caddyfile` is the shortest path.
- `ffmpeg.wasm` is vendored; `npm install` + `npm run vendor` are required Full packaging details, WSL notes and troubleshooting: **[PACKAGING.md](PACKAGING.md)**.
- WebCodecs and IndexedDB only work in secure context (`http://localhost` OK, plain HTTP over LAN is not)
- Serve over TLS for remote access ---
## Dependencies
Everything is vendored at build time — no CDN is contacted at runtime.
### Editor core
| Library | Role |
| --- | --- |
| [Fabric.js](https://github.com/fabricjs/fabric.js) | canvas object model, selection, transforms |
| [anime.js](https://github.com/juliangarnier/anime) | keyframe animation engine |
| [lottie-web](https://github.com/airbnb/lottie-web) | Lottie / Bodymovin playback |
| [ffmpeg.wasm](https://github.com/ffmpegwasm/ffmpeg.wasm) | MP4 / GIF export (single-threaded [core](https://github.com/ffmpegwasm/ffmpeg.wasm-core)) |
| [webm-writer-js](https://github.com/thenickdude/webm-writer-js) | WEBM muxing for the WebCodecs exporter |
### UI
| Library | Role |
| --- | --- |
| [jQuery](https://github.com/jquery/jquery) | DOM plumbing |
| [Pickr](https://github.com/simonwep/pickr) | color picker |
| [Selection.js](https://github.com/simonwep/selection) | timeline box-selection |
| [Sortable](https://github.com/SortableJS/Sortable) | layer reordering |
| [jquery-nice-select](https://github.com/hernansartorio/jquery-nice-select) | styled `<select>` |
| range-slider | timeline / property sliders (vendored, no upstream banner) |
| [Localbase](https://github.com/dannyconnell/localbase) | IndexedDB project storage |
| [webfontloader](https://github.com/typekit/webfontloader) | Google Fonts loading |
| [Inter](https://github.com/rsms/inter) | UI typeface |
### Build & runtime
| Package | Role |
| --- | --- |
| [Electron](https://github.com/electron/electron) | desktop shell |
| [electron-builder](https://github.com/electron-userland/electron-builder) | NSIS / AppImage / Flatpak packaging |
| [static-web-server](https://github.com/static-web-server/static-web-server) | container HTTP server |
`scripts/server.cjs` (the `npm start` server) has zero dependencies — Node 18+ builtins only.
### Still online by design
[Google Fonts](https://fonts.google.com/) (font picker), [Pixabay](https://pixabay.com/) (image/video/audio search), [Unsplash](https://unsplash.com/) (empty-state samples). All three degrade quietly when offline; the editor itself starts and exports without a network.
---
## License
[MIT](LICENSE) — same as upstream.
+53
View File
@@ -315,3 +315,56 @@ held the Playwright profile lock), so the following still needs a manual pass:
console shows `Rendering n%` (frame-accurate path) and not console shows `Rendering n%` (frame-accurate path) and not
`Falling back to the real-time encoder` `Falling back to the real-time encoder`
4. Play the result: check audio is in sync and the video layer is not smeared 4. Play the result: check audio is in sync and the video layer is not smeared
---
## Upstream open issues (alyssaxuu/motionity)
The 13 bug reports still open upstream were replayed in Chromium against this
fork. Nine were already fixed by the audit above — mp4 export (#16), blank
render (#25), image download (#10), audio on download (#30), filters not
retained (#24), `EyeDropper is not defined` (#15, #18), the endless
*Loading video…* (#28) and text selection while resizing the timeline (#5).
GIF export (#21) works through the same ffmpeg path. #8 (video from the search
tab) needs a Pixabay API key, so it stays untested; #29 and #4 carry no
reproducer. The remaining three are fixed here:
- [x] **#23 — Border radius behaved like a percentage** — `src/js/functions.js`,
`src/js/ui.js`, `src/js/events.js`, `src/js/database.js`
fabric applies `rx`/`ry` before the object's scale, and shapes are resized by
scaling, so a radius typed as 20 drew at 60 px on a rect scaled 3x while the
panel — which read `rx` back raw — still said 20. A new `cornerRadius` property
stores the pixel value the user asked for; `rx`/`ry` are derived from it
(`setCornerRadius`) and re-derived on `object:scaling` / `object:modified`
(`syncCornerRadius`), so the same number means the same pixels at any size.
`cornerRadius` was added to the four serialised property lists, and
`getCornerRadius` falls back to `rx * scaleX` for projects saved before it
existed. Known limit, unchanged from before: if `scaleX` is *keyframed*, the
drawn radius still varies over the animation.
Verified: typed 20 px draws a 20 px corner at scale 1 and at scale 3, the
panel keeps reading 20, and the value survives save, JSON round-trip and a
page reload.
- [x] **#27 — Cropping a rotated image** — `src/js/functions.js`
`crop()` compared canvas-space edges and covered only three of the four
quadrants, so any rotated image cropped the wrong region — and nothing at all
when the crop window was centred on it, since all three branches need a
strictly positive offset. The guards added in the audit stopped the crash but
left the geometry wrong. `crop()` now works in the image's own frame
(`rotateVector`), clamps the region to the bitmap, and re-centres the object on
the region it kept; the crop window is created with the image's `angle`, and
the "expand back to the full bitmap" shift in `cropImage` is rotated the same
way. The four-branch soup is gone.
Verified: at 0°, 30° and 45°, the pixels under the crop window are byte
identical before and after the crop, everything outside it is dropped, and
crop mode exits cleanly.
- [x] **#1 — A modal did not block the timeline** — `src/js/functions.js`
Modals are painted over the timeline but never took its pointer events, so the
resize handle, the seekbar, keyframes and layer bars all still reacted to a
drag behind the dialog. The onboarding modal from the report does not exist in
the open-source build; the export, import/export and credits modals all had
the bug. `dragTimeline`, `dragSeekBar`, `dragKeyframe` and `dragObjectProps`
now bail while `.modal-open` is present.
Verified: with the export modal open a real mouse drag on the handle leaves
the timeline height untouched, and dragging works again once it closes.
+8 -1
View File
@@ -1,7 +1,8 @@
{ {
"name": "motionity", "name": "motionity",
"productName": "Motionity", "productName": "Motionity",
"version": "2.0.0", "version": "2.0.2",
"desktopName": "app.motionity.desktop.desktop",
"description": "Web-based motion graphics editor with keyframing, masking, filters and text animations", "description": "Web-based motion graphics editor with keyframing, masking, filters and text animations",
"license": "MIT", "license": "MIT",
"author": "Kawa", "author": "Kawa",
@@ -16,11 +17,16 @@
"dist:appimage": "npm run vendor && npm run icons && electron-builder --linux AppImage", "dist:appimage": "npm run vendor && npm run icons && electron-builder --linux AppImage",
"dist:flatpak": "npm run vendor && npm run icons && electron-builder --linux flatpak", "dist:flatpak": "npm run vendor && npm run icons && electron-builder --linux flatpak",
"dist:linux": "npm run vendor && npm run icons && electron-builder --linux AppImage flatpak", "dist:linux": "npm run vendor && npm run icons && electron-builder --linux AppImage flatpak",
"dist:linux:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 -Targets linux -UseWsl",
"dist:win:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 -Targets win -WinInWsl -KeepInWsl",
"dist:win:wsl:portable": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1 -Targets win-portable -WinInWsl -KeepInWsl",
"docker:build": "docker build -t motionity:latest .", "docker:build": "docker build -t motionity:latest .",
"docker:run": "docker run --rm -p 8080:8080 motionity:latest", "docker:run": "docker run --rm -p 8080:8080 motionity:latest",
"release:build": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1", "release:build": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1",
"release:binaries": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -BinariesOnly", "release:binaries": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -BinariesOnly",
"release:binaries:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -BinariesOnly win -WinInWsl -KeepInWsl",
"release": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease", "release": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease",
"release:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease -UseWsl -WinInWsl -KeepInWsl",
"test": "node --test \"test/**/*.test.js\"" "test": "node --test \"test/**/*.test.js\""
}, },
"dependencies": { "dependencies": {
@@ -78,6 +84,7 @@
"icon": "build/icon.png", "icon": "build/icon.png",
"category": "Graphics", "category": "Graphics",
"synopsis": "Motion graphics editor", "synopsis": "Motion graphics editor",
"syncDesktopName": true,
"desktop": { "desktop": {
"entry": { "entry": {
"Name": "Motionity", "Name": "Motionity",
+523 -14
View File
@@ -26,12 +26,63 @@
404s. 404s.
Windows builds the .exe targets; AppImage and Flatpak need a Linux host or Windows builds the .exe targets; AppImage and Flatpak need a Linux host or
WSL (see PACKAGING.md). Nothing here cross-builds. WSL (see PACKAGING.md).
-UseWsl makes that split automatic: the .exe targets run on Windows, and the
Linux ones are handed to a WSL distro over /mnt/c, against this same worktree.
The npm install, the vendor step and the icon all happen once on the Windows
side and the WSL build reads them through the mount, so the artifacts still
land in dist/ and the checksum step below sees every one of them.
-WinInWsl sends the .exe targets to that distro as well. The reason to want
that is not portability: a locked-down Windows machine's endpoint agent takes an
exclusive lock on the freshly written unsigned Motionity.exe and the 7-Zip step
that packs the installer payload then cannot read it (PACKAGING.md has the
error). Building in the distro's ext4 filesystem is not visible to that agent.
Signing is the one thing lost, and these builds are unsigned either way — the
WSL build passes win.signExecutable=false, because the signtool.exe path runs
under Wine and there is no certificate for it to use.
What each Windows target needs on the Linux side:
win-portable nothing. electron-builder's NSIS bundle ships a native Linux
makensis, and rcedit was replaced by the resedit JS package, so
the icon and version strings are written without Wine.
win-nsis Wine, with 32-bit support, unavoidably. NSIS builds its
uninstaller by *executing* the installer stub it has just linked
(NsisTarget's computeScriptAndSignUninstaller) because the
installer then embeds that uninstaller as a file
(templates/nsis/include/installer.nsh: `File "/oname=..."
"${UNINSTALLER_OUT_FILE}"`). There is no flag to skip it, and the
stub is PE32/i386, so a 64-bit-only Wine cannot run it either.
Assert-WslWine checks for a usable one before building.
Do NOT reach for toolsets.wine=1.0.1 here. That bundle exists and
downloads cleanly, but the Linux build of it is unusable: it ships
lib/wine/x86_64-unix only, with no *-windows PE builtin directory
and no syswow64, so wine dies with
wine: failed to load .../x86_64-unix/ntdll.dll error c0000135
after the app has already been packaged. The distro's own wine is
the working path.
"win" is both, in one packaging pass. -Targets win-portable is the way to get a
usable .exe out of a machine where you cannot apt-install anything.
-KeepInWsl goes further and never copies the WSL-built artifacts back: they
stay in the staging directory, and dist/ gets only SHA256SUMS.txt plus
wsl-artifacts.json naming them. Use it when the agent quarantines the finished
unsigned .exe on write, not just during the build — publish.ps1 reads that
manifest and uploads those files to Gitea from inside the distro, so the .exe
never lands on NTFS at all.
.EXAMPLE .EXAMPLE
./scripts/build-release.ps1 ./scripts/build-release.ps1
Build every target at v<package.json version>. Build every target at v<package.json version>.
.EXAMPLE
./scripts/build-release.ps1 -UseWsl
Same, with AppImage and Flatpak built in the first non-docker WSL distro.
.EXAMPLE .EXAMPLE
./scripts/build-release.ps1 -Targets win -Tag v1.1.0 ./scripts/build-release.ps1 -Targets win -Tag v1.1.0
Windows installers only, named v1.1.0. Windows installers only, named v1.1.0.
@@ -40,15 +91,36 @@
./scripts/build-release.ps1 -Targets win,linux-appimage ./scripts/build-release.ps1 -Targets win,linux-appimage
Windows installers plus the Linux AppImage — no Flatpak. Windows installers plus the Linux AppImage — no Flatpak.
.EXAMPLE
./scripts/build-release.ps1 -Targets linux -UseWsl -WslDistro Ubuntu-24.04
Both Linux bundles, in a named distro.
.EXAMPLE
./scripts/build-release.ps1 -WinInWsl
Every target built in WSL, artifacts copied back into dist/. Nothing unsigned
is written to NTFS while the build runs.
.EXAMPLE
./scripts/build-release.ps1 -WinInWsl -KeepInWsl
Same, but the artifacts stay in the distro. dist/ gets SHA256SUMS.txt and
wsl-artifacts.json; publish.ps1 uploads from there.
.EXAMPLE
./scripts/build-release.ps1 -Targets win-portable -WinInWsl -KeepInWsl
The portable .exe only, built and left in WSL. Needs no Wine and no root in the
distro, and writes nothing to NTFS.
.EXAMPLE .EXAMPLE
./scripts/build-release.ps1 -SkipVendor -SkipDeps ./scripts/build-release.ps1 -SkipVendor -SkipDeps
Reuse src/vendor/ and node_modules as they are — the fast rebuild. Reuse src/vendor/ and node_modules as they are — the fast rebuild.
#> #>
[CmdletBinding()] [CmdletBinding()]
param( param(
# Platforms to package. "win" is NSIS + portable; "linux-appimage" and # Platforms to package. "win" is NSIS + portable in one pass, and "win-nsis" /
# "linux-flatpak" are the two Linux bundles, and "linux" is shorthand for both. # "win-portable" are those two separately — worth having because only the NSIS
[ValidateSet("win", "linux", "linux-appimage", "linux-flatpak")] # one needs Wine when built in WSL. "linux-appimage" and "linux-flatpak" are the
# two Linux bundles, and "linux" is shorthand for both.
[ValidateSet("win", "win-nsis", "win-portable", "linux", "linux-appimage", "linux-flatpak")]
[string[]]$Targets = @("win", "linux"), [string[]]$Targets = @("win", "linux"),
# Version used in the artifact names. Defaults to v<package.json version>, # Version used in the artifact names. Defaults to v<package.json version>,
@@ -62,6 +134,22 @@ param(
# Skip the npm install even when node_modules is missing. # Skip the npm install even when node_modules is missing.
[switch]$SkipDeps, [switch]$SkipDeps,
# Build the Linux targets inside WSL instead of warning that they cannot be
# built on Windows. Ignored on a Linux host, where they build natively.
[switch]$UseWsl,
# WSL distro to build in. Defaults to the first installed one that is not
# docker-desktop.
[string]$WslDistro,
# Send the "win" target to WSL too. Implies -UseWsl. Nothing unsigned is then
# written to NTFS during the build, which is what the endpoint agent reacts to.
[switch]$WinInWsl,
# Leave the WSL-built artifacts in the distro instead of copying them into
# dist/. dist/ still gets SHA256SUMS.txt and wsl-artifacts.json.
[switch]$KeepInWsl,
# Remove dist/ before building. # Remove dist/ before building.
[switch]$Clean [switch]$Clean
) )
@@ -91,6 +179,256 @@ function Get-ArtifactName {
return $Stem + '.${ext}' return $Stem + '.${ext}'
} }
# --- WSL plumbing -------------------------------------------------------------
function ConvertTo-BashScript {
<#
Strip CR. This file is stored with CRLF line endings, so a multi-line
here-string handed to bash arrives with a \r on every line and bash treats it
as part of the last token:
set: - : invalid option
cd: $'/home/kawa/.cache/motionity-build\r': No such file or directory
Single-line commands are unaffected, which is exactly why this is easy to
miss until a script gains a second line.
#>
param([Parameter(Mandatory)][AllowEmptyString()][string]$Script)
return $Script -replace "`r", ""
}
<#
A second trap in the same area, worth stating once: never combine `set -e` with an
explicit `exit 0` under `bash -lc`.
wsl -d Ubuntu -e bash -lc 'set -e; exit 0' -> 1
wsl -d Ubuntu -e bash -lc 'exit 0' -> 0
A login shell sources ~/.bash_logout on the way out, and Ubuntu's default one ends
in `[ -x /usr/bin/clear_console ] && /usr/bin/clear_console -q`, which fails with
no tty attached. With errexit set, that failure becomes the shell's exit status,
and a script that did its job reports failure. -l is not negotiable (node from nvm
or fnm is only on the login PATH), so the scripts below use `set -u` and check the
commands that matter by hand.
#>
function Invoke-Wsl {
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$Command)
Write-Host " > wsl -d $Distro -- $Command" -ForegroundColor DarkGray
# bash -lc, so PATH matches an interactive shell: node installed through nvm
# or fnm is not on the default non-login PATH.
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $Command)
if ($LASTEXITCODE -ne 0) {
throw "the WSL build in '$Distro' failed with exit code $LASTEXITCODE."
}
}
function Test-WslCommand {
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$Command)
& wsl.exe -d $Distro -e bash -lc $Command *> $null
return ($LASTEXITCODE -eq 0)
}
function Invoke-WslCapture {
<#
Like Invoke-Wsl, but returns the distro's stdout instead of echoing the
command. For the small queries (a path, a checksum listing) whose output is
the point and whose command line is noise.
Positional arguments go to bash as $1..$n; $0 is a label. Passing them this
way rather than interpolating into $Command keeps quoting out of it.
#>
param(
[Parameter(Mandatory)][string]$Distro,
[Parameter(Mandatory)][string]$Command,
[string[]]$ScriptArgs = @()
)
# 2>&1: stderr is merged in so a failure can be reported with what the distro
# actually said. Callers filter the lines they want, so the noise is harmless.
$out = @(& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $Command) "motionity-build" @ScriptArgs 2>&1 |
ForEach-Object { $_.ToString().Replace("`0", "") })
if ($LASTEXITCODE -ne 0) {
throw "a query in WSL distro '$Distro' failed with exit code ${LASTEXITCODE}: $($out -join ' | ')"
}
return $out
}
function ConvertTo-BashArg {
<#
Single quotes, always. The artifactName arguments carry a literal ${ext}
for electron-builder to expand, and bash would expand it to nothing first
inside double quotes; the repo path has a space in it.
#>
param([Parameter(Mandatory)][string]$Value)
return "'" + $Value.Replace("'", "'\''") + "'"
}
function Get-WslDistro {
<#
docker-desktop is excluded on purpose. It is Docker Desktop's own LinuxKit
VM: no apt, no user home to install the flatpak runtimes into — and it is
usually the *default* distro, so picking blind would land there.
#>
param([string]$Requested)
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
throw "-UseWsl needs wsl.exe on PATH. Install a distro with 'wsl --install -d Ubuntu' (PACKAGING.md has the rest of the setup)."
}
# wsl.exe writes its own listings as UTF-16LE, which PowerShell 5.1 reads back
# as NUL-interleaved text. WSL_UTF8 fixes it at the source; the -replace is the
# fallback for WSL older than 0.64.
$previousUtf8 = $env:WSL_UTF8
$env:WSL_UTF8 = "1"
try { $listed = (& wsl.exe --list --quiet) -join "`n" }
finally { $env:WSL_UTF8 = $previousUtf8 }
$distros = @(($listed -replace "`0", "") -split "`r?`n" |
ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($Requested) {
if ($distros -notcontains $Requested) {
throw "WSL distro '$Requested' is not installed. Installed: $($distros -join ', ')."
}
return $Requested
}
$usable = @($distros | Where-Object { $_ -notlike "docker-desktop*" })
if (-not $usable.Count) {
throw "no WSL distro that can build here (installed: $($distros -join ', ')). Run 'wsl --install -d Ubuntu', then the package setup in PACKAGING.md."
}
return $usable[0]
}
function Get-WslPath {
param([Parameter(Mandatory)][string]$Distro, [Parameter(Mandatory)][string]$WindowsPath)
# -e wslpath rather than a shell: the backslashes and the space in the repo
# path then reach wslpath as one literal argv entry, unquoted and unmangled.
$out = (& wsl.exe -d $Distro -e wslpath -a -u $WindowsPath)
$path = (@($out) -join "").Replace("`0", "").Trim()
if ($LASTEXITCODE -ne 0 -or -not $path) {
throw "wslpath failed in '$Distro' for '$WindowsPath' — is the Windows drive mounted in that distro?"
}
return $path
}
function Get-WslStageDir {
<#
Where the Linux build actually happens. It cannot be dist/ on /mnt/c:
electron-builder chmods every file it unpacks out of the Electron zip, and
drvfs answers chmod with EPERM unless /mnt/c was mounted with the metadata
option — a global change to the distro needing sudo and a wsl --shutdown.
EPERM: operation not permitted, chmod '.../linux-unpacked.tmp/locales/de.pak'
Staging in the distro's own filesystem and copying the finished bundles back
needs none of that, and is faster besides. Reading src/ over the mount is
still fine — nothing chmods the input.
#>
param([Parameter(Mandatory)][string]$Distro)
# printf, not echo: no trailing newline to trim off the path.
$out = (& wsl.exe -d $Distro -e bash -lc 'printf %s "${XDG_CACHE_HOME:-$HOME/.cache}/motionity-build"')
$path = (@($out) -join "").Replace("`0", "").Trim()
if ($LASTEXITCODE -ne 0 -or $path -notlike "/*") {
throw "could not resolve a staging directory in '$Distro' (got '$path')."
}
return $path
}
function Assert-WslWine {
<#
The NSIS target needs a wine in the distro that can run a 32-bit PE, because
the installer stub it has to execute is PE32/i386 even for an x64 app. Checked
up front: without it the build dies after packaging ~200 MB, with an error
that names ntdll rather than the missing package.
The i386 check is a warning, not an error. The directory list below covers the
usual layouts but cannot cover every distro or a hand-built wine, and a false
negative must not block a build that would have worked.
#>
param([Parameter(Mandatory)][string]$Distro)
$probe = @'
set -u
command -v wine >/dev/null 2>&1 || exit 10
wine --version >/dev/null 2>&1 || exit 11
for d in /usr/lib/wine /usr/lib64/wine /usr/lib/x86_64-linux-gnu/wine /usr/local/lib/wine /opt/wine*/lib/wine; do
[ -d "$d/i386-windows" ] && exit 0
done
exit 12
'@
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $probe) "motionity-build" *> $null
$code = $LASTEXITCODE
$installHint = @"
Inside the distro (the i386 architecture is what provides the 32-bit loader):
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install -y wine
Or skip NSIS entirely the portable .exe runs no PE, so it needs neither Wine nor root:
./scripts/build-release.ps1 -Targets win-portable -WinInWsl -KeepInWsl
"@
switch ($code) {
10 { throw "no wine in WSL distro '$Distro', and the NSIS uninstaller cannot be built without one.`n$installHint" }
11 { throw "wine is installed in '$Distro' but will not run ('wine --version' failed). A broken or partial install cannot build the NSIS uninstaller.`n$installHint" }
12 { Write-Warning "wine in '$Distro' looks 64-bit only (no i386-windows directory found). The NSIS installer stub is PE32/i386, so the build will likely fail at 'building target=nsis'. If it does:`n$installHint" }
}
}
function Assert-WslBuildEnv {
<#
Fail before the build rather than during it. electron-builder's own error
for a missing flatpak ref is a bare flatpak-builder exit code that names
neither the ref nor the remote.
#>
param(
[Parameter(Mandatory)][string]$Distro,
[Parameter(Mandatory)][string[]]$WslTargets,
[Parameter(Mandatory)]$Pkg
)
if (-not (Test-WslCommand $Distro 'command -v node')) {
throw "no node in WSL distro '$Distro'. Inside it: sudo apt update && sudo apt install -y nodejs npm"
}
# NSIS links the installer and then *runs* it with BUILD_UNINSTALLER defined to
# get the uninstaller out, so building it on Linux means executing a Windows PE.
if (@($WslTargets | Where-Object { $_ -eq "win" -or $_ -eq "win-nsis" }).Count) {
Assert-WslWine -Distro $Distro
}
# AppImage and the Windows targets need nothing else: electron-builder downloads
# its own appimage tooling and writes the squashfs itself, and it downloads the
# NSIS bundle whose makensis is a native Linux binary. libfuse2 is only needed to
# *run* an AppImage, which is not this script's job.
if ($WslTargets -notcontains "linux-flatpak") { return }
if (-not (Test-WslCommand $Distro 'command -v flatpak-builder')) {
throw "no flatpak-builder in WSL distro '$Distro'. Inside it: sudo apt install -y flatpak flatpak-builder elfutils"
}
$runtimeVersion = $Pkg.build.flatpak.runtimeVersion
$baseVersion = $Pkg.build.flatpak.baseVersion
if (-not $runtimeVersion) { $runtimeVersion = "23.08" }
if (-not $baseVersion) { $baseVersion = $runtimeVersion }
$refs = @(
"org.freedesktop.Platform//$runtimeVersion",
"org.freedesktop.Sdk//$runtimeVersion",
"org.electronjs.Electron2.BaseApp//$baseVersion"
)
$missing = @($refs | Where-Object { -not (Test-WslCommand $Distro "flatpak info $_") })
if ($missing.Count) {
throw @"
flatpak refs missing in '$Distro': $($missing -join ', ')
Inside the distro:
flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y flathub $($refs -join ' ')
"@
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot $repoRoot = Split-Path -Parent $PSScriptRoot
Push-Location $repoRoot Push-Location $repoRoot
try { try {
@@ -110,8 +448,9 @@ try {
Write-Host " output : $distDir" Write-Host " output : $distDir"
Write-Host "" Write-Host ""
# Expand "linux" to its two concrete bundles and drop duplicates, so the rest # Expand "linux" to its two concrete bundles and drop duplicates. "win" is left
# of the script only ever deals with win / linux-appimage / linux-flatpak. # alone rather than expanded the same way: electron-builder builds nsis and
# portable from one packaging pass, and splitting it would unpack Electron twice.
$resolvedTargets = @() $resolvedTargets = @()
foreach ($t in $Targets) { foreach ($t in $Targets) {
if ($t -eq "linux") { $resolvedTargets += "linux-appimage", "linux-flatpak" } if ($t -eq "linux") { $resolvedTargets += "linux-appimage", "linux-flatpak" }
@@ -119,11 +458,46 @@ try {
} }
$resolvedTargets = @($resolvedTargets | Select-Object -Unique) $resolvedTargets = @($resolvedTargets | Select-Object -Unique)
# electron-builder produces AppImage and Flatpak with Linux-only tooling # electron-builder produces AppImage and Flatpak with Linux-only tooling (its
# (appimagetool, flatpak-builder). Warned rather than blocked: the same script # downloaded appimage bundle, and flatpak-builder). -UseWsl hands those two
# runs under pwsh on a Linux box or in WSL, which is where that target belongs. # targets to a WSL distro; without it they stay a warning rather than an error,
if (($resolvedTargets -like "linux-*") -and $env:OS -eq "Windows_NT") { # because the other right answer is running this whole script under pwsh on a
Write-Warning "the Linux targets need a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows (PACKAGING.md has the WSL setup)." # Linux box, where they build natively.
#
# -WinInWsl adds "win" to that set. Not because Windows cannot build it, but
# because a locked-down Windows machine's endpoint agent interferes with the
# unsigned .exe while the installer is being packed (see the header).
$onWindows = ($env:OS -eq "Windows_NT")
$wantWsl = $UseWsl -or $WinInWsl
$wslTargets = @($resolvedTargets | Where-Object { $_ -like "linux-*" -or ($WinInWsl -and $_ -like "win*") })
$useWslHere = $onWindows -and $wantWsl -and [bool]$wslTargets.Count
$linuxTargets = @($resolvedTargets | Where-Object { $_ -like "linux-*" })
if ($onWindows -and $linuxTargets.Count -and -not $wantWsl) {
Write-Warning "the Linux targets need a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows. Add -UseWsl to build them in a WSL distro (PACKAGING.md has the setup)."
}
if ($wantWsl -and -not $onWindows) {
Write-Warning "-UseWsl/-WinInWsl ignored: this is already a Linux host, so every target builds natively."
}
if ($wantWsl -and $onWindows -and -not $wslTargets.Count) {
Write-Warning "-UseWsl ignored: no target was selected for WSL (-Targets $($Targets -join ', ')). Add -WinInWsl to send the Windows targets there too."
}
if ($KeepInWsl -and -not $useWslHere) {
Write-Warning "-KeepInWsl ignored: nothing is being built in WSL."
}
$wslRepo = $null
$wslStage = $null
if ($useWslHere) {
$WslDistro = Get-WslDistro -Requested $WslDistro
$wslRepo = Get-WslPath -Distro $WslDistro -WindowsPath $repoRoot
$wslStage = Get-WslStageDir -Distro $WslDistro
Write-Host "Targets going to WSL: $($wslTargets -join ', ')" -ForegroundColor Cyan
Write-Host " distro : $WslDistro"
Write-Host " worktree: $wslRepo"
Write-Host " staging : $wslStage$(if ($KeepInWsl) { ' (left there — -KeepInWsl)' } else { ' (copied back into dist/)' })"
Assert-WslBuildEnv -Distro $WslDistro -WslTargets $wslTargets -Pkg $pkg
Write-Host ""
} }
if ($Clean) { if ($Clean) {
@@ -179,6 +553,15 @@ try {
throw 'electron-builder not found in node_modules — run "npm ci" (or drop -SkipDeps).' throw 'electron-builder not found in node_modules — run "npm ci" (or drop -SkipDeps).'
} }
# Clear this tag's previous bundles from the staging directory once, before the
# loop — not per target. Per target, the glob would delete the artifacts an
# earlier target had just staged, which only went unnoticed while every target
# copied its output back immediately.
if ($useWslHere) {
Invoke-Wsl -Distro $WslDistro -Command (
"mkdir -p $(ConvertTo-BashArg $wslStage) && rm -f $(ConvertTo-BashArg $wslStage)/$prefix-*")
}
foreach ($target in $resolvedTargets) { foreach ($target in $resolvedTargets) {
Write-Host "Packaging $target..." -ForegroundColor Cyan Write-Host "Packaging $target..." -ForegroundColor Cyan
@@ -193,6 +576,18 @@ try {
"-c.portable.artifactName=$(Get-ArtifactName "$prefix-win-x64-portable")" "-c.portable.artifactName=$(Get-ArtifactName "$prefix-win-x64-portable")"
) )
} }
"win-nsis" {
$builderArgs = @(
"--win", "nsis", "--publish", "never",
"-c.nsis.artifactName=$(Get-ArtifactName "$prefix-win-x64-setup")"
)
}
"win-portable" {
$builderArgs = @(
"--win", "portable", "--publish", "never",
"-c.portable.artifactName=$(Get-ArtifactName "$prefix-win-x64-portable")"
)
}
"linux-appimage" { "linux-appimage" {
$builderArgs = @( $builderArgs = @(
"--linux", "AppImage", "--publish", "never", "--linux", "AppImage", "--publish", "never",
@@ -207,7 +602,49 @@ try {
} }
} }
# Two overrides that only apply to a Windows target built on Linux:
#
# signExecutable=false electron-builder still walks the signing path with no
# certificate configured, and on Linux that path shells
# out to signtool.exe under Wine before it discovers
# there is nothing to sign — `spawn wine ENOENT`, build
# over. false skips signing while still applying the
# icon and version strings (signAndEditExecutable=false
# would drop those too, which is not what is wanted).
# toolsets.wine is deliberately NOT set: leaving it unset is what makes
# electron-builder use the distro's own `wine` on Linux, and the 1.0.1 bundle
# it would otherwise download is unusable there (see the header).
#
# Passed here rather than put in package.json so a native Windows build keeps
# behaving exactly as it did.
if ($useWslHere -and $wslTargets -contains $target -and $target -like "win*") {
$builderArgs += "-c.win.signExecutable=false"
}
if ($useWslHere -and $wslTargets -contains $target) {
# `node cli.js` rather than node_modules/.bin/electron-builder: the
# extensionless shim npm writes on Windows is a sh script, and whether
# it is executable across the mount depends on the drvfs options.
#
# The output goes to the distro's filesystem (see Get-WslStageDir) and
# the bundles are copied back afterwards. `cp -f`, never `cp -p`:
# preserving modes means chmod, which is the EPERM this avoids.
#
# With -KeepInWsl there is no copy back at all: the point is that no
# unsigned .exe is ever written to NTFS, and a `cp` into dist/ is exactly
# the write the endpoint agent would quarantine.
$quotedArgs = @($builderArgs | ForEach-Object { ConvertTo-BashArg $_ }) -join " "
$stage = ConvertTo-BashArg $wslStage
$wslCommand = "cd $(ConvertTo-BashArg $wslRepo)" +
" && USE_HARD_LINKS=false node node_modules/electron-builder/cli.js $quotedArgs $(ConvertTo-BashArg "-c.directories.output=$wslStage")"
if (-not $KeepInWsl) {
$wslCommand += " && cp -f $stage/$prefix-* $(ConvertTo-BashArg "$wslRepo/dist")/"
}
Invoke-Wsl -Distro $WslDistro -Command $wslCommand
}
else {
Invoke-Checked $builder $builderArgs Invoke-Checked $builder $builderArgs
}
Write-Host "" Write-Host ""
} }
@@ -220,16 +657,80 @@ try {
} | Remove-Item -Force } | Remove-Item -Force
$built = @(Get-ChildItem $distDir -Filter "$prefix-*" -File | Sort-Object Name) $built = @(Get-ChildItem $distDir -Filter "$prefix-*" -File | Sort-Object Name)
if (-not $built.Count) {
throw "electron-builder reported success but no $prefix-* artifact landed in $distDir." # --- Artifacts left in the distro ------------------------------------------
# With -KeepInWsl the bundles never crossed the mount, so their names and hashes
# have to come from the distro. The same intermediates are cleared there first:
# `<installer>.exe.blockmap` matches the $prefix-* glob too, and would otherwise
# be listed as a release artifact.
$manifestPath = Join-Path $distDir "wsl-artifacts.json"
$stagedLines = @()
if ($useWslHere -and $KeepInWsl) {
$listScript = @'
set -u
cd "$1" || { echo "staging directory $1 is gone" >&2; exit 1; }
rm -f ./*.blockmap ./*.nsis.7z ./latest*.yml ./builder-debug.yml
shopt -s nullglob
files=("$2"-*)
if [ ${#files[@]} -gt 0 ]; then sha256sum "${files[@]}"; fi
'@
$stagedLines = @(Invoke-WslCapture -Distro $WslDistro -Command $listScript `
-ScriptArgs @($wslStage, $prefix) | Where-Object { $_ -match '^[0-9a-f]{64}\s\s\S' })
}
$stagedNames = @($stagedLines | ForEach-Object { ($_ -split '\s\s', 2)[1] } | Sort-Object)
# A name built in WSL this run wins over a same-named file sitting in dist/ from
# an earlier native build. That leftover is stale by definition, and it is also
# the likeliest file on the machine to be locked or quarantined — which is the
# whole reason for building in the distro.
if ($stagedNames.Count) {
$shadowed = @($built | Where-Object { $stagedNames -contains $_.Name })
if ($shadowed.Count) {
Write-Warning "ignoring $($shadowed.Count) stale file(s) in dist/ superseded by this run's WSL build: $(($shadowed | ForEach-Object Name) -join ', '). Delete them (or pass -Clean) to keep dist/ honest."
$built = @($built | Where-Object { $stagedNames -notcontains $_.Name })
}
}
if (-not $built.Count -and -not $stagedNames.Count) {
throw "electron-builder reported success but no $prefix-* artifact landed in $(if ($KeepInWsl) { "$distDir or $wslStage" } else { $distDir })."
}
# publish.ps1 globs dist/ for what to upload, so the files that stayed behind
# need an explicit hand-off. A stale manifest from a previous -KeepInWsl run
# would make it upload artifacts this build did not produce, so it is removed
# whenever this run left nothing in the distro.
if ($stagedNames.Count) {
$manifest = [ordered]@{
tag = $Tag
distro = $WslDistro
stageDir = $wslStage
files = @($stagedNames)
}
[System.IO.File]::WriteAllText($manifestPath,
($manifest | ConvertTo-Json -Depth 3), [System.Text.UTF8Encoding]::new($false))
}
elseif (Test-Path $manifestPath) {
Remove-Item -Force $manifestPath
} }
# --- Checksums ------------------------------------------------------------ # --- Checksums ------------------------------------------------------------
Write-Host "Writing checksums..." -ForegroundColor Cyan Write-Host "Writing checksums..." -ForegroundColor Cyan
$sumsPath = Join-Path $distDir "SHA256SUMS.txt" $sumsPath = Join-Path $distDir "SHA256SUMS.txt"
$lines = foreach ($f in $built) { # sha256sum's own output format is already `<hash> <name>`, so the lines from the
# distro go in verbatim and the two sets sort together by file name.
$lines = @(
@(foreach ($f in $built) {
try {
"$((Get-FileHash -Algorithm SHA256 $f.FullName).Hash.ToLower()) $($f.Name)" "$((Get-FileHash -Algorithm SHA256 $f.FullName).Hash.ToLower()) $($f.Name)"
} }
catch {
# An artifact in dist/ that cannot even be read is the endpoint agent
# again, and hashing is not the step to paper over it: publish.ps1
# would try to upload the same unreadable file next.
throw "cannot read $($f.FullName) to hash it ($($_.Exception.Message.Trim())). On a machine whose security agent locks unsigned executables, build with -WinInWsl -KeepInWsl and delete the leftovers in dist/ (or pass -Clean)."
}
}) + $stagedLines
) | Sort-Object { ($_ -split '\s\s', 2)[1] }
# ASCII with LF: a BOM or CRLF makes `sha256sum -c` reject the first line. # ASCII with LF: a BOM or CRLF makes `sha256sum -c` reject the first line.
[System.IO.File]::WriteAllText($sumsPath, ($lines -join "`n") + "`n", [System.Text.ASCIIEncoding]::new()) [System.IO.File]::WriteAllText($sumsPath, ($lines -join "`n") + "`n", [System.Text.ASCIIEncoding]::new())
Write-Host "" Write-Host ""
@@ -238,6 +739,14 @@ try {
foreach ($f in $built) { foreach ($f in $built) {
Write-Host " $($f.FullName) ($([math]::Round($f.Length / 1MB, 1)) MB)" -ForegroundColor Green Write-Host " $($f.FullName) ($([math]::Round($f.Length / 1MB, 1)) MB)" -ForegroundColor Green
} }
if ($stagedNames.Count) {
Write-Host " in WSL ($WslDistro), not copied to NTFS:" -ForegroundColor Green
foreach ($n in $stagedNames) {
Write-Host " $wslStage/$n" -ForegroundColor Green
}
Write-Host " reachable from Windows as \\wsl.localhost\$WslDistro$(($wslStage -replace '/', '\'))\" -ForegroundColor DarkGray
Write-Host " publish.ps1 uploads these from inside the distro (dist/wsl-artifacts.json)" -ForegroundColor DarkGray
}
Write-Host " $sumsPath" -ForegroundColor Green Write-Host " $sumsPath" -ForegroundColor Green
} }
finally { finally {
+405 -35
View File
@@ -13,11 +13,24 @@
registry, so -PublishRelease attaches them to the Gitea release for that tag registry, so -PublishRelease attaches them to the Gitea release for that tag
instead (creating the release if it does not exist). instead (creating the release if it does not exist).
An existing release is added to, not recreated: artifacts it does not have yet
are appended, and ones it already carries under the same name are replaced by the
freshly built file. That makes re-running a release after a rebuild safe, and
keeps the attachments in agreement with the SHA256SUMS.txt uploaded beside them.
-NoReplace turns a name collision back into an error.
-BinariesOnly ships just the installers: no docker build, no docker login, no -BinariesOnly ships just the installers: no docker build, no docker login, no
image push, and the release upload is implied. It takes the target list to image push, and the release upload is implied. It takes the target list to
build (win, linux-appimage, linux-flatpak — comma-separated) and that list build (win, linux-appimage, linux-flatpak — comma-separated) and that list
overrides -Targets. The Linux targets need a Linux host or WSL, which is where overrides -Targets. The Linux targets need a Linux host or WSL: on Windows,
-BinariesOnly linux-appimage,linux-flatpak belongs. add -UseWsl and build-release.ps1 hands them to a WSL distro.
-WinInWsl and -KeepInWsl are forwarded to build-release.ps1 and together keep
the unsigned .exe off NTFS entirely: it is built in the distro and, because
-KeepInWsl skips the copy back, it is still there at upload time. This script
then reads dist/wsl-artifacts.json and runs the curl upload *inside* the distro
for those files, so the endpoint agent never sees a write it can quarantine.
The token reaches the distro through WSLENV, not through the command line.
Credentials are read, in order of precedence: Credentials are read, in order of precedence:
1. -Username / -Password parameters 1. -Username / -Password parameters
@@ -45,6 +58,16 @@
./scripts/publish.ps1 -BinariesOnly win,linux-appimage -Tag v1.1.0 ./scripts/publish.ps1 -BinariesOnly win,linux-appimage -Tag v1.1.0
Windows installers plus the Linux AppImage (no Flatpak), attached to v1.1.0. Windows installers plus the Linux AppImage (no Flatpak), attached to v1.1.0.
.EXAMPLE
./scripts/publish.ps1 -Tag v1.1.0 -PublishRelease -UseWsl
Full release from Windows: image push, .exe installers built natively, AppImage
and Flatpak built in WSL, everything attached to release v1.1.0.
.EXAMPLE
./scripts/publish.ps1 -Tag v1.1.0 -PublishRelease -WinInWsl -KeepInWsl
Full release with every installer built in WSL and uploaded from there — no
unsigned binary is ever written to a Windows filesystem.
.EXAMPLE .EXAMPLE
./scripts/publish.ps1 -BinariesOnly win -NoBinaryBuild -Tag v1.1.0 ./scripts/publish.ps1 -BinariesOnly win -NoBinaryBuild -Tag v1.1.0
Retry a failed upload: attach the installers already in dist/ without rebuilding Retry a failed upload: attach the installers already in dist/ without rebuilding
@@ -100,11 +123,24 @@ param(
# Skip building the desktop installers. # Skip building the desktop installers.
[switch]$NoBinaries, [switch]$NoBinaries,
# Forwarded to build-release.ps1. "linux" is shorthand for both Linux bundles. # Forwarded to build-release.ps1. "linux" is shorthand for both Linux bundles;
[ValidateSet("win", "linux", "linux-appimage", "linux-flatpak")] # "win" is NSIS + portable, and win-nsis / win-portable are those two on their
# own (only the NSIS one needs Wine when built in WSL).
[ValidateSet("win", "win-nsis", "win-portable", "linux", "linux-appimage", "linux-flatpak")]
[string[]]$Targets = @("win", "linux"), [string[]]$Targets = @("win", "linux"),
[switch]$SkipVendor, [switch]$SkipVendor,
# Forwarded to build-release.ps1: build the Linux targets in a WSL distro
# instead of warning that Windows cannot produce them.
[switch]$UseWsl,
[string]$WslDistro,
# Forwarded to build-release.ps1: build the Windows targets in WSL too, and
# leave what WSL built inside the distro. With -KeepInWsl the upload below runs
# in the distro instead of on Windows, so the .exe never reaches NTFS.
[switch]$WinInWsl,
[switch]$KeepInWsl,
# Reuse the installers already in dist/ instead of re-running the build. For # Reuse the installers already in dist/ instead of re-running the build. For
# retrying a failed upload without paying for the build again. # retrying a failed upload without paying for the build again.
[switch]$NoBinaryBuild, [switch]$NoBinaryBuild,
@@ -113,7 +149,7 @@ param(
# -PublishRelease, since building alone is what build-release.ps1 already does. # -PublishRelease, since building alone is what build-release.ps1 already does.
# Takes the target list to build (comma-separated), which overrides -Targets: # Takes the target list to build (comma-separated), which overrides -Targets:
# -BinariesOnly win,linux-appimage # -BinariesOnly win,linux-appimage
[ValidateSet("win", "linux-appimage", "linux-flatpak")] [ValidateSet("win", "win-nsis", "win-portable", "linux-appimage", "linux-flatpak")]
[string[]]$BinariesOnly, [string[]]$BinariesOnly,
# Attach the installers to the Gitea release for $Tag, creating the release if # Attach the installers to the Gitea release for $Tag, creating the release if
@@ -126,7 +162,14 @@ param(
# Gitea base URL for the API. Defaults to https://<Registry>. # Gitea base URL for the API. Defaults to https://<Registry>.
[string]$ApiBase, [string]$ApiBase,
# Replace release attachments that already exist under the same name. # Fail instead of replacing an attachment that already exists under the same
# name. The default is to replace, because re-running a release for the same tag
# after a rebuild is the normal case and the new file is the one that matches
# SHA256SUMS.txt.
[switch]$NoReplace,
# Deprecated: replacing is now the default, so this does nothing. Kept so
# existing commands and scripts do not start failing on an unknown parameter.
[switch]$Force [switch]$Force
) )
@@ -239,20 +282,248 @@ function Send-ReleaseAsset {
} }
} }
function ConvertTo-BashScript {
<#
Strip CR. This file is stored with CRLF line endings, so a multi-line
here-string handed to bash arrives with a \r on every line and bash reads it
as part of the last token — `set: - : invalid option`, and paths that end in
a literal \r. Single-line commands never show it.
#>
param([Parameter(Mandatory)][AllowEmptyString()][string]$Script)
return $Script -replace "`r", ""
}
<#
And the companion trap: `set -e` plus an explicit `exit 0` under `bash -lc` yields
1, because a login shell sources ~/.bash_logout on exit and Ubuntu's ends in a
`[ -x /usr/bin/clear_console ] && ...` that fails with no tty, which errexit then
promotes to the shell's status. -l has to stay (node from nvm/fnm lives on the
login PATH), so the scripts below set only `-u` and check what matters explicitly.
#>
function Get-WslArtifactManifest {
<#
build-release.ps1 -KeepInWsl writes dist/wsl-artifacts.json for the artifacts
it deliberately did not copy onto NTFS. It removes the file whenever a build
leaves nothing behind, so its presence means "these files are in the distro";
the tag is still checked, because a -NoBinaryBuild run for a different tag
would otherwise upload the previous release's binaries under the new one.
#>
param([Parameter(Mandatory)][string]$DistDir, [Parameter(Mandatory)][string]$Tag)
$path = Join-Path $DistDir "wsl-artifacts.json"
if (-not (Test-Path $path)) { return $null }
$manifest = Get-Content $path -Raw | ConvertFrom-Json
if (-not $manifest.files -or -not @($manifest.files).Count) { return $null }
if ($manifest.tag -ne $Tag) {
throw "$path was written for tag '$($manifest.tag)', not '$Tag' — those artifacts belong to another release. Rebuild, or delete the file if it is stale."
}
if (-not $manifest.distro -or -not $manifest.stageDir) {
throw "$path is missing the distro or stageDir field — delete it and rebuild."
}
return $manifest
}
function Invoke-WithWslEnv {
<#
Run a script block with $Name exported into WSL through WSLENV.
WSLENV is the only way to hand a value to a WSL process without putting it in
an argument list, and an argument list is exactly where a token must not be:
wsl.exe's own command line is readable from the Windows process table. The
previous WSLENV is restored rather than overwritten, because a distro may
rely on entries somebody else put there (PATH translation flags in
particular are positional and easy to break).
#>
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Value,
[Parameter(Mandatory)][scriptblock]$Body
)
$previousValue = [Environment]::GetEnvironmentVariable($Name, "Process")
$previousWslEnv = $env:WSLENV
[Environment]::SetEnvironmentVariable($Name, $Value, "Process")
$env:WSLENV = if ($previousWslEnv) { "$previousWslEnv`:$Name" } else { $Name }
try {
& $Body
}
finally {
[Environment]::SetEnvironmentVariable($Name, $previousValue, "Process")
if ($null -eq $previousWslEnv) {
Remove-Item Env:\WSLENV -ErrorAction SilentlyContinue
}
else {
$env:WSLENV = $previousWslEnv
}
}
}
function Test-WslArtifacts {
<#
Every file the manifest names must still be in the staging directory. Without
this the first missing one surfaces as a curl error about an unreadable
upload part, halfway through a release.
#>
param(
[Parameter(Mandatory)][string]$Distro,
[Parameter(Mandatory)][string]$StageDir,
[Parameter(Mandatory)][string[]]$Names
)
$script = @'
set -u
cd "$1" || { echo "staging directory $1 is gone" >&2; exit 1; }
shift
missing=0
for f in "$@"; do
[ -f "$f" ] || { echo "$f" >&2; missing=1; }
done
exit $missing
'@
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $script) "motionity-publish" $StageDir @Names 2>&1 |
ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
if ($LASTEXITCODE -ne 0) {
throw "artifacts named in dist/wsl-artifacts.json are missing from ${StageDir} in '$Distro' (listed above) — rebuild with -WinInWsl -KeepInWsl, or drop -NoBinaryBuild."
}
}
function Get-WslArtifactMtime {
<#
Oldest mtime among the staged artifacts, as a local DateTime, so the
-NoBinaryBuild staleness check works on WSL-resident files too.
#>
param(
[Parameter(Mandatory)][string]$Distro,
[Parameter(Mandatory)][string]$StageDir,
[Parameter(Mandatory)][string[]]$Names
)
$script = @'
set -u
cd "$1" || exit 1
shift
stat -c %Y -- "$@" | sort -n | head -n 1
'@
$out = (& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $script) "motionity-publish" $StageDir @Names)
$epoch = (@($out) -join "").Replace("`0", "").Trim()
if ($LASTEXITCODE -ne 0 -or $epoch -notmatch '^\d+$') { return $null }
return [System.DateTimeOffset]::FromUnixTimeSeconds([int64]$epoch).LocalDateTime
}
function Send-ReleaseAssetFromWsl {
<#
Upload one staged file as a release attachment, with curl running inside the
distro. Same Gitea endpoint and the same --config indirection for the token as
Send-ReleaseAsset; the only reason for a second implementation is that the
file must not be copied to NTFS to be read.
The config file is written by bash from $GITEA_UPLOAD_TOKEN (arriving via
WSLENV) rather than interpolated into the command string, so the token is in
neither wsl.exe's arguments nor the distro's process table. mktemp creates it
0600, and the trap removes it even if curl dies.
#>
param(
[Parameter(Mandatory)][string]$Distro,
[Parameter(Mandatory)][string]$StageDir,
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Uri
)
# fail-with-body needs curl 7.76+ (Ubuntu 22.04 ships 7.81); the Windows path
# above already assumes it, so the two behave the same on an HTTP error.
$script = @'
set -u
command -v curl >/dev/null 2>&1 || { echo "curl is not installed in this WSL distro: sudo apt install -y curl" >&2; exit 127; }
[ -n "${GITEA_UPLOAD_TOKEN:-}" ] || { echo "GITEA_UPLOAD_TOKEN did not reach the distro — is WSLENV being overwritten?" >&2; exit 2; }
cfg=$(mktemp) || { echo "could not create a temp file for the curl config" >&2; exit 1; }
trap 'rm -f "$cfg"' EXIT
printf 'header = "Authorization: token %s"\nsilent\nshow-error\nfail-with-body\n' "$GITEA_UPLOAD_TOKEN" > "$cfg" || exit 1
cd "$1" || exit 1
# Last command on purpose: curl's status is the script's status.
curl --config "$cfg" --write-out ' http %{http_code}, %{size_upload} bytes uploaded\n' -F "attachment=@$2" "$3"
'@
Write-Host " > [$Distro] curl --config <temp> -F attachment=@$Name `"$Uri`"" -ForegroundColor DarkGray
& wsl.exe -d $Distro -e bash -lc (ConvertTo-BashScript $script) "motionity-publish" $StageDir $Name $Uri
if ($LASTEXITCODE -ne 0) {
throw "upload of '$StageDir/$Name' from '$Distro' failed (exit $LASTEXITCODE)."
}
}
function Get-ReleaseBody {
<#
The markdown shown on the Gitea release page: how to run each artifact.
A literal here-string, because an expandable one treats ``` as backtick
escapes and the third one would swallow the newline as a line continuation.
The two placeholders are substituted afterwards instead, and the content sits
at column 0 because four leading spaces would make markdown read it as code.
#>
param([Parameter(Mandatory)][string]$Tag)
$body = @'
## Linux
### AppImage
Make it executable: `chmod +x motionity-%TAG%-linux-x86_64.AppImage`, then launch it (needs `libfuse2` installed).
### Flatpak
```
flatpak install ./motionity-%TAG%-linux-x86_64.flatpak
```
## Windows
Launch the installer or the portable version directly. A SmartScreen warning may appear, as the binary is not signed.
## Docker
Recommended: `docker-compose.yml`
```yaml
services:
motionity:
image: %IMAGE%:%TAG%
ports:
- 8080:8080
restart: unless-stopped
```
'@
return $body.Replace("%IMAGE%", "$Registry/$Owner/$Image").Replace("%TAG%", $Tag)
}
function Publish-BinaryRelease { function Publish-BinaryRelease {
<# <#
Attach the installers to the release for $Tag, creating that release if it Attach the installers to the release for $Tag, creating that release if it
does not exist yet. Re-uploading the same file name is a delete + upload, does not exist yet. An existing release is added to, never recreated.
which needs -Force: overwriting an asset someone may already have linked is
not something to do silently. A file name the release already carries is replaced: Gitea does not treat
attachment names as unique, so uploading over one without removing it first
leaves two assets with the same name and no way for anyone to tell which is
which. Replacing is the default because the alternative is a release whose
binaries disagree with its own SHA256SUMS.txt after a rebuild; -NoReplace
restores the strict behaviour.
Delete-then-upload, in that order, for the same reason — which does mean a
failed upload leaves the old asset gone. Recover with -NoBinaryBuild, which
re-attaches from dist/ (or from the distro) without rebuilding.
#> #>
param( param(
[Parameter(Mandatory)][string]$ApiRoot, [Parameter(Mandatory)][string]$ApiRoot,
[Parameter(Mandatory)][string]$RepoPath, [Parameter(Mandatory)][string]$RepoPath,
[Parameter(Mandatory)][string]$Tag, [Parameter(Mandatory)][string]$Tag,
[Parameter(Mandatory)][string]$Token, [Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][string[]]$Artifacts, # Windows-side files, uploaded by curl.exe.
[switch]$Force [string[]]$Artifacts = @(),
# Files still in a WSL staging directory, uploaded by curl inside the distro.
[string[]]$WslArtifacts = @(),
[string]$WslDistro,
[string]$WslStageDir,
[switch]$NoReplace
) )
$releasesUri = "$ApiRoot/repos/$RepoPath/releases" $releasesUri = "$ApiRoot/repos/$RepoPath/releases"
@@ -263,7 +534,7 @@ function Publish-BinaryRelease {
$release = Invoke-GiteaApi -Method POST -Uri $releasesUri -Token $Token -Body @{ $release = Invoke-GiteaApi -Method POST -Uri $releasesUri -Token $Token -Body @{
tag_name = $Tag tag_name = $Tag
name = "Motionity $Tag" name = "Motionity $Tag"
body = "Desktop installers — Windows NSIS + portable, Linux AppImage + Flatpak — with SHA256SUMS.txt. Container image: ${Registry}/${Owner}/${Image}:$Tag" body = (Get-ReleaseBody -Tag $Tag)
draft = $false draft = $false
} }
if (-not $release) { throw "could not create release $Tag in $RepoPath (does the repo exist?)." } if (-not $release) { throw "could not create release $Tag in $RepoPath (does the repo exist?)." }
@@ -272,20 +543,39 @@ function Publish-BinaryRelease {
Write-Host " reusing release $Tag (id $($release.id))" -ForegroundColor DarkGray Write-Host " reusing release $Tag (id $($release.id))" -ForegroundColor DarkGray
} }
foreach ($path in $Artifacts) { # One list so the asset-already-exists handling is written once: only the final
$name = Split-Path -Leaf $path # transfer differs between a file on NTFS and one left in the distro.
$existing = $release.assets | Where-Object { $_.name -eq $name } $uploads = @()
if ($existing) { foreach ($path in $Artifacts) { $uploads += @{ Name = (Split-Path -Leaf $path); Path = $path; InWsl = $false } }
if (-not $Force) { foreach ($name in $WslArtifacts) { $uploads += @{ Name = $name; Path = $null; InWsl = $true } }
throw "release $Tag already has an attachment named '$name' — pass -Force to replace it."
foreach ($upload in $uploads) {
$name = $upload.Name
# @() because a release can already hold several assets under one name — an
# earlier run that uploaded without deleting, or a partial retry. Unwrapped,
# $existing.id would be an array and the DELETE would go to a malformed URL.
$existing = @($release.assets | Where-Object { $_.name -eq $name })
if ($existing.Count) {
if ($NoReplace) {
throw "release $Tag already has an attachment named '$name', and -NoReplace was passed. Drop it to replace the file, or upload under a different tag."
} }
Write-Host " replacing existing attachment '$name'..." -ForegroundColor DarkGray foreach ($asset in $existing) {
Write-Host " replacing attachment '$name' (asset $($asset.id))..." -ForegroundColor DarkGray
Invoke-GiteaApi -Method DELETE -Token $Token ` Invoke-GiteaApi -Method DELETE -Token $Token `
-Uri "$releasesUri/$($release.id)/assets/$($existing.id)" | Out-Null -Uri "$releasesUri/$($release.id)/assets/$($asset.id)" | Out-Null
}
}
else {
Write-Host " adding attachment '$name'..." -ForegroundColor DarkGray
} }
$encoded = [System.Uri]::EscapeDataString($name) $encoded = [System.Uri]::EscapeDataString($name)
Send-ReleaseAsset -Token $Token -Path $path ` $assetUri = "$releasesUri/$($release.id)/assets?name=$encoded"
-Uri "$releasesUri/$($release.id)/assets?name=$encoded" if ($upload.InWsl) {
Send-ReleaseAssetFromWsl -Distro $WslDistro -StageDir $WslStageDir -Name $name -Uri $assetUri
}
else {
Send-ReleaseAsset -Token $Token -Path $upload.Path -Uri $assetUri
}
} }
return "$ApiRoot/repos/$RepoPath/releases/tags/$Tag" return "$ApiRoot/repos/$RepoPath/releases/tags/$Tag"
@@ -305,6 +595,12 @@ try {
if ($NoBinaryBuild -and $NoBinaries) { if ($NoBinaryBuild -and $NoBinaries) {
throw "-NoBinaryBuild reuses the build that -NoBinaries skips entirely — pick one." throw "-NoBinaryBuild reuses the build that -NoBinaries skips entirely — pick one."
} }
if ($Force) {
Write-Warning "-Force is deprecated and ignored: replacing an attachment that already exists is now the default. -NoReplace is the opt-out."
}
if ($Force -and $NoReplace) {
throw "-Force and -NoReplace ask for opposite things — drop -Force, it is already the default."
}
if ($binariesOnlyMode) { if ($binariesOnlyMode) {
# Nothing to build, log into or push on the container side, and uploading # Nothing to build, log into or push on the container side, and uploading
# is the whole point (build-release.ps1 alone covers "just build them"). # is the whole point (build-release.ps1 alone covers "just build them").
@@ -350,7 +646,7 @@ try {
else { else {
Write-Host " image : skipped (-BinariesOnly)" Write-Host " image : skipped (-BinariesOnly)"
} }
Write-Host " binaries : $(if ($NoBinaries) { 'skipped' } elseif ($NoBinaryBuild) { 'dist/ (reused, not rebuilt)' } else { $Targets -join ', ' })" Write-Host " binaries : $(if ($NoBinaries) { 'skipped' } elseif ($NoBinaryBuild) { 'dist/ (reused, not rebuilt)' } else { $Targets -join ', ' })$(if ($WinInWsl) { ' (all in WSL)' } elseif ($UseWsl) { ' (Linux ones in WSL)' })$(if ($KeepInWsl) { ', uploaded from the distro' })"
Write-Host " release : $(if ($PublishRelease) { "$ReleaseRepo @ $Tag" } else { 'not uploaded' })" Write-Host " release : $(if ($PublishRelease) { "$ReleaseRepo @ $Tag" } else { 'not uploaded' })"
Write-Host "" Write-Host ""
@@ -377,6 +673,9 @@ try {
# Built before the push so a failing build doesn't leave a pushed image with # Built before the push so a failing build doesn't leave a pushed image with
# no matching installers for the same tag. # no matching installers for the same tag.
$artifacts = @() $artifacts = @()
$wslArtifacts = @()
$wslUploadDistro = $null
$wslStageDir = $null
if (-not $NoBinaries) { if (-not $NoBinaries) {
$distDir = Join-Path $repoRoot "dist" $distDir = Join-Path $repoRoot "dist"
@@ -387,12 +686,28 @@ try {
} }
# Uploading an installer older than the code it claims to be is the one # Uploading an installer older than the code it claims to be is the one
# way this flag can quietly go wrong, so say so rather than assume. # way this flag can quietly go wrong, so say so rather than assume. The
# artifacts a -KeepInWsl build left in the distro count as present here:
# dist/ can legitimately hold nothing but SHA256SUMS.txt.
$manifest = Get-WslArtifactManifest -DistDir $distDir -Tag $Tag
$oldest = (Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File | $oldest = (Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File |
Sort-Object LastWriteTime | Select-Object -First 1) Sort-Object LastWriteTime | Select-Object -First 1)
if (-not $oldest) { $oldestName = if ($oldest) { $oldest.Name } else { $null }
throw "no installers matching motionity-$Tag-* in $distDir — what is on disk was built under a different tag. Drop -NoBinaryBuild." $oldestTime = if ($oldest) { $oldest.LastWriteTime } else { $null }
if ($manifest) {
Write-Host " $(@($manifest.files).Count) artifact(s) staged in '$($manifest.distro)':$($manifest.stageDir)" -ForegroundColor DarkGray
Test-WslArtifacts -Distro $manifest.distro -StageDir $manifest.stageDir -Names @($manifest.files)
$wslTime = Get-WslArtifactMtime -Distro $manifest.distro -StageDir $manifest.stageDir -Names @($manifest.files)
if ($wslTime -and (-not $oldestTime -or $wslTime -lt $oldestTime)) {
$oldestTime = $wslTime
$oldestName = @($manifest.files)[0]
} }
}
if (-not $oldestTime) {
throw "no installers matching motionity-$Tag-* in $distDir and no dist/wsl-artifacts.json for $Tag — what is on disk was built under a different tag. Drop -NoBinaryBuild."
}
# src/ is the app: every extension the packaged tree actually serves, # src/ is the app: every extension the packaged tree actually serves,
# plus the packaging scripts themselves. # plus the packaging scripts themselves.
$newer = Get-ChildItem $repoRoot -Recurse -Include *.js, *.cjs, *.mjs, *.html, *.css, *.json -File | $newer = Get-ChildItem $repoRoot -Recurse -Include *.js, *.cjs, *.mjs, *.html, *.css, *.json -File |
@@ -400,10 +715,10 @@ try {
$_.FullName -notlike "$distDir*" -and $_.FullName -notlike "$distDir*" -and
$_.FullName -notlike "*\node_modules\*" -and $_.FullName -notlike "*\node_modules\*" -and
$_.FullName -notlike "*/node_modules/*" -and $_.FullName -notlike "*/node_modules/*" -and
$_.LastWriteTime -gt $oldest.LastWriteTime $_.LastWriteTime -gt $oldestTime
} }
if ($newer) { if ($newer) {
Write-Warning "$($oldest.Name) predates $($newer.Count) source file(s) — the installers may not contain your latest changes (newest: $(($newer | Sort-Object LastWriteTime -Descending)[0].Name))." Write-Warning "$oldestName predates $($newer.Count) source file(s) — the installers may not contain your latest changes (newest: $(($newer | Sort-Object LastWriteTime -Descending)[0].Name))."
} }
} }
else { else {
@@ -413,11 +728,42 @@ try {
# `& script.ps1` leaves $LASTEXITCODE untouched, and with -NoBuild no # `& script.ps1` leaves $LASTEXITCODE untouched, and with -NoBuild no
# docker command has reset it, so checking it would rethrow whatever the # docker command has reset it, so checking it would rethrow whatever the
# caller's shell last failed at. # caller's shell last failed at.
& (Join-Path $PSScriptRoot "build-release.ps1") -Tag $Tag -Targets $Targets -SkipVendor:$SkipVendor # -WslDistro is only passed when set: build-release.ps1 treats an empty
# string as "not requested" either way, but splatting nothing keeps the
# -WhatIf/-Verbose trace readable.
$buildParams = @{
Tag = $Tag
Targets = $Targets
SkipVendor = $SkipVendor
UseWsl = $UseWsl
WinInWsl = $WinInWsl
KeepInWsl = $KeepInWsl
}
if ($WslDistro) { $buildParams["WslDistro"] = $WslDistro }
& (Join-Path $PSScriptRoot "build-release.ps1") @buildParams
$manifest = Get-WslArtifactManifest -DistDir $distDir -Tag $Tag
if ($manifest) {
Test-WslArtifacts -Distro $manifest.distro -StageDir $manifest.stageDir -Names @($manifest.files)
}
} }
$artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File | ForEach-Object FullName) if ($manifest) {
if (-not $artifacts.Count) { throw "no installers for $Tag found in $distDir." } $wslArtifacts = @($manifest.files)
$wslUploadDistro = $manifest.distro
$wslStageDir = $manifest.stageDir
}
# A name that exists both in dist/ and in the distro is the WSL build's, and
# the dist/ copy is a leftover from an earlier native build — uploading both
# would collide on the release's asset names anyway.
$artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File |
Where-Object { $wslArtifacts -notcontains $_.Name } | ForEach-Object FullName)
if (-not $artifacts.Count -and -not $wslArtifacts.Count) {
throw "no installers for $Tag found in $distDir."
}
# SHA256SUMS.txt covers both sets and is written on the Windows side either
# way — it is text, so nothing objects to it landing in dist/.
$sums = Join-Path $distDir "SHA256SUMS.txt" $sums = Join-Path $distDir "SHA256SUMS.txt"
if (Test-Path $sums) { $artifacts += $sums } if (Test-Path $sums) { $artifacts += $sums }
Write-Host "" Write-Host ""
@@ -447,13 +793,34 @@ try {
# --- Release attachments -------------------------------------------------- # --- Release attachments --------------------------------------------------
$releaseUrl = $null $releaseUrl = $null
if ($PublishRelease) { if ($PublishRelease) {
if (-not $artifacts.Count) { if (-not $artifacts.Count -and -not $wslArtifacts.Count) {
throw "-PublishRelease has nothing to upload (was -NoBinaries set?)." throw "-PublishRelease has nothing to upload (was -NoBinaries set?)."
} }
Write-Host "Uploading artifacts to release $Tag..." -ForegroundColor Cyan Write-Host "Uploading artifacts to release $Tag..." -ForegroundColor Cyan
$Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo" $Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo"
$releaseUrl = Publish-BinaryRelease -ApiRoot $apiRoot -RepoPath $ReleaseRepo -Tag $Tag `
-Token $Password -Artifacts $artifacts -Force:$Force $publishArgs = @{
ApiRoot = $apiRoot
RepoPath = $ReleaseRepo
Tag = $Tag
Token = $Password
Artifacts = $artifacts
NoReplace = $NoReplace
}
if ($wslArtifacts.Count) {
$publishArgs["WslArtifacts"] = $wslArtifacts
$publishArgs["WslDistro"] = $wslUploadDistro
$publishArgs["WslStageDir"] = $wslStageDir
# The token is exported for the whole upload rather than per file: WSLENV
# is process-wide state, and setting and restoring it around every
# attachment is more windows in which a concurrent wsl.exe sees it.
$releaseUrl = Invoke-WithWslEnv -Name "GITEA_UPLOAD_TOKEN" -Value $Password -Body {
Publish-BinaryRelease @publishArgs
}
}
else {
$releaseUrl = Publish-BinaryRelease @publishArgs
}
Write-Host "" Write-Host ""
} }
@@ -462,10 +829,13 @@ try {
Write-Host "Pushed:" -ForegroundColor Green Write-Host "Pushed:" -ForegroundColor Green
foreach ($t in $tags) { Write-Host " $t" -ForegroundColor Green } foreach ($t in $tags) { Write-Host " $t" -ForegroundColor Green }
} }
if ($artifacts.Count) { if ($artifacts.Count -or $wslArtifacts.Count) {
$where = if ($PublishRelease) { "attached to release $Tag" } else { "built locally — attach to a release manually" } $where = if ($PublishRelease) { "attached to release $Tag" } else { "built locally — attach to a release manually" }
Write-Host "Artifacts ($where):" -ForegroundColor Green Write-Host "Artifacts ($where):" -ForegroundColor Green
foreach ($a in $artifacts) { Write-Host " $a" -ForegroundColor Green } foreach ($a in $artifacts) { Write-Host " $a" -ForegroundColor Green }
foreach ($a in $wslArtifacts) {
Write-Host " [$wslUploadDistro] $wslStageDir/$a" -ForegroundColor Green
}
if ($releaseUrl) { Write-Host " $ApiBase/$ReleaseRepo/releases/tag/$Tag" -ForegroundColor Green } if ($releaseUrl) { Write-Host " $ApiBase/$ReleaseRepo/releases/tag/$Tag" -ForegroundColor Green }
} }
} }
+12 -7
View File
@@ -19,17 +19,17 @@
<!-- Open Graph / Facebook --> <!-- Open Graph / Facebook -->
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://www.motionity.app/"> <meta property="og:url" content="https://motionity.kawa.zip/">
<meta property="og:title" content="Motionity - The web-based motion graphics editor for everyone"> <meta property="og:title" content="Motionity - The web-based motion graphics editor for everyone">
<meta property="og:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. "> <meta property="og:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. ">
<meta property="og:image" content="https://motionity.app/meta.png"> <meta property="og:image" content="https://motionity.kawa.zip/meta.png">
<!-- Twitter --> <!-- Twitter -->
<meta property="twitter:card" content="summary_large_image"> <meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://www.motionity.app/"> <meta property="twitter:url" content="https://motionity.kawa.zip/">
<meta property="twitter:title" content="Motionity - The web-based motion graphics editor for everyone"> <meta property="twitter:title" content="Motionity - The web-based motion graphics editor for everyone">
<meta property="twitter:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. "> <meta property="twitter:description" content="Create animated videos for free with Motionity, an open source motion graphics editor with keyframing, masking, filters, text animations, and more. ">
<meta property="twitter:image" content="https://motionity.app/meta.png"> <meta property="twitter:image" content="https://motionity.kawa.zip/meta.png">
</head> </head>
<body draggable="false"> <body draggable="false">
<div id="disclaimer"> <div id="disclaimer">
@@ -37,7 +37,6 @@
<div id="emoji">🤔</div> <div id="emoji">🤔</div>
<div id="opt-title">Motionity isn't optimized for mobile</div> <div id="opt-title">Motionity isn't optimized for mobile</div>
<div id="opt-desc">You need to use a computer to be able to create animations with Motionity.</div> <div id="opt-desc">You need to use a computer to be able to create animations with Motionity.</div>
<a href="https://twitter.com/alyssaxuu" target="_blank" id="opt-button">Other products by the maker</a>
</div> </div>
<div id="disc-overlay"></div> <div id="disc-overlay"></div>
</div> </div>
@@ -105,8 +104,8 @@
</div> </div>
<div id="credits-modal"> <div id="credits-modal">
<p class="header">Credits</p> <p class="header">Credits</p>
<p class="subtitle">Created by <span>Alyssa X</span></p> <p class="subtitle">Created by <span><a href="https://github.com/alyssaxuu">Alyssa X</a></span></p>
<p class="subtitle">Maintained by <span>Kawa</span></p> <p class="subtitle">Current version maintained by <span><a href="https://github.com/KawaKode/Motionity">Kawa</a></span></p>
<hr style="margin-top: 15px; margin-bottom: 15px;"> <hr style="margin-top: 15px; margin-bottom: 15px;">
<p class="subheader">Libraries & Tools</p> <p class="subheader">Libraries & Tools</p>
<div id="credits-list"> <div id="credits-list">
@@ -312,6 +311,12 @@
<img src="assets/loading-video.svg" id="load-video" class="load-media"> <img src="assets/loading-video.svg" id="load-video" class="load-media">
<canvas id="canvas"></canvas> <canvas id="canvas"></canvas>
</div> </div>
<div id="browser-handle" class="panel-handle noselect" title="Drag to resize">
<div class="panel-toggle" id="browser-toggle"></div>
</div>
<div id="properties-handle" class="panel-handle noselect" title="Drag to resize">
<div class="panel-toggle" id="properties-toggle"></div>
</div>
<div id="timeline-handle"></div> <div id="timeline-handle"></div>
<div id="bottom-area" class="noselect"> <div id="bottom-area" class="noselect">
<div id="keyframe-properties"> <div id="keyframe-properties">
+13
View File
@@ -50,8 +50,11 @@ function checkDB() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'hasBorders',
'evented',
'subTargetCheck', 'subTargetCheck',
'id', 'id',
'hoverCursor', 'hoverCursor',
@@ -226,8 +229,11 @@ async function autoSave() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'hasBorders',
'evented',
'subTargetCheck', 'subTargetCheck',
'id', 'id',
'hoverCursor', 'hoverCursor',
@@ -346,6 +352,13 @@ function loadProject() {
}); });
replaceSource(canvas.getItemById(object.id), canvas); replaceSource(canvas.getItemById(object.id), canvas);
} else { } else {
// Projects saved before audio layers were flagged still carry
// controls / borders, which show up as a phantom box at 0,0.
canvas.getItemById(object.id).set({
hasControls: false,
hasBorders: false,
evented: false,
});
renderProp('volume', canvas.getItemById(object.id)); renderProp('volume', canvas.getItemById(object.id));
} }
}); });
+6
View File
@@ -100,6 +100,8 @@ $(document).ready(function () {
canvas.on('object:scaling', function (e) { canvas.on('object:scaling', function (e) {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
// Keep the corner radius at its pixel value while the handle is dragged
syncCornerRadius(e.target);
if (cropping) { if (cropping) {
updateCropBounds(); updateCropBounds();
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
@@ -135,12 +137,16 @@ $(document).ready(function () {
canvas.renderAll(); canvas.renderAll();
if (e.target.type == 'activeSelection') { if (e.target.type == 'activeSelection') {
const tempselection = canvas.getActiveObject(); const tempselection = canvas.getActiveObject();
// Discarding first bakes the group transform into the children, so
// their scale is final by the time the radius is re-derived
canvas.discardActiveObject(); canvas.discardActiveObject();
e.target._objects.forEach(function (object) { e.target._objects.forEach(function (object) {
syncCornerRadius(object);
autoKeyframe(object, e, true); autoKeyframe(object, e, true);
}); });
reselect(tempselection); reselect(tempselection);
} else { } else {
syncCornerRadius(e.target);
autoKeyframe(e.target, e, false); autoKeyframe(e.target, e, false);
} }
updatePanelValues(); updatePanelValues();
+353 -68
View File
@@ -495,6 +495,7 @@ async function updateRecordCanvas() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'subTargetCheck', 'subTargetCheck',
@@ -684,6 +685,7 @@ function save() {
'strokeUniform', 'strokeUniform',
'rx', 'rx',
'ry', 'ry',
'cornerRadius',
'selectable', 'selectable',
'hasControls', 'hasControls',
'subTargetCheck', 'subTargetCheck',
@@ -1050,13 +1052,57 @@ $(document).on('click', '#redo', function () {
} }
}); });
// Corner radius, in the canvas pixels the panel advertises.
//
// fabric applies rx/ry in the object's own coordinate space, before its scale,
// and shapes are resized by scaling rather than by changing width/height. The
// raw input was written straight to rx, so a radius typed as 20 drew at 60 on a
// rect scaled 3x: the value behaved like a proportion of the shape instead of
// pixels. `cornerRadius` stores what the user asked for and rx/ry are derived
// from it, so the same number means the same number of pixels at any size.
function getCornerRadius(object) {
if (!object) {
return 0;
}
if (typeof object.cornerRadius == 'number') {
return object.cornerRadius;
}
// Projects saved before cornerRadius existed only carry the raw rx
return (
(object.get('rx') || 0) * Math.abs(object.get('scaleX') || 1)
);
}
function setCornerRadius(object, radius) {
if (!object) {
return;
}
var px = radius > 0 ? radius : 0;
object.cornerRadius = px;
object.set({
rx: px / Math.abs(object.get('scaleX') || 1),
ry: px / Math.abs(object.get('scaleY') || 1),
});
}
// Re-derive rx/ry after a resize so the drawn radius keeps the typed value
function syncCornerRadius(object) {
if (!object || object.get('type') != 'rect') {
return;
}
if (typeof object.cornerRadius != 'number') {
return;
}
setCornerRadius(object, object.cornerRadius);
}
// Generate keyframes // Generate keyframes
function keyframeChanges(object, type, id, selection) { function keyframeChanges(object, type, id, selection) {
if (object.get('type') == 'rect') { if (object.get('type') == 'rect') {
object.set({ setCornerRadius(
rx: parseFloat($('#object-corners input').val()), object,
ry: parseFloat($('#object-corners input').val()), parseFloat($('#object-corners input').val())
}); );
} else if (object.get('type') == 'textbox') { } else if (object.get('type') == 'textbox') {
object.set({ object.set({
charSpacing: parseFloat($('#text-h input').val()) * 10, charSpacing: parseFloat($('#text-h input').val()) * 10,
@@ -3512,7 +3558,23 @@ function checkCrop(obj) {
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} }
// Rotate a vector by `radians` around the origin
function rotateVector(x, y, radians) {
if (!radians) {
return { x: x, y: y };
}
var cos = Math.cos(radians);
var sin = Math.sin(radians);
return { x: x * cos - y * sin, y: x * sin + y * cos };
}
// Perform a crop // Perform a crop
//
// Everything is computed in the image's own frame rather than along the canvas
// axes, so a rotated image crops the region the crop window actually covers.
// The previous version compared canvas-space edges and only handled three of
// the four quadrants, which cropped the wrong area of any rotated image (and
// nothing at all when the crop window was centred on it).
function crop(obj) { function crop(obj) {
var cropUI = canvas.getItemById('crop'); var cropUI = canvas.getItemById('crop');
if (!obj || !cropUI || !cropobj) { if (!obj || !cropUI || !cropobj) {
@@ -3520,58 +3582,54 @@ function crop(obj) {
} }
cropobj.setCoords(); cropobj.setCoords();
cropUI.setCoords(); cropUI.setCoords();
var cleft = // Read everything before writing: the final call passes cropobj as `obj`
cropUI.get('left') - (cropUI.get('width') * cropUI.get('scaleX')) / 2; var scaleX = cropobj.get('scaleX') || 1;
var ctop = var scaleY = cropobj.get('scaleY') || 1;
cropUI.get('top') - (cropUI.get('height') * cropUI.get('scaleY')) / 2; var ogWidth = cropobj.get('width');
var height = var ogHeight = cropobj.get('height');
(cropUI.get('height') / cropobj.get('scaleY')) * cropUI.get('scaleY'); var centerX = cropobj.get('left');
var width = var centerY = cropobj.get('top');
(cropUI.get('width') / cropobj.get('scaleX')) * cropUI.get('scaleX'); var radians = fabric.util.degreesToRadians(
var img_height = cropobj.get('height') * cropobj.get('scaleY'); cropobj.get('angle') || 0
var img_width = cropobj.get('width') * cropobj.get('scaleX'); );
var left =
cleft - // The crop window carries the image's angle, so its own width and height are
(cropobj.get('left') - // already axis-aligned with the image
(cropobj.get('width') * cropobj.get('scaleX')) / 2); var width = (cropUI.get('width') * cropUI.get('scaleX')) / scaleX;
var top = var height = (cropUI.get('height') * cropUI.get('scaleY')) / scaleY;
ctop -
(cropobj.get('top') - // Where the crop window sits relative to the image centre, un-rotated
(cropobj.get('height') * cropobj.get('scaleY')) / 2); var offset = rotateVector(
if (left < 0 && top > 0) { cropUI.get('left') - centerX,
obj cropUI.get('top') - centerY,
.set({ cropY: top / cropobj.get('scaleY'), height: height }) -radians
.setCoords(); );
canvas.renderAll(); var cropX = offset.x / scaleX + ogWidth / 2 - width / 2;
obj.set({ var cropY = offset.y / scaleY + ogHeight / 2 - height / 2;
top: ctop + (obj.get('height') * obj.get('scaleY')) / 2,
}); // Never leave the source bitmap: fabric draws nothing for a negative crop
canvas.renderAll(); width = Math.max(1, Math.min(width, ogWidth));
} else if (top < 0 && left > 0) { height = Math.max(1, Math.min(height, ogHeight));
obj cropX = Math.max(0, Math.min(cropX, ogWidth - width));
.set({ cropX: left / cropobj.get('scaleX'), width: width }) cropY = Math.max(0, Math.min(cropY, ogHeight - height));
.setCoords();
canvas.renderAll(); // Put the cropped object back under the (clamped) region it represents
obj.set({ var center = rotateVector(
left: cleft + (obj.get('width') * obj.get('scaleX')) / 2, (cropX + width / 2 - ogWidth / 2) * scaleX,
}); (cropY + height / 2 - ogHeight / 2) * scaleY,
canvas.renderAll(); radians
} else if (top > 0 && left > 0) { );
obj obj
.set({ .set({
cropX: left / cropobj.get('scaleX'), cropX: cropX,
cropY: top / cropobj.get('scaleY'), cropY: cropY,
height: height,
width: width, width: width,
height: height,
left: centerX + center.x,
top: centerY + center.y,
}) })
.setCoords(); .setCoords();
canvas.renderAll(); canvas.renderAll();
obj.set({
left: cleft + (obj.get('width') * obj.get('scaleX')) / 2,
top: ctop + (obj.get('height') * obj.get('scaleY')) / 2,
});
canvas.renderAll();
}
if (obj.get('id') != 'cropped') { if (obj.get('id') != 'cropped') {
canvas.remove(cropUI); canvas.remove(cropUI);
canvas.remove(canvas.getItemById('crop-overlay')); canvas.remove(canvas.getItemById('crop-overlay'));
@@ -3626,20 +3684,23 @@ function cropImage(object) {
cropobj = object; cropobj = object;
canvas.uniformScaling = false; canvas.uniformScaling = false;
cropobj.setCoords(); cropobj.setCoords();
var left = // The visible region, in the source bitmap's own pixels, before the image
cropobj.get('left') - // is expanded back to its full size below
(cropobj.get('width') * cropobj.get('scaleX')) / 2;
var top =
cropobj.get('top') -
(cropobj.get('height') * cropobj.get('scaleY')) / 2;
var cropx = cropobj.get('cropX'); var cropx = cropobj.get('cropX');
var cropy = cropobj.get('cropY'); var cropy = cropobj.get('cropY');
var shownWidth = cropobj.get('width');
var shownHeight = cropobj.get('height');
var shownLeft = cropobj.get('left');
var shownTop = cropobj.get('top');
overlay(); overlay();
var cropUI = new fabric.Rect({ var cropUI = new fabric.Rect({
left: object.get('left'), left: object.get('left'),
top: object.get('top'), top: object.get('top'),
width: object.get('width') * object.get('scaleX') - 5, width: object.get('width') * object.get('scaleX') - 5,
height: object.get('height') * object.get('scaleY') - 5, height: object.get('height') * object.get('scaleY') - 5,
// The crop window has to turn with the image, otherwise the region it
// covers cannot be expressed as a crop at all
angle: object.get('angle') || 0,
originX: 'center', originX: 'center',
originY: 'center', originY: 'center',
id: 'crop', id: 'crop',
@@ -3674,15 +3735,19 @@ function cropImage(object) {
}) })
.setCoords(); .setCoords();
canvas.renderAll(); canvas.renderAll();
// Growing back to the full bitmap moves the centre, so shift the image so
// the region that was on screen stays exactly where it was. The offset is
// in the image's own frame, hence the rotation.
var shift = rotateVector(
(cropx + shownWidth / 2 - cropobj.get('width') / 2) *
cropobj.get('scaleX'),
(cropy + shownHeight / 2 - cropobj.get('height') / 2) *
cropobj.get('scaleY'),
fabric.util.degreesToRadians(cropobj.get('angle') || 0)
);
cropobj.set({ cropobj.set({
left: left: shownLeft - shift.x,
left + top: shownTop - shift.y,
(cropobj.get('width') * cropobj.get('scaleX')) / 2 -
cropx * cropobj.get('scaleX'),
top:
top +
(cropobj.get('height') * cropobj.get('scaleY')) / 2 -
cropy * cropobj.get('scaleY'),
}); });
cropUI.setControlsVisibility({ cropUI.setControlsVisibility({
mt: false, mt: false,
@@ -4058,6 +4123,7 @@ function newRectangle(color) {
backgroundColor: 'rgba(255,255,255,0)', backgroundColor: 'rgba(255,255,255,0)',
rx: 0, rx: 0,
ry: 0, ry: 0,
cornerRadius: 0,
fill: color, fill: color,
cursorWidth: 1, cursorWidth: 1,
cursorDuration: 1, cursorDuration: 1,
@@ -4315,6 +4381,11 @@ function newAudioLayer(src) {
duration: audio.duration * 1000, duration: audio.duration * 1000,
opacity: 0, opacity: 0,
selectable: false, selectable: false,
// Audio layers are invisible placeholders. Selecting them (from the
// layer list) must not paint controls / a bounding box on the canvas.
hasControls: false,
hasBorders: false,
evented: false,
volume: 0.5, volume: 0.5,
assetType: 'audio', assetType: 'audio',
shadow: { shadow: {
@@ -4862,9 +4933,17 @@ function bindPointerDrag(e, el, onMove, onEnd) {
window.addEventListener('blur', end); window.addEventListener('blur', end);
} }
// A modal covers the timeline but does not cover pointer events, so every
// timeline drag has to opt out while one is open - otherwise the seekbar,
// keyframes, layer bars and the resize handle all still react to a drag
// happening "behind" the dialog.
function modalOpen() {
return $('.modal-open').length > 0;
}
// Dragging a keyframe // Dragging a keyframe
function dragKeyframe(e) { function dragKeyframe(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
e.stopPropagation(); e.stopPropagation();
@@ -5014,7 +5093,7 @@ function updateTime(drag, check) {
// Dragging the seekbar // Dragging the seekbar
function dragSeekBar(e) { function dragSeekBar(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
// Stop the browser from turning the press into a native drag-and-drop. // Stop the browser from turning the press into a native drag-and-drop.
@@ -5085,7 +5164,7 @@ $(document).on(
// Dragging layer horizontally // Dragging layer horizontally
function dragObjectProps(e) { function dragObjectProps(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
var drag = $(this).parent(); var drag = $(this).parent();
@@ -5293,13 +5372,17 @@ function resetHeight() {
'calc(100% - ' + (top + 97) + 'px)' 'calc(100% - ' + (top + 97) + 'px)'
); );
$('#properties').css('height', 'calc(100% - ' + (top + 97) + 'px)'); $('#properties').css('height', 'calc(100% - ' + (top + 97) + 'px)');
$('.panel-handle').css(
'height',
'calc(100% - ' + (top + 97) + 'px)'
);
$('#timeline-handle').css('bottom', top + 95); $('#timeline-handle').css('bottom', top + 95);
resizeCanvas(); resizeCanvas();
} }
// Dragging timeline vertically // Dragging timeline vertically
function dragTimeline(e) { function dragTimeline(e) {
if (e.which == 3) { if (e.which == 3 || modalOpen()) {
return false; return false;
} }
// Suppress text selection for the duration of the drag only - leaving these // Suppress text selection for the duration of the drag only - leaving these
@@ -5325,6 +5408,208 @@ $(document).on('pointerdown', '#timeline-handle', dragTimeline);
oldtimelinepos = $(window).height() - 92 - $('#timearea').height(); oldtimelinepos = $(window).height() - 92 - $('#timearea').height();
// Side panels: horizontal resize and hide/show
// The widths live as CSS variables on :root - every panel rule is a calc() of
// them, so moving a handle only has to write one number.
const RAIL_WIDTH = 76;
const BROWSER_MIN_WIDTH = 210;
const BROWSER_MAX_WIDTH = 640;
const PROPS_MIN_WIDTH = 300;
const PROPS_MAX_WIDTH = 640;
// Canvas width kept free while dragging, whatever the window size
const CANVAS_MIN_WIDTH = 240;
const PANEL_LAYOUT_KEY = 'motionity-panel-layout';
var browserwidth = 299;
var propswidth = 300;
var propshidden = false;
// Tool to reopen the library with, set when it gets collapsed
var lasttool = $('.tool-active').attr('id') || 'shape-tool';
function clampWidth(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function browserHidden() {
return $('#browser').hasClass('collapsed');
}
function applyPanelWidths() {
const root = document.documentElement.style;
const hidden = browserHidden();
var browser = hidden ? 0 : browserwidth;
var properties = propshidden ? 0 : propswidth;
// Shrink to fit rather than squeeze the canvas out of a narrow window. The
// stored widths are left alone, so the panels grow back on a wider one.
var over =
RAIL_WIDTH +
browser +
properties +
CANVAS_MIN_WIDTH -
$(window).width();
if (over > 0 && properties > PROPS_MIN_WIDTH) {
const cut = Math.min(over, properties - PROPS_MIN_WIDTH);
properties -= cut;
over -= cut;
}
if (over > 0 && browser > BROWSER_MIN_WIDTH) {
browser -= Math.min(over, browser - BROWSER_MIN_WIDTH);
}
root.setProperty('--browser-w', browser + 'px');
root.setProperty('--props-w', properties + 'px');
// The layer list and the timeline keep their column width while the library
// is hidden, otherwise layer names would get squashed into the tool rail.
if (!hidden) {
root.setProperty('--layers-w', RAIL_WIDTH + browser + 'px');
}
$('#browser-handle').toggleClass('panel-hidden', hidden);
$('#properties-handle').toggleClass('panel-hidden', propshidden);
$('#properties').toggleClass('collapsed', propshidden);
$('#browser-toggle').attr(
'title',
hidden ? 'Show the library' : 'Hide the library'
);
$('#properties-toggle').attr(
'title',
propshidden ? 'Show the properties' : 'Hide the properties'
);
}
function savePanelLayout() {
try {
localStorage.setItem(
PANEL_LAYOUT_KEY,
JSON.stringify({
browser: browserwidth,
properties: propswidth,
propertieshidden: propshidden,
})
);
} catch (err) {
// Private browsing or a full quota - the layout just won't persist
}
}
function restorePanelLayout() {
var stored;
try {
stored = JSON.parse(localStorage.getItem(PANEL_LAYOUT_KEY));
} catch (err) {
stored = null;
}
if (stored) {
if (typeof stored.browser == 'number') {
browserwidth = clampWidth(
stored.browser,
BROWSER_MIN_WIDTH,
BROWSER_MAX_WIDTH
);
}
if (typeof stored.properties == 'number') {
propswidth = clampWidth(
stored.properties,
PROPS_MIN_WIDTH,
PROPS_MAX_WIDTH
);
}
propshidden = stored.propertieshidden === true;
}
applyPanelWidths();
}
// Dragging a side panel border
function dragPanel(e) {
if (e.which == 3 || modalOpen() || $(this).hasClass('panel-hidden')) {
return false;
}
e.preventDefault();
const handle = $(this);
const isbrowser = handle.attr('id') == 'browser-handle';
const startx = e.pageX;
const startwidth = isbrowser ? browserwidth : propswidth;
// Suppress text selection for the duration of the drag only
const previousSelectStart = document.onselectstart;
document.onselectstart = function () {
return false;
};
handle.addClass('handle-dragging');
function draggingPanelBorder(ev) {
// The properties panel is anchored to the right, so its width grows the
// other way round
const delta = isbrowser ? ev.pageX - startx : startx - ev.pageX;
const otherwidth = isbrowser
? propshidden
? 0
: propswidth
: browserHidden()
? 0
: browserwidth;
const room =
$(window).width() - RAIL_WIDTH - CANVAS_MIN_WIDTH - otherwidth;
if (isbrowser) {
browserwidth = clampWidth(
startwidth + delta,
BROWSER_MIN_WIDTH,
Math.min(BROWSER_MAX_WIDTH, room)
);
} else {
propswidth = clampWidth(
startwidth + delta,
PROPS_MIN_WIDTH,
Math.min(PROPS_MAX_WIDTH, room)
);
}
applyPanelWidths();
resizeCanvas();
}
function releasedPanelBorder() {
document.onselectstart = previousSelectStart || null;
handle.removeClass('handle-dragging');
savePanelLayout();
}
bindPointerDrag(e, this, draggingPanelBorder, releasedPanelBorder);
}
$(document).on('pointerdown', '.panel-handle', dragPanel);
// Keep a click on a hide/show button from starting a resize drag
$(document).on('pointerdown', '.panel-toggle', function (e) {
e.stopPropagation();
});
$(document).on('click', '#browser-toggle', function (e) {
e.stopPropagation();
if (browserHidden()) {
// Reopening goes through the tool click so the rail icons stay in sync
$('#' + lasttool).trigger('click');
} else {
collapsePanel();
}
});
$(document).on('click', '#properties-toggle', function (e) {
e.stopPropagation();
propshidden = !propshidden;
applyPanelWidths();
savePanelLayout();
resizeCanvas();
});
// Re-fits the panels after the window itself changed size. Runs after the
// resizeCanvas listener registered at the top of this file, so the canvas is
// measured again once the new widths are in.
window.addEventListener(
'resize',
function () {
applyPanelWidths();
resizeCanvas();
},
false
);
restorePanelLayout();
resizeCanvas();
// Sync scrolling (vertical) // Sync scrolling (vertical)
function syncScroll(el1, el2) { function syncScroll(el1, el2) {
var $el1 = $(el1); var $el1 = $(el1);
+15 -6
View File
@@ -570,7 +570,7 @@ function updatePanelValues() {
o_slider.setValue(object.get('opacity') * 100); o_slider.setValue(object.get('opacity') * 100);
if (object.get('type') == 'rect') { if (object.get('type') == 'rect') {
$('#object-corners input').val( $('#object-corners input').val(
parseFloat(object.get('rx').toFixed(2)) parseFloat(getCornerRadius(object).toFixed(2))
); );
colormode = 'fill'; colormode = 'fill';
o_fill.setColor(object.get('fill')); o_fill.setColor(object.get('fill'));
@@ -1219,12 +1219,13 @@ $(document).on(
// Switch tool // Switch tool
function switchTool(e) { function switchTool(e) {
$('#browser').removeClass('collapsed');
$('#canvas-area').removeClass('canvas-full');
if ($(this).attr('id') == 'more-tool') { if ($(this).attr('id') == 'more-tool') {
showMore(); showMore();
return false; return false;
} }
$('#browser').removeClass('collapsed');
$('#behind-browser').removeClass('collapsed');
applyPanelWidths();
resizeCanvas(); resizeCanvas();
var act = $('.tool-active'); var act = $('.tool-active');
if (act.attr('id') == 'image-tool') { if (act.attr('id') == 'image-tool') {
@@ -1292,6 +1293,10 @@ function dragObject(e) {
if (e.which == 3) { if (e.which == 3) {
return false; return false;
} }
// Measure before the clone leaves the panel - panel items are sized in
// percentages of a resizable panel, so a clone parented to <body> would
// report the body width instead.
var sourcewidth = $(this).width();
var drag = $(this).clone(); var drag = $(this).clone();
drag.css({ drag.css({
background: 'transparent', background: 'transparent',
@@ -1304,7 +1309,7 @@ function dragObject(e) {
zIndex: 9999999, zIndex: 9999999,
left: $(this).offset().left, left: $(this).offset().left,
top: $(this).offset().top, top: $(this).offset().top,
width: canvas.getZoom() * drag.width(), width: canvas.getZoom() * sourcewidth,
pointerEvents: 'none', pointerEvents: 'none',
opacity: 0, opacity: 0,
}); });
@@ -1619,10 +1624,13 @@ $(document).on(
// Collapse library // Collapse library
function collapsePanel() { function collapsePanel() {
var act = $('.tool-active');
if (act.length > 0) {
// Remembered so the handle button can reopen the same tab
lasttool = act.attr('id');
}
$('#browser').addClass('collapsed'); $('#browser').addClass('collapsed');
$('#behind-browser').addClass('collapsed'); $('#behind-browser').addClass('collapsed');
$('#canvas-area').addClass('canvas-full');
var act = $('.tool-active');
if (act.attr('id') == 'image-tool') { if (act.attr('id') == 'image-tool') {
act.find('img').attr('src', 'assets/image.svg'); act.find('img').attr('src', 'assets/image.svg');
} else if (act.attr('id') == 'text-tool') { } else if (act.attr('id') == 'text-tool') {
@@ -1637,6 +1645,7 @@ function collapsePanel() {
act.find('img').attr('src', 'assets/uploads.svg'); act.find('img').attr('src', 'assets/uploads.svg');
} }
$('.tool-active').removeClass('tool-active'); $('.tool-active').removeClass('tool-active');
applyPanelWidths();
resizeCanvas(); resizeCanvas();
} }
$(document).on('click', '#collapse', collapsePanel); $(document).on('click', '#collapse', collapsePanel);
+141 -24
View File
@@ -7,6 +7,14 @@
--input-color: #22233e; --input-color: #22233e;
--accent-color: #166ef1; --accent-color: #166ef1;
--button-hover: #262746; --button-hover: #262746;
/* Panel geometry. Every rule that used to hardcode 76/299/300/375 derives
from these so the resize handles only have to move one number each.
--layers-w tracks --rail-w + --browser-w while the library panel is
visible, but stays put when it is hidden so the layer names keep room. */
--rail-w: 76px;
--browser-w: 299px;
--props-w: 300px;
--layers-w: 375px;
} }
/* /*
:root { :root {
@@ -41,7 +49,7 @@ body {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
background: var(--panel-back); background: var(--panel-back);
width: 76px; width: var(--rail-w);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
box-sizing: border-box; box-sizing: border-box;
} }
@@ -244,7 +252,7 @@ body {
#toolbar { #toolbar {
position: absolute; position: absolute;
height: calc(100% - 340px); height: calc(100% - 340px);
width: 76px; width: var(--rail-w);
background-color: var(--panel-back); background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
left: 0px; left: 0px;
@@ -308,8 +316,8 @@ body {
#behind-browser { #behind-browser {
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 299px; width: var(--browser-w);
left: 76px; left: var(--rail-w);
background: var(--panel-back); background: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
z-index: 1; z-index: 1;
@@ -318,10 +326,10 @@ body {
#browser { #browser {
position: absolute; position: absolute;
height: calc(100% - 450px); height: calc(100% - 450px);
width: 299px; width: var(--browser-w);
background-color: var(--panel-back); background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke); border-right: 1px solid var(--panel-stroke);
left: 76px; left: var(--rail-w);
top: 110px; top: 110px;
box-sizing: border-box; box-sizing: border-box;
z-index: 999999; z-index: 999999;
@@ -333,7 +341,7 @@ body {
display: none !important; display: none !important;
} }
#browser-container { #browser-container {
width: 260px; width: calc(var(--browser-w) - 39px);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
height: 100%; height: 100%;
@@ -359,7 +367,7 @@ body {
} }
.image-grid-item, .image-grid-item,
.video-grid-item { .video-grid-item {
width: 120px; width: 100%;
margin-bottom: 15px; margin-bottom: 15px;
position: relative; position: relative;
} }
@@ -388,7 +396,7 @@ body {
text-align: right; text-align: right;
text-decoration: none; text-decoration: none;
text-shadow: 0px 1px 5px #000000; text-shadow: 0px 1px 5px #000000;
width: 110px; width: calc(100% - 10px);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -436,7 +444,9 @@ body {
grid-gap: 15px; grid-gap: 15px;
width: 100%; width: 100%;
grid-auto-columns: auto; grid-auto-columns: auto;
grid-template-columns: 52px 52px 52px 52px; /* Column count follows the panel width so the grid reflows when the library
is resized instead of overflowing a fixed four-column track. */
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
overflow: hidden; overflow: hidden;
} }
.scroll-row:before { .scroll-row:before {
@@ -461,7 +471,7 @@ body {
#search-fixed { #search-fixed {
position: fixed; position: fixed;
top: 0px; top: 0px;
width: 279px; width: calc(var(--browser-w) - 20px);
padding-left: 19px; padding-left: 19px;
margin-left: -19px; margin-left: -19px;
z-index: 999999; z-index: 999999;
@@ -776,12 +786,13 @@ body {
position: absolute; position: absolute;
right: 0px; right: 0px;
top: 0px; top: 0px;
width: 300px; width: var(--props-w);
height: calc(100% - 340px); height: calc(100% - 340px);
background-color: var(--panel-back); background-color: var(--panel-back);
border-left: 1px solid var(--panel-stroke); border-left: 1px solid var(--panel-stroke);
z-index: 999; z-index: 999;
overflow-y: overlay; overflow-y: overlay;
overflow-x: hidden;
} }
#properties-overlay { #properties-overlay {
width: 100%; width: 100%;
@@ -830,9 +841,12 @@ hr {
background-color: var(--panel-stroke); background-color: var(--panel-stroke);
margin-bottom: 20px; margin-bottom: 20px;
} }
#properties hr {
width: calc(var(--props-w) - 40px);
}
/* Property sections */ /* Property sections */
.panel-section { .panel-section {
width: 260px; width: calc(var(--props-w) - 40px);
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
@@ -869,8 +883,17 @@ th {
width: 189px; width: 189px;
display: flex; display: flex;
align-items: center; align-items: center;
/* Keeps the inputs against the right edge once the column can be wider
than its contents */
justify-content: flex-end;
margin-left: auto; margin-left: auto;
} }
/* Inside the properties panel the value column follows the panel width, so
dropdowns and sliders grow when the panel is widened. 111px is the label
column plus the panel gutters. The filters popup keeps the fixed width. */
#properties .value-col {
width: calc(var(--props-w) - 111px);
}
/* Dropdows */ /* Dropdows */
.nice-select, .nice-select,
.list, .list,
@@ -1550,16 +1573,12 @@ label span {
#canvas-area { #canvas-area {
position: absolute; position: absolute;
top: 0px; top: 0px;
left: 375px; left: calc(var(--rail-w) + var(--browser-w));
height: calc(100% - 342px); height: calc(100% - 342px);
width: calc(100% - 675px); width: calc(100% - var(--rail-w) - var(--browser-w) - var(--props-w));
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
} }
.canvas-full {
left: 76px !important;
width: calc(100% - 376px) !important;
}
.canvas-container { .canvas-container {
left: 0px; left: 0px;
top: 0px; top: 0px;
@@ -1614,7 +1633,7 @@ canvas {
#layer-list { #layer-list {
position: absolute; position: absolute;
height: 245px; height: 245px;
width: 375px; width: var(--layers-w);
left: 0px; left: 0px;
bottom: 60px; bottom: 60px;
background-color: var(--panel-back); background-color: var(--panel-back);
@@ -1816,7 +1835,7 @@ layer:nth-child(even) .properties {
color: var(--secondary-text-color); color: var(--secondary-text-color);
line-height: 40px; line-height: 40px;
height: 40px; height: 40px;
width: 375px; width: var(--layers-w);
position: fixed; position: fixed;
margin-top: -35px; margin-top: -35px;
background-color: var(--panel-back); background-color: var(--panel-back);
@@ -1840,10 +1859,10 @@ layer:nth-child(even) .properties {
#timearea { #timearea {
position: absolute; position: absolute;
bottom: 60px; bottom: 60px;
left: 375px; left: var(--layers-w);
height: 245px; height: 245px;
background-color: var(--main-back); background-color: var(--main-back);
width: calc(100% - 375px); width: calc(100% - var(--layers-w));
border-top: 1px solid var(--panel-stroke); border-top: 1px solid var(--panel-stroke);
z-index: 999; z-index: 999;
} }
@@ -1858,10 +1877,108 @@ layer:nth-child(even) .properties {
cursor: ns-resize; cursor: ns-resize;
background-color: var(--panel-stroke); background-color: var(--panel-stroke);
} }
/* Side panel resize handles. Each one straddles the border between a side
panel and the canvas, and carries the hide/show button for that panel.
Their height is kept in sync with the panels by resetHeight(). */
.panel-handle {
position: absolute;
top: 0px;
height: calc(100% - 340px);
width: 7px;
z-index: 9999999;
box-sizing: border-box;
touch-action: none;
-webkit-user-drag: none;
-webkit-user-select: none;
user-select: none;
}
#browser-handle {
left: calc(var(--rail-w) + var(--browser-w) - 3px);
}
#properties-handle {
right: calc(var(--props-w) - 3px);
}
#properties-handle.panel-hidden {
right: 0px;
}
.panel-handle:not(.panel-hidden):hover,
.panel-handle.handle-dragging {
cursor: ew-resize;
}
.panel-handle:before {
content: "";
position: absolute;
left: 2px;
top: 0px;
width: 2px;
height: 100%;
background-color: var(--accent-color);
opacity: 0;
}
.panel-handle:not(.panel-hidden):hover:before,
.panel-handle.handle-dragging:before {
opacity: 1;
}
.panel-toggle {
position: absolute;
top: 50%;
margin-top: -18px;
width: 18px;
height: 36px;
background-color: var(--input-color);
border: 1px solid var(--panel-stroke);
box-sizing: border-box;
/* Always on screen, dimmed. Revealing it on hover meant having to find the
3px resize line first, then travel onto the button. */
opacity: 0.4;
}
.panel-toggle:hover {
cursor: pointer;
background-color: var(--button-hover);
}
.panel-toggle:hover,
.panel-handle:hover .panel-toggle,
.panel-handle.handle-dragging .panel-toggle,
.panel-handle.panel-hidden .panel-toggle {
opacity: 1;
}
/* Chevron, pointing at the edge the panel would collapse towards */
.panel-toggle:after {
content: "";
position: absolute;
width: 5px;
height: 5px;
top: 50%;
left: 50%;
border-left: 1.5px solid var(--secondary-text-color);
border-bottom: 1.5px solid var(--secondary-text-color);
}
#browser-handle .panel-toggle {
left: 5px;
border-radius: 0px 4px 4px 0px;
}
#properties-handle .panel-toggle {
right: 5px;
border-radius: 4px 0px 0px 4px;
}
#properties-handle.panel-hidden .panel-toggle {
right: 4px;
}
#browser-handle .panel-toggle:after,
#properties-handle.panel-hidden .panel-toggle:after {
margin: -4px 0px 0px -2px;
transform: rotate(45deg);
}
#properties-handle .panel-toggle:after,
#browser-handle.panel-hidden .panel-toggle:after {
margin: -4px 0px 0px -5px;
transform: rotate(-135deg);
}
#seekarea { #seekarea {
height: 100%; height: 100%;
z-index: 99999; z-index: 99999;
width: calc(100% - 375px); /* Fixed positioning - the percentage is the viewport, not #timearea. */
width: calc(100% - var(--layers-w));
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
position: fixed; position: fixed;