Compare commits
26
Commits
f8f863e7e2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eaa3cd072 | ||
|
|
00bd5d8b13 | ||
|
|
298ca15b97 | ||
|
|
1996647dfe | ||
|
|
45c37a5e73 | ||
|
|
fe57429603 | ||
|
|
094bfbdc60 | ||
|
|
414e0ee30c | ||
|
|
cc439c1152 | ||
|
|
e775ea478f | ||
|
|
dcb804e48e | ||
|
|
f380249c1c | ||
|
|
a87716dc5b | ||
|
|
109f737c2a | ||
|
|
d808c715fd | ||
|
|
3ed8f5683f | ||
|
|
fd0314549b | ||
|
|
89771b2555 | ||
|
|
f5d18a10a9 | ||
|
|
271107203d | ||
|
|
9a4d14613f | ||
|
|
a6ec6c980a | ||
|
|
337f38d58e | ||
|
|
7505ecdbe5 | ||
|
|
d501778ae5 | ||
|
|
f2b9f28d14 |
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.playwright-mcp
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
electron
|
||||||
|
test
|
||||||
|
deploy
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Always re-downloaded inside the build so the image is reproducible.
|
||||||
|
src/vendor
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
name: Sync Gitea releases to GitHub
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.azuze.fr
|
||||||
|
GITEA_OWNER: kawa
|
||||||
|
GITEA_REPO: Motionity
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync-releases:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install jq
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y jq
|
||||||
|
|
||||||
|
- name: Fetch Gitea releases and sync to GitHub
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
AUTH_HEADER=()
|
||||||
|
if [ -n "${GITEA_TOKEN:-}" ]; then
|
||||||
|
AUTH_HEADER=(-H "Authorization: token ${GITEA_TOKEN}")
|
||||||
|
fi
|
||||||
|
# Récupère toutes les releases Gitea (pagination simple, 50 max, ajuster si besoin)
|
||||||
|
releases=$(curl -sf "${AUTH_HEADER[@]}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/releases?limit=50")
|
||||||
|
echo "$releases" | jq -c '.[]' | while read -r release; do
|
||||||
|
tag=$(echo "$release" | jq -r '.tag_name')
|
||||||
|
name=$(echo "$release" | jq -r '.name // .tag_name')
|
||||||
|
body=$(echo "$release" | jq -r '.body // ""')
|
||||||
|
prerelease=$(echo "$release" | jq -r '.prerelease')
|
||||||
|
draft=$(echo "$release" | jq -r '.draft')
|
||||||
|
# Skip si la release existe déjà sur GitHub
|
||||||
|
if gh release view "$tag" >/dev/null 2>&1; then
|
||||||
|
echo "Release $tag existe déjà sur GitHub, on passe."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "Création de la release $tag sur GitHub..."
|
||||||
|
flags=()
|
||||||
|
[ "$prerelease" = "true" ] && flags+=(--prerelease)
|
||||||
|
[ "$draft" = "true" ] && flags+=(--draft)
|
||||||
|
# Le tag doit exister sur le repo GitHub ; s'il n'existe pas encore,
|
||||||
|
# on utilise --notes-file avec target par défaut (branche par défaut)
|
||||||
|
gh release create "$tag" \
|
||||||
|
--title "$name" \
|
||||||
|
--notes "$body" \
|
||||||
|
"${flags[@]}"
|
||||||
|
# Télécharge et attache les assets de la release Gitea
|
||||||
|
asset_count=$(echo "$release" | jq '.assets | length')
|
||||||
|
if [ "$asset_count" -gt 0 ]; then
|
||||||
|
tmpdir=$(mktemp -d)
|
||||||
|
echo "$release" | jq -c '.assets[]' | while read -r asset; do
|
||||||
|
asset_name=$(echo "$asset" | jq -r '.name')
|
||||||
|
asset_url=$(echo "$asset" | jq -r '.browser_download_url')
|
||||||
|
echo "Téléchargement de $asset_name..."
|
||||||
|
curl -sfL "${AUTH_HEADER[@]}" -o "${tmpdir}/${asset_name}" "$asset_url"
|
||||||
|
gh release upload "$tag" "${tmpdir}/${asset_name}" --clobber
|
||||||
|
done
|
||||||
|
rm -rf "$tmpdir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/icon.png
|
||||||
|
|
||||||
|
# Populated by `npm run vendor` (~19 MB, mostly the ffmpeg asm.js build).
|
||||||
|
src/vendor/
|
||||||
|
|
||||||
|
.playwright-mcp/
|
||||||
|
*.log
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
#
|
||||||
|
# Motionity is a static app, so the runtime image is a static file server and
|
||||||
|
# nothing else: no Node, no shell tooling, ~8 MB of base image.
|
||||||
|
#
|
||||||
|
# Build: docker build -t motionity:latest .
|
||||||
|
# Run: docker run --rm -p 8080:8080 motionity:latest
|
||||||
|
#
|
||||||
|
# WITH_FFMPEG=0 drops the 23 MB ffmpeg.wasm core from the image and disables
|
||||||
|
# MP4/GIF export, which then reports itself as unavailable. It no longer falls
|
||||||
|
# back to downloading the encoder at run time: the old asm.js build came from a
|
||||||
|
# public archive.org mirror with no integrity check. Everything else, WEBM
|
||||||
|
# export included, is unaffected.
|
||||||
|
|
||||||
|
FROM node:22-alpine AS vendor
|
||||||
|
ARG WITH_FFMPEG=1
|
||||||
|
WORKDIR /app
|
||||||
|
# ffmpeg.wasm is a runtime dependency, so --omit=dev pulls it in without
|
||||||
|
# dragging electron and electron-builder (~400 MB) into the build.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev --no-audit --no-fund
|
||||||
|
COPY scripts/vendor.mjs scripts/
|
||||||
|
COPY src/index.html src/
|
||||||
|
RUN if [ "$WITH_FFMPEG" = "1" ]; then \
|
||||||
|
node scripts/vendor.mjs; \
|
||||||
|
else \
|
||||||
|
node scripts/vendor.mjs --skip-ffmpeg; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
FROM ghcr.io/static-web-server/static-web-server:2-alpine
|
||||||
|
|
||||||
|
COPY --chown=65534:65534 src/ /public/
|
||||||
|
COPY --from=vendor --chown=65534:65534 /app/src/vendor/ /public/vendor/
|
||||||
|
|
||||||
|
ENV SERVER_ROOT=/public \
|
||||||
|
SERVER_HOST=0.0.0.0 \
|
||||||
|
SERVER_PORT=8080 \
|
||||||
|
SERVER_COMPRESSION=true \
|
||||||
|
SERVER_COMPRESSION_LEVEL=default \
|
||||||
|
SERVER_CACHE_CONTROL_HEADERS=true \
|
||||||
|
SERVER_LOG_LEVEL=warn \
|
||||||
|
SERVER_SECURITY_HEADERS=false \
|
||||||
|
SERVER_HEALTH=true
|
||||||
|
|
||||||
|
USER 65534:65534
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
||||||
|
CMD wget -q --spider http://127.0.0.1:8080/health || exit 1
|
||||||
|
|
||||||
|
# NOTE: browsers expose WebCodecs (the fast exporter) and IndexedDB (project
|
||||||
|
# saving) only in a secure context. Reaching this container over plain http://
|
||||||
|
# from another machine disables both. Publish it behind TLS, or keep access on
|
||||||
|
# http://localhost.
|
||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
# Packaging Motionity
|
||||||
|
|
||||||
|
Three distribution targets share one source tree (`src/`, a plain static app):
|
||||||
|
|
||||||
|
| Target | Output | Build host |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 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 |
|
||||||
|
| Bare metal | `src/` behind Node, nginx or Caddy | any |
|
||||||
|
|
||||||
|
## 0. One prerequisite for every target: vendor the assets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # now required for every target, not just the desktop ones
|
||||||
|
npm run vendor # ~24 MB, writes src/vendor/ (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
`index.html` used to pull jQuery, fabric.js, lottie, pickr, selection-js, the
|
||||||
|
WebFont loader and Inter from five different CDNs. `scripts/vendor.mjs`
|
||||||
|
downloads all of them into `src/vendor/` and the app now references only those
|
||||||
|
local copies. Without this step the page loads but every script tag 404s.
|
||||||
|
|
||||||
|
ffmpeg.wasm is handled differently: `vendor.mjs` **copies** it out of
|
||||||
|
`node_modules` instead of downloading it, which is why `npm install` is now a
|
||||||
|
prerequisite everywhere. `package-lock.json` pins those packages by integrity
|
||||||
|
hash, so the bytes that reach the image are the bytes npm verified. The asm.js
|
||||||
|
build this replaced was fetched from a public archive.org mirror with no
|
||||||
|
integrity check of any kind — a changed object there would have executed in the
|
||||||
|
page unnoticed.
|
||||||
|
|
||||||
|
Two consequences worth knowing:
|
||||||
|
|
||||||
|
- `@ffmpeg/core-st` is the **single-threaded** core, chosen deliberately. The
|
||||||
|
default `@ffmpeg/core` is built with pthreads and needs `SharedArrayBuffer`,
|
||||||
|
which requires COOP/COEP cross-origin isolation, which would break the
|
||||||
|
Pixabay, Unsplash and Google Fonts requests the editor makes.
|
||||||
|
- The two `@ffmpeg/*` packages are `dependencies`, not `devDependencies`, so the
|
||||||
|
Docker vendor stage can `npm ci --omit=dev` without pulling in electron. That
|
||||||
|
makes electron-builder want to bundle them into the asar too, so `build.files`
|
||||||
|
excludes `node_modules/**` outright — the packaged app requires nothing but
|
||||||
|
`electron` and node builtins, and the copies it loads live in
|
||||||
|
`src/vendor/ffmpeg/`.
|
||||||
|
|
||||||
|
The Docker build runs `npm ci` and the vendor step inside the image, so it is
|
||||||
|
the one target where you can skip both locally.
|
||||||
|
|
||||||
|
## 1. Desktop — Electron
|
||||||
|
|
||||||
|
`electron/main.js` starts the same static server the bare-metal target uses, on
|
||||||
|
`127.0.0.1` with a random port, and points the window at it.
|
||||||
|
|
||||||
|
**This is deliberate, do not "simplify" it to `loadFile()`.** Chromium exposes
|
||||||
|
WebCodecs (`VideoEncoder`, the fast exporter in `src/js/render.js`) and
|
||||||
|
IndexedDB (project storage via localbase) only in a secure context. `file://`
|
||||||
|
is not one; a loopback HTTP origin is. Load the app from `file://` and export
|
||||||
|
silently falls back to real-time `MediaRecorder` capture and projects stop
|
||||||
|
saving.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # run the desktop app from source
|
||||||
|
|
||||||
|
npm run dist:win # NSIS installer + portable .exe -> dist/
|
||||||
|
npm run dist:appimage # .AppImage -> dist/
|
||||||
|
npm run dist:flatpak # .flatpak -> dist/
|
||||||
|
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`
|
||||||
|
(`scripts/make-icon.cjs` rasterises the logo geometry with zlib only — no
|
||||||
|
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`
|
||||||
|
|
||||||
|
On a locked-down Windows machine the security agent can take an exclusive lock
|
||||||
|
on the freshly written 214 MB unsigned `dist/win-unpacked/Motionity.exe`, and
|
||||||
|
the 7-Zip step that builds the installer payload then cannot read it. The
|
||||||
|
symptom is a build that produces `dist/win-unpacked/` correctly and dies right
|
||||||
|
after "Archive size":
|
||||||
|
|
||||||
|
```
|
||||||
|
.\Motionity.exe : Accès refusé.
|
||||||
|
WARNING: Cannot open 2 files
|
||||||
|
```
|
||||||
|
|
||||||
|
The ACL is intact (the owner still has FullControl), so this is a filter
|
||||||
|
driver, not a permissions problem. Fixes, in order of preference:
|
||||||
|
|
||||||
|
1. **Build the Windows targets in WSL**, where the agent cannot see the files at
|
||||||
|
all — `npm run dist:win:wsl`, covered in [its own section](#can-the-exe-be-built-in-wsl-too)
|
||||||
|
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
|
||||||
|
|
||||||
|
VS Code exports `ELECTRON_RUN_AS_NODE=1`, which turns the `electron` binary into
|
||||||
|
plain Node and leaves the `electron` module empty. `main.js` detects this and
|
||||||
|
prints a message. Fix it in the shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env -u ELECTRON_RUN_AS_NODE npm run dev # bash
|
||||||
|
$env:ELECTRON_RUN_AS_NODE=$null; npm run dev # PowerShell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Is WSL enough for AppImage and Flatpak?
|
||||||
|
|
||||||
|
Yes, and `build-release.ps1 -UseWsl` does it for you from Windows (`-WinInWsl`
|
||||||
|
moves the `.exe` targets there too — see [below](#can-the-exe-be-built-in-wsl-too)):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
./scripts/build-release.ps1 -UseWsl # .exe on Windows, Linux bundles in WSL
|
||||||
|
./scripts/build-release.ps1 -Targets linux -UseWsl # Linux bundles only
|
||||||
|
./scripts/publish.ps1 -Tag v2.0.1 -PublishRelease -UseWsl # same, then attach to the Gitea release
|
||||||
|
```
|
||||||
|
|
||||||
|
Both bundles have been built this way on this machine (`Ubuntu`, WSL2 kernel
|
||||||
|
6.18): a 172 MB AppImage and a 133 MB Flatpak, checksums verified with
|
||||||
|
`sha256sum -c`. Under the hood:
|
||||||
|
|
||||||
|
- the `npm ci`, `vendor` and icon steps run **once on the Windows side**, and the
|
||||||
|
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
|
||||||
|
wsl --install -d Ubuntu
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inside Ubuntu:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y nodejs npm libfuse2 # AppImage (libfuse2 only to run it)
|
||||||
|
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 \
|
||||||
|
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
|
flatpak install --user -y flathub \
|
||||||
|
org.freedesktop.Platform//23.08 \
|
||||||
|
org.freedesktop.Sdk//23.08 \
|
||||||
|
org.electronjs.Electron2.BaseApp//23.08
|
||||||
|
```
|
||||||
|
|
||||||
|
### Can the `.exe` be built in WSL too?
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t motionity:latest .
|
||||||
|
docker run --rm -p 8080:8080 motionity:latest
|
||||||
|
# or: docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The runtime image is `static-web-server` (Rust) on Alpine — 9 MB of base image,
|
||||||
|
no Node, no shell tooling, running as UID 65534. Everything above that is the
|
||||||
|
app itself: 46 MB of bundled stock media plus 20 MB of vendored libraries.
|
||||||
|
|
||||||
|
Two knobs if the size matters more than offline completeness:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# -23 MB: MP4/GIF export reports itself unavailable (WEBM export is unaffected).
|
||||||
|
# There is no runtime download to fall back on any more, by design.
|
||||||
|
docker build --build-arg WITH_FFMPEG=0 -t motionity:slim .
|
||||||
|
|
||||||
|
# -33 MB: drop the bundled royalty-free music library (removes the Audio panel
|
||||||
|
# presets; uploads still work). Add to .dockerignore:
|
||||||
|
# src/assets/audio
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Bare metal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run vendor
|
||||||
|
npm start # http://127.0.0.1:8080
|
||||||
|
HOST=0.0.0.0 PORT=3000 npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/server.cjs` is dependency-free: correct MIME types, byte ranges (the
|
||||||
|
audio and video panels seek), no directory listings, path-traversal guard. Node
|
||||||
|
18+.
|
||||||
|
|
||||||
|
For a real install, `deploy/` has a systemd unit plus nginx and Caddy configs
|
||||||
|
that serve `src/` directly, no Node process involved:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp -r . /opt/motionity
|
||||||
|
sudo cp deploy/motionity.service /etc/systemd/system/
|
||||||
|
sudo systemctl enable --now motionity
|
||||||
|
```
|
||||||
|
|
||||||
|
## The secure-context rule, once more
|
||||||
|
|
||||||
|
Both the container and the bare-metal targets serve plain HTTP. That is fine on
|
||||||
|
`http://localhost`, which browsers treat as secure. It is **not** fine on a LAN
|
||||||
|
address or a domain: `VideoEncoder` disappears (export falls back to slow
|
||||||
|
real-time capture) and IndexedDB is blocked (projects stop saving), with no
|
||||||
|
error message beyond a console warning.
|
||||||
|
|
||||||
|
Anything beyond localhost needs TLS. `deploy/Caddyfile` is the shortest path —
|
||||||
|
it obtains the certificate itself.
|
||||||
|
|
||||||
|
## What still needs the internet
|
||||||
|
|
||||||
|
Vendoring makes the editor start and export offline. Three features remain
|
||||||
|
online by design, and degrade quietly rather than breaking:
|
||||||
|
|
||||||
|
1. **Google Fonts** — the font picker loads families through `WebFont.load`,
|
||||||
|
and `src/js/init.js` lists them from the Google Fonts API. Offline, text
|
||||||
|
falls back to a system font. Only Inter (the UI font) is bundled.
|
||||||
|
2. **Pixabay** — the Images, Videos and Audio browsers search Pixabay live.
|
||||||
|
3. **Unsplash** — sample images on the empty-state screen.
|
||||||
|
|
||||||
|
MP4 and GIF export used to be a fourth: the encoder came from archive.org at
|
||||||
|
conversion time. It is now vendored, so export works fully offline.
|
||||||
@@ -1,3 +1,162 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="src/assets/logo.svg" alt="Motionity" width="72">
|
||||||
|
|
||||||
# Motionity
|
# Motionity
|
||||||
|
|
||||||
This is a fork of the original project aiming to fix issues and add features.
|
**Web-based motion graphics editor** — keyframing, masking, filters, text animations.
|
||||||
|
A free alternative to After Effects and Canva, running entirely in the browser.
|
||||||
|
|
||||||
|
### ▶ [Try the live demo — motionity.kawa.zip](https://motionity.kawa.zip/)
|
||||||
|
|
||||||
|
[](https://motionity.kawa.zip/)
|
||||||
|
[](LICENSE)
|
||||||
|
[](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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
HOST=0.0.0.0 PORT=3000 npm start # bind elsewhere
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source — desktop
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run vendor
|
||||||
|
npm run dev # Electron
|
||||||
|
```
|
||||||
|
|
||||||
|
> In a VS Code terminal, `ELECTRON_RUN_AS_NODE=1` breaks this. Unset it:
|
||||||
|
> `$env:ELECTRON_RUN_AS_NODE=$null; npm run dev`
|
||||||
|
|
||||||
|
### Two things to know
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
Full packaging details, WSL notes and troubleshooting: **[PACKAGING.md](PACKAGING.md)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
+370
@@ -0,0 +1,370 @@
|
|||||||
|
# Bugs & Issues — audit + fixes
|
||||||
|
|
||||||
|
Audit of `src/js`, then all findings fixed. Every file below passes `node --check`.
|
||||||
|
No live browser smoke test was run (a Chrome instance held the Playwright profile
|
||||||
|
lock), so the changes are verified statically only — see **Not verified** at the end.
|
||||||
|
|
||||||
|
Legend: `[x]` fixed · P0 = feature broken · P1 = wrong data written · P2 = crash on edge path · P3 = silently wrong UI · P4 = perf/leak · P5 = cleanup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 — Broken features
|
||||||
|
|
||||||
|
- [x] **MP4 export dead-ended and locked the UI** — `src/js/functions.js`
|
||||||
|
The mp4 branch of `downloadRecording` was `type = 'video/mp4'`, an implicit global
|
||||||
|
assignment that did nothing: no download, `recording` stuck at `true`, the button
|
||||||
|
stuck on "Downloading...", and `downloadModal()` refusing to reopen until reload.
|
||||||
|
mp4 now goes through `convertStreams(blob, 'mp4')` like gif, and a new shared
|
||||||
|
`resetRecordingUI()` unlocks the editor on every exit path (success, unknown
|
||||||
|
format, worker error, FileReader error, `MediaRecorder` error).
|
||||||
|
|
||||||
|
- [x] **Copying keyframes threw** — `src/js/events.js`
|
||||||
|
`canvas.getActiveObject().isEditing` ran on `null`, because selecting keyframes
|
||||||
|
clears the canvas selection — the normal path. The active object is now resolved
|
||||||
|
once and guarded, and empty `$.grep` results are no longer pushed to the clipboard.
|
||||||
|
|
||||||
|
- [x] **`canvas.getItemByid` typo** — `src/js/functions.js`
|
||||||
|
Lowercase `i`; TypeError when pasting text/`charSpacing` keyframes.
|
||||||
|
|
||||||
|
- [x] **Ctrl+Z crashed on an empty stack; Ctrl+Shift+Z was a no-op** — `src/js/events.js`
|
||||||
|
Merged into one guarded handler: shift picks redo, and each branch checks its own
|
||||||
|
stack length. Previously Ctrl+Shift+Z matched both `if` blocks (redo then undo).
|
||||||
|
|
||||||
|
- [x] **Blur slider threw with nothing selected** — `src/js/events.js`
|
||||||
|
`obj.applyFilters()` was outside the `if (canvas.getActiveObject())` guard. Also
|
||||||
|
renamed the shadowed `x` in the blur/noise/chroma `find()` callbacks.
|
||||||
|
|
||||||
|
## P1 — Wrong values written
|
||||||
|
|
||||||
|
- [x] **`height` keyframes stored the width** — `src/js/functions.js` (3 sites: twice in `keyframeChanges`, once in `crop`)
|
||||||
|
- [x] **`var scaleX = obj, scaleX;`** — `src/js/text.js`; the fabric object was being assigned as a scale factor.
|
||||||
|
- [x] **NaN letter delay + shadowed global `duration`** — `src/js/text.js`
|
||||||
|
`delay = i * duration` read the hoisted local before its own initialiser. Renamed
|
||||||
|
to `step`, computed before use, and `!(delay > 0)` now catches NaN.
|
||||||
|
- [x] **Every letter animation wrote to the last letter** — `src/js/text.js`
|
||||||
|
`index`, `animation`, `start` and `instance` are `let` per iteration instead of `var`.
|
||||||
|
- [x] **Shadow defaults/keyframes stored `undefined`** — `src/js/functions.js`
|
||||||
|
fabric's `get()` is not a path getter. Added `getPropValue()` (handles `shadow.*`)
|
||||||
|
and `setDefaultValue()` / `getDefaultValue()`, and routed ~26 direct
|
||||||
|
`.defaults.find(...).value = …` writes through them — which also creates the entry
|
||||||
|
when a project predates a property instead of throwing.
|
||||||
|
- [x] **WebGL filter backend clobbered with `undefined`** — `src/js/init.js`, `src/js/database.js`
|
||||||
|
Both assignments are now conditional on the backend having constructed.
|
||||||
|
|
||||||
|
## P2 — Crashes on edge paths
|
||||||
|
|
||||||
|
- [x] **`RangeError: Invalid array length`** — `src/js/functions.js`
|
||||||
|
`temparr.length = findIndex(...)` went negative for a stale keyframe reference.
|
||||||
|
`lastKeyframe` / `nextKeyframe` are now index lookups that return `false`.
|
||||||
|
- [x] **`animate()` had no null guards** — `src/js/functions.js`
|
||||||
|
Object and `p_keyframes` lookups are resolved once and guarded in every per-frame
|
||||||
|
loop (`animate`, `recordAnimate`, the playback update loop, `playVideos`,
|
||||||
|
`playAudio`), which also removed dozens of repeated `.find()` calls.
|
||||||
|
- [x] **`getAssets()` recursed synchronously forever** — `src/js/database.js`
|
||||||
|
Retries on a 250 ms timer, capped at 20 attempts, and rebuilds the asset arrays
|
||||||
|
instead of appending duplicates on re-entry after an import.
|
||||||
|
- [x] **`deleteObject` threw and leaked `files`** — `src/js/functions.js`
|
||||||
|
The entry was compared to a string so it never matched; now filtered by `name`.
|
||||||
|
Video elements are also paused and unloaded on delete.
|
||||||
|
- [x] **Unguarded `keyarr[0]` / `.defaults.find(...)`** — `copyKeyframes`, `updateKeyframe`,
|
||||||
|
`applyEasing`, `keyframeProperties`, `removeKeyframe`, `checkAnyKeyframe`.
|
||||||
|
The four repetitive counterpart blocks were replaced by one
|
||||||
|
`KEYFRAME_COUNTERPARTS` map, so a missing counterpart is skipped, not fatal.
|
||||||
|
- [x] **Other unguarded lookups** — `deleteAsset`, `reGroup`, `renderLayer`,
|
||||||
|
`renderProp`, `setDuration`, `setTimelineZoom`, `saveLayerName`, `updateInputs`,
|
||||||
|
`updatePanel`, `updateStrokeValues`, `animateText`, `scrollIntoView` (3 sites),
|
||||||
|
`object:modified` / `object:rotating` / `mouse:out` / `mouse:up` handlers.
|
||||||
|
`importProject` validates the payload before touching `data.project[0]`, and
|
||||||
|
`line_h`/`line_v` go through a new `hideGuides()` helper.
|
||||||
|
|
||||||
|
## P3 — Silently wrong behaviour
|
||||||
|
|
||||||
|
- [x] **`document.onmousedown` permanently disabled** — `src/js/functions.js`
|
||||||
|
`dragTimeline` installed a `return false` handler and never removed it. It now
|
||||||
|
only overrides `onselectstart`, and restores it on mouseup.
|
||||||
|
- [x] **Keyframe time drifted on every drag** — `src/js/functions.js`
|
||||||
|
`data-time` is now always the absolute timeline time (the value every lookup keys
|
||||||
|
off), with the visual offset applied as CSS only. This also fixes keyframes on an
|
||||||
|
*expanded* row, whose `data-time` used to be layer-relative so no lookup matched.
|
||||||
|
`updateKeyframe`'s unused third argument is gone.
|
||||||
|
- [x] **Shift-deselect never removed a keyframe** — `this` inside a `$.grep` callback is not the element.
|
||||||
|
- [x] **`e.shiftDown`** → `e.shiftKey`.
|
||||||
|
- [x] **Snap guide never hid** — the row-local index was compared against the global `.keyframe` count.
|
||||||
|
- [x] **Comparison / typo bugs**
|
||||||
|
`canvas.getActiveObjects.length` → `getActiveObjects().length` ·
|
||||||
|
`strokeDashArray == [10, 5]` → element comparison ·
|
||||||
|
chroma `setValue(distance)` → `distance * 100` · `'#FFFFF'` → `'#FFFFFF'` ·
|
||||||
|
`videoPlayer.videoheight` → `videoHeight` (and both thumbnail helpers no longer
|
||||||
|
draw the canvas onto itself) · `if (start && play && !paused)` in `playAudio`
|
||||||
|
(`play` is the global function, always truthy) · `#redo` gated on `redo.length` ·
|
||||||
|
`:last-child()` → `:last-child`.
|
||||||
|
- [x] **Paste loop closure** — `var imgObj` → `let`, so each thumbnail saves its own file.
|
||||||
|
- [x] **Layers added mid-timeline were shortened** — `end: duration - currenttime` → `duration`
|
||||||
|
(media layers clamp to `min(start + assetDuration, duration)`).
|
||||||
|
- [x] **Malformed HTML** — 5 unterminated `<img …'` strings and a stray `</div>` in `src/js/ui.js`.
|
||||||
|
- [x] **Duplicate DOM ids** — `id="easing"` on both wrapper and `<select>` (the select
|
||||||
|
is now `easing-select`; `#easing select` still matches), `id="filters-title"` ×4 and
|
||||||
|
`id='item-text'` ×6 became classes, with `src/styles.css` updated to match.
|
||||||
|
Added the missing `<meta charset>`.
|
||||||
|
|
||||||
|
## P4 — Performance, leaks, export gaps
|
||||||
|
|
||||||
|
- [x] **`save()` rebuilt the record canvas on every edit** — `src/js/functions.js`
|
||||||
|
`updateRecordCanvas()` + `autoSave()` now run through a 400 ms debounce
|
||||||
|
(`schedulePersist`). `record()` still awaits `updateRecordCanvas()` directly, so an
|
||||||
|
export always captures current state. `updateObjectValues` no longer calls
|
||||||
|
`autoSave()` on every keystroke.
|
||||||
|
- [x] **`async forEach` raced the snapshot** — `src/js/functions.js`, `src/js/database.js`
|
||||||
|
Both filter-stripping loops are sequential `for…of` in `async` functions, so
|
||||||
|
`toJSON` / `toDatalessJSON` can no longer run mid-strip.
|
||||||
|
- [x] **O(n²·log n) playback** — `src/js/functions.js`
|
||||||
|
`buildKeyframeIndex()` groups keyframes by `id|name` once per rendered frame;
|
||||||
|
`lastKeyframe`, `nextKeyframe` and `checkAnyKeyframe` use it instead of sorting a
|
||||||
|
copy of the entire keyframe list per keyframe per frame. The two duplicated inner
|
||||||
|
`nextKeyframe` copies are gone.
|
||||||
|
- [x] **Exports lost audio-layer sound** — `src/js/recorder.js`
|
||||||
|
Audio layers were never routed into the capture stream. All sources (video
|
||||||
|
elements, audio layers, background audio) now mix through **one** AudioContext
|
||||||
|
into **one** destination track — necessary because `MediaRecorder` only records
|
||||||
|
the first audio track. Audio layers start/stop on their `p_keyframes` boundaries.
|
||||||
|
- [x] **Export timing** — `src/js/recorder.js`
|
||||||
|
The render clock starts after `recorder.start()` (it used to run before, losing the
|
||||||
|
first frames), stops when the animation clock covers `duration` rather than on a
|
||||||
|
wall-clock `setTimeout`, and has a `duration + 5s` safety stop.
|
||||||
|
- [x] **Leaks** — AudioContexts are closed and stream tracks stopped on export end;
|
||||||
|
object URLs are revoked (`downloadRecording`, `PostBlob`, `exportProject`,
|
||||||
|
`importProject`); the browser scroll handler is unbound before rebinding; `sortable`
|
||||||
|
is initialised once instead of per rendered layer; deleted videos are paused and
|
||||||
|
unloaded; category/audio grids are cleared before repopulating.
|
||||||
|
|
||||||
|
## P5 — Dead code, config, cleanup
|
||||||
|
|
||||||
|
- [x] **Unreachable code calling an undefined `waitForEvent`** — `src/js/recorder.js`
|
||||||
|
`initRecorder` / `recordFrame` / `exportRecording` and ~180 lines of commented-out
|
||||||
|
abandoned experiments were removed; the file is now just the live export path.
|
||||||
|
- [x] **Placeholder API keys** — `src/js/init.js`
|
||||||
|
`HAS_FONTS_KEY` / `HAS_PIXABAY_KEY` gate the requests. Without a fonts key the
|
||||||
|
pickers fall back to the bundled families instead of staying empty; without a
|
||||||
|
Pixabay key search shows an explanatory message instead of 401-ing.
|
||||||
|
- [x] **`var timeout` collided with `recorder.js`'s `timeout()`** — removed (it was unused).
|
||||||
|
- [x] **Implicit globals** — `srcfreeze`, `osc`, `type`, `newcolorkeyframe` (deleted, never read).
|
||||||
|
- [x] **Duplicate / unreachable branches** — the second `shadow.blur` arm in all three
|
||||||
|
`setValue` copies, the duplicated `objectCaching` key, the three copies of the
|
||||||
|
keyframe sort comparator (now `sortKeyframes()`), and the two identical crop blocks
|
||||||
|
in `events.js` (now `updateCropBounds()`).
|
||||||
|
- [x] **Converter was unfinished** — `src/js/converter.js`
|
||||||
|
The `workerReady` handshake never fired (`buffersReady` was never set); worker
|
||||||
|
readiness is now tracked across calls, since the worker is created once and reused.
|
||||||
|
Added `onerror` handling, an empty-result guard, and a user-visible failure path.
|
||||||
|
- [x] **Misc** — `overlay()` no longer reuses the artboard's `overlay` id ·
|
||||||
|
`getItemById` recurses so nested groups are found and stops at the first hit ·
|
||||||
|
`fabric.Lottie` guards `this.canvas` before the object is added ·
|
||||||
|
`newLottieAnimation` no longer multiplies an ms duration by 1000 ·
|
||||||
|
lottie layers get a colour · `calculateTextWidth` honours the requested font ·
|
||||||
|
`changeFont` / `loadImage` / `loadVideo` / `handleLottieUpload` have failure paths ·
|
||||||
|
`checkFilter` no longer tests for a non-existent `video` fabric type ·
|
||||||
|
`AnimatedText.assignTo` applies `text`/`props` · `animate(currenttime, false)` →
|
||||||
|
`animate(false, currenttime)` (3 sites — text animation changes never refreshed) ·
|
||||||
|
`exportProject` uses a Blob instead of a `data:` URL · `clearProject` reloads after
|
||||||
|
the deletes resolve · `readTextFile` handles blob-URL status 0 and errors ·
|
||||||
|
`.catch` added to the Localbase promises · opacity inputs clamp the input, not the
|
||||||
|
wrapper · arrow-key nudge calls `setCoords()` and is suppressed while typing ·
|
||||||
|
layer reorder shortcuts accept Ctrl as well as Cmd · alignment guides ignore hidden
|
||||||
|
objects · `alignObject` saves and dropped its `console.log`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frame-accurate export (new)
|
||||||
|
|
||||||
|
The real-time capture path was the root cause of "heavy scenes drop frames" and
|
||||||
|
"video layers are sampled at the wrong moment". It is now the *fallback*, not the
|
||||||
|
default.
|
||||||
|
|
||||||
|
## `src/js/render.js` — offline renderer
|
||||||
|
|
||||||
|
No clock is attached to the render. For each of `duration × 30` frames:
|
||||||
|
|
||||||
|
1. every video layer is seeked to the exact frame time and the code **awaits
|
||||||
|
`seeked`** before drawing (a bare `currentTime =` is async — drawing straight
|
||||||
|
after captures the *previous* frame, which is why real-time exports smeared
|
||||||
|
video layers);
|
||||||
|
2. lottie layers are advanced to the same time;
|
||||||
|
3. `recordAnimate(time)` lays out the frame, `canvasrecord.renderAll()` draws it;
|
||||||
|
4. a `VideoFrame` is built from the canvas with an explicit timestamp and pushed
|
||||||
|
through a `VideoEncoder` (VP9, falling back to VP8), with the encoder queue
|
||||||
|
held at ≤ 8 frames so memory stays bounded.
|
||||||
|
|
||||||
|
Audio is mixed in one `OfflineAudioContext` pass — every audio layer, every video
|
||||||
|
layer's soundtrack and the background track, each placed at its own
|
||||||
|
`start`/`trimstart`/`end` with its own gain — then encoded to Opus with
|
||||||
|
`AudioEncoder`. Sample-accurate, and unlike the live path it does not depend on
|
||||||
|
playback keeping up.
|
||||||
|
|
||||||
|
Both tracks are sorted by timestamp and muxed into one WebM.
|
||||||
|
|
||||||
|
`recordAnimate()` skips `playVideos()` while `offlinerender` is set, so real-time
|
||||||
|
playback cannot fight the explicit seeking.
|
||||||
|
|
||||||
|
## `src/js/webm-writer2.js` — extended
|
||||||
|
|
||||||
|
Was single-track video only, and unreferenced by `index.html`. Now loaded, and:
|
||||||
|
|
||||||
|
- optional second **Opus** track (`A_OPUS` + 19-byte `OpusHead` CodecPrivate,
|
||||||
|
`CodecDelay`, `SeekPreRoll`, `Audio` element with sample rate/channels);
|
||||||
|
- `addFrame(chunk, trackNumber)` accepts `EncodedAudioChunk` as well as
|
||||||
|
`EncodedVideoChunk`; `addFrameToCluster` no longer hardcodes track 1.
|
||||||
|
|
||||||
|
Three bugs fixed in the vendored library along the way:
|
||||||
|
|
||||||
|
- **`MAX_CLUSTER_DURATION_MSEC` was `5000000`** (~58 days), so every frame went
|
||||||
|
into a single cluster and block timecodes — a signed 16-bit offset — overflowed
|
||||||
|
past ~32 s. Now 5000 ms, and clusters close on a video keyframe.
|
||||||
|
- **The header buffer was a fixed 256 bytes**, which a second `TrackEntry`
|
||||||
|
overflows (`ArrayBufferDataStream's pos lies beyond end of buffer`). Now 1024.
|
||||||
|
- **`instanceof Uint8Array`** for byte payloads fails across a realm boundary;
|
||||||
|
`ArrayBuffer.isView` is now accepted too.
|
||||||
|
|
||||||
|
## Safety net
|
||||||
|
|
||||||
|
The muxer is hand-rolled, so `renderFrameAccurate()` loads the finished blob into
|
||||||
|
a `<video>` element and checks it decodes before handing it over. Any failure —
|
||||||
|
no WebCodecs, unsupported codec, encoder error, failed verification — returns
|
||||||
|
`null` and `record()` transparently falls back to the real-time encoder.
|
||||||
|
|
||||||
|
## Tests — `test/webm-muxer.test.js`
|
||||||
|
|
||||||
|
Runs in plain Node (no deps): `node test/webm-muxer.test.js`
|
||||||
|
|
||||||
|
Loads the muxer in a VM context, feeds fake encoded chunks, then parses the
|
||||||
|
resulting bytes with a small EBML reader and asserts the EBML header, Segment,
|
||||||
|
one or two `TrackEntry` with the right `CodecID`/`TrackType`, a 19-byte
|
||||||
|
`OpusHead`, multiple clusters, every block present on the right track, block
|
||||||
|
timecodes inside signed 16-bit range, ascending cluster timecodes, and that
|
||||||
|
`cluster timecode + block timecode` reconstructs the input timestamps to within
|
||||||
|
1 ms. Covers video-only and video+audio at 75 s (13 clusters, 3000 blocks).
|
||||||
|
|
||||||
|
Both cases pass. The 256-byte header bug above was caught by this test, not by
|
||||||
|
inspection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Left in place deliberately
|
||||||
|
|
||||||
|
- **`src/js/encode-worker.js`** is still unreferenced. It was the old
|
||||||
|
File-System-Access-API sketch; `render.js` supersedes it and buffers to memory
|
||||||
|
instead of requiring a save-file picker. Deleting it is a product decision.
|
||||||
|
- **MP4 and GIF** go through ffmpeg.wasm (`converter.js`), vendored out of
|
||||||
|
`node_modules` and pinned by `package-lock.json`. This replaced the asm.js
|
||||||
|
worker loaded from archive.org, which had no integrity check and needed the
|
||||||
|
network. See "ffmpeg.wasm migration" below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ffmpeg.wasm migration (new)
|
||||||
|
|
||||||
|
MP4/GIF export used to `importScripts()` an 18.5 MB asm.js ffmpeg build from
|
||||||
|
`https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js` — no integrity check, no
|
||||||
|
pinning, and executed in the page. `scripts/vendor.mjs` now **copies**
|
||||||
|
ffmpeg.wasm out of `node_modules`, where `package-lock.json` pins it by hash.
|
||||||
|
There is no CDN fallback left anywhere in the app.
|
||||||
|
|
||||||
|
Three things this ran into, all of which cost a debugging round:
|
||||||
|
|
||||||
|
- **`@ffmpeg/core` needs `SharedArrayBuffer`.** The default core is built with
|
||||||
|
pthreads, which requires COOP/COEP cross-origin isolation, which would break
|
||||||
|
the Pixabay, Unsplash and Google Fonts requests. `@ffmpeg/core-st` — the
|
||||||
|
single-threaded build — is used instead. Verified: the core loads with
|
||||||
|
`crossOriginIsolated === false` and `SharedArrayBuffer` undefined.
|
||||||
|
- **`mainName: 'main'` is mandatory with that core.** The loader defaults to
|
||||||
|
`proxy_main`, which only the multi-threaded build exports. Without it, `load()`
|
||||||
|
compiles all 23 MB and then aborts with *Cannot call unknown function
|
||||||
|
proxy_main*.
|
||||||
|
- **One conversion per load.** The single-threaded core's `main` calls `exit()`,
|
||||||
|
so a second `run()` on the same instance dies with *Program terminated with
|
||||||
|
exit(0)*. `convertStreams` therefore builds and tears down an instance per
|
||||||
|
conversion — measured at ~110 ms, and it returns the 23 MB heap in between.
|
||||||
|
The teardown also runs on failure: an interrupted run otherwise leaves the
|
||||||
|
loader's internal "running" flag set and every later conversion fails with
|
||||||
|
*can only run one command at a time* until the page is reloaded.
|
||||||
|
|
||||||
|
MP4 now encodes with `libx264 -crf 23 -pix_fmt yuv420p` plus AAC audio rather
|
||||||
|
than `mpeg4 -b:v 6400k`. Same core, better quality per byte, and `yuv420p` is
|
||||||
|
what makes it play in Safari and QuickTime.
|
||||||
|
|
||||||
|
`WITH_FFMPEG=0` no longer means "download it at run time" — it means MP4/GIF
|
||||||
|
export is unavailable, and `converter.js` says so instead of failing obscurely.
|
||||||
|
|
||||||
|
Verified in Chromium against a real MediaRecorder WebM: MP4 24 KB with an
|
||||||
|
`ftypisom` header that decodes to 320x240 / 2.00 s, GIF 138 KB with a `GIF89a`
|
||||||
|
header, the two run back to back, and the missing-core path produces the right
|
||||||
|
message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not verified
|
||||||
|
|
||||||
|
`node --check` passes on every script; the muxer is covered by the Node test
|
||||||
|
above. The app itself was **not** loaded in a browser (a running Chrome instance
|
||||||
|
held the Playwright profile lock), so the following still needs a manual pass:
|
||||||
|
|
||||||
|
1. `cd src && python -m http.server 8765`, open `http://localhost:8765`
|
||||||
|
2. Add a shape, keyframe it, drag the keyframe, scrub, undo/redo
|
||||||
|
3. Export as WEBM with a video layer and an audio layer present — confirm the
|
||||||
|
console shows `Rendering n%` (frame-accurate path) and not
|
||||||
|
`Falling back to the real-time encoder`
|
||||||
|
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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Serves src/ directly — no Node process needed, and Caddy gets a real
|
||||||
|
# certificate automatically, which the app requires away from localhost:
|
||||||
|
# WebCodecs (fast export) and IndexedDB (project saving) are secure-context
|
||||||
|
# only, so plain http:// on a LAN address silently degrades both.
|
||||||
|
#
|
||||||
|
# sudo caddy run --config deploy/Caddyfile
|
||||||
|
#
|
||||||
|
# Replace the site address with your hostname. For a purely local install use
|
||||||
|
# `localhost` and Caddy will install its own trusted certificate.
|
||||||
|
|
||||||
|
motionity.example.com {
|
||||||
|
root * /opt/motionity/src
|
||||||
|
file_server
|
||||||
|
|
||||||
|
encode zstd gzip
|
||||||
|
|
||||||
|
@static {
|
||||||
|
path *.js *.css *.svg *.png *.jpg *.jpeg *.gif *.webp *.woff2 *.woff *.mp3 *.wav *.mp4 *.webm
|
||||||
|
}
|
||||||
|
header @static Cache-Control "public, max-age=604800"
|
||||||
|
header /index.html Cache-Control "no-cache"
|
||||||
|
|
||||||
|
header {
|
||||||
|
X-Content-Type-Options nosniff
|
||||||
|
Referrer-Policy no-referrer
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
; Bare-metal unit for the built-in Node server.
|
||||||
|
;
|
||||||
|
; Install:
|
||||||
|
; sudo useradd --system --home /opt/motionity --shell /usr/sbin/nologin motionity
|
||||||
|
; sudo cp -r . /opt/motionity && sudo chown -R motionity: /opt/motionity
|
||||||
|
; sudo -u motionity node /opt/motionity/scripts/vendor.mjs
|
||||||
|
; sudo cp deploy/motionity.service /etc/systemd/system/
|
||||||
|
; sudo systemctl enable --now motionity
|
||||||
|
;
|
||||||
|
; Binds to loopback by default. To serve other machines, put a TLS reverse
|
||||||
|
; proxy in front (deploy/nginx.conf or deploy/Caddyfile) — WebCodecs and
|
||||||
|
; IndexedDB are unavailable over plain http:// on a non-localhost origin.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Motionity static server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=motionity
|
||||||
|
Group=motionity
|
||||||
|
WorkingDirectory=/opt/motionity
|
||||||
|
Environment=HOST=127.0.0.1
|
||||||
|
Environment=PORT=8080
|
||||||
|
ExecStart=/usr/bin/node /opt/motionity/scripts/server.cjs
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
# The process only ever reads static files.
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictAddressFamilies=AF_INET AF_INET6
|
||||||
|
RestrictNamespaces=true
|
||||||
|
MemoryDenyWriteExecute=false
|
||||||
|
LockPersonality=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Serves src/ directly. Drop into /etc/nginx/sites-available/motionity and
|
||||||
|
# point ssl_certificate at your own files.
|
||||||
|
#
|
||||||
|
# TLS is not decoration here: WebCodecs (the fast exporter) and IndexedDB
|
||||||
|
# (project saving) are secure-context only, so the app loses both when reached
|
||||||
|
# over plain http:// on anything other than localhost.
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name motionity.example.com;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name motionity.example.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/motionity.example.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/motionity.example.com/privkey.pem;
|
||||||
|
|
||||||
|
root /opt/motionity/src;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# The audio and video panels seek, which needs byte ranges (on by default).
|
||||||
|
# Large media should not be buffered through gzip.
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy no-referrer always;
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~* \.(js|css|svg|png|jpe?g|gif|webp|woff2?|mp3|wav|mp4|webm)$ {
|
||||||
|
add_header Cache-Control "public, max-age=604800" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
motionity:
|
||||||
|
image: git.azuze.fr/kawa/motionity:latest
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
read_only: true
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Motionity desktop shell.
|
||||||
|
//
|
||||||
|
// The renderer is loaded over http://127.0.0.1 rather than file:// on purpose:
|
||||||
|
// the exporter needs WebCodecs (VideoEncoder) and project storage needs
|
||||||
|
// IndexedDB, and Chromium gates both behind a secure context. A loopback
|
||||||
|
// origin qualifies; file:// does not.
|
||||||
|
|
||||||
|
const { app, BrowserWindow, Menu, shell, session } = require('electron');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { startServer: start } = require('../scripts/server.cjs');
|
||||||
|
|
||||||
|
// Electron's fs shim reads through the asar archive, so the same path works
|
||||||
|
// in development and in a packaged build.
|
||||||
|
const appRoot = path.join(__dirname, '..', 'src');
|
||||||
|
|
||||||
|
let serverUrl = null;
|
||||||
|
let httpServer = null;
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
const { server, url } = await start({
|
||||||
|
root: appRoot,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 0,
|
||||||
|
});
|
||||||
|
httpServer = server;
|
||||||
|
serverUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
minWidth: 1100,
|
||||||
|
minHeight: 700,
|
||||||
|
backgroundColor: '#141629',
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
// The editor builds its ffmpeg worker from a blob: URL.
|
||||||
|
webSecurity: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
win.once('ready-to-show', () => win.show());
|
||||||
|
win.loadURL(serverUrl);
|
||||||
|
|
||||||
|
// Pixabay/sponsor links are target="_blank"; send them to the real browser
|
||||||
|
// instead of opening a chrome-less Electron window.
|
||||||
|
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (/^https?:/.test(url)) shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
win.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (!url.startsWith(serverUrl)) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (/^https?:/.test(url)) shell.openExternal(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMenu() {
|
||||||
|
const isMac = process.platform === 'darwin';
|
||||||
|
Menu.setApplicationMenu(
|
||||||
|
Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'File',
|
||||||
|
submenu: [isMac ? { role: 'close' } : { role: 'quit' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' },
|
||||||
|
{ role: 'redo' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ role: 'selectAll' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'reload' },
|
||||||
|
{ role: 'forceReload' },
|
||||||
|
{ role: 'toggleDevTools' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'resetZoom' },
|
||||||
|
{ role: 'zoomIn' },
|
||||||
|
{ role: 'zoomOut' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'togglefullscreen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// VS Code's integrated terminal exports ELECTRON_RUN_AS_NODE=1, which makes the
|
||||||
|
// electron binary behave as plain Node and leaves the electron module empty.
|
||||||
|
if (!app || typeof app.whenReady !== 'function') {
|
||||||
|
console.error(
|
||||||
|
'Electron APIs are unavailable. ELECTRON_RUN_AS_NODE is probably set in ' +
|
||||||
|
'this shell (VS Code sets it). Unset it and run `npm run dev` again.'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
const [win] = BrowserWindow.getAllWindows();
|
||||||
|
if (win) {
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
// Nothing in the editor needs camera, mic, geolocation or notifications.
|
||||||
|
session.defaultSession.setPermissionRequestHandler((_wc, _perm, done) =>
|
||||||
|
done(false)
|
||||||
|
);
|
||||||
|
|
||||||
|
await startServer();
|
||||||
|
buildMenu();
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (httpServer) httpServer.close();
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+3681
File diff suppressed because it is too large
Load Diff
+116
@@ -0,0 +1,116 @@
|
|||||||
|
{
|
||||||
|
"name": "motionity",
|
||||||
|
"productName": "Motionity",
|
||||||
|
"version": "2.0.2",
|
||||||
|
"desktopName": "app.motionity.desktop.desktop",
|
||||||
|
"description": "Web-based motion graphics editor with keyframing, masking, filters and text animations",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "Kawa",
|
||||||
|
"main": "electron/main.js",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"vendor": "node scripts/vendor.mjs",
|
||||||
|
"icons": "node scripts/make-icon.cjs",
|
||||||
|
"start": "node scripts/server.cjs",
|
||||||
|
"dev": "electron .",
|
||||||
|
"dist:win": "npm run vendor && npm run icons && electron-builder --win",
|
||||||
|
"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: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:run": "docker run --rm -p 8080:8080 motionity:latest",
|
||||||
|
"release:build": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/build-release.ps1",
|
||||||
|
"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:wsl": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease -UseWsl -WinInWsl -KeepInWsl",
|
||||||
|
"test": "node --test \"test/**/*.test.js\""
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ffmpeg/core-st": "^0.11.1",
|
||||||
|
"@ffmpeg/ffmpeg": "^0.11.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^43.4.0",
|
||||||
|
"electron-builder": "^26.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "app.motionity.desktop",
|
||||||
|
"productName": "Motionity",
|
||||||
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist",
|
||||||
|
"buildResources": "build"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"electron/**/*",
|
||||||
|
"scripts/server.cjs",
|
||||||
|
"src/**/*",
|
||||||
|
"!src/**/*.map",
|
||||||
|
"!node_modules/**"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "portable",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "build/icon.png"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"perMachine": false,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"shortcutName": "Motionity"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"flatpak"
|
||||||
|
],
|
||||||
|
"icon": "build/icon.png",
|
||||||
|
"category": "Graphics",
|
||||||
|
"synopsis": "Motion graphics editor",
|
||||||
|
"syncDesktopName": true,
|
||||||
|
"desktop": {
|
||||||
|
"entry": {
|
||||||
|
"Name": "Motionity",
|
||||||
|
"Categories": "Graphics;AudioVideo;VideoEditor;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"appImage": {
|
||||||
|
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
||||||
|
},
|
||||||
|
"flatpak": {
|
||||||
|
"runtimeVersion": "23.08",
|
||||||
|
"baseVersion": "23.08",
|
||||||
|
"finishArgs": [
|
||||||
|
"--share=ipc",
|
||||||
|
"--socket=x11",
|
||||||
|
"--socket=wayland",
|
||||||
|
"--socket=pulseaudio",
|
||||||
|
"--device=dri",
|
||||||
|
"--share=network",
|
||||||
|
"--filesystem=xdg-download",
|
||||||
|
"--filesystem=xdg-documents",
|
||||||
|
"--filesystem=xdg-pictures",
|
||||||
|
"--filesystem=xdg-videos",
|
||||||
|
"--filesystem=xdg-music"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
#requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build the Motionity desktop installers with electron-builder.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Produces, in dist/:
|
||||||
|
|
||||||
|
motionity-<tag>-win-x64-setup.exe NSIS installer
|
||||||
|
motionity-<tag>-win-x64-portable.exe portable exe
|
||||||
|
motionity-<tag>-linux-x86_64.AppImage AppImage
|
||||||
|
motionity-<tag>-linux-x86_64.flatpak Flatpak bundle
|
||||||
|
SHA256SUMS.txt
|
||||||
|
|
||||||
|
The PowerShell equivalent of `npm run dist:win` / `npm run dist:linux`, with
|
||||||
|
two differences that matter for a release:
|
||||||
|
|
||||||
|
* every artifact name carries the tag, so publish.ps1 can glob exactly this
|
||||||
|
tag's files and never ship a stale one from an earlier build;
|
||||||
|
* NSIS and portable get distinct names. package.json's global artifactName
|
||||||
|
(`${productName}-${version}-${arch}.${ext}`) resolves to the same file for
|
||||||
|
both, so one silently overwrites the other.
|
||||||
|
|
||||||
|
The vendor step runs first: index.html references only src/vendor/, which is
|
||||||
|
gitignored, so a package built without it ships an app whose every script tag
|
||||||
|
404s.
|
||||||
|
|
||||||
|
Windows builds the .exe targets; AppImage and Flatpak need a Linux host or
|
||||||
|
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
|
||||||
|
./scripts/build-release.ps1
|
||||||
|
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
|
||||||
|
./scripts/build-release.ps1 -Targets win -Tag v1.1.0
|
||||||
|
Windows installers only, named v1.1.0.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/build-release.ps1 -Targets win,linux-appimage
|
||||||
|
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
|
||||||
|
./scripts/build-release.ps1 -SkipVendor -SkipDeps
|
||||||
|
Reuse src/vendor/ and node_modules as they are — the fast rebuild.
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
# Platforms to package. "win" is NSIS + portable in one pass, and "win-nsis" /
|
||||||
|
# "win-portable" are those two separately — worth having because only the NSIS
|
||||||
|
# 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"),
|
||||||
|
|
||||||
|
# Version used in the artifact names. Defaults to v<package.json version>,
|
||||||
|
# because electron-builder stamps that same version into the app itself — a
|
||||||
|
# git-describe tag here would disagree with what the installed app reports.
|
||||||
|
[string]$Tag,
|
||||||
|
|
||||||
|
# Skip `npm run vendor` (the ~20 MB third-party download into src/vendor/).
|
||||||
|
[switch]$SkipVendor,
|
||||||
|
|
||||||
|
# Skip the npm install even when node_modules is missing.
|
||||||
|
[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.
|
||||||
|
[switch]$Clean
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Invoke-Checked {
|
||||||
|
# NB: the param is $CmdArgs, not $Args. $Args is an automatic variable in
|
||||||
|
# PowerShell; a param of that name never binds the passed array (it stays the
|
||||||
|
# function's own empty $args), so `& $Exe @Args` would run the exe with no
|
||||||
|
# arguments — e.g. bare `npm`, which just prints usage and exits 1.
|
||||||
|
param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$CmdArgs)
|
||||||
|
Write-Host " > $Exe $($CmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||||
|
& $Exe @CmdArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "'$Exe $($CmdArgs -join ' ')' failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ArtifactName {
|
||||||
|
<#
|
||||||
|
electron-builder rejects an artifactName that has no ${ext} macro, and
|
||||||
|
PowerShell would read `${ext}` inside a double-quoted string as a variable,
|
||||||
|
so the macro is appended from a single-quoted literal.
|
||||||
|
#>
|
||||||
|
param([Parameter(Mandatory)][string]$Stem)
|
||||||
|
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
|
||||||
|
Push-Location $repoRoot
|
||||||
|
try {
|
||||||
|
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
|
||||||
|
|
||||||
|
if (-not $Tag) { $Tag = "v$($pkg.version)" }
|
||||||
|
if ($Tag.TrimStart("v") -ne $pkg.version) {
|
||||||
|
Write-Warning "-Tag '$Tag' does not match package.json version '$($pkg.version)'. electron-builder stamps package.json into the app, so the file names and the app's own About version would disagree — bump package.json first."
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = "motionity-$Tag"
|
||||||
|
$distDir = Join-Path $repoRoot "dist"
|
||||||
|
|
||||||
|
Write-Host "Motionity release build" -ForegroundColor Cyan
|
||||||
|
Write-Host " tag : $Tag"
|
||||||
|
Write-Host " targets : $($Targets -join ', ')"
|
||||||
|
Write-Host " output : $distDir"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Expand "linux" to its two concrete bundles and drop duplicates. "win" is left
|
||||||
|
# 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 = @()
|
||||||
|
foreach ($t in $Targets) {
|
||||||
|
if ($t -eq "linux") { $resolvedTargets += "linux-appimage", "linux-flatpak" }
|
||||||
|
else { $resolvedTargets += $t }
|
||||||
|
}
|
||||||
|
$resolvedTargets = @($resolvedTargets | Select-Object -Unique)
|
||||||
|
|
||||||
|
# electron-builder produces AppImage and Flatpak with Linux-only tooling (its
|
||||||
|
# downloaded appimage bundle, and flatpak-builder). -UseWsl hands those two
|
||||||
|
# targets to a WSL distro; without it they stay a warning rather than an error,
|
||||||
|
# because the other right answer is running this whole script under pwsh on a
|
||||||
|
# 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) {
|
||||||
|
Write-Host "Cleaning dist/..." -ForegroundColor Cyan
|
||||||
|
if (Test-Path $distDir) { Remove-Item -Recurse -Force $distDir }
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
|
||||||
|
|
||||||
|
# --- Dependencies ---------------------------------------------------------
|
||||||
|
# Only when node_modules is absent: `npm ci` deletes the tree and re-extracts
|
||||||
|
# ~250 MB of Electron every time, which turns a 2-minute rebuild into a
|
||||||
|
# 10-minute one for no gain.
|
||||||
|
if (-not $SkipDeps -and -not (Test-Path (Join-Path $repoRoot "node_modules"))) {
|
||||||
|
Write-Host "Installing dependencies..." -ForegroundColor Cyan
|
||||||
|
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "npm not found — install Node 18+, or pass -SkipDeps if node_modules is provided some other way."
|
||||||
|
}
|
||||||
|
Invoke-Checked npm @("ci", "--no-audit", "--no-fund")
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Vendored assets ------------------------------------------------------
|
||||||
|
# index.html points only at src/vendor/, which is gitignored, so this has to
|
||||||
|
# run before electron-builder copies src/ into the package — not after.
|
||||||
|
if (-not $SkipVendor) {
|
||||||
|
Write-Host "Vendoring third-party assets..." -ForegroundColor Cyan
|
||||||
|
Invoke-Checked node @("scripts/vendor.mjs")
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# A missing src/vendor/ yields an installer that opens to a blank editor and
|
||||||
|
# only fails at run time, so check one file that must be there.
|
||||||
|
$vendorProbe = Join-Path $repoRoot "src/vendor/fabric.min.js"
|
||||||
|
if (-not (Test-Path $vendorProbe)) {
|
||||||
|
throw "src/vendor/fabric.min.js is missing — the package would ship an app whose scripts all 404. Run without -SkipVendor."
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Icon -----------------------------------------------------------------
|
||||||
|
# build/icon.png is gitignored and generated; electron-builder derives the
|
||||||
|
# Windows .ico and the Linux icon set from it and fails without it.
|
||||||
|
Write-Host "Rendering icon..." -ForegroundColor Cyan
|
||||||
|
Invoke-Checked node @("scripts/make-icon.cjs")
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# --- Package --------------------------------------------------------------
|
||||||
|
# The local binary rather than npx: npx renamed --no-install to --no in npm 10,
|
||||||
|
# and a version-dependent flag inside a release script is a trap.
|
||||||
|
$builder = Join-Path $repoRoot "node_modules/.bin/electron-builder.cmd"
|
||||||
|
if (-not (Test-Path $builder)) {
|
||||||
|
$builder = Join-Path $repoRoot "node_modules/.bin/electron-builder"
|
||||||
|
}
|
||||||
|
if (-not (Test-Path $builder)) {
|
||||||
|
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) {
|
||||||
|
Write-Host "Packaging $target..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
# --publish never: electron-builder otherwise tries to upload to whatever
|
||||||
|
# provider it infers from the repo URL as soon as the tag looks like a
|
||||||
|
# release. Publishing is publish.ps1's job, against Gitea.
|
||||||
|
switch ($target) {
|
||||||
|
"win" {
|
||||||
|
$builderArgs = @(
|
||||||
|
"--win", "--publish", "never",
|
||||||
|
"-c.nsis.artifactName=$(Get-ArtifactName "$prefix-win-x64-setup")",
|
||||||
|
"-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" {
|
||||||
|
$builderArgs = @(
|
||||||
|
"--linux", "AppImage", "--publish", "never",
|
||||||
|
"-c.appImage.artifactName=$(Get-ArtifactName "$prefix-linux-x86_64")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"linux-flatpak" {
|
||||||
|
$builderArgs = @(
|
||||||
|
"--linux", "flatpak", "--publish", "never",
|
||||||
|
"-c.flatpak.artifactName=$(Get-ArtifactName "$prefix-linux-x86_64")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# electron-builder drops auto-update metadata and the NSIS payload beside the
|
||||||
|
# installers. publish.ps1 uploads everything matching the tag prefix, so clear
|
||||||
|
# them out here instead of attaching 80 MB of intermediates to the release.
|
||||||
|
Get-ChildItem $distDir -File | Where-Object {
|
||||||
|
$_.Name -like "*.blockmap" -or $_.Name -like "latest*.yml" -or
|
||||||
|
$_.Name -like "*.nsis.7z" -or $_.Name -eq "builder-debug.yml"
|
||||||
|
} | Remove-Item -Force
|
||||||
|
|
||||||
|
$built = @(Get-ChildItem $distDir -Filter "$prefix-*" -File | Sort-Object Name)
|
||||||
|
|
||||||
|
# --- 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 ------------------------------------------------------------
|
||||||
|
Write-Host "Writing checksums..." -ForegroundColor Cyan
|
||||||
|
$sumsPath = Join-Path $distDir "SHA256SUMS.txt"
|
||||||
|
# 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)"
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
[System.IO.File]::WriteAllText($sumsPath, ($lines -join "`n") + "`n", [System.Text.ASCIIEncoding]::new())
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Write-Host "Done. Built:" -ForegroundColor Green
|
||||||
|
foreach ($f in $built) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Renders build/icon.png (512x512) from the same geometry as
|
||||||
|
// src/assets/logo.svg: three white rounded bars on the app's dark background.
|
||||||
|
//
|
||||||
|
// Hand-rolled so packaging needs no image toolchain (no ImageMagick, no sharp).
|
||||||
|
// electron-builder derives the Windows .ico and the Linux icon set from it.
|
||||||
|
|
||||||
|
const { deflateSync } = require('node:zlib');
|
||||||
|
const { mkdirSync, writeFileSync } = require('node:fs');
|
||||||
|
const { join, resolve } = require('node:path');
|
||||||
|
|
||||||
|
const SIZE = 512;
|
||||||
|
const SS = 4; // supersampling factor, for antialiased edges
|
||||||
|
const BG = [0x14, 0x16, 0x29];
|
||||||
|
const FG = [0xff, 0xff, 0xff];
|
||||||
|
const BG_RADIUS = 96; // squircle-ish corner on the icon plate
|
||||||
|
|
||||||
|
// viewBox="0 0 24 19" in src/assets/logo.svg
|
||||||
|
const VIEW = { w: 24, h: 19 };
|
||||||
|
const BARS = [
|
||||||
|
{ x: 17.3193, y: 8.36011, w: 6.08, h: 10.64, r: 3.04 },
|
||||||
|
{ x: 8.95996, y: 0, w: 6.08, h: 19, r: 3.04 },
|
||||||
|
{ x: 0.599609, y: 0, w: 6.08, h: 19, r: 3.04 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function insideRoundedRect(px, py, { x, y, w, h, r }) {
|
||||||
|
const dx = Math.max(x + r - px, 0, px - (x + w - r));
|
||||||
|
const dy = Math.max(y + r - py, 0, py - (y + h - r));
|
||||||
|
return dx * dx + dy * dy <= r * r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The logo occupies 62% of the plate, centred.
|
||||||
|
const scale = (SIZE * 0.62) / VIEW.h;
|
||||||
|
const offsetX = (SIZE - VIEW.w * scale) / 2;
|
||||||
|
const offsetY = (SIZE - VIEW.h * scale) / 2;
|
||||||
|
|
||||||
|
const plate = { x: 0, y: 0, w: SIZE, h: SIZE, r: BG_RADIUS };
|
||||||
|
|
||||||
|
// Raw RGBA scanlines, each prefixed with filter type 0.
|
||||||
|
const raw = Buffer.alloc(SIZE * (1 + SIZE * 4));
|
||||||
|
for (let py = 0; py < SIZE; py++) {
|
||||||
|
const rowStart = py * (1 + SIZE * 4);
|
||||||
|
raw[rowStart] = 0;
|
||||||
|
for (let px = 0; px < SIZE; px++) {
|
||||||
|
let plateHits = 0;
|
||||||
|
let barHits = 0;
|
||||||
|
for (let sy = 0; sy < SS; sy++) {
|
||||||
|
for (let sx = 0; sx < SS; sx++) {
|
||||||
|
const fx = px + (sx + 0.5) / SS;
|
||||||
|
const fy = py + (sy + 0.5) / SS;
|
||||||
|
if (!insideRoundedRect(fx, fy, plate)) continue;
|
||||||
|
plateHits++;
|
||||||
|
const lx = (fx - offsetX) / scale;
|
||||||
|
const ly = (fy - offsetY) / scale;
|
||||||
|
if (BARS.some((bar) => insideRoundedRect(lx, ly, bar))) barHits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const total = SS * SS;
|
||||||
|
const alpha = Math.round((plateHits / total) * 255);
|
||||||
|
// Blend bar coverage over the plate colour; alpha carries the plate edge.
|
||||||
|
const mix = plateHits ? barHits / plateHits : 0;
|
||||||
|
const o = rowStart + 1 + px * 4;
|
||||||
|
for (let c = 0; c < 3; c++) {
|
||||||
|
raw[o + c] = Math.round(BG[c] + (FG[c] - BG[c]) * mix);
|
||||||
|
}
|
||||||
|
raw[o + 3] = alpha;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRC_TABLE = (() => {
|
||||||
|
const table = new Int32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let c = 0xffffffff;
|
||||||
|
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||||
|
return (c ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type, data) {
|
||||||
|
const out = Buffer.alloc(8 + data.length + 4);
|
||||||
|
out.writeUInt32BE(data.length, 0);
|
||||||
|
out.write(type, 4, 'ascii');
|
||||||
|
data.copy(out, 8);
|
||||||
|
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(SIZE, 0);
|
||||||
|
ihdr.writeUInt32BE(SIZE, 4);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 6; // colour type: RGBA
|
||||||
|
const png = Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr),
|
||||||
|
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||||
|
chunk('IEND', Buffer.alloc(0)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const buildDir = resolve(__dirname, '..', 'build');
|
||||||
|
mkdirSync(buildDir, { recursive: true });
|
||||||
|
const dest = join(buildDir, 'icon.png');
|
||||||
|
writeFileSync(dest, png);
|
||||||
|
console.log(`Wrote ${dest} (${SIZE}x${SIZE}, ${Math.round(png.length / 1024)} KB)`);
|
||||||
@@ -0,0 +1,844 @@
|
|||||||
|
#requires -Version 5.1
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Build the Motionity container image + desktop installers; push the image to the
|
||||||
|
Gitea registry and attach the installers to a Gitea release.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Builds the Docker image from the repo Dockerfile, tags it for the Gitea
|
||||||
|
registry (git.azuze.fr by default), logs in, and pushes one or more tags.
|
||||||
|
|
||||||
|
It also builds the desktop installers (scripts/build-release.ps1) from the same
|
||||||
|
commit, so both carry the same -Tag. Installers cannot live in a container
|
||||||
|
registry, so -PublishRelease attaches them to the Gitea release for that tag
|
||||||
|
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
|
||||||
|
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
|
||||||
|
overrides -Targets. The Linux targets need a Linux host or WSL: on Windows,
|
||||||
|
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:
|
||||||
|
1. -Username / -Password parameters
|
||||||
|
2. $env:GITEA_USER / $env:GITEA_TOKEN
|
||||||
|
3. Interactive prompt (token is read as a SecureString)
|
||||||
|
|
||||||
|
Use a Gitea access token (Settings -> Applications) as the password, not your
|
||||||
|
account password. The image push needs package read/write scope; the release
|
||||||
|
upload needs repository write scope (`write:repository`).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/publish.ps1
|
||||||
|
Build and push :latest plus v<package.json version>; build installers locally.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/publish.ps1 -Tag v1.1.0 -PublishRelease
|
||||||
|
Full release: push the image and attach every dist/ installer to release v1.1.0.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/publish.ps1 -BinariesOnly win -Tag v1.1.0
|
||||||
|
Windows installers only — build them and attach them to release v1.1.0. Docker
|
||||||
|
is never invoked, so this works with Docker Desktop stopped.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/publish.ps1 -BinariesOnly win,linux-appimage -Tag 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
|
||||||
|
./scripts/publish.ps1 -BinariesOnly win -NoBinaryBuild -Tag v1.1.0
|
||||||
|
Retry a failed upload: attach the installers already in dist/ without rebuilding
|
||||||
|
(the target list is required syntactically but ignored — every dist/ installer
|
||||||
|
for the tag is uploaded regardless).
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
./scripts/publish.ps1 -NoBinaries
|
||||||
|
Container only — no installer build, so no Node toolchain needed.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
$env:GITEA_USER = "kawa"; $env:GITEA_TOKEN = "xxxx"; ./scripts/publish.ps1 -SkipLogin:$false
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
# Registry host (Gitea instance).
|
||||||
|
[string]$Registry = "git.azuze.fr",
|
||||||
|
|
||||||
|
# Owner / organisation that holds the package and the repo.
|
||||||
|
[string]$Owner = "kawa",
|
||||||
|
|
||||||
|
# Image name.
|
||||||
|
[string]$Image = "motionity",
|
||||||
|
|
||||||
|
# Repository name holding the releases. The image and the repo are not named
|
||||||
|
# the same here (motionity vs Motionity), so this is separate from -Image.
|
||||||
|
[string]$Repo = "Motionity",
|
||||||
|
|
||||||
|
# Primary tag. Defaults to v<package.json version>.
|
||||||
|
[string]$Tag,
|
||||||
|
|
||||||
|
# Also push :latest. On by default.
|
||||||
|
[switch]$NoLatest,
|
||||||
|
|
||||||
|
# Registry username. Falls back to $env:GITEA_USER then a prompt.
|
||||||
|
[string]$Username,
|
||||||
|
|
||||||
|
# Registry token/password. Falls back to $env:GITEA_TOKEN then a prompt.
|
||||||
|
[string]$Password,
|
||||||
|
|
||||||
|
# Ship the image without the 18.5 MB asm.js ffmpeg build: "0" makes MP4/GIF
|
||||||
|
# export fetch it from archive.org on first use instead of working offline.
|
||||||
|
# The Dockerfile declares this ARG; it has no ARG VERSION.
|
||||||
|
[ValidateSet("0", "1")]
|
||||||
|
[string]$WithFfmpeg = "1",
|
||||||
|
|
||||||
|
# Skip the image build and only push existing local tags.
|
||||||
|
[switch]$NoBuild,
|
||||||
|
|
||||||
|
# Skip docker login (assume already authenticated).
|
||||||
|
[switch]$SkipLogin,
|
||||||
|
|
||||||
|
# Skip building the desktop installers.
|
||||||
|
[switch]$NoBinaries,
|
||||||
|
|
||||||
|
# Forwarded to build-release.ps1. "linux" is shorthand for both Linux bundles;
|
||||||
|
# "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"),
|
||||||
|
[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
|
||||||
|
# retrying a failed upload without paying for the build again.
|
||||||
|
[switch]$NoBinaryBuild,
|
||||||
|
|
||||||
|
# Ship only the installers: no docker build, login or push. Implies
|
||||||
|
# -PublishRelease, since building alone is what build-release.ps1 already does.
|
||||||
|
# Takes the target list to build (comma-separated), which overrides -Targets:
|
||||||
|
# -BinariesOnly win,linux-appimage
|
||||||
|
[ValidateSet("win", "win-nsis", "win-portable", "linux-appimage", "linux-flatpak")]
|
||||||
|
[string[]]$BinariesOnly,
|
||||||
|
|
||||||
|
# Attach the installers to the Gitea release for $Tag, creating the release if
|
||||||
|
# it is missing.
|
||||||
|
[switch]$PublishRelease,
|
||||||
|
|
||||||
|
# owner/repo holding the release. Defaults to $Owner/$Repo.
|
||||||
|
[string]$ReleaseRepo,
|
||||||
|
|
||||||
|
# Gitea base URL for the API. Defaults to https://<Registry>.
|
||||||
|
[string]$ApiBase,
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Invoke-Checked {
|
||||||
|
# $CmdArgs, not $Args: $Args is a PowerShell automatic variable and never
|
||||||
|
# binds the passed array, so `& $Exe @Args` would run the exe bare.
|
||||||
|
param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$CmdArgs)
|
||||||
|
Write-Host " > $Exe $($CmdArgs -join ' ')" -ForegroundColor DarkGray
|
||||||
|
& $Exe @CmdArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "'$Exe $($CmdArgs -join ' ')' failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-Token {
|
||||||
|
<#
|
||||||
|
The token for both the registry push and the release API: parameter, then
|
||||||
|
env, then an interactive SecureString prompt. Read once and reused, so a
|
||||||
|
run that does both does not prompt twice.
|
||||||
|
#>
|
||||||
|
param([string]$Provided, [Parameter(Mandatory)][string]$Purpose)
|
||||||
|
|
||||||
|
if ($Provided) { return $Provided }
|
||||||
|
if ($env:GITEA_TOKEN) { return $env:GITEA_TOKEN }
|
||||||
|
$secure = Read-Host "Gitea token ($Purpose)" -AsSecureString
|
||||||
|
return [System.Net.NetworkCredential]::new("", $secure).Password
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-GiteaApi {
|
||||||
|
<#
|
||||||
|
JSON call against the Gitea API. Returns $null on 404 instead of throwing,
|
||||||
|
because "does this release exist yet?" is a 404 in the normal case and
|
||||||
|
Invoke-RestMethod treats any 4xx as terminating.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Method,
|
||||||
|
[Parameter(Mandatory)][string]$Uri,
|
||||||
|
[Parameter(Mandatory)][string]$Token,
|
||||||
|
$Body
|
||||||
|
)
|
||||||
|
|
||||||
|
$params = @{
|
||||||
|
Method = $Method
|
||||||
|
Uri = $Uri
|
||||||
|
Headers = @{ Authorization = "token $Token"; Accept = "application/json" }
|
||||||
|
}
|
||||||
|
if ($null -ne $Body) {
|
||||||
|
$params.Body = ($Body | ConvertTo-Json -Depth 5)
|
||||||
|
$params.ContentType = "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Invoke-RestMethod @params
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
$status = $_.Exception.Response.StatusCode.value__
|
||||||
|
if ($status -eq 404) { return $null }
|
||||||
|
if ($status -eq 401) {
|
||||||
|
throw "Gitea API $Method $Uri returned 401 — the token was rejected. Check GITEA_TOKEN (a registry-only token works for docker push but not for the API)."
|
||||||
|
}
|
||||||
|
if ($status -eq 403) {
|
||||||
|
throw "Gitea API $Method $Uri returned 403 — the token is valid but lacks repository write scope (write:repository)."
|
||||||
|
}
|
||||||
|
throw "Gitea API $Method $Uri failed: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Send-ReleaseAsset {
|
||||||
|
<#
|
||||||
|
Upload one file as a release attachment.
|
||||||
|
|
||||||
|
curl.exe rather than Invoke-RestMethod -Form: -Form needs PowerShell 6+,
|
||||||
|
and hand-rolling a multipart body in 5.1 means loading the whole binary
|
||||||
|
into a string — these installers are 80-200 MB. The token goes in a
|
||||||
|
--config file, never in the argument list, so it stays out of the process
|
||||||
|
table and the shell history.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$Uri,
|
||||||
|
[Parameter(Mandatory)][string]$Token,
|
||||||
|
[Parameter(Mandatory)][string]$Path
|
||||||
|
)
|
||||||
|
|
||||||
|
$curl = (Get-Command curl.exe -ErrorAction SilentlyContinue).Source
|
||||||
|
if (-not $curl) { $curl = (Get-Command curl -ErrorAction SilentlyContinue).Source }
|
||||||
|
if (-not $curl) { throw "curl not found — needed to upload release attachments." }
|
||||||
|
|
||||||
|
$configFile = [System.IO.Path]::GetTempFileName()
|
||||||
|
try {
|
||||||
|
# curl --config syntax: one option per line, `name = "value"`, and a value
|
||||||
|
# may not span lines. Only the header belongs here — everything else goes
|
||||||
|
# on the command line, where a stray escape can't silently split a line.
|
||||||
|
Set-Content -Path $configFile -Encoding ASCII -Value @(
|
||||||
|
"header = `"Authorization: token $Token`"",
|
||||||
|
"silent",
|
||||||
|
"show-error",
|
||||||
|
"fail-with-body"
|
||||||
|
)
|
||||||
|
Write-Host " > curl --config <temp> -F attachment=@$(Split-Path -Leaf $Path) `"$Uri`"" -ForegroundColor DarkGray
|
||||||
|
# Single-quoted: the \n is curl's own escape in -w, not PowerShell's.
|
||||||
|
& $curl "--config" $configFile `
|
||||||
|
"--write-out" ' http %{http_code}, %{size_upload} bytes uploaded\n' `
|
||||||
|
"-F" "attachment=@$Path" $Uri
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "upload of '$Path' failed (curl exit $LASTEXITCODE)." }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -Force $configFile -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
<#
|
||||||
|
Attach the installers to the release for $Tag, creating that release if it
|
||||||
|
does not exist yet. An existing release is added to, never recreated.
|
||||||
|
|
||||||
|
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(
|
||||||
|
[Parameter(Mandatory)][string]$ApiRoot,
|
||||||
|
[Parameter(Mandatory)][string]$RepoPath,
|
||||||
|
[Parameter(Mandatory)][string]$Tag,
|
||||||
|
[Parameter(Mandatory)][string]$Token,
|
||||||
|
# Windows-side files, uploaded by curl.exe.
|
||||||
|
[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"
|
||||||
|
$release = Invoke-GiteaApi -Method GET -Uri "$releasesUri/tags/$Tag" -Token $Token
|
||||||
|
|
||||||
|
if (-not $release) {
|
||||||
|
Write-Host " creating release $Tag in $RepoPath..." -ForegroundColor DarkGray
|
||||||
|
$release = Invoke-GiteaApi -Method POST -Uri $releasesUri -Token $Token -Body @{
|
||||||
|
tag_name = $Tag
|
||||||
|
name = "Motionity $Tag"
|
||||||
|
body = (Get-ReleaseBody -Tag $Tag)
|
||||||
|
draft = $false
|
||||||
|
}
|
||||||
|
if (-not $release) { throw "could not create release $Tag in $RepoPath (does the repo exist?)." }
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " reusing release $Tag (id $($release.id))" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
|
||||||
|
# One list so the asset-already-exists handling is written once: only the final
|
||||||
|
# transfer differs between a file on NTFS and one left in the distro.
|
||||||
|
$uploads = @()
|
||||||
|
foreach ($path in $Artifacts) { $uploads += @{ Name = (Split-Path -Leaf $path); Path = $path; InWsl = $false } }
|
||||||
|
foreach ($name in $WslArtifacts) { $uploads += @{ Name = $name; Path = $null; InWsl = $true } }
|
||||||
|
|
||||||
|
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."
|
||||||
|
}
|
||||||
|
foreach ($asset in $existing) {
|
||||||
|
Write-Host " replacing attachment '$name' (asset $($asset.id))..." -ForegroundColor DarkGray
|
||||||
|
Invoke-GiteaApi -Method DELETE -Token $Token `
|
||||||
|
-Uri "$releasesUri/$($release.id)/assets/$($asset.id)" | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " adding attachment '$name'..." -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
$encoded = [System.Uri]::EscapeDataString($name)
|
||||||
|
$assetUri = "$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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve repo root (parent of this script's folder) so the script works from anywhere.
|
||||||
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
Push-Location $repoRoot
|
||||||
|
try {
|
||||||
|
# --- Mode resolution ------------------------------------------------------
|
||||||
|
# -BinariesOnly is a target list, so its mere presence (a non-empty array) is
|
||||||
|
# what selects the mode.
|
||||||
|
$binariesOnlyMode = $BinariesOnly.Count -gt 0
|
||||||
|
if ($binariesOnlyMode -and $NoBinaries) {
|
||||||
|
throw "-BinariesOnly and -NoBinaries cancel each other out — pick one."
|
||||||
|
}
|
||||||
|
if ($NoBinaryBuild -and $NoBinaries) {
|
||||||
|
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) {
|
||||||
|
# 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").
|
||||||
|
$NoBuild = $true
|
||||||
|
$SkipLogin = $true
|
||||||
|
$PublishRelease = $true
|
||||||
|
# The targets named on -BinariesOnly are what to build.
|
||||||
|
$Targets = $BinariesOnly
|
||||||
|
}
|
||||||
|
$pushImage = -not $binariesOnlyMode
|
||||||
|
|
||||||
|
if (-not $ReleaseRepo) { $ReleaseRepo = "$Owner/$Repo" }
|
||||||
|
if (-not $ApiBase) { $ApiBase = "https://$Registry" }
|
||||||
|
$apiRoot = "$($ApiBase.TrimEnd('/'))/api/v1"
|
||||||
|
|
||||||
|
# --- Tag resolution -------------------------------------------------------
|
||||||
|
# Same default as build-release.ps1, so the image tag, the installer names and
|
||||||
|
# the version the app reports in its own window all agree.
|
||||||
|
if (-not $Tag) {
|
||||||
|
$pkg = Get-Content (Join-Path $repoRoot "package.json") -Raw | ConvertFrom-Json
|
||||||
|
$Tag = "v$($pkg.version)"
|
||||||
|
}
|
||||||
|
# A published tag nobody can check out again is worth naming out loud. The tag
|
||||||
|
# comes from package.json rather than git describe, so the dirty state has to
|
||||||
|
# be asked for separately.
|
||||||
|
$dirty = $false
|
||||||
|
try { $dirty = [bool](git status --porcelain 2>$null) } catch { }
|
||||||
|
if ($dirty -or $Tag -like "*-dirty") {
|
||||||
|
Write-Warning "the worktree is dirty — the artifacts published as '$Tag' won't match any commit. Commit first."
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = "$Registry/$Owner/$Image"
|
||||||
|
$tags = @("$base`:$Tag")
|
||||||
|
if (-not $NoLatest -and $Tag -ne "latest") { $tags += "$base`:latest" }
|
||||||
|
|
||||||
|
Write-Host "Motionity publish" -ForegroundColor Cyan
|
||||||
|
Write-Host " registry : $Registry"
|
||||||
|
if ($pushImage) {
|
||||||
|
Write-Host " image : $base"
|
||||||
|
Write-Host " tags : $($tags -join ', ')"
|
||||||
|
Write-Host " ffmpeg : $(if ($WithFfmpeg -eq '1') { 'bundled' } else { 'fetched at run time (WITH_FFMPEG=0)' })"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host " image : skipped (-BinariesOnly)"
|
||||||
|
}
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
# PowerShell 5.1 still defaults to TLS 1.0 on some hosts, which every current
|
||||||
|
# Gitea rejects — the API call would fail with an opaque connection error.
|
||||||
|
if ($PublishRelease -and [Net.ServicePointManager]::SecurityProtocol -notmatch 'Tls12') {
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol =
|
||||||
|
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Build ----------------------------------------------------------------
|
||||||
|
if (-not $NoBuild) {
|
||||||
|
Write-Host "Building image..." -ForegroundColor Cyan
|
||||||
|
# The Dockerfile has no ARG VERSION — the image is a static file server and
|
||||||
|
# carries no version string of its own, so the tag is the only marker.
|
||||||
|
$buildArgs = @("build") + @("--build-arg", "WITH_FFMPEG=$WithFfmpeg")
|
||||||
|
foreach ($t in $tags) { $buildArgs += @("-t", $t) }
|
||||||
|
$buildArgs += "."
|
||||||
|
Invoke-Checked docker $buildArgs
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Release artifacts ----------------------------------------------------
|
||||||
|
# Built before the push so a failing build doesn't leave a pushed image with
|
||||||
|
# no matching installers for the same tag.
|
||||||
|
$artifacts = @()
|
||||||
|
$wslArtifacts = @()
|
||||||
|
$wslUploadDistro = $null
|
||||||
|
$wslStageDir = $null
|
||||||
|
if (-not $NoBinaries) {
|
||||||
|
$distDir = Join-Path $repoRoot "dist"
|
||||||
|
|
||||||
|
if ($NoBinaryBuild) {
|
||||||
|
Write-Host "Reusing existing build..." -ForegroundColor Cyan
|
||||||
|
if (-not (Test-Path $distDir)) {
|
||||||
|
throw "-NoBinaryBuild was set but $distDir does not exist — build first (drop the flag, or run scripts/build-release.ps1)."
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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. 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 |
|
||||||
|
Sort-Object LastWriteTime | Select-Object -First 1)
|
||||||
|
$oldestName = if ($oldest) { $oldest.Name } else { $null }
|
||||||
|
$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,
|
||||||
|
# plus the packaging scripts themselves.
|
||||||
|
$newer = Get-ChildItem $repoRoot -Recurse -Include *.js, *.cjs, *.mjs, *.html, *.css, *.json -File |
|
||||||
|
Where-Object {
|
||||||
|
$_.FullName -notlike "$distDir*" -and
|
||||||
|
$_.FullName -notlike "*\node_modules\*" -and
|
||||||
|
$_.FullName -notlike "*/node_modules/*" -and
|
||||||
|
$_.LastWriteTime -gt $oldestTime
|
||||||
|
}
|
||||||
|
if ($newer) {
|
||||||
|
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 {
|
||||||
|
Write-Host "Building desktop installers..." -ForegroundColor Cyan
|
||||||
|
# build-release.ps1 throws on any failure and $ErrorActionPreference=Stop
|
||||||
|
# propagates it, so there is nothing to test an exit code against —
|
||||||
|
# `& script.ps1` leaves $LASTEXITCODE untouched, and with -NoBuild no
|
||||||
|
# docker command has reset it, so checking it would rethrow whatever the
|
||||||
|
# caller's shell last failed at.
|
||||||
|
# -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($manifest) {
|
||||||
|
$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"
|
||||||
|
if (Test-Path $sums) { $artifacts += $sums }
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Login ----------------------------------------------------------------
|
||||||
|
if (-not $SkipLogin) {
|
||||||
|
if (-not $Username) { $Username = $env:GITEA_USER }
|
||||||
|
if (-not $Username) { $Username = Read-Host "Gitea username for $Registry" }
|
||||||
|
|
||||||
|
$Password = Resolve-Token -Provided $Password -Purpose "registry push as $Username"
|
||||||
|
|
||||||
|
Write-Host "Logging in to $Registry as $Username..." -ForegroundColor Cyan
|
||||||
|
# Pass the token via stdin so it never lands in process args or history.
|
||||||
|
$Password | docker login $Registry --username $Username --password-stdin
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "docker login failed (exit $LASTEXITCODE)." }
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Push -----------------------------------------------------------------
|
||||||
|
if ($pushImage) {
|
||||||
|
Write-Host "Pushing image..." -ForegroundColor Cyan
|
||||||
|
foreach ($t in $tags) { Invoke-Checked docker @("push", $t) }
|
||||||
|
Write-Host ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Release attachments --------------------------------------------------
|
||||||
|
$releaseUrl = $null
|
||||||
|
if ($PublishRelease) {
|
||||||
|
if (-not $artifacts.Count -and -not $wslArtifacts.Count) {
|
||||||
|
throw "-PublishRelease has nothing to upload (was -NoBinaries set?)."
|
||||||
|
}
|
||||||
|
Write-Host "Uploading artifacts to release $Tag..." -ForegroundColor Cyan
|
||||||
|
$Password = Resolve-Token -Provided $Password -Purpose "release upload to $ReleaseRepo"
|
||||||
|
|
||||||
|
$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 "Done." -ForegroundColor Green
|
||||||
|
if ($pushImage) {
|
||||||
|
Write-Host "Pushed:" -ForegroundColor Green
|
||||||
|
foreach ($t in $tags) { Write-Host " $t" -ForegroundColor Green }
|
||||||
|
}
|
||||||
|
if ($artifacts.Count -or $wslArtifacts.Count) {
|
||||||
|
$where = if ($PublishRelease) { "attached to release $Tag" } else { "built locally — attach to a release manually" }
|
||||||
|
Write-Host "Artifacts ($where):" -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Zero-dependency static file server for src/. Shared by the bare-metal target
|
||||||
|
// (npm start) and by the Electron build, which runs it on 127.0.0.1 so the
|
||||||
|
// renderer gets a secure context — WebCodecs (VideoEncoder) and IndexedDB are
|
||||||
|
// both unavailable over file://.
|
||||||
|
//
|
||||||
|
// CommonJS on purpose: the Electron main process requires it straight out of
|
||||||
|
// the asar archive, where ESM loading is not guaranteed.
|
||||||
|
//
|
||||||
|
// Range requests matter here: the audio/video panels seek in media files.
|
||||||
|
|
||||||
|
const { createReadStream, statSync } = require('node:fs');
|
||||||
|
const { createServer } = require('node:http');
|
||||||
|
const { extname, join, normalize, resolve, sep } = require('node:path');
|
||||||
|
|
||||||
|
const TYPES = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.mjs': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.wav': 'audio/wav',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.ogg': 'audio/ogg',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.otf': 'font/otf',
|
||||||
|
'.wasm': 'application/wasm',
|
||||||
|
'.map': 'application/json; charset=utf-8',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
function send(res, status, body, headers = {}) {
|
||||||
|
res.writeHead(status, {
|
||||||
|
'content-type': 'text/plain; charset=utf-8',
|
||||||
|
...headers,
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer({ root, host = '127.0.0.1', port = 0 } = {}) {
|
||||||
|
const rootDir = resolve(root);
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
let pathname;
|
||||||
|
try {
|
||||||
|
pathname = decodeURIComponent(url.pathname);
|
||||||
|
} catch {
|
||||||
|
return send(res, 400, 'Bad request');
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/')) pathname += 'index.html';
|
||||||
|
|
||||||
|
// normalize() collapses ../ before we compare, so nothing outside rootDir
|
||||||
|
// can be reached even with encoded traversal sequences.
|
||||||
|
const filePath = join(rootDir, normalize(pathname));
|
||||||
|
if (filePath !== rootDir && !filePath.startsWith(rootDir + sep)) {
|
||||||
|
return send(res, 403, 'Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
let stat;
|
||||||
|
try {
|
||||||
|
stat = statSync(filePath);
|
||||||
|
if (stat.isDirectory()) throw new Error('directory');
|
||||||
|
} catch {
|
||||||
|
return send(res, 404, 'Not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extname(filePath).toLowerCase();
|
||||||
|
const headers = {
|
||||||
|
'content-type': TYPES[ext] || 'application/octet-stream',
|
||||||
|
'accept-ranges': 'bytes',
|
||||||
|
// The HTML entry point must never be cached or a rebuild ships stale
|
||||||
|
// script tags; everything else is safe to keep for a session.
|
||||||
|
'cache-control': ext === '.html' ? 'no-cache' : 'public, max-age=3600',
|
||||||
|
'x-content-type-options': 'nosniff',
|
||||||
|
};
|
||||||
|
|
||||||
|
const range = req.headers.range;
|
||||||
|
if (range) {
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(range.trim());
|
||||||
|
if (match) {
|
||||||
|
const size = stat.size;
|
||||||
|
let start = match[1] === '' ? null : Number(match[1]);
|
||||||
|
let end = match[2] === '' ? null : Number(match[2]);
|
||||||
|
if (start === null) {
|
||||||
|
// Suffix form: "bytes=-500" means the last 500 bytes.
|
||||||
|
start = Math.max(0, size - (end || 0));
|
||||||
|
end = size - 1;
|
||||||
|
} else {
|
||||||
|
end = end === null ? size - 1 : Math.min(end, size - 1);
|
||||||
|
}
|
||||||
|
if (start > end || start >= size) {
|
||||||
|
return send(res, 416, 'Range not satisfiable', {
|
||||||
|
'content-range': `bytes */${size}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.writeHead(206, {
|
||||||
|
...headers,
|
||||||
|
'content-range': `bytes ${start}-${end}/${size}`,
|
||||||
|
'content-length': end - start + 1,
|
||||||
|
});
|
||||||
|
if (req.method === 'HEAD') return res.end();
|
||||||
|
return createReadStream(filePath, { start, end }).pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { ...headers, 'content-length': stat.size });
|
||||||
|
if (req.method === 'HEAD') return res.end();
|
||||||
|
createReadStream(filePath).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((ok, fail) => {
|
||||||
|
server.on('error', fail);
|
||||||
|
server.listen(port, host, () => {
|
||||||
|
const bound = server.address().port;
|
||||||
|
ok({ server, port: bound, url: `http://${host}:${bound}/` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startServer };
|
||||||
|
|
||||||
|
// Direct invocation: npm start / node scripts/server.cjs
|
||||||
|
if (require.main === module) {
|
||||||
|
const root = resolve(__dirname, '..', 'src');
|
||||||
|
const host = process.env.HOST || '127.0.0.1';
|
||||||
|
const port = Number(process.env.PORT || 8080);
|
||||||
|
startServer({ root, host, port }).then(({ url }) => {
|
||||||
|
console.log(`Motionity serving ${root}`);
|
||||||
|
console.log(` ${url}`);
|
||||||
|
if (host !== '127.0.0.1' && host !== 'localhost') {
|
||||||
|
console.log(
|
||||||
|
`\nNOTE: browsers expose WebCodecs and IndexedDB only in a secure\n` +
|
||||||
|
`context. Reached over plain http:// from another machine this\n` +
|
||||||
|
`disables the fast exporter and project saving. Use TLS for LAN or\n` +
|
||||||
|
`remote access.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Downloads every third-party asset that index.html used to pull from a CDN
|
||||||
|
// into src/vendor/, so the app runs with no network access. Run once before
|
||||||
|
// packaging (npm run vendor); the directory is gitignored.
|
||||||
|
//
|
||||||
|
// The only runtime network dependency left after this is the Google Fonts
|
||||||
|
// family the user picks in the text panel (WebFont.load), which degrades to a
|
||||||
|
// fallback font when offline.
|
||||||
|
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { existsSync, statSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const vendorDir = join(root, 'src', 'vendor');
|
||||||
|
const fontsDir = join(vendorDir, 'fonts');
|
||||||
|
const ffmpegDir = join(vendorDir, 'ffmpeg');
|
||||||
|
|
||||||
|
// A desktop UA is required for the Google Fonts API to answer with woff2
|
||||||
|
// instead of the ancient truetype payload.
|
||||||
|
const UA =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||||
|
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
const assets = [
|
||||||
|
{
|
||||||
|
url: 'https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js',
|
||||||
|
file: 'lottie.min.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js',
|
||||||
|
file: 'selection.min.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js',
|
||||||
|
file: 'jquery.min.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js',
|
||||||
|
file: 'pickr.min.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css',
|
||||||
|
file: 'pickr-nano.min.css',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js',
|
||||||
|
file: 'fabric.min.js',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js',
|
||||||
|
file: 'webfont.js',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ffmpeg.wasm, used by converter.js for MP4/GIF export. Copied out of
|
||||||
|
// node_modules rather than downloaded: package-lock.json pins these by integrity
|
||||||
|
// hash, so the bytes that land in the image are the bytes npm verified. The
|
||||||
|
// asm.js build this replaced came from a public archive.org mirror with no
|
||||||
|
// integrity check at all.
|
||||||
|
//
|
||||||
|
// The loader's lazily-loaded webpack chunk has to sit beside it, and the core's
|
||||||
|
// .wasm and .worker.js beside ffmpeg-core.js — the loader derives both paths by
|
||||||
|
// substitution, so the layout is not negotiable.
|
||||||
|
const ffmpegFiles = [
|
||||||
|
{ from: '@ffmpeg/ffmpeg/dist/ffmpeg.min.js', to: 'ffmpeg.min.js' },
|
||||||
|
{ from: '@ffmpeg/ffmpeg/dist/046d0074eee1d99a674a.js', to: '046d0074eee1d99a674a.js' },
|
||||||
|
{ from: '@ffmpeg/core-st/dist/ffmpeg-core.js', to: 'ffmpeg-core.js' },
|
||||||
|
{ from: '@ffmpeg/core-st/dist/ffmpeg-core.worker.js', to: 'ffmpeg-core.worker.js' },
|
||||||
|
// 23 MB, and the only reason --skip-ffmpeg exists.
|
||||||
|
{ from: '@ffmpeg/core-st/dist/ffmpeg-core.wasm', to: 'ffmpeg-core.wasm', heavy: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// core-st is the single-threaded core on purpose: the default @ffmpeg/core is
|
||||||
|
// built with pthreads and needs SharedArrayBuffer, which means COOP/COEP
|
||||||
|
// isolation, which would break the Pixabay, Unsplash and Google Fonts requests.
|
||||||
|
async function vendorFfmpeg({ skipHeavy }) {
|
||||||
|
// An existing checkout still has the 18.5 MB asm.js blob the archive.org path
|
||||||
|
// left behind. Nothing loads it any more, but src/vendor/ is packaged whole,
|
||||||
|
// so it would ship in every installer until someone noticed.
|
||||||
|
const legacy = join(vendorDir, 'ffmpeg_asm.js');
|
||||||
|
if (existsSync(legacy)) {
|
||||||
|
await rm(legacy);
|
||||||
|
console.log(' prune src/vendor/ffmpeg_asm.js (replaced by ffmpeg.wasm)');
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(ffmpegDir, { recursive: true });
|
||||||
|
for (const { from, to, heavy } of ffmpegFiles) {
|
||||||
|
const src = join(root, 'node_modules', from);
|
||||||
|
if (heavy && skipHeavy) {
|
||||||
|
console.log(` omit src/vendor/ffmpeg/${to} (--skip-ffmpeg)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!existsSync(src)) {
|
||||||
|
throw new Error(
|
||||||
|
`${from} is missing — run "npm install" before vendoring (ffmpeg.wasm now ` +
|
||||||
|
`comes from node_modules, not a CDN).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await copyFile(src, join(ffmpegDir, to));
|
||||||
|
console.log(` copy src/vendor/ffmpeg/${to} (${human(statSync(src).size)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontCss =
|
||||||
|
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap';
|
||||||
|
|
||||||
|
async function fetchBuffer(url) {
|
||||||
|
const res = await fetch(url, { headers: { 'user-agent': UA } });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText} for ${url}`);
|
||||||
|
return Buffer.from(await res.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function human(bytes) {
|
||||||
|
return bytes > 1e6
|
||||||
|
? `${(bytes / 1e6).toFixed(1)} MB`
|
||||||
|
: `${Math.round(bytes / 1024)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(url, dest, { force }) {
|
||||||
|
if (!force && existsSync(dest) && statSync(dest).size > 0) {
|
||||||
|
console.log(` skip ${dest.slice(root.length + 1)} (already vendored)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const buf = await fetchBuffer(url);
|
||||||
|
await writeFile(dest, buf);
|
||||||
|
console.log(` get ${dest.slice(root.length + 1)} (${human(buf.length)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewrites the remote font files referenced by the Google Fonts stylesheet to
|
||||||
|
// local copies so no request leaves the machine at startup.
|
||||||
|
async function vendorFonts({ force }) {
|
||||||
|
const dest = join(vendorDir, 'inter.css');
|
||||||
|
if (!force && existsSync(dest) && statSync(dest).size > 0) {
|
||||||
|
console.log(` skip src/vendor/inter.css (already vendored)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let css = (await fetchBuffer(fontCss)).toString('utf8');
|
||||||
|
const urls = [...new Set([...css.matchAll(/url\((https:[^)]+)\)/g)].map((m) => m[1]))];
|
||||||
|
for (const url of urls) {
|
||||||
|
const ext = url.split('.').pop().split('?')[0];
|
||||||
|
const name = `inter-${createHash('sha1').update(url).digest('hex').slice(0, 10)}.${ext}`;
|
||||||
|
await writeFile(join(fontsDir, name), await fetchBuffer(url));
|
||||||
|
css = css.split(url).join(`fonts/${name}`);
|
||||||
|
}
|
||||||
|
await writeFile(dest, css);
|
||||||
|
console.log(` get src/vendor/inter.css (+${urls.length} font files)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const force = process.argv.includes('--force');
|
||||||
|
// Docker builds can drop the 23 MB ffmpeg core. Unlike the old asm.js build
|
||||||
|
// there is no runtime mirror to fall back to, so this now means MP4/GIF export
|
||||||
|
// is unavailable in that image — converter.js says so rather than failing late.
|
||||||
|
const skipFfmpeg = process.argv.includes('--skip-ffmpeg');
|
||||||
|
|
||||||
|
await mkdir(fontsDir, { recursive: true });
|
||||||
|
console.log(`Vendoring third-party assets into src/vendor/`);
|
||||||
|
for (const asset of assets) {
|
||||||
|
await download(asset.url, join(vendorDir, asset.file), { force });
|
||||||
|
}
|
||||||
|
await vendorFonts({ force });
|
||||||
|
await vendorFfmpeg({ skipHeavy: skipFfmpeg });
|
||||||
|
|
||||||
|
// Sanity check: index.html must not have regained a CDN reference.
|
||||||
|
const html = await readFile(join(root, 'src', 'index.html'), 'utf8');
|
||||||
|
const remote = [...html.matchAll(/(?:src|href)="(https?:\/\/[^"]+)"/g)]
|
||||||
|
.map((m) => m[1])
|
||||||
|
.filter((u) => !/github\.com|motionity\.app|twitter\.com/.test(u));
|
||||||
|
if (remote.length) {
|
||||||
|
console.warn(`\nWARNING: index.html still loads remote assets:`);
|
||||||
|
for (const u of remote) console.warn(` ${u}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`\nDone. index.html loads no remote assets.`);
|
||||||
|
}
|
||||||
+61
-22
@@ -1,15 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
<meta charset="utf-8">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<!-- Third-party assets live in vendor/ so the app works offline. -->
|
||||||
|
<!-- Run `npm run vendor` to populate that directory. -->
|
||||||
|
<link rel="stylesheet" href="vendor/inter.css">
|
||||||
<link rel="stylesheet" href="nice-select.css">
|
<link rel="stylesheet" href="nice-select.css">
|
||||||
<link ref="stylesheet" href="range-slider.min.css">
|
<link ref="stylesheet" href="range-slider.min.css">
|
||||||
<link rel="stylesheet" href="magic-check.min.css">
|
<link rel="stylesheet" href="magic-check.min.css">
|
||||||
<link rel="stylesheet" href="styles.css">
|
<link rel="stylesheet" href="styles.css">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css"/>
|
<link rel="stylesheet" href="vendor/pickr-nano.min.css"/>
|
||||||
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<!-- Primary Meta Tags -->
|
<!-- Primary Meta Tags -->
|
||||||
<title>Motionity - The web-based motion graphics editor for everyone</title>
|
<title>Motionity - The web-based motion graphics editor for everyone</title>
|
||||||
<meta name="title" content="Motionity - The web-based motion graphics editor for everyone">
|
<meta name="title" content="Motionity - The web-based motion graphics editor for everyone">
|
||||||
@@ -17,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">
|
||||||
@@ -35,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>
|
||||||
@@ -79,6 +80,18 @@
|
|||||||
<input class="magic-radio" type="radio" name="radio" value="image" id="image-format">
|
<input class="magic-radio" type="radio" name="radio" value="image" id="image-format">
|
||||||
<label for="image-format">Image</label>
|
<label for="image-format">Image</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="framerate-row">
|
||||||
|
<p class="subheader">Frame rate</p>
|
||||||
|
<select id="framerate">
|
||||||
|
<option value="12">12 fps</option>
|
||||||
|
<option value="15">15 fps</option>
|
||||||
|
<option value="24">24 fps</option>
|
||||||
|
<option value="25">25 fps</option>
|
||||||
|
<option value="30" selected>30 fps</option>
|
||||||
|
<option value="50">50 fps</option>
|
||||||
|
<option value="60">60 fps</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div id="download-real">Download</div>
|
<div id="download-real">Download</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="import-export-modal">
|
<div id="import-export-modal">
|
||||||
@@ -89,6 +102,22 @@
|
|||||||
<p class="header-2">Export this project</p>
|
<p class="header-2">Export this project</p>
|
||||||
<div id="export-project"><img src="assets/download-icon.svg"> <span>Export</span></div>
|
<div id="export-project"><img src="assets/download-icon.svg"> <span>Export</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="credits-modal">
|
||||||
|
<p class="header">Credits</p>
|
||||||
|
<p class="subtitle">Created by <span><a href="https://github.com/alyssaxuu">Alyssa X</a></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;">
|
||||||
|
<p class="subheader">Libraries & Tools</p>
|
||||||
|
<div id="credits-list">
|
||||||
|
<div class="credit-item">Fabric.js</div>
|
||||||
|
<div class="credit-item">Anime.js</div>
|
||||||
|
<div class="credit-item">Lottie</div>
|
||||||
|
<div class="credit-item">Pickr</div>
|
||||||
|
<div class="credit-item">Sortable.js</div>
|
||||||
|
<div class="credit-item">FFmpeg.wasm</div>
|
||||||
|
<div class="credit-item">jQuery</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="background-overlay"></div>
|
<div id="background-overlay"></div>
|
||||||
<div id="color-picker"></div>
|
<div id="color-picker"></div>
|
||||||
<div id="color-picker-fill"></div>
|
<div id="color-picker-fill"></div>
|
||||||
@@ -168,7 +197,7 @@
|
|||||||
<div id="filters">
|
<div id="filters">
|
||||||
<div id="filters-container">
|
<div id="filters-container">
|
||||||
<div id="filters-header">
|
<div id="filters-header">
|
||||||
<div id="filters-title">Filters</div>
|
<div class="filters-title">Filters</div>
|
||||||
<img src="assets/close.svg" id="filters-close">
|
<img src="assets/close.svg" id="filters-close">
|
||||||
</div>
|
</div>
|
||||||
<select id="filters-list">
|
<select id="filters-list">
|
||||||
@@ -183,7 +212,7 @@
|
|||||||
<option value="Polaroid">Polaroid</option>
|
<option value="Polaroid">Polaroid</option>
|
||||||
</select>
|
</select>
|
||||||
<hr>
|
<hr>
|
||||||
<div id="filters-title">Adjustments</div>
|
<div class="filters-title">Adjustments</div>
|
||||||
<div id="reset-filters"><img src="assets/repeat.svg"> Reset</div>
|
<div id="reset-filters"><img src="assets/repeat.svg"> Reset</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<th class="name-col">Brightness</th>
|
<th class="name-col">Brightness</th>
|
||||||
@@ -216,7 +245,7 @@
|
|||||||
</th>
|
</th>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div id="filters-title">Chroma key</div>
|
<div class="filters-title">Chroma key</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<th class="name-col">Status</th>
|
<th class="name-col">Status</th>
|
||||||
<th class="value-col">
|
<th class="value-col">
|
||||||
@@ -242,7 +271,7 @@
|
|||||||
</th>
|
</th>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div id="filters-title">Stylize</div>
|
<div class="filters-title">Stylize</div>
|
||||||
<div class="filter-row">
|
<div class="filter-row">
|
||||||
<th class="name-col">Noise</th>
|
<th class="name-col">Noise</th>
|
||||||
<th class="value-col">
|
<th class="value-col">
|
||||||
@@ -276,20 +305,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bottom-canvas">
|
<div id="bottom-canvas">
|
||||||
<a id="sponsor" href="https://github.com/sponsors/alyssaxuu" target="_blank"><img src="assets/sponsor.svg"> Sponsor</a>
|
|
||||||
<a id="alyssa-credit" href="https://twitter.com/alyssaxuu" target="_blank">Made by <span>Alyssa X</span> <img src="assets/alyssaimg.jpeg"></a>
|
|
||||||
</div>
|
</div>
|
||||||
<img src="assets/replace-image.svg" id="replace-image">
|
<img src="assets/replace-image.svg" id="replace-image">
|
||||||
<img src="assets/loading-image.svg" id="load-image" class="load-media">
|
<img src="assets/loading-image.svg" id="load-image" class="load-media">
|
||||||
<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">
|
||||||
<div id="easing">
|
<div id="easing">
|
||||||
<p class="property-title">Keyframe easing</p>
|
<p class="property-title">Keyframe easing</p>
|
||||||
<select id="easing">
|
<select id="easing-select">
|
||||||
<option value="linear">Linear</option>
|
<option value="linear">Linear</option>
|
||||||
<option value="easeInQuad">Ease in</option>
|
<option value="easeInQuad">Ease in</option>
|
||||||
<option value="easeOutQuad">Ease out</option>
|
<option value="easeOutQuad">Ease out</option>
|
||||||
@@ -361,21 +394,25 @@
|
|||||||
<div id="share"><img src="assets/importexport.svg"> Import & export</div>
|
<div id="share"><img src="assets/importexport.svg"> Import & export</div>
|
||||||
<div id="download"><img src="assets/download-icon.svg"> Download</div>
|
<div id="download"><img src="assets/download-icon.svg"> Download</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="credits-button">Credits</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<video id="test-video"></video>
|
<video id="test-video"></video>
|
||||||
<input id="emptyInput" value=" " style="opacity:0">
|
<input id="emptyInput" value=" " style="opacity:0">
|
||||||
<script src="js/libraries/localbase.js"></script>
|
<script src="js/libraries/localbase.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js"></script>
|
<script src="vendor/lottie.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js"></script>
|
<script src="vendor/selection.min.js"></script>
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
<script src="vendor/jquery.min.js"></script>
|
||||||
<script src="js/libraries/sortable.min.js"></script>
|
<script src="js/libraries/sortable.min.js"></script>
|
||||||
<script src="js/libraries/range-slider.min.js"></script>
|
<script src="js/libraries/range-slider.min.js"></script>
|
||||||
<script src="js/libraries/jquery.nice-select.min.js"></script>
|
<script src="js/libraries/jquery.nice-select.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js"></script>
|
<script src="vendor/pickr.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script>
|
<script src="vendor/fabric.min.js"></script>
|
||||||
<script src="js/libraries/anime.min.js"></script>
|
<script src="js/libraries/anime.min.js"></script>
|
||||||
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
|
<script src="vendor/webfont.js"></script>
|
||||||
|
<!-- ffmpeg.wasm loader; the 23 MB core beside it is fetched on first
|
||||||
|
MP4/GIF export, not at page load. -->
|
||||||
|
<script src="vendor/ffmpeg/ffmpeg.min.js"></script>
|
||||||
<script src="js/init.js"></script>
|
<script src="js/init.js"></script>
|
||||||
<script src="js/ui.js"></script>
|
<script src="js/ui.js"></script>
|
||||||
<script src="js/align.js"></script>
|
<script src="js/align.js"></script>
|
||||||
@@ -383,6 +420,8 @@
|
|||||||
<script src="js/database.js"></script>
|
<script src="js/database.js"></script>
|
||||||
<script src="js/lottie.js"></script>
|
<script src="js/lottie.js"></script>
|
||||||
<script src="js/text.js"></script>
|
<script src="js/text.js"></script>
|
||||||
|
<script src="js/webm-writer2.js"></script>
|
||||||
|
<script src="js/render.js"></script>
|
||||||
<script src="js/recorder.js"></script>
|
<script src="js/recorder.js"></script>
|
||||||
<script src="js/functions.js"></script>
|
<script src="js/functions.js"></script>
|
||||||
<script src="js/events.js"></script>
|
<script src="js/events.js"></script>
|
||||||
|
|||||||
+26
-3
@@ -78,6 +78,20 @@ function initLines() {
|
|||||||
canvas.add(line_v);
|
canvas.add(line_v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide the alignment guides after a canvas reload.
|
||||||
|
// They can be missing entirely in projects saved before they existed, so
|
||||||
|
// never dereference them directly.
|
||||||
|
function hideGuides(inst) {
|
||||||
|
const h = inst.getItemById('line_h');
|
||||||
|
const v = inst.getItemById('line_v');
|
||||||
|
if (h) {
|
||||||
|
h.set({ opacity: 0 });
|
||||||
|
}
|
||||||
|
if (v) {
|
||||||
|
v.set({ opacity: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function alignControls(object, type) {
|
function alignControls(object, type) {
|
||||||
if (type == 'align-top') {
|
if (type == 'align-top') {
|
||||||
object.set(
|
object.set(
|
||||||
@@ -122,8 +136,10 @@ function alignControls(object, type) {
|
|||||||
function alignObject() {
|
function alignObject() {
|
||||||
const type = $(this).attr('id');
|
const type = $(this).attr('id');
|
||||||
const object = canvas.getActiveObject();
|
const object = canvas.getActiveObject();
|
||||||
console.log(canvas.getActiveObject().type);
|
if (!object) {
|
||||||
if (canvas.getActiveObject().type == 'activeSelection') {
|
return;
|
||||||
|
}
|
||||||
|
if (object.type == 'activeSelection') {
|
||||||
const tempselection = canvas.getActiveObject();
|
const tempselection = canvas.getActiveObject();
|
||||||
canvas.discardActiveObject();
|
canvas.discardActiveObject();
|
||||||
tempselection._objects.forEach(function (object) {
|
tempselection._objects.forEach(function (object) {
|
||||||
@@ -157,6 +173,8 @@ function alignObject() {
|
|||||||
);
|
);
|
||||||
newKeyframe('top', object, currenttime, object.get('top'), true);
|
newKeyframe('top', object, currenttime, object.get('top'), true);
|
||||||
}
|
}
|
||||||
|
canvas.renderAll();
|
||||||
|
save();
|
||||||
}
|
}
|
||||||
$(document).on('click', '.align', alignObject);
|
$(document).on('click', '.align', alignObject);
|
||||||
|
|
||||||
@@ -240,7 +258,12 @@ function centerLines(e) {
|
|||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (obj != e.target && obj != line_h && obj != line_v) {
|
if (
|
||||||
|
obj != e.target &&
|
||||||
|
obj != line_h &&
|
||||||
|
obj != line_v &&
|
||||||
|
obj.visible !== false
|
||||||
|
) {
|
||||||
if (
|
if (
|
||||||
obj.get('id') == 'center_h' ||
|
obj.get('id') == 'center_h' ||
|
||||||
obj.get('id') == 'center_v'
|
obj.get('id') == 'center_v'
|
||||||
|
|||||||
+154
-96
@@ -1,92 +1,163 @@
|
|||||||
var workerPath =
|
// MP4/GIF export transcodes the captured WebM with ffmpeg.wasm.
|
||||||
'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js';
|
//
|
||||||
|
// Everything is served from vendor/ffmpeg/, vendored out of node_modules by
|
||||||
|
// `npm run vendor` and pinned by package-lock.json. There is deliberately no
|
||||||
|
// CDN fallback: the asm.js build this replaced was fetched from a public
|
||||||
|
// archive.org mirror with no integrity check, so a changed object there would
|
||||||
|
// have run in the page unnoticed.
|
||||||
|
//
|
||||||
|
// The core is the single-threaded build. The default multi-threaded one needs
|
||||||
|
// SharedArrayBuffer, which requires COOP/COEP isolation, which would break the
|
||||||
|
// Pixabay, Unsplash and Google Fonts requests the editor makes.
|
||||||
|
|
||||||
function processInWebWorker() {
|
var FFMPEG_DIR = 'vendor/ffmpeg/';
|
||||||
var blob = URL.createObjectURL(
|
var FFMPEG_CORE = FFMPEG_DIR + 'ffmpeg-core.js';
|
||||||
new Blob(
|
var FFMPEG_WASM = FFMPEG_DIR + 'ffmpeg-core.wasm';
|
||||||
[
|
var FFMPEG_WORKER = FFMPEG_DIR + 'ffmpeg-core.worker.js';
|
||||||
'importScripts("' +
|
|
||||||
workerPath +
|
// One instance per conversion, deliberately not cached. The single-threaded
|
||||||
'");var now = Date.now;function print(text) {postMessage({"type" : "stdout","data" : text});};onmessage = function(event) {var message = event.data;if (message.type === "command") {var Module = {print: print,printErr: print,files: message.files || [],arguments: message.arguments || [],TOTAL_MEMORY: message.TOTAL_MEMORY||536870912 || false};postMessage({"type" : "start","data" : Module.arguments.join(" ")});postMessage({"type" : "stdout","data" : "Received command: " +Module.arguments.join(" ") +((Module.TOTAL_MEMORY ) ? ". Processing with " + Module.TOTAL_MEMORY + " bits." : "")});var time = now();var result = ffmpeg_run(Module);var totalTime = now() - time;postMessage({"type" : "stdout","data" : "Finished processing (took " + totalTime + "ms)"});postMessage({"type" : "done","data" : result,"time" : totalTime});}};postMessage({"type" : "ready"});',
|
// core's `main` calls exit() when the command finishes, which tears the wasm
|
||||||
],
|
// runtime down: a second run on the same instance dies with "Program terminated
|
||||||
{
|
// with exit(0)". Reloading costs about 110ms and returns the 23 MB heap in
|
||||||
type: 'application/javascript',
|
// between, so this is cheaper than it looks.
|
||||||
|
var ffmpegBusy = false;
|
||||||
|
|
||||||
|
function ffmpegAvailable() {
|
||||||
|
return typeof FFmpeg !== 'undefined' && typeof FFmpeg.createFFmpeg === 'function';
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
// The loader resolves the core through `new URL(corePath, import.meta.url)`,
|
||||||
|
// which points at the bundle rather than the page once it is minified. Passing
|
||||||
|
// all three paths absolute skips that resolution entirely.
|
||||||
|
function absolute(path) {
|
||||||
|
return new URL(path, location.href).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFfmpeg() {
|
||||||
|
if (!ffmpegAvailable()) {
|
||||||
|
throw new Error(
|
||||||
|
'the ffmpeg.wasm loader is missing — run "npm run vendor" to populate src/vendor/'
|
||||||
);
|
);
|
||||||
|
|
||||||
var worker = new Worker(blob);
|
|
||||||
URL.revokeObjectURL(blob);
|
|
||||||
return worker;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var worker;
|
// A build made with WITH_FFMPEG=0 ships the loader but not the 23 MB core,
|
||||||
|
// so check before paying for the load and report it as a build choice
|
||||||
function convertStreams(videoBlob, setting) {
|
// rather than a failure.
|
||||||
var aab;
|
var head = await fetch(FFMPEG_WASM, { method: 'HEAD' }).catch(function () {
|
||||||
var buffersReady;
|
return null;
|
||||||
var workerReady;
|
|
||||||
var posted;
|
|
||||||
|
|
||||||
var fileReader = new FileReader();
|
|
||||||
fileReader.onload = function () {
|
|
||||||
aab = this.result;
|
|
||||||
postMessage();
|
|
||||||
};
|
|
||||||
fileReader.readAsArrayBuffer(videoBlob);
|
|
||||||
|
|
||||||
if (!worker) {
|
|
||||||
worker = processInWebWorker();
|
|
||||||
}
|
|
||||||
worker.onmessage = function (event) {
|
|
||||||
var message = event.data;
|
|
||||||
if (message.type == 'ready') {
|
|
||||||
workerReady = true;
|
|
||||||
if (buffersReady) postMessage();
|
|
||||||
} else if (message.type == 'done') {
|
|
||||||
var result = message.data[0];
|
|
||||||
if (setting == 'gif') {
|
|
||||||
var blob = new File([result.data], 'test.gif', {
|
|
||||||
type: 'image/gif',
|
|
||||||
});
|
});
|
||||||
PostBlob(blob);
|
if (!head || !head.ok) {
|
||||||
} else if (setting == 'mp4') {
|
throw new Error(
|
||||||
var blob = new File([result.data], 'test.mp4', {
|
'this build ships without the ffmpeg core (WITH_FFMPEG=0), so MP4 and GIF ' +
|
||||||
type: 'video/mp4',
|
'export are unavailable. WEBM export always works.'
|
||||||
});
|
);
|
||||||
PostBlob(blob);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
var instance = FFmpeg.createFFmpeg({
|
||||||
var postMessage = function () {
|
corePath: absolute(FFMPEG_CORE),
|
||||||
posted = true;
|
wasmPath: absolute(FFMPEG_WASM),
|
||||||
if (setting == 'gif') {
|
workerPath: absolute(FFMPEG_WORKER),
|
||||||
worker.postMessage({
|
// The loader defaults to the entry point of the multi-threaded core; the
|
||||||
type: 'command',
|
// single-threaded one exports plain `main`. Without this, load() gets as
|
||||||
arguments: '-i video.webm -r 24 output-10.gif'.split(' '),
|
// far as compiling the 23 MB wasm and then aborts with
|
||||||
files: [
|
// "Cannot call unknown function proxy_main".
|
||||||
{
|
mainName: 'main',
|
||||||
data: new Uint8Array(aab),
|
log: false,
|
||||||
name: 'video.webm',
|
logger: function (entry) {
|
||||||
|
if (entry.type === 'fferr') console.debug('[ffmpeg]', entry.message);
|
||||||
},
|
},
|
||||||
],
|
progress: function (entry) {
|
||||||
});
|
if (typeof entry.ratio === 'number' && entry.ratio >= 0 && entry.ratio <= 1) {
|
||||||
} else if (setting == 'mp4') {
|
$('#download-real').html('Converting ' + Math.round(entry.ratio * 100) + '%');
|
||||||
worker.postMessage({
|
}
|
||||||
type: 'command',
|
|
||||||
arguments:
|
|
||||||
'-i video.webm -c:v mpeg4 -b:v 6400k -strict experimental output.mp4'.split(
|
|
||||||
' '
|
|
||||||
),
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
data: new Uint8Array(aab),
|
|
||||||
name: 'video.webm',
|
|
||||||
},
|
},
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
await instance.load();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The recording was made at this rate, so the transcode has to keep it: a
|
||||||
|
// different -r would duplicate or drop frames and drift the timing.
|
||||||
|
function conversionArgs(setting, fps) {
|
||||||
|
if (setting === 'gif') {
|
||||||
|
return ['-i', 'input.webm', '-r', String(fps), 'output.gif'];
|
||||||
|
}
|
||||||
|
// libx264 rather than the mpeg4 the asm.js path used: same core, far better
|
||||||
|
// quality per byte, and it plays in Safari and QuickTime. yuv420p is what
|
||||||
|
// makes that true — x264 defaults to yuv444p here, which they refuse.
|
||||||
|
return [
|
||||||
|
'-i', 'input.webm',
|
||||||
|
'-c:v', 'libx264',
|
||||||
|
'-preset', 'ultrafast',
|
||||||
|
'-crf', '23',
|
||||||
|
'-pix_fmt', 'yuv420p',
|
||||||
|
'-r', String(fps),
|
||||||
|
'-c:a', 'aac',
|
||||||
|
'-b:a', '192k',
|
||||||
|
'output.mp4',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function convertStreams(videoBlob, setting) {
|
||||||
|
var outputName = setting === 'gif' ? 'output.gif' : 'output.mp4';
|
||||||
|
var mimeType = setting === 'gif' ? 'image/gif' : 'video/mp4';
|
||||||
|
|
||||||
|
function convertFailed(reason) {
|
||||||
|
console.error('Conversion failed: ' + reason);
|
||||||
|
alert(
|
||||||
|
'Sorry, the ' +
|
||||||
|
setting.toUpperCase() +
|
||||||
|
' conversion failed. The WEBM format is always available.\n\n' +
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
resetRecordingUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting !== 'gif' && setting !== 'mp4') {
|
||||||
|
convertFailed('unknown output format "' + setting + '"');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ffmpegBusy) {
|
||||||
|
convertFailed('a conversion is already running — wait for it to finish');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpegBusy = true;
|
||||||
|
var ffmpeg = null;
|
||||||
|
try {
|
||||||
|
$('#download-real').html('Loading converter...');
|
||||||
|
ffmpeg = await loadFfmpeg();
|
||||||
|
|
||||||
|
ffmpeg.FS('writeFile', 'input.webm', new Uint8Array(await videoBlob.arrayBuffer()));
|
||||||
|
$('#download-real').html('Converting 0%');
|
||||||
|
await ffmpeg.run.apply(ffmpeg, conversionArgs(setting, getExportFramerate()));
|
||||||
|
|
||||||
|
var data = ffmpeg.FS('readFile', outputName);
|
||||||
|
if (!data || !data.length) {
|
||||||
|
convertFailed('the encoder returned no data');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Copy out of the wasm heap before unlinking: the view would otherwise be
|
||||||
|
// backed by memory ffmpeg is free to reuse.
|
||||||
|
PostBlob(new File([data.slice()], 'video.' + setting, { type: mimeType }));
|
||||||
|
} catch (err) {
|
||||||
|
convertFailed((err && err.message) || String(err));
|
||||||
|
} finally {
|
||||||
|
// Tear the instance down either way. After a success the runtime has
|
||||||
|
// already exited and is unusable; after a failure the loader's internal
|
||||||
|
// "running" flag would otherwise stay set and every later conversion would
|
||||||
|
// fail with "can only run one command at a time" until a page reload.
|
||||||
|
// Dropping the reference also returns the 23 MB heap and whatever MEMFS
|
||||||
|
// still holds, which for a long export is most of the memory in play.
|
||||||
|
if (ffmpeg) {
|
||||||
|
try {
|
||||||
|
ffmpeg.exit();
|
||||||
|
} catch (e) {
|
||||||
|
/* already torn down by its own exit(0) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ffmpegBusy = false;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function PostBlob(blob) {
|
function PostBlob(blob) {
|
||||||
@@ -97,22 +168,9 @@ function PostBlob(blob) {
|
|||||||
a.download = blob.name || 'video';
|
a.download = blob.name || 'video';
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
recording = false;
|
document.body.removeChild(a);
|
||||||
currenttime = 0;
|
window.setTimeout(function () {
|
||||||
animate(false, 0);
|
URL.revokeObjectURL(url);
|
||||||
$('#seekbar').offset({
|
}, 60000);
|
||||||
left:
|
resetRecordingUI();
|
||||||
offset_left +
|
|
||||||
$('#inner-timeline').offset().left +
|
|
||||||
currenttime / timelinetime,
|
|
||||||
});
|
|
||||||
canvas.renderAll();
|
|
||||||
resizeCanvas();
|
|
||||||
if (background_audio != false) {
|
|
||||||
background_audio.pause();
|
|
||||||
background_audio = new Audio(background_audio.src);
|
|
||||||
}
|
|
||||||
$('#download-real').html('Download');
|
|
||||||
$('#download-real').removeClass('downloading');
|
|
||||||
updateRecordCanvas();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-30
@@ -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',
|
||||||
@@ -88,15 +91,24 @@ function checkDB() {
|
|||||||
} else {
|
} else {
|
||||||
loadProject();
|
loadProject();
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error('Could not open the local project database', e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically save project (locally)
|
// Automatically save project (locally)
|
||||||
function autoSave() {
|
async function autoSave() {
|
||||||
if (checkstatus) {
|
if (checkstatus) {
|
||||||
canvas.clipPath = null;
|
canvas.clipPath = null;
|
||||||
objects.forEach(async function (object) {
|
// Sequential, not forEach(async): toDatalessJSON below must not run
|
||||||
|
// while filters are still being stripped off the objects.
|
||||||
|
for (const object of objects) {
|
||||||
var obj = canvas.getItemById(object.id);
|
var obj = canvas.getItemById(object.id);
|
||||||
|
if (!obj) {
|
||||||
|
object.filters = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (obj.filters) {
|
if (obj.filters) {
|
||||||
if (obj.filters.length > 0) {
|
if (obj.filters.length > 0) {
|
||||||
object.filters = [];
|
object.filters = [];
|
||||||
@@ -174,7 +186,7 @@ function autoSave() {
|
|||||||
} else {
|
} else {
|
||||||
object.filters = [];
|
object.filters = [];
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
const inst = canvas.toDatalessJSON([
|
const inst = canvas.toDatalessJSON([
|
||||||
'volume',
|
'volume',
|
||||||
'audioSrc',
|
'audioSrc',
|
||||||
@@ -217,8 +229,11 @@ function autoSave() {
|
|||||||
'strokeUniform',
|
'strokeUniform',
|
||||||
'rx',
|
'rx',
|
||||||
'ry',
|
'ry',
|
||||||
|
'cornerRadius',
|
||||||
'selectable',
|
'selectable',
|
||||||
'hasControls',
|
'hasControls',
|
||||||
|
'hasBorders',
|
||||||
|
'evented',
|
||||||
'subTargetCheck',
|
'subTargetCheck',
|
||||||
'id',
|
'id',
|
||||||
'hoverCursor',
|
'hoverCursor',
|
||||||
@@ -250,6 +265,9 @@ function autoSave() {
|
|||||||
activepreset: activepreset,
|
activepreset: activepreset,
|
||||||
width: artboard.width,
|
width: artboard.width,
|
||||||
height: artboard.height,
|
height: artboard.height,
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error('Autosave failed', e);
|
||||||
});
|
});
|
||||||
objects.forEach(function (object) {
|
objects.forEach(function (object) {
|
||||||
replaceSource(canvas.getItemById(object.id), canvas);
|
replaceSource(canvas.getItemById(object.id), canvas);
|
||||||
@@ -289,17 +307,24 @@ function loadProject() {
|
|||||||
currenttime = 0;
|
currenttime = 0;
|
||||||
canvas.clipPath = null;
|
canvas.clipPath = null;
|
||||||
canvas.clear();
|
canvas.clear();
|
||||||
|
if (webglBackend) {
|
||||||
fabric.filterBackend = webglBackend;
|
fabric.filterBackend = webglBackend;
|
||||||
|
}
|
||||||
f = fabric.Image.filters;
|
f = fabric.Image.filters;
|
||||||
canvas.loadFromJSON(JSON.parse(project.canvas), function () {
|
canvas.loadFromJSON(JSON.parse(project.canvas), function () {
|
||||||
canvas.clipPath = artboard;
|
canvas.clipPath = artboard;
|
||||||
canvas.getItemById('line_h').set({ opacity: 0 });
|
hideGuides(canvas);
|
||||||
canvas.getItemById('line_v').set({ opacity: 0 });
|
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
$('.object-props').remove();
|
$('.object-props').remove();
|
||||||
$('.layer').remove();
|
$('.layer').remove();
|
||||||
objects.forEach(function (object) {
|
objects.forEach(function (object) {
|
||||||
var animatethis = false;
|
var animatethis = false;
|
||||||
|
if (!object.animate) {
|
||||||
|
object.animate = [];
|
||||||
|
}
|
||||||
|
if (!canvas.getItemById(object.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (object.animate.length > 5) {
|
if (object.animate.length > 5) {
|
||||||
if (isSameSet(object.animate, props)) {
|
if (isSameSet(object.animate, props)) {
|
||||||
animatethis = true;
|
animatethis = true;
|
||||||
@@ -327,10 +352,20 @@ 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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
keyframes.forEach(function (keyframe) {
|
keyframes.forEach(function (keyframe) {
|
||||||
|
if (!canvas.getItemById(keyframe.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
keyframe.name != 'top' &&
|
keyframe.name != 'top' &&
|
||||||
keyframe.name != 'scaleY' &&
|
keyframe.name != 'scaleY' &&
|
||||||
@@ -510,12 +545,18 @@ function deleteAsset(key) {
|
|||||||
.doc({ key: key })
|
.doc({ key: key })
|
||||||
.get()
|
.get()
|
||||||
.then((asset) => {
|
.then((asset) => {
|
||||||
|
if (!asset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var temp = files.filter((x) => x.file == asset.src);
|
var temp = files.filter((x) => x.file == asset.src);
|
||||||
if (temp.length > 0) {
|
if (temp.length > 0) {
|
||||||
temp.forEach(function (file) {
|
temp.forEach(function (file) {
|
||||||
deleteObject(canvas.getItemById(file.name));
|
const object = canvas.getItemById(file.name);
|
||||||
|
if (object) {
|
||||||
|
deleteObject(object);
|
||||||
|
}
|
||||||
files = $.grep(files, function (a) {
|
files = $.grep(files, function (a) {
|
||||||
return a != file;
|
return a !== file;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -534,14 +575,26 @@ function deleteAsset(key) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAssets() {
|
function getAssets(attempt) {
|
||||||
|
attempt = attempt || 0;
|
||||||
db.collection('assets')
|
db.collection('assets')
|
||||||
.get()
|
.get()
|
||||||
.then((assets) => {
|
.then((assets) => {
|
||||||
// Sometimes the assets aren't ready when importing, really annoying
|
// Sometimes the assets aren't ready when importing, really annoying.
|
||||||
|
// Retry on a timer with a hard cap - a bare synchronous re-call spins
|
||||||
|
// the CPU forever when the collection never resolves.
|
||||||
if (assets === undefined) {
|
if (assets === undefined) {
|
||||||
getAssets();
|
if (attempt < 20) {
|
||||||
|
window.setTimeout(function () {
|
||||||
|
getAssets(attempt + 1);
|
||||||
|
}, 250);
|
||||||
|
} else {
|
||||||
|
console.error('Could not read assets from the local database');
|
||||||
|
}
|
||||||
} else if (assets.length > 0) {
|
} else if (assets.length > 0) {
|
||||||
|
// Rebuild rather than append: getAssets() runs again after an import
|
||||||
|
uploaded_images = [];
|
||||||
|
uploaded_videos = [];
|
||||||
assets.forEach(function (asset) {
|
assets.forEach(function (asset) {
|
||||||
if (asset.type == 'image') {
|
if (asset.type == 'image') {
|
||||||
uploaded_images.push({
|
uploaded_images.push({
|
||||||
@@ -562,6 +615,9 @@ function getAssets() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error('Could not read assets', e);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,9 +626,19 @@ function readTextFile(file, callback) {
|
|||||||
rawFile.overrideMimeType('application/json');
|
rawFile.overrideMimeType('application/json');
|
||||||
rawFile.open('GET', file, true);
|
rawFile.open('GET', file, true);
|
||||||
rawFile.onreadystatechange = function () {
|
rawFile.onreadystatechange = function () {
|
||||||
if (rawFile.readyState === 4 && rawFile.status == '200') {
|
if (rawFile.readyState === 4) {
|
||||||
|
// A blob: URL resolves with status 0
|
||||||
|
if (rawFile.status == 200 || rawFile.status === 0) {
|
||||||
callback(rawFile.responseText);
|
callback(rawFile.responseText);
|
||||||
|
} else {
|
||||||
|
alert('Could not read the file');
|
||||||
|
$('#import-project span').html('Import');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rawFile.onerror = function () {
|
||||||
|
alert('Could not read the file');
|
||||||
|
$('#import-project span').html('Import');
|
||||||
};
|
};
|
||||||
rawFile.send(null);
|
rawFile.send(null);
|
||||||
}
|
}
|
||||||
@@ -582,10 +648,20 @@ async function importProject(e) {
|
|||||||
var file = e.target.files[0];
|
var file = e.target.files[0];
|
||||||
var path = (window.URL || window.webkitURL).createObjectURL(file);
|
var path = (window.URL || window.webkitURL).createObjectURL(file);
|
||||||
readTextFile(path, function (text) {
|
readTextFile(path, function (text) {
|
||||||
var data = JSON.parse(text);
|
var data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
data = null;
|
||||||
|
}
|
||||||
|
if (!data || !Array.isArray(data.project) || data.project.length == 0) {
|
||||||
|
alert('Wrong file type');
|
||||||
|
$('#import-project span').html('Import');
|
||||||
|
return;
|
||||||
|
}
|
||||||
delete data.project[0].id;
|
delete data.project[0].id;
|
||||||
if (data.project.length > 0) {
|
{
|
||||||
if (data.assets.length > 0) {
|
if (Array.isArray(data.assets) && data.assets.length > 0) {
|
||||||
data.assets.forEach(function (asset) {
|
data.assets.forEach(function (asset) {
|
||||||
delete asset.id;
|
delete asset.id;
|
||||||
db.collection('assets').add(asset);
|
db.collection('assets').add(asset);
|
||||||
@@ -598,10 +674,14 @@ async function importProject(e) {
|
|||||||
$('#import-project span').html('Import');
|
$('#import-project span').html('Import');
|
||||||
hideModals();
|
hideModals();
|
||||||
loadProject();
|
loadProject();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error('Import failed', e);
|
||||||
|
alert('Import failed');
|
||||||
|
$('#import-project span').html('Import');
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
alert('Wrong file type');
|
|
||||||
}
|
}
|
||||||
|
window.URL.revokeObjectURL(path);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,18 +699,24 @@ function exportProject() {
|
|||||||
.get()
|
.get()
|
||||||
.then((assets) => {
|
.then((assets) => {
|
||||||
var exportarr = { project: project, assets: assets };
|
var exportarr = { project: project, assets: assets };
|
||||||
$('<a />', {
|
// Blob, not a data: URL - projects with media blow past the
|
||||||
download: 'data.json',
|
// maximum URL length.
|
||||||
href:
|
const url = URL.createObjectURL(
|
||||||
'data:application/json,' +
|
new Blob([JSON.stringify(exportarr)], {
|
||||||
encodeURIComponent(JSON.stringify(exportarr)),
|
type: 'application/json',
|
||||||
})
|
})
|
||||||
.appendTo('body')
|
);
|
||||||
.click(function () {
|
const a = document.createElement('a');
|
||||||
$(this).remove();
|
a.style.display = 'none';
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'data.json';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.setTimeout(function () {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 60000);
|
||||||
$('#export-project span').html('Export');
|
$('#export-project span').html('Export');
|
||||||
})[0]
|
|
||||||
.click();
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
alert('Empty project');
|
alert('Empty project');
|
||||||
@@ -649,11 +735,16 @@ function clearProject() {
|
|||||||
'Are you sure you want to clear this project? This action cannot be undone.'
|
'Are you sure you want to clear this project? This action cannot be undone.'
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
db.collection('projects').delete();
|
Promise.all([
|
||||||
db.collection('assets').delete();
|
db.collection('projects').delete(),
|
||||||
window.setTimeout(function () {
|
db.collection('assets').delete(),
|
||||||
|
])
|
||||||
|
.catch(function (e) {
|
||||||
|
console.error('Could not clear the project', e);
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
location.reload();
|
location.reload();
|
||||||
}, 1000);
|
});
|
||||||
}
|
}
|
||||||
hideMore();
|
hideMore();
|
||||||
}
|
}
|
||||||
|
|||||||
+138
-113
@@ -1,17 +1,79 @@
|
|||||||
|
// Remember the last valid crop rectangle so it can be restored if the user
|
||||||
|
// drags it outside the image.
|
||||||
|
function updateCropBounds() {
|
||||||
|
const cropUI = canvas.getItemById('crop');
|
||||||
|
if (!cropUI || !cropobj) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cropUI.isContainedWithinObject(cropobj)) {
|
||||||
|
cropleft = cropUI.get('left');
|
||||||
|
croptop = cropUI.get('top');
|
||||||
|
cropscalex = cropUI.get('scaleX');
|
||||||
|
cropscaley = cropUI.get('scaleY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag-to-reorder for the layer list. html5sortable only wires up the
|
||||||
|
// children present when it runs, so this is re-run every time a layer is
|
||||||
|
// added. The sortstop handler is bound only once.
|
||||||
|
let layerSortBound = false;
|
||||||
|
function initLayerSortable() {
|
||||||
|
const list = document.getElementById('layer-inner-list');
|
||||||
|
if (!list) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sortableList = sortable(list, {
|
||||||
|
handle: '.layer-handle',
|
||||||
|
customDragImage: (draggedElement, elementOffset, event) => {
|
||||||
|
return {
|
||||||
|
element: document.getElementById('nothing'),
|
||||||
|
posX: event.pageX - elementOffset.left,
|
||||||
|
posY: event.pageY - elementOffset.top,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
})[0];
|
||||||
|
|
||||||
|
// Re-initializing marks every handle draggable again, so locked layers
|
||||||
|
// have to be opted back out.
|
||||||
|
$('#layer-inner-list .layer').each(function () {
|
||||||
|
if ($(this).find('.lock').hasClass('locked')) {
|
||||||
|
$(this).find('.layer-handle').attr('draggable', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (layerSortBound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
layerSortBound = true;
|
||||||
|
sortableList.addEventListener('sortupdate', function () {
|
||||||
|
syncTimelineOrder();
|
||||||
|
orderLayers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-order the timeline rows to match the layer list
|
||||||
|
function syncTimelineOrder() {
|
||||||
|
const timeline = document.getElementById('inner-timeline');
|
||||||
|
if (!timeline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#layer-inner-list .layer').each(function () {
|
||||||
|
const row = document.getElementById(
|
||||||
|
$(this).attr('data-object')
|
||||||
|
);
|
||||||
|
if (row) {
|
||||||
|
timeline.appendChild(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
// An object is being moved in the canvas
|
// An object is being moved in the canvas
|
||||||
canvas.on('object:moving', function (e) {
|
canvas.on('object:moving', function (e) {
|
||||||
e.target.hasControls = false;
|
e.target.hasControls = false;
|
||||||
centerLines(e);
|
centerLines(e);
|
||||||
if (cropping) {
|
if (cropping) {
|
||||||
if (
|
updateCropBounds();
|
||||||
canvas.getItemById('crop').isContainedWithinObject(cropobj)
|
|
||||||
) {
|
|
||||||
cropleft = canvas.getItemById('crop').get('left');
|
|
||||||
croptop = canvas.getItemById('crop').get('top');
|
|
||||||
cropscalex = canvas.getItemById('crop').get('scaleX');
|
|
||||||
cropscaley = canvas.getItemById('crop').get('scaleY');
|
|
||||||
}
|
|
||||||
crop(canvas.getItemById('cropped'));
|
crop(canvas.getItemById('cropped'));
|
||||||
} else if (
|
} else if (
|
||||||
lockmovement &&
|
lockmovement &&
|
||||||
@@ -38,15 +100,10 @@ $(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) {
|
||||||
if (
|
updateCropBounds();
|
||||||
canvas.getItemById('crop').isContainedWithinObject(cropobj)
|
|
||||||
) {
|
|
||||||
cropleft = canvas.getItemById('crop').get('left');
|
|
||||||
croptop = canvas.getItemById('crop').get('top');
|
|
||||||
cropscalex = canvas.getItemById('crop').get('scaleX');
|
|
||||||
cropscaley = canvas.getItemById('crop').get('scaleY');
|
|
||||||
}
|
|
||||||
crop(canvas.getItemById('cropped'));
|
crop(canvas.getItemById('cropped'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -56,24 +113,15 @@ $(document).ready(function () {
|
|||||||
e.target.hasControls = false;
|
e.target.hasControls = false;
|
||||||
centerLines(e);
|
centerLines(e);
|
||||||
if (cropping) {
|
if (cropping) {
|
||||||
if (
|
updateCropBounds();
|
||||||
canvas.getItemById('crop').isContainedWithinObject(cropobj)
|
|
||||||
) {
|
|
||||||
cropleft = canvas.getItemById('crop').get('left');
|
|
||||||
croptop = canvas.getItemById('crop').get('top');
|
|
||||||
cropscalex = canvas.getItemById('crop').get('scaleX');
|
|
||||||
cropscaley = canvas.getItemById('crop').get('scaleY');
|
|
||||||
}
|
|
||||||
crop(canvas.getItemById('cropped'));
|
crop(canvas.getItemById('cropped'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// An object is being rotated in the canvas
|
// An object is being rotated in the canvas
|
||||||
canvas.on('object:rotating', function (e) {
|
canvas.on('object:rotating', function (e) {
|
||||||
if (e.e.shiftKey) {
|
if (canvas.getActiveObject()) {
|
||||||
canvas.getActiveObject().snapAngle = 15;
|
canvas.getActiveObject().snapAngle = e.e.shiftKey ? 15 : 0;
|
||||||
} else {
|
|
||||||
canvas.getActiveObject().snapAngle = 0;
|
|
||||||
}
|
}
|
||||||
e.target.hasControls = false;
|
e.target.hasControls = false;
|
||||||
});
|
});
|
||||||
@@ -82,17 +130,23 @@ $(document).ready(function () {
|
|||||||
canvas.on('object:modified', function (e) {
|
canvas.on('object:modified', function (e) {
|
||||||
e.target.hasControls = true;
|
e.target.hasControls = true;
|
||||||
if (!editinggroup && !cropping) {
|
if (!editinggroup && !cropping) {
|
||||||
|
if (canvas.getActiveObject()) {
|
||||||
canvas.getActiveObject().lockMovementX = false;
|
canvas.getActiveObject().lockMovementX = false;
|
||||||
canvas.getActiveObject().lockMovementY = false;
|
canvas.getActiveObject().lockMovementY = false;
|
||||||
|
}
|
||||||
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();
|
||||||
@@ -193,8 +247,12 @@ $(document).ready(function () {
|
|||||||
this.setViewportTransform(this.viewportTransform);
|
this.setViewportTransform(this.viewportTransform);
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.selection = true;
|
this.selection = true;
|
||||||
|
if (line_h) {
|
||||||
line_h.opacity = 0;
|
line_h.opacity = 0;
|
||||||
|
}
|
||||||
|
if (line_v) {
|
||||||
line_v.opacity = 0;
|
line_v.opacity = 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect mouse over canvas (for dragging objects from the library)
|
// Detect mouse over canvas (for dragging objects from the library)
|
||||||
@@ -214,7 +272,9 @@ $(document).ready(function () {
|
|||||||
canvas.on('mouse:out', function (e) {
|
canvas.on('mouse:out', function (e) {
|
||||||
overCanvas = false;
|
overCanvas = false;
|
||||||
if (wip) {
|
if (wip) {
|
||||||
|
if (e.target) {
|
||||||
e.target.hasControls = true;
|
e.target.hasControls = true;
|
||||||
|
}
|
||||||
canvas.discardActiveObject();
|
canvas.discardActiveObject();
|
||||||
wip = false;
|
wip = false;
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
@@ -320,14 +380,17 @@ $(document).ready(function () {
|
|||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
// Redo
|
// Redo / undo (shift decides which; never both in one keypress)
|
||||||
if (e.which === 90 && (e.ctrlKey || e.metaKey) && e.shiftKey) {
|
if (e.which === 90 && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (redo.length >= 1) {
|
||||||
undoRedo(redo, undo, redoarr, undoarr);
|
undoRedo(redo, undo, redoarr, undoarr);
|
||||||
}
|
}
|
||||||
// Undo
|
} else if (undo.length >= 1) {
|
||||||
if (e.which === 90 && (e.ctrlKey || e.metaKey)) {
|
|
||||||
undoRedo(undo, redo, undoarr, redoarr);
|
undoRedo(undo, redo, undoarr, redoarr);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// Duplicate object
|
// Duplicate object
|
||||||
if (e.which === 68 && (e.ctrlKey || e.metaKey)) {
|
if (e.which === 68 && (e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -353,51 +416,30 @@ $(document).ready(function () {
|
|||||||
if (e.keyCode === 13 && editingproject) {
|
if (e.keyCode === 13 && editingproject) {
|
||||||
saveProjectName();
|
saveProjectName();
|
||||||
}
|
}
|
||||||
// Left arrow key (move object to the left)
|
// Arrow keys nudge the selection
|
||||||
if (e.keyCode === 37 && canvas.getActiveObject()) {
|
if (
|
||||||
var obj = canvas.getActiveObject();
|
e.keyCode >= 37 &&
|
||||||
var step = 2;
|
e.keyCode <= 40 &&
|
||||||
|
canvas.getActiveObject() &&
|
||||||
|
!canvas.getActiveObject().isEditing &&
|
||||||
|
!focus &&
|
||||||
|
!editinglayer &&
|
||||||
|
!editingproject
|
||||||
|
) {
|
||||||
|
const obj = canvas.getActiveObject();
|
||||||
// Bigger step if shift is down
|
// Bigger step if shift is down
|
||||||
if (e.shiftKey) {
|
const step = e.shiftKey ? 7 : 2;
|
||||||
step = 7;
|
if (e.keyCode === 37) {
|
||||||
}
|
|
||||||
obj.left = obj.left - step;
|
obj.left = obj.left - step;
|
||||||
canvas.renderAll();
|
} else if (e.keyCode === 38) {
|
||||||
autoKeyframe(obj, { action: 'drag' }, false);
|
|
||||||
}
|
|
||||||
// Up arrow key (move object up)
|
|
||||||
if (e.keyCode === 38 && canvas.getActiveObject()) {
|
|
||||||
var obj = canvas.getActiveObject();
|
|
||||||
var step = 2;
|
|
||||||
// Bigger step if shift is down
|
|
||||||
if (e.shiftKey) {
|
|
||||||
step = 7;
|
|
||||||
}
|
|
||||||
obj.top = obj.top - step;
|
obj.top = obj.top - step;
|
||||||
canvas.renderAll();
|
} else if (e.keyCode === 39) {
|
||||||
autoKeyframe(obj, { action: 'drag' }, false);
|
|
||||||
}
|
|
||||||
// Right arrow key (move object to the right)
|
|
||||||
if (e.keyCode === 39 && canvas.getActiveObject()) {
|
|
||||||
var obj = canvas.getActiveObject();
|
|
||||||
var step = 2;
|
|
||||||
// Bigger step if shift is down
|
|
||||||
if (e.shiftKey) {
|
|
||||||
step = 7;
|
|
||||||
}
|
|
||||||
obj.left = obj.left + step;
|
obj.left = obj.left + step;
|
||||||
canvas.renderAll();
|
} else {
|
||||||
autoKeyframe(obj, { action: 'drag' }, false);
|
|
||||||
}
|
|
||||||
// Down arrow key (move object down)
|
|
||||||
if (e.keyCode === 40 && canvas.getActiveObject()) {
|
|
||||||
var obj = canvas.getActiveObject();
|
|
||||||
var step = 2;
|
|
||||||
// Bigger step if shift is down
|
|
||||||
if (e.shiftKey) {
|
|
||||||
step = 7;
|
|
||||||
}
|
|
||||||
obj.top = obj.top + step;
|
obj.top = obj.top + step;
|
||||||
|
}
|
||||||
|
// Without this the selection box stays where the object used to be
|
||||||
|
obj.setCoords();
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
autoKeyframe(obj, { action: 'drag' }, false);
|
autoKeyframe(obj, { action: 'drag' }, false);
|
||||||
}
|
}
|
||||||
@@ -406,7 +448,7 @@ $(document).ready(function () {
|
|||||||
if (
|
if (
|
||||||
e.keyCode === 221 &&
|
e.keyCode === 221 &&
|
||||||
canvas.getActiveObjects() &&
|
canvas.getActiveObjects() &&
|
||||||
e.metaKey
|
(e.metaKey || e.ctrlKey)
|
||||||
) {
|
) {
|
||||||
if (canvas.getActiveObjects().length == 1) {
|
if (canvas.getActiveObjects().length == 1) {
|
||||||
var obj = canvas.getActiveObject();
|
var obj = canvas.getActiveObject();
|
||||||
@@ -434,7 +476,7 @@ $(document).ready(function () {
|
|||||||
if (
|
if (
|
||||||
e.keyCode === 219 &&
|
e.keyCode === 219 &&
|
||||||
canvas.getActiveObjects() &&
|
canvas.getActiveObjects() &&
|
||||||
e.metaKey
|
(e.metaKey || e.ctrlKey)
|
||||||
) {
|
) {
|
||||||
if (canvas.getActiveObjects().length == 1) {
|
if (canvas.getActiveObjects().length == 1) {
|
||||||
var obj = canvas.getActiveObject();
|
var obj = canvas.getActiveObject();
|
||||||
@@ -545,25 +587,24 @@ $(document).ready(function () {
|
|||||||
|
|
||||||
// Copy event
|
// Copy event
|
||||||
window.addEventListener('copy', function (e) {
|
window.addEventListener('copy', function (e) {
|
||||||
|
// Selecting keyframes clears the canvas selection, so there is often no
|
||||||
|
// active object at all here - never dereference it unguarded.
|
||||||
|
const activeObject = canvas.getActiveObject();
|
||||||
|
if (activeObject && activeObject.isEditing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Copy selected object
|
// Copy selected object
|
||||||
if (
|
if (activeObject && shiftkeys.length == 0) {
|
||||||
canvas.getActiveObject() &&
|
|
||||||
shiftkeys.length == 0 &&
|
|
||||||
!canvas.getActiveObject().isEditing
|
|
||||||
) {
|
|
||||||
var emptyInp = document.getElementById('emptyInput');
|
var emptyInp = document.getElementById('emptyInput');
|
||||||
emptyInp.select();
|
emptyInp.select();
|
||||||
emptyInp.focus();
|
emptyInp.focus();
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
document.execCommand('copy');
|
document.execCommand('copy');
|
||||||
}, 0);
|
}, 0);
|
||||||
clipboard = canvas.getActiveObject();
|
clipboard = activeObject;
|
||||||
cliptype = 'object';
|
cliptype = 'object';
|
||||||
// Copy selected keyframe(s)
|
// Copy selected keyframe(s)
|
||||||
} else if (
|
} else if (shiftkeys.length > 0) {
|
||||||
shiftkeys.length > 0 &&
|
|
||||||
!canvas.getActiveObject().isEditing
|
|
||||||
) {
|
|
||||||
var emptyInp = document.getElementById('emptyInput');
|
var emptyInp = document.getElementById('emptyInput');
|
||||||
emptyInp.select();
|
emptyInp.select();
|
||||||
emptyInp.focus();
|
emptyInp.focus();
|
||||||
@@ -580,7 +621,9 @@ $(document).ready(function () {
|
|||||||
e.name == drag.attr('data-property')
|
e.name == drag.attr('data-property')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
if (keyarr.length > 0) {
|
||||||
clipboard.push(keyarr[0]);
|
clipboard.push(keyarr[0]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
cliptype = 'keyframe';
|
cliptype = 'keyframe';
|
||||||
}
|
}
|
||||||
@@ -598,7 +641,9 @@ $(document).ready(function () {
|
|||||||
} else {
|
} else {
|
||||||
for (var i = 0; i < imgs.length; i++) {
|
for (var i = 0; i < imgs.length; i++) {
|
||||||
if (imgs[i].type.indexOf('image') == -1) continue;
|
if (imgs[i].type.indexOf('image') == -1) continue;
|
||||||
var imgObj = imgs[i].getAsFile();
|
// `let` so each async thumbnail callback keeps its own file
|
||||||
|
let imgObj = imgs[i].getAsFile();
|
||||||
|
if (!imgObj) continue;
|
||||||
if (imgObj.size / 1024 / 1024 <= 10) {
|
if (imgObj.size / 1024 / 1024 <= 10) {
|
||||||
createThumbnail(imgObj, 250).then(function (data) {
|
createThumbnail(imgObj, 250).then(function (data) {
|
||||||
saveFile(
|
saveFile(
|
||||||
@@ -803,27 +848,7 @@ $(document).ready(function () {
|
|||||||
syncScrollHoz($('#timeline'), $('#seekarea'));
|
syncScrollHoz($('#timeline'), $('#seekarea'));
|
||||||
|
|
||||||
// Initialize layer sorting
|
// Initialize layer sorting
|
||||||
sortable('#layer-inner-list', {
|
initLayerSortable();
|
||||||
customDragImage: (draggedElement, elementOffset, event) => {
|
|
||||||
return {
|
|
||||||
element: document.getElementById('nothing'),
|
|
||||||
posX: event.pageX - elementOffset.left,
|
|
||||||
posY: event.pageY - elementOffset.top,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})[0].addEventListener('sortstop', function (e) {
|
|
||||||
const id = $(e.detail.item).attr('data-object');
|
|
||||||
const previd = $(e.detail.item).prev().attr('data-object');
|
|
||||||
if ($('.sortable-dragging').length == 1) {
|
|
||||||
$('.sortable-dragging').remove();
|
|
||||||
if (previd == undefined) {
|
|
||||||
$('#inner-timeline').prepend($('#' + id));
|
|
||||||
} else {
|
|
||||||
$('#' + id).insertAfter($('#' + previd));
|
|
||||||
}
|
|
||||||
orderLayers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize dropdown for keyframe easing
|
// Initialize dropdown for keyframe easing
|
||||||
$('#easing select').niceSelect();
|
$('#easing select').niceSelect();
|
||||||
@@ -936,8 +961,8 @@ $(document).ready(function () {
|
|||||||
onmove: function (x) {
|
onmove: function (x) {
|
||||||
if (canvas.getActiveObject()) {
|
if (canvas.getActiveObject()) {
|
||||||
var obj = canvas.getActiveObject();
|
var obj = canvas.getActiveObject();
|
||||||
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
|
if (obj.filters.find((i) => i.type == 'RemoveColor')) {
|
||||||
obj.filters.find((x) => x.type == 'RemoveColor').distance =
|
obj.filters.find((i) => i.type == 'RemoveColor').distance =
|
||||||
x / 100;
|
x / 100;
|
||||||
}
|
}
|
||||||
obj.applyFilters();
|
obj.applyFilters();
|
||||||
@@ -964,8 +989,8 @@ $(document).ready(function () {
|
|||||||
onmove: function (x) {
|
onmove: function (x) {
|
||||||
if (canvas.getActiveObject()) {
|
if (canvas.getActiveObject()) {
|
||||||
var obj = canvas.getActiveObject();
|
var obj = canvas.getActiveObject();
|
||||||
if (obj.filters.find((x) => x.type == 'Noise')) {
|
if (obj.filters.find((i) => i.type == 'Noise')) {
|
||||||
obj.filters.find((x) => x.type == 'Noise').noise = x;
|
obj.filters.find((i) => i.type == 'Noise').noise = x;
|
||||||
} else {
|
} else {
|
||||||
obj.filters.push(
|
obj.filters.push(
|
||||||
new f.Noise({
|
new f.Noise({
|
||||||
@@ -997,8 +1022,8 @@ $(document).ready(function () {
|
|||||||
onmove: function (x) {
|
onmove: function (x) {
|
||||||
if (canvas.getActiveObject()) {
|
if (canvas.getActiveObject()) {
|
||||||
var obj = canvas.getActiveObject();
|
var obj = canvas.getActiveObject();
|
||||||
if (obj.filters.find((x) => x.type == 'Blur')) {
|
if (obj.filters.find((i) => i.type == 'Blur')) {
|
||||||
obj.filters.find((x) => x.type == 'Blur').blur = x / 100;
|
obj.filters.find((i) => i.type == 'Blur').blur = x / 100;
|
||||||
} else {
|
} else {
|
||||||
obj.filters.push(
|
obj.filters.push(
|
||||||
new f.Blur({
|
new f.Blur({
|
||||||
@@ -1006,9 +1031,9 @@ $(document).ready(function () {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
obj.applyFilters();
|
obj.applyFilters();
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onfinish: function (x) {
|
onfinish: function (x) {
|
||||||
save();
|
save();
|
||||||
|
|||||||
+1082
-1231
File diff suppressed because it is too large
Load Diff
+53
-23
@@ -3,7 +3,6 @@ var GOOGLE_FONTS_API_KEY = 'GOOGLE_FONTS_API_KEY';
|
|||||||
|
|
||||||
// for legacy browsers
|
// for legacy browsers
|
||||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||||
const audioContext = new AudioContext();
|
|
||||||
var oldsrc, oldobj;
|
var oldsrc, oldobj;
|
||||||
var oldtimelinepos;
|
var oldtimelinepos;
|
||||||
var speed = 1;
|
var speed = 1;
|
||||||
@@ -58,7 +57,6 @@ var editingpanel = false;
|
|||||||
var files = [];
|
var files = [];
|
||||||
var re = /(?:\.([^.]+))?$/;
|
var re = /(?:\.([^.]+))?$/;
|
||||||
var filelist = [];
|
var filelist = [];
|
||||||
var timeout;
|
|
||||||
var spacehold = false;
|
var spacehold = false;
|
||||||
var spacerelease = false;
|
var spacerelease = false;
|
||||||
var tempselection;
|
var tempselection;
|
||||||
@@ -94,7 +92,7 @@ var chromaslider, noiseslider, blurslider;
|
|||||||
var isChrome =
|
var isChrome =
|
||||||
window.chrome && Object.values(window.chrome).length !== 0;
|
window.chrome && Object.values(window.chrome).length !== 0;
|
||||||
var eyeDropper;
|
var eyeDropper;
|
||||||
if (isChrome) {
|
if (isChrome && typeof EyeDropper !== 'undefined') {
|
||||||
eyeDropper = new EyeDropper();
|
eyeDropper = new EyeDropper();
|
||||||
}
|
}
|
||||||
var presets = [
|
var presets = [
|
||||||
@@ -156,7 +154,13 @@ var sliders = [];
|
|||||||
var hovertime = 0;
|
var hovertime = 0;
|
||||||
var animatedtext = [];
|
var animatedtext = [];
|
||||||
|
|
||||||
// Get list of fonts
|
// Get list of fonts.
|
||||||
|
// Both API keys are placeholders in the repository - replace them to enable
|
||||||
|
// the Google Fonts list and the Pixabay browser.
|
||||||
|
const HAS_FONTS_KEY =
|
||||||
|
GOOGLE_FONTS_API_KEY && GOOGLE_FONTS_API_KEY != 'GOOGLE_FONTS_API_KEY';
|
||||||
|
const HAS_PIXABAY_KEY = API_KEY && API_KEY != 'PIXABAY_API';
|
||||||
|
if (HAS_FONTS_KEY) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url:
|
url:
|
||||||
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
|
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
|
||||||
@@ -169,7 +173,11 @@ $.ajax({
|
|||||||
fonts.push(item.family);
|
fonts.push(item.family);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
error: function () {
|
||||||
|
console.warn('Could not load the Google Fonts list');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Panel variants
|
// Panel variants
|
||||||
const canvas_panel =
|
const canvas_panel =
|
||||||
@@ -444,6 +452,25 @@ var text_items = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Without a Google Fonts key the font pickers would be empty, so fall back
|
||||||
|
// to the families that are already bundled in the text browser.
|
||||||
|
// (Declared here because it reads text_items, defined just above.)
|
||||||
|
if (!HAS_FONTS_KEY) {
|
||||||
|
Object.keys(text_items).forEach(function (group) {
|
||||||
|
text_items[group].forEach(function (item) {
|
||||||
|
if (fonts.indexOf(item.fontname) == -1) {
|
||||||
|
fonts.push(item.fontname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['Inter', 'Syne'].forEach(function (name) {
|
||||||
|
if (fonts.indexOf(name) == -1) {
|
||||||
|
fonts.push(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fonts.sort();
|
||||||
|
}
|
||||||
|
|
||||||
WebFont.load({
|
WebFont.load({
|
||||||
google: {
|
google: {
|
||||||
families: ['Syne'],
|
families: ['Syne'],
|
||||||
@@ -460,7 +487,11 @@ try {
|
|||||||
var canvas2dBackend = new fabric.Canvas2dFilterBackend();
|
var canvas2dBackend = new fabric.Canvas2dFilterBackend();
|
||||||
|
|
||||||
fabric.filterBackend = fabric.initFilterBackend();
|
fabric.filterBackend = fabric.initFilterBackend();
|
||||||
|
// Only take over the backend if WebGL actually initialized, otherwise filters
|
||||||
|
// would silently break on machines without a usable WebGL context.
|
||||||
|
if (webglBackend) {
|
||||||
fabric.filterBackend = webglBackend;
|
fabric.filterBackend = webglBackend;
|
||||||
|
}
|
||||||
|
|
||||||
// Lottie support
|
// Lottie support
|
||||||
fabric.Lottie = fabric.util.createClass(fabric.Image, {
|
fabric.Lottie = fabric.util.createClass(fabric.Image, {
|
||||||
@@ -491,14 +522,18 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.lottieItem.addEventListener('enterFrame', (e) => {
|
this.lottieItem.addEventListener('enterFrame', (e) => {
|
||||||
|
if (this.canvas) {
|
||||||
this.canvas.requestRenderAll();
|
this.canvas.requestRenderAll();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.lottieItem.addEventListener('DOMLoaded', () => {
|
this.lottieItem.addEventListener('DOMLoaded', () => {
|
||||||
this.lottieItem.goToAndStop(currenttime, false);
|
this.lottieItem.goToAndStop(currenttime, false);
|
||||||
this.lottieItem.duration =
|
this.lottieItem.duration =
|
||||||
this.lottieItem.getDuration(false) * 1000;
|
this.lottieItem.getDuration(false) * 1000;
|
||||||
|
if (this.canvas) {
|
||||||
this.canvas.requestRenderAll();
|
this.canvas.requestRenderAll();
|
||||||
|
}
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
canvas.fire('lottie:loaded', { any: 'payload' });
|
canvas.fire('lottie:loaded', { any: 'payload' });
|
||||||
});
|
});
|
||||||
@@ -508,7 +543,9 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
|
|||||||
|
|
||||||
goToSeconds: function (seconds) {
|
goToSeconds: function (seconds) {
|
||||||
this.lottieItem.goToAndStop(seconds, false);
|
this.lottieItem.goToAndStop(seconds, false);
|
||||||
|
if (this.canvas) {
|
||||||
this.canvas.requestRenderAll();
|
this.canvas.requestRenderAll();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
goToFrame: function (frame) {
|
goToFrame: function (frame) {
|
||||||
this.lottieItem.goToAndStop(frame, true);
|
this.lottieItem.goToAndStop(frame, true);
|
||||||
@@ -758,30 +795,23 @@ textBoxControls.mr = new fabric.Control({
|
|||||||
|
|
||||||
// Get any object by ID
|
// Get any object by ID
|
||||||
fabric.Canvas.prototype.getItemById = function (name) {
|
fabric.Canvas.prototype.getItemById = function (name) {
|
||||||
var object = null,
|
function search(list) {
|
||||||
objects = this.getObjects();
|
for (var i = 0; i < list.length; i++) {
|
||||||
for (var i = 0, len = this.size(); i < len; i++) {
|
const item = list[i];
|
||||||
if (objects[i].get('type') == 'group') {
|
if (item.id && item.id === name) {
|
||||||
if (objects[i].get('id') && objects[i].get('id') === name) {
|
return item;
|
||||||
object = objects[i];
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
var wip = i;
|
// Recurse so groups nested more than one level deep are found too
|
||||||
for (var o = 0; o < objects[i]._objects.length; o++) {
|
if (item._objects && item._objects.length > 0) {
|
||||||
if (
|
const found = search(item._objects);
|
||||||
objects[wip]._objects[o].id &&
|
if (found) {
|
||||||
objects[wip]._objects[o].id === name
|
return found;
|
||||||
) {
|
|
||||||
object = objects[wip]._objects[o];
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (objects[i].id && objects[i].id === name) {
|
|
||||||
object = objects[i];
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return object;
|
return search(this.getObjects());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the artboard
|
// Create the artboard
|
||||||
|
|||||||
Vendored
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -14,7 +14,7 @@ async function newLottieAnimation(x, y, json) {
|
|||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
cursorDuration: 1,
|
cursorDuration: 1,
|
||||||
cursorDelay: 250,
|
cursorDelay: 250,
|
||||||
duration: duration * 1000,
|
duration: duration,
|
||||||
assetType: 'sprite',
|
assetType: 'sprite',
|
||||||
id: 'Sprite' + layer_count,
|
id: 'Sprite' + layer_count,
|
||||||
objectCaching: false,
|
objectCaching: false,
|
||||||
|
|||||||
+173
-291
@@ -1,68 +1,108 @@
|
|||||||
const FPS = 30;
|
// Everything the current export needs to tear down when it finishes
|
||||||
let frame = 0;
|
var exportAudio = null;
|
||||||
var chunks = [];
|
|
||||||
var stream;
|
|
||||||
var rec;
|
|
||||||
var track;
|
|
||||||
|
|
||||||
function timeout(ms) {
|
// Route every sound source of the project into a single destination node.
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
//
|
||||||
|
// This has to be one shared AudioContext with one destination:
|
||||||
|
// - MediaRecorder only records the first audio track of a stream, so
|
||||||
|
// adding one track per source silently dropped all but one of them,
|
||||||
|
// - a context per source was never closed, and browsers cap how many a
|
||||||
|
// page may hold.
|
||||||
|
function buildExportAudio(stream) {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const destination = ctx.createMediaStreamDestination();
|
||||||
|
const elements = [];
|
||||||
|
var connected = false;
|
||||||
|
|
||||||
|
function connect(element) {
|
||||||
|
try {
|
||||||
|
ctx.createMediaElementSource(element).connect(destination);
|
||||||
|
connected = true;
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// Already bound to another context, or tainted by CORS
|
||||||
|
console.warn('Could not route audio for export', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initRecorder() {
|
objects.forEach(function (object) {
|
||||||
stream = document.getElementById('canvasrecord').captureStream(0);
|
const obj = canvasrecord.getItemById(object.id);
|
||||||
track = stream.getVideoTracks()[0];
|
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
||||||
|
if (!obj || !p_keyframe) {
|
||||||
if (!track.requestFrame) {
|
return;
|
||||||
track.requestFrame = () => stream.requestFrame();
|
}
|
||||||
|
if (obj.get('assetType') == 'video') {
|
||||||
|
const element = $(obj.getElement())[0];
|
||||||
|
if (element) {
|
||||||
|
connect(element);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
obj.get('assetType') == 'audio' &&
|
||||||
|
obj.get('audioSrc')
|
||||||
|
) {
|
||||||
|
// Audio layers used to be left out of the export entirely
|
||||||
|
const element = new Audio(obj.get('audioSrc'));
|
||||||
|
element.crossOrigin = 'anonymous';
|
||||||
|
element.volume = obj.get('volume');
|
||||||
|
if (connect(element)) {
|
||||||
|
elements.push({
|
||||||
|
element: element,
|
||||||
|
start: p_keyframe.start,
|
||||||
|
end: p_keyframe.end,
|
||||||
|
trimstart: p_keyframe.trimstart,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rec = new MediaRecorder(stream, {
|
|
||||||
bitsPerSecond: 3200000,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
rec.ondataavailable = function (evt) {
|
if (background_audio != false && connect(background_audio)) {
|
||||||
console.log('chunky');
|
elements.push({
|
||||||
chunks.push(evt.data);
|
element: background_audio,
|
||||||
};
|
start: 0,
|
||||||
|
end: duration,
|
||||||
rec.start();
|
trimstart: 0,
|
||||||
|
});
|
||||||
console.log('Recorder has been started');
|
|
||||||
|
|
||||||
rec.onstart = function () {
|
|
||||||
rec.pause();
|
|
||||||
console.log('start!');
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordFrame() {
|
if (connected) {
|
||||||
console.log(frame);
|
stream.addTrack(destination.stream.getAudioTracks()[0]);
|
||||||
|
}
|
||||||
waitForEvent(rec, 'pause');
|
return { context: ctx, elements: elements, timers: [] };
|
||||||
|
|
||||||
//rec.onpause = async function(e) {
|
|
||||||
|
|
||||||
// wake up the recorder
|
|
||||||
rec.resume();
|
|
||||||
recordAnimate(false, (frame / FPS) * 1000);
|
|
||||||
//animate(false, (frame/FPS)*1000)
|
|
||||||
// force write the frame
|
|
||||||
track.requestFrame();
|
|
||||||
|
|
||||||
// wait until our frame-time elapsed
|
|
||||||
await timeout(1000 / FPS);
|
|
||||||
|
|
||||||
// sleep recorder
|
|
||||||
rec.pause();
|
|
||||||
//}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportRecording() {
|
// Start the scheduled audio layers relative to the start of the recording
|
||||||
rec.stop();
|
function startExportAudio(audio) {
|
||||||
stream.getTracks().forEach((track) => track.stop());
|
if (!audio) {
|
||||||
await waitForEvent(rec, 'stop');
|
return;
|
||||||
return new Blob(chunks);
|
}
|
||||||
|
audio.elements.forEach(function (item) {
|
||||||
|
item.element.currentTime = item.trimstart / 1000;
|
||||||
|
audio.timers.push(
|
||||||
|
window.setTimeout(function () {
|
||||||
|
item.element.play();
|
||||||
|
}, Math.max(0, item.start))
|
||||||
|
);
|
||||||
|
audio.timers.push(
|
||||||
|
window.setTimeout(function () {
|
||||||
|
item.element.pause();
|
||||||
|
}, Math.max(0, item.end))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopExportAudio(audio) {
|
||||||
|
if (!audio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audio.timers.forEach(window.clearTimeout);
|
||||||
|
audio.timers = [];
|
||||||
|
audio.elements.forEach(function (item) {
|
||||||
|
item.element.pause();
|
||||||
|
});
|
||||||
|
if (audio.context && audio.context.state != 'closed') {
|
||||||
|
audio.context.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record canvas
|
// Record canvas
|
||||||
@@ -85,280 +125,122 @@ async function record() {
|
|||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
recording = false;
|
recording = false;
|
||||||
updateRecordCanvas();
|
updateRecordCanvas();
|
||||||
} else {
|
return;
|
||||||
if (!recording) {
|
}
|
||||||
|
if (recording) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
recording = true;
|
recording = true;
|
||||||
paused = true;
|
paused = true;
|
||||||
await recordAnimate(0);
|
await recordAnimate(0);
|
||||||
$('#download-real').html('Rendering...');
|
$('#download-real').html('Rendering...');
|
||||||
$('#download-real').addClass('downloading');
|
$('#download-real').addClass('downloading');
|
||||||
var fps = 60;
|
|
||||||
var aCtx = new AudioContext();
|
// Preferred path: render every frame offline, with no clock attached, so
|
||||||
|
// nothing is dropped and video layers land on the exact frame.
|
||||||
|
if (frameAccurateSupported()) {
|
||||||
|
const blob = await renderFrameAccurate();
|
||||||
|
if (blob) {
|
||||||
|
deliverRecording(blob);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn('Falling back to the real-time encoder');
|
||||||
|
$('#download-real').html('Rendering...');
|
||||||
|
await updateRecordCanvas();
|
||||||
|
await recordAnimate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fps = getExportFramerate();
|
||||||
|
const aCtx = new AudioContext();
|
||||||
|
|
||||||
|
// requestAnimationFrame is throttled in background tabs, an oscillator is
|
||||||
|
// not, so the render keeps running while the tab is hidden.
|
||||||
function audioTimerLoop(callback, frequency) {
|
function audioTimerLoop(callback, frequency) {
|
||||||
var freq = frequency / 1000;
|
const freq = frequency / 1000;
|
||||||
var silence = aCtx.createGain();
|
const silence = aCtx.createGain();
|
||||||
silence.gain.value = 0;
|
silence.gain.value = 0;
|
||||||
silence.connect(aCtx.destination);
|
silence.connect(aCtx.destination);
|
||||||
onOSCend();
|
|
||||||
var stopped = false;
|
var stopped = false;
|
||||||
|
var osc;
|
||||||
function onOSCend() {
|
function onOSCend() {
|
||||||
|
if (stopped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
osc = aCtx.createOscillator();
|
osc = aCtx.createOscillator();
|
||||||
osc.onended = onOSCend;
|
osc.onended = onOSCend;
|
||||||
osc.connect(silence);
|
osc.connect(silence);
|
||||||
osc.start(0);
|
osc.start(0);
|
||||||
osc.stop(aCtx.currentTime + freq);
|
osc.stop(aCtx.currentTime + freq);
|
||||||
callback(aCtx.currentTime);
|
callback(aCtx.currentTime);
|
||||||
if (stopped) {
|
|
||||||
osc.onended = function () {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
onOSCend();
|
||||||
return function () {
|
return function () {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
if (osc) {
|
||||||
|
osc.onended = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
|
|
||||||
var stream = document
|
const stream = document
|
||||||
.getElementById('canvasrecord')
|
.getElementById('canvasrecord')
|
||||||
.captureStream(fps);
|
.captureStream(fps);
|
||||||
objects.forEach(function (object) {
|
exportAudio = buildExportAudio(stream);
|
||||||
if (
|
|
||||||
canvasrecord.getItemById(object.id).get('assetType') &&
|
const chunks = [];
|
||||||
canvasrecord.getItemById(object.id).get('assetType') ==
|
const recorder = new MediaRecorder(stream, {
|
||||||
'video'
|
|
||||||
) {
|
|
||||||
var audio = $(
|
|
||||||
canvasrecord.getItemById(object.id).getElement()
|
|
||||||
)[0];
|
|
||||||
var audioContext = new AudioContext();
|
|
||||||
var audioSource =
|
|
||||||
audioContext.createMediaElementSource(audio);
|
|
||||||
var audioDestination =
|
|
||||||
audioContext.createMediaStreamDestination();
|
|
||||||
audioSource.connect(audioDestination);
|
|
||||||
stream.addTrack(
|
|
||||||
audioDestination.stream.getAudioTracks()[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (background_audio != false) {
|
|
||||||
var audioContext = new AudioContext();
|
|
||||||
var audioSource =
|
|
||||||
audioContext.createMediaElementSource(background_audio);
|
|
||||||
var audioDestination =
|
|
||||||
audioContext.createMediaStreamDestination();
|
|
||||||
audioSource.connect(audioDestination);
|
|
||||||
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
|
|
||||||
background_audio.currentTime = 0;
|
|
||||||
background_audio.play();
|
|
||||||
}
|
|
||||||
let chunks = [];
|
|
||||||
var recorder = new MediaRecorder(stream, {
|
|
||||||
bitsPerSecond: 3200000,
|
bitsPerSecond: 3200000,
|
||||||
});
|
});
|
||||||
recorder.ondataavailable = (e) => chunks.push(e.data);
|
recorder.ondataavailable = (e) => chunks.push(e.data);
|
||||||
recorder.onstop = (e) => {
|
recorder.onerror = (e) => {
|
||||||
|
console.error('Recording failed', e);
|
||||||
stopAnim();
|
stopAnim();
|
||||||
|
stopExportAudio(exportAudio);
|
||||||
|
exportAudio = null;
|
||||||
|
aCtx.close();
|
||||||
|
resetRecordingUI();
|
||||||
|
};
|
||||||
|
recorder.onstop = () => {
|
||||||
|
stopAnim();
|
||||||
|
stopExportAudio(exportAudio);
|
||||||
|
exportAudio = null;
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
aCtx.close();
|
||||||
downloadRecording(chunks);
|
downloadRecording(chunks);
|
||||||
animate(false, 0);
|
|
||||||
$('#seekbar').offset({
|
|
||||||
left:
|
|
||||||
offset_left +
|
|
||||||
$('#inner-timeline').offset().left +
|
|
||||||
currenttime / timelinetime,
|
|
||||||
});
|
|
||||||
canvas.renderAll();
|
|
||||||
console.log('Finished rendering');
|
console.log('Finished rendering');
|
||||||
};
|
};
|
||||||
recorder.start();
|
|
||||||
|
|
||||||
setTimeout(function () {
|
|
||||||
recorder.stop();
|
|
||||||
}, duration);
|
|
||||||
|
|
||||||
|
// The capture is real time, so the animation clock is driven off the audio
|
||||||
|
// clock and the recording is stopped once it has covered the timeline.
|
||||||
|
var origin = null;
|
||||||
|
var stopping = false;
|
||||||
async function renderAnim(time) {
|
async function renderAnim(time) {
|
||||||
await recordAnimate(time * 1000);
|
if (origin === null) {
|
||||||
|
origin = time;
|
||||||
}
|
}
|
||||||
}
|
const elapsed = (time - origin) * 1000;
|
||||||
}
|
if (elapsed >= duration) {
|
||||||
}
|
if (!stopping) {
|
||||||
|
stopping = true;
|
||||||
/*
|
await recordAnimate(duration);
|
||||||
|
if (recorder.state != 'inactive') {
|
||||||
initRecorder();
|
|
||||||
|
|
||||||
//await timeout(2000)
|
|
||||||
|
|
||||||
// draw one frame at a time
|
|
||||||
while (frame++ < FPS * (duration/1000)) {
|
|
||||||
await longDraw(); // do the long drawing
|
|
||||||
await recordFrame(); // record at constant FPS
|
|
||||||
}
|
|
||||||
// now all the frames have been drawn
|
|
||||||
const recorded = await exportRecording(); // we can get our final video file
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.style.display = 'none';
|
|
||||||
a.href = URL.createObjectURL(recorded);
|
|
||||||
a.download = "test.webm";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
recording = false;
|
|
||||||
currenttime = 0;
|
|
||||||
animate(false, 0);
|
|
||||||
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
|
|
||||||
canvas.renderAll();
|
|
||||||
resizeCanvas();
|
|
||||||
if (background_audio != false) {
|
|
||||||
background_audio.pause();
|
|
||||||
background_audio = new Audio(background_audio.src)
|
|
||||||
}
|
|
||||||
$("#download-real").html("Download");
|
|
||||||
$("#download-real").removeClass("downloading");
|
|
||||||
updateRecordCanvas();
|
|
||||||
|
|
||||||
// Fake long drawing operations that make real-time recording impossible
|
|
||||||
function longDraw() {
|
|
||||||
recordAnimate((frame/FPS)*1000)
|
|
||||||
return wait(Math.random() * 300)
|
|
||||||
.then(recordAnimate((frame/FPS)*1000));
|
|
||||||
}*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
paused = true;
|
|
||||||
recording = true;
|
|
||||||
$("#download-real").html("Rendering...");
|
|
||||||
$("#download-real").addClass("downloading");
|
|
||||||
var fps = 60;
|
|
||||||
var aCtx = new AudioContext();
|
|
||||||
function audioTimerLoop(callback, frequency) {
|
|
||||||
var freq = frequency / 1000;
|
|
||||||
var silence = aCtx.createGain();
|
|
||||||
silence.gain.value = 0;
|
|
||||||
silence.connect(aCtx.destination);
|
|
||||||
onOSCend();
|
|
||||||
var stopped = false;
|
|
||||||
function onOSCend() {
|
|
||||||
osc = aCtx.createOscillator();
|
|
||||||
osc.onended = onOSCend;
|
|
||||||
osc.connect(silence);
|
|
||||||
osc.start(0);
|
|
||||||
osc.stop(aCtx.currentTime + freq);
|
|
||||||
callback(aCtx.currentTime);
|
|
||||||
if (stopped) {
|
|
||||||
osc.onended = function() {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return function() {
|
|
||||||
stopped = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
var stopAnim = audioTimerLoop(renderAnim, 1000/(fps));
|
|
||||||
var stream = document.getElementById("canvasrecord").captureStream(fps);
|
|
||||||
objects.forEach(function(object){
|
|
||||||
if (canvasrecord.getItemById(object.id).get("assetType") && canvasrecord.getItemById(object.id).get("assetType") == "video") {
|
|
||||||
var audio = $(canvasrecord.getItemById(object.id).getElement())[0];
|
|
||||||
var audioContext = new AudioContext();
|
|
||||||
var audioSource = audioContext.createMediaElementSource(audio);
|
|
||||||
var audioDestination = audioContext.createMediaStreamDestination();
|
|
||||||
audioSource.connect(audioDestination);
|
|
||||||
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (background_audio != false) {
|
|
||||||
var audioContext = new AudioContext();
|
|
||||||
var audioSource = audioContext.createMediaElementSource(background_audio);
|
|
||||||
var audioDestination = audioContext.createMediaStreamDestination();
|
|
||||||
audioSource.connect(audioDestination);
|
|
||||||
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
|
|
||||||
background_audio.currentTime = 0;
|
|
||||||
background_audio.play();
|
|
||||||
}
|
|
||||||
let chunks = [];
|
|
||||||
var recorder = new MediaRecorder(stream, {
|
|
||||||
bitsPerSecond : 3200000,
|
|
||||||
});
|
|
||||||
recorder.ondataavailable = e => chunks.push(e.data);
|
|
||||||
recorder.onstop = e => {
|
|
||||||
stopAnim();
|
|
||||||
downloadRecording(chunks);
|
|
||||||
animate(false, 0);
|
|
||||||
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
|
|
||||||
canvas.renderAll();
|
|
||||||
console.log("Finished rendering")
|
|
||||||
}
|
|
||||||
recorder.start();
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
}, duration)
|
|
||||||
|
|
||||||
async function renderAnim(time) {
|
|
||||||
await animate(false, time*1000);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
$("#download-real").html("Rendering...");
|
|
||||||
$("#download-real").addClass("downloading");
|
|
||||||
|
|
||||||
// browser check
|
|
||||||
if (typeof MediaStreamTrackGenerator === undefined || typeof MediaStream === undefined || typeof VideoFrame === undefined) {
|
|
||||||
console.log('Your browser does not support the web APIs used in this demo');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await recordAnimate(elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
// recording setup
|
|
||||||
const fps = 60;
|
|
||||||
const generator = new MediaStreamTrackGenerator({ kind: "video" });
|
|
||||||
const writer = generator.writable.getWriter();
|
|
||||||
const stream = new MediaStream();
|
|
||||||
stream.addTrack(generator);
|
|
||||||
const recorder = new MediaRecorder(stream, { mimeType: "video/webm" });
|
|
||||||
recorder.start();
|
recorder.start();
|
||||||
|
startExportAudio(exportAudio);
|
||||||
|
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
|
||||||
|
|
||||||
function timeout(ms) {
|
// Safety net: never leave the UI stuck if the oscillator clock stalls
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
window.setTimeout(function () {
|
||||||
}
|
if (recorder.state != 'inactive') {
|
||||||
|
|
||||||
// animate stuff
|
|
||||||
console.log('rendering...')
|
|
||||||
console.log(duration);
|
|
||||||
for (let i = 0; i < (duration/1000)*fps; i++) {
|
|
||||||
animate(false, (i/fps)*1000);
|
|
||||||
const frame = new VideoFrame(document.getElementById("canvasrecord"), {
|
|
||||||
timestamp: (i / fps)*1000
|
|
||||||
});
|
|
||||||
await writer.write(frame);
|
|
||||||
await timeout(100)
|
|
||||||
console.log("frame "+(i/fps)*1000);
|
|
||||||
}
|
|
||||||
console.log('rendering done');
|
|
||||||
|
|
||||||
// stop recording and
|
|
||||||
recorder.addEventListener("dataavailable", (evt) => {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.style.display = 'none';
|
|
||||||
a.href = URL.createObjectURL(evt.data);
|
|
||||||
a.download = "test.webm";
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
recording = false;
|
|
||||||
currenttime = 0;
|
|
||||||
animate(false, 0);
|
|
||||||
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
|
|
||||||
canvas.renderAll();
|
|
||||||
resizeCanvas();
|
|
||||||
if (background_audio != false) {
|
|
||||||
background_audio.pause();
|
|
||||||
background_audio = new Audio(background_audio.src)
|
|
||||||
}
|
|
||||||
$("#download-real").html("Download");
|
|
||||||
$("#download-real").removeClass("downloading");
|
|
||||||
updateRecordCanvas();
|
|
||||||
});
|
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
*/
|
}
|
||||||
|
}, duration + 5000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
// Frame-accurate offline renderer.
|
||||||
|
//
|
||||||
|
// The original export path captured the canvas with MediaRecorder in real
|
||||||
|
// time, so anything the browser could not draw at 60fps was simply dropped and
|
||||||
|
// video layers were sampled wherever they happened to be. This renders one
|
||||||
|
// frame at a time with no clock attached: every frame is drawn at the frame
|
||||||
|
// rate picked in the download modal, every video layer is seeked to the exact
|
||||||
|
// frame time, and the audio is mixed offline.
|
||||||
|
//
|
||||||
|
// Needs WebCodecs (Chrome/Edge). record() falls back to the real-time path
|
||||||
|
// when this is unavailable or fails.
|
||||||
|
|
||||||
|
// The muxer can only close a cluster on a video keyframe, and a cluster may
|
||||||
|
// not span more than ~32s (block timecodes are a signed 16 bit offset), so
|
||||||
|
// keyframes have to stay frequent.
|
||||||
|
const RENDER_KEYFRAME_INTERVAL = 2; // seconds
|
||||||
|
// A seek that never completes must not hang the whole export
|
||||||
|
const RENDER_MAX_SEEK_WAIT = 2000;
|
||||||
|
|
||||||
|
// recordAnimate() drives video playback in real time; while rendering offline
|
||||||
|
// the frames are seeked explicitly instead.
|
||||||
|
var offlinerender = false;
|
||||||
|
|
||||||
|
function frameAccurateSupported() {
|
||||||
|
return (
|
||||||
|
typeof VideoEncoder !== 'undefined' &&
|
||||||
|
typeof VideoFrame !== 'undefined' &&
|
||||||
|
typeof WebMWriter !== 'undefined' &&
|
||||||
|
typeof OfflineAudioContext !== 'undefined'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgress(text) {
|
||||||
|
$('#download-real').html(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Media positioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Put every time-based layer at exactly `time` and wait until it is actually
|
||||||
|
// showing that frame. A plain currentTime assignment is asynchronous - drawing
|
||||||
|
// before 'seeked' captures the previous frame.
|
||||||
|
async function seekMediaForFrame(time) {
|
||||||
|
const waits = [];
|
||||||
|
objects.forEach(function (object) {
|
||||||
|
const obj = canvasrecord.getItemById(object.id);
|
||||||
|
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
||||||
|
if (!obj || !p_keyframe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.type == 'lottie') {
|
||||||
|
obj.goToSeconds(time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.get('assetType') != 'video') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = $(obj.getElement())[0];
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
element.pause();
|
||||||
|
|
||||||
|
const visible =
|
||||||
|
time >= p_keyframe.trimstart + p_keyframe.start &&
|
||||||
|
time <= p_keyframe.end;
|
||||||
|
obj.set('visible', visible);
|
||||||
|
if (!visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target =
|
||||||
|
(time - p_keyframe.start + p_keyframe.trimstart) / 1000;
|
||||||
|
if (element.duration) {
|
||||||
|
target = Math.min(target, Math.max(0, element.duration - 0.001));
|
||||||
|
}
|
||||||
|
target = Math.max(0, target);
|
||||||
|
if (Math.abs(element.currentTime - target) < 0.0005) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
waits.push(
|
||||||
|
new Promise(function (resolve) {
|
||||||
|
var settled = false;
|
||||||
|
function finish() {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
element.removeEventListener('seeked', finish);
|
||||||
|
element.removeEventListener('error', finish);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
element.addEventListener('seeked', finish);
|
||||||
|
element.addEventListener('error', finish);
|
||||||
|
// A stuck seek must not hang the whole export
|
||||||
|
window.setTimeout(finish, RENDER_MAX_SEEK_WAIT);
|
||||||
|
try {
|
||||||
|
element.currentTime = target;
|
||||||
|
} catch (e) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await Promise.all(waits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Audio
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Every sound in the project, with where it sits on the timeline
|
||||||
|
function collectAudioSources() {
|
||||||
|
const sources = [];
|
||||||
|
objects.forEach(function (object) {
|
||||||
|
const obj = canvasrecord.getItemById(object.id);
|
||||||
|
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
|
||||||
|
if (!obj || !p_keyframe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (obj.get('assetType') == 'audio' && obj.get('audioSrc')) {
|
||||||
|
sources.push({
|
||||||
|
src: obj.get('audioSrc'),
|
||||||
|
start: p_keyframe.start,
|
||||||
|
end: p_keyframe.end,
|
||||||
|
trimstart: p_keyframe.trimstart,
|
||||||
|
volume: obj.get('volume'),
|
||||||
|
});
|
||||||
|
} else if (obj.get('assetType') == 'video' && obj.get('source')) {
|
||||||
|
sources.push({
|
||||||
|
src: obj.get('source'),
|
||||||
|
start: p_keyframe.start,
|
||||||
|
end: p_keyframe.end,
|
||||||
|
trimstart: p_keyframe.trimstart,
|
||||||
|
volume: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (background_audio != false && background_audio.src) {
|
||||||
|
sources.push({
|
||||||
|
src: background_audio.src,
|
||||||
|
start: 0,
|
||||||
|
end: duration,
|
||||||
|
trimstart: 0,
|
||||||
|
volume: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix the whole timeline in one pass. OfflineAudioContext renders faster than
|
||||||
|
// real time and is sample-accurate, unlike routing live elements into a
|
||||||
|
// MediaStream.
|
||||||
|
async function renderAudioBuffer() {
|
||||||
|
const sources = collectAudioSources();
|
||||||
|
if (sources.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleRate = 48000;
|
||||||
|
const frames = Math.ceil((duration / 1000) * sampleRate);
|
||||||
|
if (!(frames > 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const offline = new OfflineAudioContext(2, frames, sampleRate);
|
||||||
|
|
||||||
|
const decoded = [];
|
||||||
|
for (const source of sources) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(source.src);
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
const buffer = await offline.decodeAudioData(bytes);
|
||||||
|
decoded.push({ source: source, buffer: buffer });
|
||||||
|
} catch (e) {
|
||||||
|
// Silent video, unsupported container, or an unreachable asset
|
||||||
|
console.warn('Skipping audio source', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (decoded.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded.forEach(function (item) {
|
||||||
|
const node = offline.createBufferSource();
|
||||||
|
node.buffer = item.buffer;
|
||||||
|
const gain = offline.createGain();
|
||||||
|
gain.gain.value =
|
||||||
|
typeof item.source.volume == 'number' ? item.source.volume : 1;
|
||||||
|
node.connect(gain);
|
||||||
|
gain.connect(offline.destination);
|
||||||
|
|
||||||
|
const when = Math.max(0, item.source.start / 1000);
|
||||||
|
const offset = Math.max(0, item.source.trimstart / 1000);
|
||||||
|
const length = Math.max(
|
||||||
|
0,
|
||||||
|
(item.source.end - item.source.start) / 1000
|
||||||
|
);
|
||||||
|
if (length > 0 && offset < item.buffer.duration) {
|
||||||
|
node.start(when, offset, length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await offline.startRendering();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matroska needs the Opus identification header as CodecPrivate
|
||||||
|
function buildOpusHead(channels, sampleRate) {
|
||||||
|
const head = new Uint8Array(19);
|
||||||
|
const view = new DataView(head.buffer);
|
||||||
|
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // "OpusHead"
|
||||||
|
head[8] = 1; // version
|
||||||
|
head[9] = channels;
|
||||||
|
view.setUint16(10, 3840, true); // pre-skip
|
||||||
|
view.setUint32(12, sampleRate, true);
|
||||||
|
view.setUint16(16, 0, true); // output gain
|
||||||
|
head[18] = 0; // channel mapping family
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encodeAudioBuffer(audioBuffer) {
|
||||||
|
const channels = Math.min(2, audioBuffer.numberOfChannels);
|
||||||
|
const sampleRate = audioBuffer.sampleRate;
|
||||||
|
const config = {
|
||||||
|
codec: 'opus',
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
numberOfChannels: channels,
|
||||||
|
bitrate: 128000,
|
||||||
|
};
|
||||||
|
if (typeof AudioEncoder === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const support = await AudioEncoder.isConfigSupported(config);
|
||||||
|
if (!support || !support.supported) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
var failed = false;
|
||||||
|
const encoder = new AudioEncoder({
|
||||||
|
output: function (chunk) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
},
|
||||||
|
error: function (e) {
|
||||||
|
failed = true;
|
||||||
|
console.error('Audio encoding failed', e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
encoder.configure(config);
|
||||||
|
|
||||||
|
const sliceFrames = Math.round(sampleRate / 10); // 100ms
|
||||||
|
const planes = [];
|
||||||
|
for (var c = 0; c < channels; c++) {
|
||||||
|
planes.push(audioBuffer.getChannelData(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
var offset = 0;
|
||||||
|
offset < audioBuffer.length && !failed;
|
||||||
|
offset += sliceFrames
|
||||||
|
) {
|
||||||
|
const count = Math.min(sliceFrames, audioBuffer.length - offset);
|
||||||
|
const planar = new Float32Array(count * channels);
|
||||||
|
for (var ch = 0; ch < channels; ch++) {
|
||||||
|
planar.set(
|
||||||
|
planes[ch].subarray(offset, offset + count),
|
||||||
|
ch * count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = new AudioData({
|
||||||
|
format: 'f32-planar',
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
numberOfFrames: count,
|
||||||
|
numberOfChannels: channels,
|
||||||
|
timestamp: Math.round((offset / sampleRate) * 1e6),
|
||||||
|
data: planar,
|
||||||
|
});
|
||||||
|
encoder.encode(data);
|
||||||
|
data.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await encoder.flush();
|
||||||
|
encoder.close();
|
||||||
|
if (failed || chunks.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chunks: chunks,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
channels: channels,
|
||||||
|
codecPrivate: buildOpusHead(channels, sampleRate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Video
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function pickVideoCodec(width, height, fps) {
|
||||||
|
const candidates = [
|
||||||
|
{ codec: 'vp09.00.10.08', name: 'VP9' },
|
||||||
|
{ codec: 'vp8', name: 'VP8' },
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const config = {
|
||||||
|
codec: candidate.codec,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
bitrate: 8000000,
|
||||||
|
framerate: fps,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const support = await VideoEncoder.isConfigSupported(config);
|
||||||
|
if (support && support.supported) {
|
||||||
|
return { config: config, name: candidate.name };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// try the next one
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Orchestration
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Confirm the muxed file is actually decodable before handing it to the user.
|
||||||
|
// The muxer is hand-rolled, so a silent failure here would otherwise reach
|
||||||
|
// the user as a file that will not play.
|
||||||
|
function verifyRenderedBlob(blob) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
const element = document.createElement('video');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
var settled = false;
|
||||||
|
function finish(ok) {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
element.removeAttribute('src');
|
||||||
|
resolve(ok);
|
||||||
|
}
|
||||||
|
element.onloadedmetadata = function () {
|
||||||
|
finish(element.videoWidth > 0 && element.videoHeight > 0);
|
||||||
|
};
|
||||||
|
element.onerror = function () {
|
||||||
|
finish(false);
|
||||||
|
};
|
||||||
|
window.setTimeout(function () {
|
||||||
|
finish(false);
|
||||||
|
}, 5000);
|
||||||
|
element.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a WebM Blob, or null to tell the caller to use the real-time path
|
||||||
|
async function renderFrameAccurate() {
|
||||||
|
const canvasElement = document.getElementById('canvasrecord');
|
||||||
|
const width = canvasElement.width;
|
||||||
|
const height = canvasElement.height;
|
||||||
|
if (!(width > 0 && height > 0)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fps = getExportFramerate();
|
||||||
|
const selected = await pickVideoCodec(width, height, fps);
|
||||||
|
if (!selected) {
|
||||||
|
console.warn('No supported WebCodecs video configuration');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
offlinerender = true;
|
||||||
|
var encoder = null;
|
||||||
|
try {
|
||||||
|
renderProgress('Mixing audio...');
|
||||||
|
var audio = null;
|
||||||
|
try {
|
||||||
|
const audioBuffer = await renderAudioBuffer();
|
||||||
|
if (audioBuffer) {
|
||||||
|
audio = await encodeAudioBuffer(audioBuffer);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Rendering without audio', e);
|
||||||
|
audio = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const writer = new WebMWriter({
|
||||||
|
codec: selected.name,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
audio: audio
|
||||||
|
? {
|
||||||
|
sampleRate: audio.sampleRate,
|
||||||
|
channels: audio.channels,
|
||||||
|
codecPrivate: audio.codecPrivate,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoChunks = [];
|
||||||
|
var encoderFailed = false;
|
||||||
|
encoder = new VideoEncoder({
|
||||||
|
output: function (chunk) {
|
||||||
|
videoChunks.push(chunk);
|
||||||
|
},
|
||||||
|
error: function (e) {
|
||||||
|
encoderFailed = true;
|
||||||
|
console.error('Video encoding failed', e);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
encoder.configure(selected.config);
|
||||||
|
|
||||||
|
const totalFrames = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round((duration / 1000) * fps)
|
||||||
|
);
|
||||||
|
const frameDuration = 1e6 / fps;
|
||||||
|
const keyframeEvery = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(fps * RENDER_KEYFRAME_INTERVAL)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var i = 0; i < totalFrames; i++) {
|
||||||
|
if (encoderFailed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const time = (i / fps) * 1000;
|
||||||
|
|
||||||
|
await seekMediaForFrame(time);
|
||||||
|
await recordAnimate(time);
|
||||||
|
canvasrecord.renderAll();
|
||||||
|
|
||||||
|
const frame = new VideoFrame(canvasElement, {
|
||||||
|
timestamp: Math.round(i * frameDuration),
|
||||||
|
duration: Math.round(frameDuration),
|
||||||
|
});
|
||||||
|
encoder.encode(frame, { keyFrame: i % keyframeEvery == 0 });
|
||||||
|
frame.close();
|
||||||
|
|
||||||
|
// Keep the encoder queue short so memory stays bounded
|
||||||
|
while (encoder.encodeQueueSize > 8 && !encoderFailed) {
|
||||||
|
await new Promise(function (resolve) {
|
||||||
|
window.setTimeout(resolve, 4);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i % 5 == 0) {
|
||||||
|
renderProgress(
|
||||||
|
'Rendering ' +
|
||||||
|
Math.round(((i + 1) / totalFrames) * 100) +
|
||||||
|
'%'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await encoder.flush();
|
||||||
|
encoder.close();
|
||||||
|
encoder = null;
|
||||||
|
if (encoderFailed || videoChunks.length == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderProgress('Muxing...');
|
||||||
|
// Interleave both tracks in timestamp order: a cluster's blocks have to
|
||||||
|
// be ordered, and this avoids threading the two producers together.
|
||||||
|
const all = [];
|
||||||
|
videoChunks.forEach(function (chunk) {
|
||||||
|
all.push({ chunk: chunk, track: 1 });
|
||||||
|
});
|
||||||
|
if (audio) {
|
||||||
|
audio.chunks.forEach(function (chunk) {
|
||||||
|
all.push({ chunk: chunk, track: 2 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
all.sort(function (a, b) {
|
||||||
|
if (a.chunk.timestamp == b.chunk.timestamp) {
|
||||||
|
return a.track - b.track;
|
||||||
|
}
|
||||||
|
return a.chunk.timestamp - b.chunk.timestamp;
|
||||||
|
});
|
||||||
|
all.forEach(function (item) {
|
||||||
|
writer.addFrame(item.chunk, item.track);
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await writer.complete();
|
||||||
|
if (!blob || blob.size == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!(await verifyRenderedBlob(blob))) {
|
||||||
|
console.warn(
|
||||||
|
'Rendered file failed verification, using the real-time encoder'
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return blob;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Frame-accurate render failed', e);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
offlinerender = false;
|
||||||
|
if (encoder && encoder.state != 'closed') {
|
||||||
|
try {
|
||||||
|
encoder.close();
|
||||||
|
} catch (e) {
|
||||||
|
// already torn down
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-18
@@ -1,10 +1,16 @@
|
|||||||
function animateText(group, ms, play, props, cv, id) {
|
function animateText(group, ms, play, props, cv, id) {
|
||||||
var starttime = p_keyframes.find((x) => x.id == id).start;
|
const p_keyframe = p_keyframes.find((x) => x.id == id);
|
||||||
ms -= starttime;
|
if (!group || !p_keyframe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ms -= p_keyframe.start;
|
||||||
var length = group._objects.length;
|
var length = group._objects.length;
|
||||||
var globaldelay = 0;
|
var globaldelay = 0;
|
||||||
|
// Every letter needs its own binding: the anime callbacks below run long
|
||||||
|
// after the loop has finished, so `var` would make them all share the last
|
||||||
|
// letter's state.
|
||||||
for (var i = 0; i < length; i++) {
|
for (var i = 0; i < length; i++) {
|
||||||
var index = i;
|
let index = i;
|
||||||
if (props.order == 'backward') {
|
if (props.order == 'backward') {
|
||||||
index = length - i - 1;
|
index = length - i - 1;
|
||||||
}
|
}
|
||||||
@@ -12,9 +18,10 @@ function animateText(group, ms, play, props, cv, id) {
|
|||||||
let top = group.item(index).defaultTop;
|
let top = group.item(index).defaultTop;
|
||||||
let scaleX = group.item(index).defaultScaleX;
|
let scaleX = group.item(index).defaultScaleX;
|
||||||
let scaleY = group.item(index).defaultScaleY;
|
let scaleY = group.item(index).defaultScaleY;
|
||||||
var delay = i * duration;
|
// Named `step` so it does not shadow the global project duration
|
||||||
var duration = props.duration / length;
|
let step = props.duration / length;
|
||||||
var animation = {
|
let delay = i * step;
|
||||||
|
let animation = {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
top: top,
|
top: top,
|
||||||
left: left,
|
left: left,
|
||||||
@@ -22,7 +29,7 @@ function animateText(group, ms, play, props, cv, id) {
|
|||||||
scaleY: scaleY,
|
scaleY: scaleY,
|
||||||
};
|
};
|
||||||
if (props.typeAnim == 'letter') {
|
if (props.typeAnim == 'letter') {
|
||||||
delay = i * duration - 100;
|
delay = i * step - 100;
|
||||||
} else if (props.typeAnim == 'word') {
|
} else if (props.typeAnim == 'word') {
|
||||||
if (group.item(index).text == ' ') {
|
if (group.item(index).text == ' ') {
|
||||||
globaldelay += 500;
|
globaldelay += 500;
|
||||||
@@ -30,8 +37,8 @@ function animateText(group, ms, play, props, cv, id) {
|
|||||||
delay = globaldelay;
|
delay = globaldelay;
|
||||||
}
|
}
|
||||||
if (props.preset == 'typewriter') {
|
if (props.preset == 'typewriter') {
|
||||||
delay = i * duration;
|
delay = i * step;
|
||||||
duration = 20;
|
step = 20;
|
||||||
} else if (props.preset == 'fade in') {
|
} else if (props.preset == 'fade in') {
|
||||||
} else if (props.preset == 'slide top') {
|
} else if (props.preset == 'slide top') {
|
||||||
animation.top += 20;
|
animation.top += 20;
|
||||||
@@ -48,14 +55,14 @@ function animateText(group, ms, play, props, cv, id) {
|
|||||||
animation.scaleX = 1.5;
|
animation.scaleX = 1.5;
|
||||||
animation.scaleY = 1.5;
|
animation.scaleY = 1.5;
|
||||||
}
|
}
|
||||||
if (delay < 0) {
|
if (!(delay > 0)) {
|
||||||
delay = 0;
|
delay = 0;
|
||||||
}
|
}
|
||||||
if (duration < 20) {
|
if (!(step > 20)) {
|
||||||
duration = 20;
|
step = 20;
|
||||||
}
|
}
|
||||||
var start = false;
|
let start = false;
|
||||||
var instance = anime({
|
let instance = anime({
|
||||||
targets: animation,
|
targets: animation,
|
||||||
delay: delay,
|
delay: delay,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@@ -63,7 +70,7 @@ function animateText(group, ms, play, props, cv, id) {
|
|||||||
top: top,
|
top: top,
|
||||||
scaleX: scaleX,
|
scaleX: scaleX,
|
||||||
scaleY: scaleY,
|
scaleY: scaleY,
|
||||||
duration: duration,
|
duration: step,
|
||||||
easing: props.easing,
|
easing: props.easing,
|
||||||
autoplay: play,
|
autoplay: play,
|
||||||
update: function () {
|
update: function () {
|
||||||
@@ -228,8 +235,7 @@ class AnimatedText {
|
|||||||
var obj = cv.getItemById(this.id);
|
var obj = cv.getItemById(this.id);
|
||||||
var left = obj.left;
|
var left = obj.left;
|
||||||
var top = obj.top;
|
var top = obj.top;
|
||||||
var scaleX = obj,
|
var scaleX = obj.scaleX;
|
||||||
scaleX;
|
|
||||||
var scaleY = obj.scaleY;
|
var scaleY = obj.scaleY;
|
||||||
var angle = obj.angle;
|
var angle = obj.angle;
|
||||||
var start = p_keyframes.find((x) => x.id == this.id).start;
|
var start = p_keyframes.find((x) => x.id == this.id).start;
|
||||||
@@ -260,10 +266,16 @@ class AnimatedText {
|
|||||||
cv,
|
cv,
|
||||||
this.id
|
this.id
|
||||||
);
|
);
|
||||||
animate(currenttime, false);
|
animate(false, currenttime);
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
assignTo(id, text, props) {
|
assignTo(id, text, props) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
|
if (text !== undefined) {
|
||||||
|
this.text = text;
|
||||||
|
}
|
||||||
|
if (props !== undefined) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+189
-89
@@ -1,5 +1,8 @@
|
|||||||
// Update panel (when selecting / de-selecting objects)
|
// Update panel (when selecting / de-selecting objects)
|
||||||
function updatePanel(selection) {
|
function updatePanel(selection) {
|
||||||
|
if (selection && !canvas.getActiveObject()) {
|
||||||
|
selection = false;
|
||||||
|
}
|
||||||
if (!selection) {
|
if (!selection) {
|
||||||
$('#align').addClass('align-off');
|
$('#align').addClass('align-off');
|
||||||
$('#object-specific').html(canvas_panel);
|
$('#object-specific').html(canvas_panel);
|
||||||
@@ -330,17 +333,19 @@ function convertToHex(nonHexColorString) {
|
|||||||
|
|
||||||
function updateStrokeValues() {
|
function updateStrokeValues() {
|
||||||
const object = canvas.getActiveObject();
|
const object = canvas.getActiveObject();
|
||||||
|
if (!object) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$('.line-join-active').removeClass('line-join-active');
|
$('.line-join-active').removeClass('line-join-active');
|
||||||
if (
|
const dash = object.get('strokeDashArray');
|
||||||
object.get('strokeDashArray') == false &&
|
const hasDash = Array.isArray(dash) && dash.length > 0;
|
||||||
object.get('strokeWidth') == 0
|
if (!hasDash && object.get('strokeWidth') == 0) {
|
||||||
) {
|
|
||||||
$('#miter').addClass('line-join-active');
|
$('#miter').addClass('line-join-active');
|
||||||
$('#miter img').attr('src', 'assets/miter-active.svg');
|
$('#miter img').attr('src', 'assets/miter-active.svg');
|
||||||
} else if (object.get('strokeDashArray') == false) {
|
} else if (!hasDash) {
|
||||||
$('#bevel').addClass('line-join-active');
|
$('#bevel').addClass('line-join-active');
|
||||||
$('#bevel img').attr('src', 'assets/bevel-active.svg');
|
$('#bevel img').attr('src', 'assets/bevel-active.svg');
|
||||||
} else if (object.get('strokeDashArray') == [10, 5]) {
|
} else if (dash[0] == 10 && dash[1] == 5) {
|
||||||
$('#round').addClass('line-join-active');
|
$('#round').addClass('line-join-active');
|
||||||
$('#round img').attr('src', 'assets/round-active.svg');
|
$('#round img').attr('src', 'assets/round-active.svg');
|
||||||
} else {
|
} else {
|
||||||
@@ -350,39 +355,39 @@ function updateStrokeValues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleAnimationOrder() {
|
function toggleAnimationOrder() {
|
||||||
var object = canvas.getActiveObject();
|
const text = activeAnimatedText();
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$('.order-toggle-item-active').removeClass(
|
$('.order-toggle-item-active').removeClass(
|
||||||
'order-toggle-item-active'
|
'order-toggle-item-active'
|
||||||
);
|
);
|
||||||
if ($(this).attr('id') == 'order-backward') {
|
if ($(this).attr('id') == 'order-backward') {
|
||||||
animatedtext
|
text.setProp({ order: 'backward' });
|
||||||
.find((x) => x.id == object.id)
|
|
||||||
.setProp({ order: 'backward' }, canvas);
|
|
||||||
} else if ($(this).attr('id') == 'order-forward') {
|
} else if ($(this).attr('id') == 'order-forward') {
|
||||||
animatedtext
|
text.setProp({ order: 'forward' });
|
||||||
.find((x) => x.id == object.id)
|
|
||||||
.setProp({ order: 'forward' }, canvas);
|
|
||||||
}
|
}
|
||||||
$(this).addClass('order-toggle-item-active');
|
$(this).addClass('order-toggle-item-active');
|
||||||
animate(currenttime, false);
|
// animate(play, time) - the arguments used to be the other way round, so
|
||||||
|
// the canvas never refreshed after changing a text animation.
|
||||||
|
animate(false, currenttime);
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
function toggleAnimationType() {
|
function toggleAnimationType() {
|
||||||
var object = canvas.getActiveObject();
|
const text = activeAnimatedText();
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$('.order-toggle-item-active-2').removeClass(
|
$('.order-toggle-item-active-2').removeClass(
|
||||||
'order-toggle-item-active-2'
|
'order-toggle-item-active-2'
|
||||||
);
|
);
|
||||||
if ($(this).attr('id') == 'type-words') {
|
if ($(this).attr('id') == 'type-words') {
|
||||||
animatedtext
|
text.setProp({ typeAnim: 'word' });
|
||||||
.find((x) => x.id == object.id)
|
|
||||||
.setProp({ typeAnim: 'word' }, canvas);
|
|
||||||
} else if ($(this).attr('id') == 'type-letters') {
|
} else if ($(this).attr('id') == 'type-letters') {
|
||||||
animatedtext
|
text.setProp({ typeAnim: 'letter' });
|
||||||
.find((x) => x.id == object.id)
|
|
||||||
.setProp({ typeAnim: 'letter' }, canvas);
|
|
||||||
}
|
}
|
||||||
$(this).addClass('order-toggle-item-active-2');
|
$(this).addClass('order-toggle-item-active-2');
|
||||||
animate(currenttime, false);
|
animate(false, currenttime);
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
$(document).on(
|
$(document).on(
|
||||||
@@ -473,7 +478,7 @@ function updatePanelValues() {
|
|||||||
var tempstore = false;
|
var tempstore = false;
|
||||||
var object = canvas.getActiveObject();
|
var object = canvas.getActiveObject();
|
||||||
if (
|
if (
|
||||||
canvas.getActiveObjects.length > 1 ||
|
canvas.getActiveObjects().length > 1 ||
|
||||||
object.get('type') == 'activeSelection'
|
object.get('type') == 'activeSelection'
|
||||||
) {
|
) {
|
||||||
object = object.toGroup();
|
object = object.toGroup();
|
||||||
@@ -565,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'));
|
||||||
@@ -590,7 +595,8 @@ function updatePanelValues() {
|
|||||||
|
|
||||||
// Update opacity input
|
// Update opacity input
|
||||||
function updateInputs(id) {
|
function updateInputs(id) {
|
||||||
if (canvas.getActiveObject().get('assetType') == 'audio') {
|
const active = canvas.getActiveObject();
|
||||||
|
if (!active || active.get('assetType') == 'audio') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($('#object-o input').val() > 100) {
|
if ($('#object-o input').val() > 100) {
|
||||||
@@ -604,7 +610,7 @@ function updateInputs(id) {
|
|||||||
id == 'object-color-fill-opacity'
|
id == 'object-color-fill-opacity'
|
||||||
) {
|
) {
|
||||||
if ($('#object-color-fill-opacity input').val() > 100) {
|
if ($('#object-color-fill-opacity input').val() > 100) {
|
||||||
$('#object-color-fill-opacity').val(100);
|
$('#object-color-fill-opacity input').val(100);
|
||||||
} else if ($('#object-color-fill-opacity input').val() < 0) {
|
} else if ($('#object-color-fill-opacity input').val() < 0) {
|
||||||
$('#object-color-fill-opacity input').val(0);
|
$('#object-color-fill-opacity input').val(0);
|
||||||
}
|
}
|
||||||
@@ -628,7 +634,7 @@ function updateInputs(id) {
|
|||||||
id == 'object-color-stroke-opacity'
|
id == 'object-color-stroke-opacity'
|
||||||
) {
|
) {
|
||||||
if ($('#object-color-stroke-opacity input').val() > 100) {
|
if ($('#object-color-stroke-opacity input').val() > 100) {
|
||||||
$('#object-color-stroke-opacity').val(100);
|
$('#object-color-stroke-opacity input').val(100);
|
||||||
} else if ($('#object-color-stroke-opacity input').val() < 0) {
|
} else if ($('#object-color-stroke-opacity input').val() < 0) {
|
||||||
$('#object-color-stroke-opacity input').val(0);
|
$('#object-color-stroke-opacity input').val(0);
|
||||||
}
|
}
|
||||||
@@ -652,7 +658,7 @@ function updateInputs(id) {
|
|||||||
id == 'object-color-shadow-opacity'
|
id == 'object-color-shadow-opacity'
|
||||||
) {
|
) {
|
||||||
if ($('#object-color-shadow-opacity input').val() > 100) {
|
if ($('#object-color-shadow-opacity input').val() > 100) {
|
||||||
$('#object-color-shadow-opacity').val(100);
|
$('#object-color-shadow-opacity input').val(100);
|
||||||
} else if ($('#object-color-shadow-opacity input').val() < 0) {
|
} else if ($('#object-color-shadow-opacity input').val() < 0) {
|
||||||
$('#object-color-shadow-opacity input').val(0);
|
$('#object-color-shadow-opacity input').val(0);
|
||||||
}
|
}
|
||||||
@@ -673,7 +679,6 @@ function updateInputs(id) {
|
|||||||
|
|
||||||
// Update object position based on panel input values
|
// Update object position based on panel input values
|
||||||
function updateObjectValues(type) {
|
function updateObjectValues(type) {
|
||||||
autoSave();
|
|
||||||
if (canvas.getActiveObjects().length > 0) {
|
if (canvas.getActiveObjects().length > 0) {
|
||||||
if ($(this).find('input').val() || type) {
|
if ($(this).find('input').val() || type) {
|
||||||
var object = canvas.getActiveObject();
|
var object = canvas.getActiveObject();
|
||||||
@@ -832,14 +837,11 @@ function updateObjectValues(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setTextAnimation() {
|
function setTextAnimation() {
|
||||||
var object = canvas.getActiveObject();
|
const text = activeAnimatedText();
|
||||||
animatedtext
|
if (!text) {
|
||||||
.find((x) => x.id == object.id)
|
return;
|
||||||
.reset(
|
}
|
||||||
$(this).parent().find('input').val(),
|
text.reset($(this).parent().find('input').val(), text.props, canvas);
|
||||||
animatedtext.find((x) => x.id == object.id).props,
|
|
||||||
canvas
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).on('input', '.property-input', updateObjectValues);
|
$(document).on('input', '.property-input', updateObjectValues);
|
||||||
@@ -850,8 +852,10 @@ $(document).on('click', '#animatedset', setTextAnimation);
|
|||||||
// Toggle picker (maybe it could be condensed?)
|
// Toggle picker (maybe it could be condensed?)
|
||||||
function togglePicker() {
|
function togglePicker() {
|
||||||
const object = canvas.getActiveObject();
|
const object = canvas.getActiveObject();
|
||||||
|
if (!object && $(this).attr('id') != 'canvas-color') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!o_fill.isOpen()) {
|
if (!o_fill.isOpen()) {
|
||||||
newcolorkeyframe = true;
|
|
||||||
if ($(this).attr('id') == 'object-color-fill') {
|
if ($(this).attr('id') == 'object-color-fill') {
|
||||||
colormode = 'fill';
|
colormode = 'fill';
|
||||||
o_fill.setColor(object.get('fill'));
|
o_fill.setColor(object.get('fill'));
|
||||||
@@ -879,7 +883,6 @@ function togglePicker() {
|
|||||||
colormode = 'shadow';
|
colormode = 'shadow';
|
||||||
o_fill.setColor(object.shadow.color);
|
o_fill.setColor(object.shadow.color);
|
||||||
}
|
}
|
||||||
newcolorkeyframe = false;
|
|
||||||
o_fill.show();
|
o_fill.show();
|
||||||
} else {
|
} else {
|
||||||
o_fill.hide();
|
o_fill.hide();
|
||||||
@@ -917,6 +920,7 @@ function populateGrid(type) {
|
|||||||
});
|
});
|
||||||
} else if (type == 'image-tool') {
|
} else if (type == 'image-tool') {
|
||||||
$('#images-grid').html('');
|
$('#images-grid').html('');
|
||||||
|
$('#categories').html('');
|
||||||
image_categories.forEach(function (category) {
|
image_categories.forEach(function (category) {
|
||||||
$('#categories').append(
|
$('#categories').append(
|
||||||
"<div class='category' data-name='" +
|
"<div class='category' data-name='" +
|
||||||
@@ -930,6 +934,7 @@ function populateGrid(type) {
|
|||||||
});
|
});
|
||||||
} else if (type == 'video-tool') {
|
} else if (type == 'video-tool') {
|
||||||
$('#images-grid').html('');
|
$('#images-grid').html('');
|
||||||
|
$('#categories').html('');
|
||||||
video_categories.forEach(function (category) {
|
video_categories.forEach(function (category) {
|
||||||
$('#categories').append(
|
$('#categories').append(
|
||||||
"<div class='category' data-name='" +
|
"<div class='category' data-name='" +
|
||||||
@@ -959,7 +964,7 @@ function populateGrid(type) {
|
|||||||
item.key +
|
item.key +
|
||||||
"'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" +
|
"'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" +
|
||||||
item.thumb +
|
item.thumb +
|
||||||
"'</div>"
|
"'></div>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -999,6 +1004,7 @@ function populateGrid(type) {
|
|||||||
}
|
}
|
||||||
} else if (type == 'audio-tool') {
|
} else if (type == 'audio-tool') {
|
||||||
var flag = false;
|
var flag = false;
|
||||||
|
$('#audio-list').html('');
|
||||||
audio_items.forEach(function (item) {
|
audio_items.forEach(function (item) {
|
||||||
if (item.src == background_key) {
|
if (item.src == background_key) {
|
||||||
flag = true;
|
flag = true;
|
||||||
@@ -1015,7 +1021,7 @@ function populateGrid(type) {
|
|||||||
item.desc +
|
item.desc +
|
||||||
"</a><div class='audio-info-duration'>" +
|
"</a><div class='audio-info-duration'>" +
|
||||||
item.duration +
|
item.duration +
|
||||||
'</div></div></div></div>'
|
'</div></div></div>'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$('#audio-list').append(
|
$('#audio-list').append(
|
||||||
@@ -1031,7 +1037,7 @@ function populateGrid(type) {
|
|||||||
item.desc +
|
item.desc +
|
||||||
"</a><div class='audio-info-duration'>" +
|
"</a><div class='audio-info-duration'>" +
|
||||||
item.duration +
|
item.duration +
|
||||||
'</div></div></div></div>'
|
'</div></div></div>'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1057,7 +1063,7 @@ function populateGrid(type) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('#shapes-cont').append(
|
$('#shapes-cont').append(
|
||||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
"<div class='item-text add-text noselect' data-font='" +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
"' style='font-family: " +
|
"' style='font-family: " +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
@@ -1074,7 +1080,7 @@ function populateGrid(type) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('#shapes-cont').append(
|
$('#shapes-cont').append(
|
||||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
"<div class='item-text add-text noselect' data-font='" +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
"' style='font-family: " +
|
"' style='font-family: " +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
@@ -1091,7 +1097,7 @@ function populateGrid(type) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('#shapes-cont').append(
|
$('#shapes-cont').append(
|
||||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
"<div class='item-text add-text noselect' data-font='" +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
"' style='font-family: " +
|
"' style='font-family: " +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
@@ -1108,7 +1114,7 @@ function populateGrid(type) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('#shapes-cont').append(
|
$('#shapes-cont').append(
|
||||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
"<div class='item-text add-text noselect' data-font='" +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
"' style='font-family: " +
|
"' style='font-family: " +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
@@ -1125,7 +1131,7 @@ function populateGrid(type) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('#shapes-cont').append(
|
$('#shapes-cont').append(
|
||||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
"<div class='item-text add-text noselect' data-font='" +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
"' style='font-family: " +
|
"' style='font-family: " +
|
||||||
text.fontname +
|
text.fontname +
|
||||||
@@ -1175,27 +1181,27 @@ function updateBrowser(type) {
|
|||||||
if (type == 'image-tool') {
|
if (type == 'image-tool') {
|
||||||
$('#browser-container').html(image_browser);
|
$('#browser-container').html(image_browser);
|
||||||
populateGrid(type);
|
populateGrid(type);
|
||||||
$('#browser').on('scroll', scrollBottom);
|
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||||
} else if (type == 'shape-tool') {
|
} else if (type == 'shape-tool') {
|
||||||
$('#browser-container').html(shape_browser);
|
$('#browser-container').html(shape_browser);
|
||||||
populateGrid(type);
|
populateGrid(type);
|
||||||
$('#browser').on('scroll', scrollBottom);
|
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||||
} else if (type == 'video-tool') {
|
} else if (type == 'video-tool') {
|
||||||
$('#browser-container').html(video_browser);
|
$('#browser-container').html(video_browser);
|
||||||
populateGrid(type);
|
populateGrid(type);
|
||||||
$('#browser').on('scroll', scrollBottom);
|
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||||
} else if (type == 'text-tool') {
|
} else if (type == 'text-tool') {
|
||||||
$('#browser-container').html(text_browser);
|
$('#browser-container').html(text_browser);
|
||||||
populateGrid(type);
|
populateGrid(type);
|
||||||
$('#browser').on('scroll', scrollBottom);
|
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||||
} else if (type == 'upload-tool') {
|
} else if (type == 'upload-tool') {
|
||||||
$('#browser-container').html(upload_browser);
|
$('#browser-container').html(upload_browser);
|
||||||
populateGrid('images-tab');
|
populateGrid('images-tab');
|
||||||
$('#browser').on('scroll', scrollBottom);
|
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||||
} else if (type == 'audio-tool') {
|
} else if (type == 'audio-tool') {
|
||||||
$('#browser-container').html(audio_browser);
|
$('#browser-container').html(audio_browser);
|
||||||
populateGrid(type);
|
populateGrid(type);
|
||||||
$('#browser').on('scroll', scrollBottom);
|
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,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') {
|
||||||
@@ -1260,10 +1267,14 @@ $(document).on('click', '.tool:not(.tool-active)', switchTool);
|
|||||||
|
|
||||||
// Replace image or video by dragging on top and holding a key
|
// Replace image or video by dragging on top and holding a key
|
||||||
function replaceObject(src, object) {
|
function replaceObject(src, object) {
|
||||||
|
if (!src || !object) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var img = new Image();
|
var img = new Image();
|
||||||
var width = object.width;
|
var width = object.width;
|
||||||
var height = object.height;
|
var height = object.height;
|
||||||
oldsrc = object._originalElement.currentSrc;
|
const element = object.getElement();
|
||||||
|
oldsrc = element ? element.currentSrc || element.src : null;
|
||||||
oldobj = object;
|
oldobj = object;
|
||||||
img.onload = function () {
|
img.onload = function () {
|
||||||
object.setElement(img);
|
object.setElement(img);
|
||||||
@@ -1271,6 +1282,9 @@ function replaceObject(src, object) {
|
|||||||
object.set('height', height);
|
object.set('height', height);
|
||||||
canvas.renderAll();
|
canvas.renderAll();
|
||||||
};
|
};
|
||||||
|
img.onerror = function () {
|
||||||
|
console.warn('Could not load replacement image');
|
||||||
|
};
|
||||||
img.src = src;
|
img.src = src;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,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',
|
||||||
@@ -1291,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,
|
||||||
});
|
});
|
||||||
@@ -1339,7 +1357,9 @@ function dragObject(e) {
|
|||||||
(replacing && !e.ctrlKey)
|
(replacing && !e.ctrlKey)
|
||||||
) {
|
) {
|
||||||
drag.css('visibility', 'visible');
|
drag.css('visibility', 'visible');
|
||||||
|
if (oldsrc && oldobj) {
|
||||||
replaceObject(oldsrc, oldobj);
|
replaceObject(oldsrc, oldobj);
|
||||||
|
}
|
||||||
replacing = false;
|
replacing = false;
|
||||||
canvas.discardActiveObject();
|
canvas.discardActiveObject();
|
||||||
$('#replace-image').removeClass('replace-active');
|
$('#replace-image').removeClass('replace-active');
|
||||||
@@ -1361,7 +1381,21 @@ function dragObject(e) {
|
|||||||
$('#properties').removeClass('noselect');
|
$('#properties').removeClass('noselect');
|
||||||
$('#controls').removeClass('noselect');
|
$('#controls').removeClass('noselect');
|
||||||
draggingPanel = false;
|
draggingPanel = false;
|
||||||
$('body').off('mousemove', dragging).off('mouseup', released);
|
// An aborted drag (pointercancel, a stray native dragstart, or the window
|
||||||
|
// losing focus) carries no pointer position, so there is nowhere to drop.
|
||||||
|
// Undo any in-progress replacement and bin the ghost.
|
||||||
|
if (!e || e.type != 'pointerup') {
|
||||||
|
if (replacing) {
|
||||||
|
if (oldsrc && oldobj) {
|
||||||
|
replaceObject(oldsrc, oldobj);
|
||||||
|
}
|
||||||
|
replacing = false;
|
||||||
|
canvas.discardActiveObject();
|
||||||
|
canvas.renderAll();
|
||||||
|
}
|
||||||
|
drag.remove();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
canvasx = canvas.getPointer(e).x;
|
canvasx = canvas.getPointer(e).x;
|
||||||
canvasy = canvas.getPointer(e).y;
|
canvasy = canvas.getPointer(e).y;
|
||||||
var xpos = canvasx + offsetx - artboard.get('left');
|
var xpos = canvasx + offsetx - artboard.get('left');
|
||||||
@@ -1561,23 +1595,42 @@ function dragObject(e) {
|
|||||||
}
|
}
|
||||||
drag.remove();
|
drag.remove();
|
||||||
}
|
}
|
||||||
$('body').on('mouseup', released).on('mousemove', dragging);
|
// No pointer capture here (unlike the timeline drags): the drop target is
|
||||||
|
// worked out from the canvas hover state, and capturing would retarget those
|
||||||
|
// events to the panel item and leave overCanvas permanently false.
|
||||||
|
bindPointerDrag(e, null, dragging, released);
|
||||||
}
|
}
|
||||||
$(document).on('mousedown', '.image-grid-item', dragObject);
|
$(document).on('pointerdown', '.image-grid-item', dragObject);
|
||||||
$(document).on('mousedown', '.video-grid-item', dragObject);
|
$(document).on('pointerdown', '.video-grid-item', dragObject);
|
||||||
$(document).on('mousedown', '.grid-item', dragObject);
|
$(document).on('pointerdown', '.grid-item', dragObject);
|
||||||
$(document).on('mousedown', '.grid-emoji-item', dragObject);
|
$(document).on('pointerdown', '.grid-emoji-item', dragObject);
|
||||||
$(document).on('mousedown', '.add-text', dragObject);
|
$(document).on('pointerdown', '.add-text', dragObject);
|
||||||
$(document).on('mousedown click mouseup', '.credit', function (e) {
|
$(document).on(
|
||||||
|
'pointerdown mousedown click mouseup',
|
||||||
|
'.credit',
|
||||||
|
function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
// Panel items must never become native drag sources - a native drag swallows
|
||||||
|
// the pointerup and leaves the dragged ghost stuck to the cursor
|
||||||
|
$(document).on(
|
||||||
|
'dragstart',
|
||||||
|
'.image-grid-item, .video-grid-item, .grid-item, .grid-emoji-item, .add-text, .credit',
|
||||||
|
function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// 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') {
|
||||||
@@ -1592,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);
|
||||||
@@ -1613,18 +1667,29 @@ function setPreset() {
|
|||||||
}
|
}
|
||||||
$(document).on('change', '#preset', setPreset);
|
$(document).on('change', '#preset', setPreset);
|
||||||
|
|
||||||
|
function activeAnimatedText() {
|
||||||
|
const object = canvas.getActiveObject();
|
||||||
|
if (!object) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return animatedtext.find((x) => x.id == object.id) || null;
|
||||||
|
}
|
||||||
function setTextPreset() {
|
function setTextPreset() {
|
||||||
var object = canvas.getActiveObject();
|
const text = activeAnimatedText();
|
||||||
animatedtext
|
if (!text) {
|
||||||
.find((x) => x.id == object.id)
|
return;
|
||||||
.setProp({ preset: $(this).val() }, canvas);
|
}
|
||||||
|
text.setProp({ preset: $(this).val() });
|
||||||
|
animate(false, currenttime);
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
function setTextEasing() {
|
function setTextEasing() {
|
||||||
var object = canvas.getActiveObject();
|
const text = activeAnimatedText();
|
||||||
animatedtext
|
if (!text) {
|
||||||
.find((x) => x.id == object.id)
|
return;
|
||||||
.setProp({ easing: $(this).val() }, canvas);
|
}
|
||||||
|
text.setProp({ easing: $(this).val() });
|
||||||
|
animate(false, currenttime);
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
$(document).on('change', '#preset-picker', setTextPreset);
|
$(document).on('change', '#preset-picker', setTextPreset);
|
||||||
@@ -1651,11 +1716,14 @@ function saveLayerName() {
|
|||||||
if ($('.name-active').val() == '') {
|
if ($('.name-active').val() == '') {
|
||||||
$('.name-active').val('Untitled layer');
|
$('.name-active').val('Untitled layer');
|
||||||
}
|
}
|
||||||
objects.find(
|
const entry = objects.find(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.id == $('.name-active').parent().parent().attr('data-object')
|
x.id == $('.name-active').parent().parent().attr('data-object')
|
||||||
).label = $('.name-active').val();
|
);
|
||||||
|
if (entry) {
|
||||||
|
entry.label = $('.name-active').val();
|
||||||
save();
|
save();
|
||||||
|
}
|
||||||
$('.name-active').removeClass('name-active');
|
$('.name-active').removeClass('name-active');
|
||||||
if (window.getSelection) {
|
if (window.getSelection) {
|
||||||
if (window.getSelection().empty) {
|
if (window.getSelection().empty) {
|
||||||
@@ -1726,6 +1794,14 @@ function importExportModal() {
|
|||||||
}
|
}
|
||||||
$('#share').on('click', importExportModal);
|
$('#share').on('click', importExportModal);
|
||||||
|
|
||||||
|
// Open credits modal
|
||||||
|
function creditsModal() {
|
||||||
|
hideModals();
|
||||||
|
$('#credits-modal').toggleClass('modal-open');
|
||||||
|
$('#background-overlay').toggleClass('modal-open');
|
||||||
|
}
|
||||||
|
$('#credits-button').on('click', creditsModal);
|
||||||
|
|
||||||
function searchInput() {
|
function searchInput() {
|
||||||
var value = $(this).val().toLowerCase();
|
var value = $(this).val().toLowerCase();
|
||||||
if (value == '') {
|
if (value == '') {
|
||||||
@@ -1750,6 +1826,9 @@ function fancyTimeFormat(duration) {
|
|||||||
|
|
||||||
function loadMoreMedia() {
|
function loadMoreMedia() {
|
||||||
var value = $('#browser-search input').val();
|
var value = $('#browser-search input').val();
|
||||||
|
if (!HAS_PIXABAY_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (value != '' && page != false) {
|
if (value != '' && page != false) {
|
||||||
page += 1;
|
page += 1;
|
||||||
if ($('#image-tool').hasClass('tool-active')) {
|
if ($('#image-tool').hasClass('tool-active')) {
|
||||||
@@ -1772,7 +1851,7 @@ function loadMoreMedia() {
|
|||||||
hit.user +
|
hit.user +
|
||||||
"</a><img draggable=false onload='onLoadImage(this)' src='" +
|
"</a><img draggable=false onload='onLoadImage(this)' src='" +
|
||||||
hit.webformatURL +
|
hit.webformatURL +
|
||||||
"'</div>"
|
"'></div>"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -1800,7 +1879,7 @@ function loadMoreMedia() {
|
|||||||
hit.user +
|
hit.user +
|
||||||
"</a><div id='time-video'>" +
|
"</a><div id='time-video'>" +
|
||||||
fancyTimeFormat(hit.duration) +
|
fancyTimeFormat(hit.duration) +
|
||||||
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>"
|
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
|
||||||
);
|
);
|
||||||
createVideoThumbnail(video, 250, 0, true).then(function (
|
createVideoThumbnail(video, 250, 0, true).then(function (
|
||||||
data
|
data
|
||||||
@@ -1821,6 +1900,15 @@ function loadMoreMedia() {
|
|||||||
function search() {
|
function search() {
|
||||||
page = 1;
|
page = 1;
|
||||||
var value = $('#browser-search input').val();
|
var value = $('#browser-search input').val();
|
||||||
|
const pixabaySearch =
|
||||||
|
$('#image-tool').hasClass('tool-active') ||
|
||||||
|
$('#video-tool').hasClass('tool-active');
|
||||||
|
if (pixabaySearch && !HAS_PIXABAY_KEY) {
|
||||||
|
$('#shapes-cont').html(
|
||||||
|
"<div id='no-results'>Image and video search needs a Pixabay API key. Set API_KEY in js/init.js.</div>"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($('#image-tool').hasClass('tool-active')) {
|
if ($('#image-tool').hasClass('tool-active')) {
|
||||||
var URL =
|
var URL =
|
||||||
'https://pixabay.com/api/?key=' +
|
'https://pixabay.com/api/?key=' +
|
||||||
@@ -1845,7 +1933,7 @@ function search() {
|
|||||||
hit.user +
|
hit.user +
|
||||||
"</a><img draggable=false onload='onLoadImage(this)' src='" +
|
"</a><img draggable=false onload='onLoadImage(this)' src='" +
|
||||||
hit.webformatURL +
|
hit.webformatURL +
|
||||||
"'</div>"
|
"'></div>"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -1883,7 +1971,7 @@ function search() {
|
|||||||
hit.user +
|
hit.user +
|
||||||
"</a><div id='time-video'>" +
|
"</a><div id='time-video'>" +
|
||||||
fancyTimeFormat(hit.duration) +
|
fancyTimeFormat(hit.duration) +
|
||||||
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>"
|
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
|
||||||
);
|
);
|
||||||
//createVideoThumbnail(video, 250, 0, true).then(function(data){
|
//createVideoThumbnail(video, 250, 0, true).then(function(data){
|
||||||
$(".image-grid-item[data-src='" + video + "']")
|
$(".image-grid-item[data-src='" + video + "']")
|
||||||
@@ -1963,7 +2051,7 @@ function search() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('#shapes-cont').append(
|
$('#shapes-cont').append(
|
||||||
"<div id='item-text' class='add-text noselect' data-font='" +
|
"<div class='item-text add-text noselect' data-font='" +
|
||||||
font +
|
font +
|
||||||
"' style='font-family: " +
|
"' style='font-family: " +
|
||||||
font +
|
font +
|
||||||
@@ -2069,10 +2157,7 @@ function checkFilter() {
|
|||||||
resetFilters();
|
resetFilters();
|
||||||
if (canvas.getActiveObject()) {
|
if (canvas.getActiveObject()) {
|
||||||
var obj = canvas.getActiveObject();
|
var obj = canvas.getActiveObject();
|
||||||
if (
|
if (canvas.getActiveObjects().length == 1 && obj.filters) {
|
||||||
canvas.getActiveObjects().length == 1 &&
|
|
||||||
(obj.type == 'image' || obj.type == 'video')
|
|
||||||
) {
|
|
||||||
var value = 'none';
|
var value = 'none';
|
||||||
if (obj.filters.length > 0) {
|
if (obj.filters.length > 0) {
|
||||||
obj.filters.forEach(function (filter) {
|
obj.filters.forEach(function (filter) {
|
||||||
@@ -2224,6 +2309,9 @@ $(document).on('click', '#reset-filters', removeFilters);
|
|||||||
function updateChromaValues() {
|
function updateChromaValues() {
|
||||||
if (canvas.getActiveObject()) {
|
if (canvas.getActiveObject()) {
|
||||||
var obj = canvas.getActiveObject();
|
var obj = canvas.getActiveObject();
|
||||||
|
if (!obj.filters) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($('.status-active').attr('id') == 'status-on') {
|
if ($('.status-active').attr('id') == 'status-on') {
|
||||||
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
|
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
|
||||||
obj.filters.find((x) => x.type == 'RemoveColor').distance =
|
obj.filters.find((x) => x.type == 'RemoveColor').distance =
|
||||||
@@ -2264,7 +2352,8 @@ function updateChromaUI() {
|
|||||||
$('.status-active').removeClass('status-active');
|
$('.status-active').removeClass('status-active');
|
||||||
$('#status-on').addClass('status-active');
|
$('#status-on').addClass('status-active');
|
||||||
chromaslider.setValue(
|
chromaslider.setValue(
|
||||||
obj.filters.find((x) => x.type == 'RemoveColor').distance
|
obj.filters.find((x) => x.type == 'RemoveColor').distance *
|
||||||
|
100
|
||||||
);
|
);
|
||||||
$('#chroma-color input').val(
|
$('#chroma-color input').val(
|
||||||
obj.filters.find((x) => x.type == 'RemoveColor').color
|
obj.filters.find((x) => x.type == 'RemoveColor').color
|
||||||
@@ -2278,7 +2367,7 @@ function updateChromaUI() {
|
|||||||
$('#status-off').addClass('status-active');
|
$('#status-off').addClass('status-active');
|
||||||
chromaslider.setValue(1);
|
chromaslider.setValue(1);
|
||||||
$('#chroma-color input').val('#FFFFFF');
|
$('#chroma-color input').val('#FFFFFF');
|
||||||
$('#color-chroma-side').css('background-color', '#FFFFF');
|
$('#color-chroma-side').css('background-color', '#FFFFFF');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2350,13 +2439,24 @@ function hideMore() {
|
|||||||
|
|
||||||
function handleLottieUpload() {
|
function handleLottieUpload() {
|
||||||
var filething = $('#filepick3').get(0).files;
|
var filething = $('#filepick3').get(0).files;
|
||||||
|
if (!filething || filething.length == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onload = function (event) {
|
reader.onload = function (event) {
|
||||||
|
try {
|
||||||
newLottieAnimation(
|
newLottieAnimation(
|
||||||
artboard.get('left') + artboard.get('width') / 2,
|
artboard.get('left') + artboard.get('width') / 2,
|
||||||
artboard.get('top') + artboard.get('height') / 2,
|
artboard.get('top') + artboard.get('height') / 2,
|
||||||
event.target.result
|
event.target.result
|
||||||
);
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('That does not look like a valid Lottie file');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = function () {
|
||||||
|
alert('Could not read the file');
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(filething.item(0));
|
reader.readAsDataURL(filething.item(0));
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-16
@@ -596,7 +596,12 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
|
|||||||
buffer.writeEBMLVarInt(4); // Size field
|
buffer.writeEBMLVarInt(4); // Size field
|
||||||
ebml.dataOffset = buffer.pos + bufferFileOffset;
|
ebml.dataOffset = buffer.pos + bufferFileOffset;
|
||||||
buffer.writeFloatBE(ebml.data.value);
|
buffer.writeFloatBE(ebml.data.value);
|
||||||
} else if (ebml.data instanceof Uint8Array) {
|
} else if (
|
||||||
|
ebml.data instanceof Uint8Array ||
|
||||||
|
(ArrayBuffer.isView(ebml.data) &&
|
||||||
|
ebml.data.BYTES_PER_ELEMENT === 1)) {
|
||||||
|
// isView as well as instanceof: a byte array that crossed a realm
|
||||||
|
// boundary (worker, vm context) fails the instanceof check
|
||||||
buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field
|
buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field
|
||||||
ebml.dataOffset = buffer.pos + bufferFileOffset;
|
ebml.dataOffset = buffer.pos + bufferFileOffset;
|
||||||
buffer.writeBytes(ebml.data);
|
buffer.writeBytes(ebml.data);
|
||||||
@@ -630,7 +635,8 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
|
|||||||
*/
|
*/
|
||||||
let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
||||||
return function(options) {
|
return function(options) {
|
||||||
let MAX_CLUSTER_DURATION_MSEC = 5000000, DEFAULT_TRACK_NUMBER = 1,
|
let MAX_CLUSTER_DURATION_MSEC = 5000, DEFAULT_TRACK_NUMBER = 1,
|
||||||
|
AUDIO_TRACK_NUMBER = 2,
|
||||||
writtenHeader = false, videoWidth = 0, videoHeight = 0,
|
writtenHeader = false, videoWidth = 0, videoHeight = 0,
|
||||||
firstTimestampEver = true, earliestTimestamp = 0,
|
firstTimestampEver = true, earliestTimestamp = 0,
|
||||||
|
|
||||||
@@ -649,6 +655,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
|||||||
// (optional)
|
// (optional)
|
||||||
codec: 'VP8', // Codec to write to webm file
|
codec: 'VP8', // Codec to write to webm file
|
||||||
|
|
||||||
|
// Optional second track holding Opus audio. Supply:
|
||||||
|
// {sampleRate, channels, codecPrivate: Uint8Array (OpusHead)}
|
||||||
|
audio: null,
|
||||||
},
|
},
|
||||||
|
|
||||||
seekPoints = {
|
seekPoints = {
|
||||||
@@ -787,9 +796,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
let tracks = {
|
let trackEntries = [{
|
||||||
'id': 0x1654ae6b, // Tracks
|
|
||||||
'data': [{
|
|
||||||
'id': 0xae, // TrackEntry
|
'id': 0xae, // TrackEntry
|
||||||
'data': [
|
'data': [
|
||||||
{
|
{
|
||||||
@@ -868,7 +875,87 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
|||||||
'data': options.codec
|
'data': options.codec
|
||||||
},*/
|
},*/
|
||||||
]
|
]
|
||||||
}]
|
}];
|
||||||
|
|
||||||
|
// Optional Opus audio track.
|
||||||
|
// CodecPrivate must be an OpusHead block, and Opus needs the pre-roll
|
||||||
|
// hints or players will start it with audible artefacts.
|
||||||
|
if (options.audio) {
|
||||||
|
trackEntries.push({
|
||||||
|
'id': 0xae, // TrackEntry
|
||||||
|
'data': [
|
||||||
|
{
|
||||||
|
'id': 0xd7, // TrackNumber
|
||||||
|
'data': AUDIO_TRACK_NUMBER
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x73c5, // TrackUID
|
||||||
|
'data': AUDIO_TRACK_NUMBER
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x83, // TrackType (2 = audio)
|
||||||
|
'data': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x9c, // FlagLacing
|
||||||
|
'data': 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x22b59c, // Language
|
||||||
|
'data': 'und'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0xb9, // FlagEnabled
|
||||||
|
'data': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x88, // FlagDefault
|
||||||
|
'data': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x55aa, // FlagForced
|
||||||
|
'data': 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x86, // CodecID
|
||||||
|
'data': 'A_OPUS'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x63A2, // CodecPrivate
|
||||||
|
'data': options.audio.codecPrivate
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x56AA, // CodecDelay (ns)
|
||||||
|
'data': 6500000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x56BB, // SeekPreRoll (ns)
|
||||||
|
'data': 80000000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0xe1, // Audio
|
||||||
|
'data': [
|
||||||
|
{
|
||||||
|
'id': 0xb5, // SamplingFrequency
|
||||||
|
'data': new EBMLFloat64(options.audio.sampleRate)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x9f, // Channels
|
||||||
|
'data': options.audio.channels
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 0x6264, // BitDepth
|
||||||
|
'data': 32
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tracks = {
|
||||||
|
'id': 0x1654ae6b, // Tracks
|
||||||
|
'data': trackEntries
|
||||||
};
|
};
|
||||||
|
|
||||||
ebmlSegment = {
|
ebmlSegment = {
|
||||||
@@ -881,7 +968,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
let bufferStream = new ArrayBufferDataStream(256);
|
// Has to fit the header, SeekHead, SegmentInfo and every TrackEntry.
|
||||||
|
// 256 was only ever enough for a single video track.
|
||||||
|
let bufferStream = new ArrayBufferDataStream(1024);
|
||||||
|
|
||||||
writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]);
|
writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]);
|
||||||
blobBuffer.write(bufferStream.getAsDataArray());
|
blobBuffer.write(bufferStream.getAsDataArray());
|
||||||
@@ -1036,7 +1125,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
|||||||
* @param {Frame} frame
|
* @param {Frame} frame
|
||||||
*/
|
*/
|
||||||
function addFrameToCluster(frame) {
|
function addFrameToCluster(frame) {
|
||||||
frame.trackNumber = DEFAULT_TRACK_NUMBER;
|
frame.trackNumber = frame.trackNumber || DEFAULT_TRACK_NUMBER;
|
||||||
var time = frame.intime / 1000;
|
var time = frame.intime / 1000;
|
||||||
if (firstTimestampEver) {
|
if (firstTimestampEver) {
|
||||||
earliestTimestamp = time;
|
earliestTimestamp = time;
|
||||||
@@ -1045,19 +1134,30 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
|||||||
} else {
|
} else {
|
||||||
time = time - earliestTimestamp;
|
time = time - earliestTimestamp;
|
||||||
}
|
}
|
||||||
|
if (time > lastTimeCode) {
|
||||||
lastTimeCode = time;
|
lastTimeCode = time;
|
||||||
if (clusterDuration == 0) clusterStartTime = time;
|
}
|
||||||
|
|
||||||
|
// Start a new cluster on a video keyframe once the current one has
|
||||||
|
// grown past the limit. SimpleBlock timecodes are a signed 16 bit
|
||||||
|
// offset from the cluster, so clusters cannot span more than ~32s.
|
||||||
|
const isVideoKeyframe =
|
||||||
|
frame.trackNumber == DEFAULT_TRACK_NUMBER && frame.type == 'key';
|
||||||
|
if (
|
||||||
|
clusterFrameBuffer.length > 0 && isVideoKeyframe &&
|
||||||
|
time - clusterStartTime >= MAX_CLUSTER_DURATION_MSEC) {
|
||||||
|
flushClusterFrameBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clusterFrameBuffer.length === 0) {
|
||||||
|
clusterStartTime = time;
|
||||||
|
}
|
||||||
|
|
||||||
// Frame timecodes are relative to the start of their cluster:
|
// Frame timecodes are relative to the start of their cluster:
|
||||||
// frame.timecode = Math.round(clusterDuration);
|
|
||||||
frame.timecode = Math.round(time - clusterStartTime);
|
frame.timecode = Math.round(time - clusterStartTime);
|
||||||
|
|
||||||
clusterFrameBuffer.push(frame);
|
clusterFrameBuffer.push(frame);
|
||||||
clusterDuration = frame.timecode + 1;
|
clusterDuration = frame.timecode + 1;
|
||||||
|
|
||||||
if (clusterDuration >= MAX_CLUSTER_DURATION_MSEC) {
|
|
||||||
flushClusterFrameBuffer();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1106,24 +1206,33 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
|
|||||||
* toDataUrl() on an image yourself.
|
* toDataUrl() on an image yourself.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
this.addFrame = function(frame) {
|
this.addFrame = function(frame, trackNumber) {
|
||||||
if (!writtenHeader) {
|
if (!writtenHeader) {
|
||||||
videoWidth = options.width;
|
videoWidth = options.width;
|
||||||
videoHeight = options.height;
|
videoHeight = options.height;
|
||||||
writeHeader();
|
writeHeader();
|
||||||
}
|
}
|
||||||
if (frame.constructor.name == 'EncodedVideoChunk') {
|
const name = frame.constructor.name;
|
||||||
|
if (name == 'EncodedVideoChunk' || name == 'EncodedAudioChunk') {
|
||||||
let frameData = new Uint8Array(frame.byteLength);
|
let frameData = new Uint8Array(frame.byteLength);
|
||||||
frame.copyTo(frameData);
|
frame.copyTo(frameData);
|
||||||
addFrameToCluster({
|
addFrameToCluster({
|
||||||
frame: frameData,
|
frame: frameData,
|
||||||
intime: frame.timestamp,
|
intime: frame.timestamp,
|
||||||
type: frame.type,
|
type: frame.type,
|
||||||
|
trackNumber: trackNumber ||
|
||||||
|
(name == 'EncodedAudioChunk' ? AUDIO_TRACK_NUMBER :
|
||||||
|
DEFAULT_TRACK_NUMBER),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Encoded audio for the optional second track
|
||||||
|
this.addAudioChunk = function(chunk) {
|
||||||
|
this.addFrame(chunk, AUDIO_TRACK_NUMBER);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finish writing the video and return a Promise to signal completion.
|
* Finish writing the video and return a Promise to signal completion.
|
||||||
*
|
*
|
||||||
|
|||||||
+299
-80
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -204,6 +212,27 @@ body {
|
|||||||
.hand-active:hover {
|
.hand-active:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
#credits-button {
|
||||||
|
width: 100px;
|
||||||
|
position: absolute;
|
||||||
|
right: 330px;
|
||||||
|
bottom: 12px;
|
||||||
|
height: 38px;
|
||||||
|
line-height: 38px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: #30314e;
|
||||||
|
color: var(--main-text-color);
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: inset 0px 2px 2px rgba(255, 255, 255, 0.05),
|
||||||
|
inset 0px -2px 1px rgba(0, 0, 0, 0.05);
|
||||||
|
font-family: Inter;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#credits-button:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
/* Bottom of canvas */
|
/* Bottom of canvas */
|
||||||
#bottom-canvas {
|
#bottom-canvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -212,58 +241,6 @@ body {
|
|||||||
z-index: 9999999;
|
z-index: 9999999;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
#sponsor {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 10px;
|
|
||||||
left: -10px;
|
|
||||||
height: 32px;
|
|
||||||
line-height: 32px;
|
|
||||||
font-family: Inter;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent-color);
|
|
||||||
background: rgba(22, 95, 205, 0.1);
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
padding-left: 10px;
|
|
||||||
padding-right: 10px;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
#sponsor img {
|
|
||||||
margin-right: 3px;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
}
|
|
||||||
#sponsor:hover {
|
|
||||||
background: rgba(22, 95, 205, 0.3) !important;
|
|
||||||
}
|
|
||||||
#alyssa-credit {
|
|
||||||
color: var(--secondary-text-color);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
position: absolute;
|
|
||||||
right: -20px;
|
|
||||||
bottom: 15px;
|
|
||||||
font-family: Inter;
|
|
||||||
width: 200px;
|
|
||||||
filter: drop-shadow(0px 1px 15px #141629);
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
#alyssa-credit:hover {
|
|
||||||
filter: drop-shadow(0px 1px 10px #141629) !important;
|
|
||||||
}
|
|
||||||
#alyssa-credit span {
|
|
||||||
color: var(--main-text-color) !important;
|
|
||||||
text-decoration: none !important;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
#alyssa-credit img {
|
|
||||||
display: inline-block;
|
|
||||||
margin-left: 5px;
|
|
||||||
border-radius: 50%;
|
|
||||||
vertical-align: middle;
|
|
||||||
width: 18px;
|
|
||||||
margin-top: -2px;
|
|
||||||
}
|
|
||||||
.hide-folder {
|
.hide-folder {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -275,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;
|
||||||
@@ -339,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;
|
||||||
@@ -349,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;
|
||||||
@@ -364,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%;
|
||||||
@@ -390,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;
|
||||||
}
|
}
|
||||||
@@ -419,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;
|
||||||
@@ -467,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 {
|
||||||
@@ -492,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;
|
||||||
@@ -734,7 +713,7 @@ body {
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
#item-text {
|
.item-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -807,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%;
|
||||||
@@ -861,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;
|
||||||
}
|
}
|
||||||
@@ -900,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,
|
||||||
@@ -1375,6 +1367,49 @@ input[type="number"] {
|
|||||||
display: block;
|
display: block;
|
||||||
z-index: 99999999999;
|
z-index: 99999999999;
|
||||||
}
|
}
|
||||||
|
/* Credits modal */
|
||||||
|
#credits-modal {
|
||||||
|
width: 300px;
|
||||||
|
max-height: 400px;
|
||||||
|
background-color: var(--panel-back);
|
||||||
|
border: 1px solid var(--panel-stroke);
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
position: absolute;
|
||||||
|
right: 280px;
|
||||||
|
bottom: 60px;
|
||||||
|
visibility: hidden;
|
||||||
|
display: block;
|
||||||
|
z-index: 99999999999;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#credits-modal .header {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
#credits-modal .subtitle {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
#credits-modal .subtitle span {
|
||||||
|
color: var(--main-text-color) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
#credits-modal .subheader {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
#credits-list {
|
||||||
|
margin-left: 20px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
.credit-item {
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
.subtitle {
|
.subtitle {
|
||||||
color: var(--secondary-text-color);
|
color: var(--secondary-text-color);
|
||||||
font-family: Inter;
|
font-family: Inter;
|
||||||
@@ -1418,7 +1453,8 @@ input[type="number"] {
|
|||||||
/* Download modal */
|
/* Download modal */
|
||||||
#download-modal {
|
#download-modal {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
height: 255px;
|
/* Grows with its content: the frame rate row is hidden for image exports */
|
||||||
|
padding-bottom: 20px;
|
||||||
background-color: var(--panel-back);
|
background-color: var(--panel-back);
|
||||||
border: 1px solid var(--panel-stroke);
|
border: 1px solid var(--panel-stroke);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -1463,6 +1499,28 @@ input[type="number"] {
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
#framerate-row {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
#framerate-row .subheader {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
#framerate {
|
||||||
|
background-color: var(--input-color);
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--main-text-color);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 35px;
|
||||||
|
outline: none;
|
||||||
|
padding-left: 11px;
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
#framerate:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.magic-radio:checked + label:before {
|
.magic-radio:checked + label:before {
|
||||||
background-color: var(--accent-color) !important;
|
background-color: var(--accent-color) !important;
|
||||||
border: 0px !important;
|
border: 0px !important;
|
||||||
@@ -1515,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;
|
||||||
@@ -1579,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);
|
||||||
@@ -1626,6 +1680,46 @@ layer:nth-child(even) .properties {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-indent: 25px;
|
text-indent: 25px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.layer-handle {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 9px;
|
||||||
|
height: 16px;
|
||||||
|
text-indent: 0;
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: grab;
|
||||||
|
background-image: radial-gradient(
|
||||||
|
currentColor 1px,
|
||||||
|
transparent 1.5px
|
||||||
|
);
|
||||||
|
background-size: 4px 5px;
|
||||||
|
background-position: 1px 2px;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.layer:hover .layer-handle {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.layer .layer-handle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.layer-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.layer-handle[draggable="false"] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.15 !important;
|
||||||
|
}
|
||||||
|
.sortable-placeholder {
|
||||||
|
height: 35px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px dashed var(--accent-color);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(0, 122, 255, 0.12);
|
||||||
}
|
}
|
||||||
.layer-custom-name {
|
.layer-custom-name {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -1741,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);
|
||||||
@@ -1765,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;
|
||||||
}
|
}
|
||||||
@@ -1783,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;
|
||||||
@@ -2020,6 +2212,33 @@ layer:nth-child(even) .properties {
|
|||||||
z-index: 99999999;
|
z-index: 99999999;
|
||||||
pointer-events: all;
|
pointer-events: all;
|
||||||
top: 0px;
|
top: 0px;
|
||||||
|
/* Never let the browser start a native drag or a selection on the seekbar */
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
#seekbar:after,
|
||||||
|
#seek-hover {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Same for the other timeline drag targets */
|
||||||
|
.keyframe,
|
||||||
|
.main-row,
|
||||||
|
.row-el,
|
||||||
|
.trim-row,
|
||||||
|
#timeline-handle {
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
/* And for the library panel items dragged onto the canvas */
|
||||||
|
.image-grid-item,
|
||||||
|
.video-grid-item,
|
||||||
|
.grid-item,
|
||||||
|
.grid-emoji-item,
|
||||||
|
.add-text,
|
||||||
|
.credit {
|
||||||
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
#seekbar:hover {
|
#seekbar:hover {
|
||||||
outline: 3px solid rgba(255, 255, 255, 0.1);
|
outline: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
@@ -2438,10 +2657,10 @@ layer:nth-child(even) .properties {
|
|||||||
margin-top: -45px;
|
margin-top: -45px;
|
||||||
box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%);
|
box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%);
|
||||||
}
|
}
|
||||||
#filters-header #filters-title {
|
#filters-header .filters-title {
|
||||||
margin-top: 0px!important;
|
margin-top: 0px!important;
|
||||||
}
|
}
|
||||||
#filters-title {
|
.filters-title {
|
||||||
color: var(--main-text-color);
|
color: var(--main-text-color);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
// Exercise the extended WebM muxer without a browser and validate the EBML it
|
||||||
|
// produces. Feeds fake encoded chunks, then parses the resulting file.
|
||||||
|
const fs = require('fs');
|
||||||
|
const vm = require('vm');
|
||||||
|
|
||||||
|
const code = fs.readFileSync(
|
||||||
|
require('path').join(__dirname, '..', 'src', 'js', 'webm-writer2.js'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const sandbox = { Blob, console };
|
||||||
|
sandbox.self = sandbox;
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
// `module` must stay undefined so the browser branch runs
|
||||||
|
vm.runInContext(code, sandbox);
|
||||||
|
|
||||||
|
const WebMWriter = sandbox.WebMWriter;
|
||||||
|
if (!WebMWriter) throw new Error('WebMWriter not exported');
|
||||||
|
|
||||||
|
class EncodedVideoChunk {
|
||||||
|
constructor(o) {
|
||||||
|
this.timestamp = o.timestamp;
|
||||||
|
this.type = o.type;
|
||||||
|
this._data = o.data;
|
||||||
|
this.byteLength = o.data.length;
|
||||||
|
}
|
||||||
|
copyTo(dst) {
|
||||||
|
dst.set(this._data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class EncodedAudioChunk {
|
||||||
|
constructor(o) {
|
||||||
|
this.timestamp = o.timestamp;
|
||||||
|
this.type = o.type;
|
||||||
|
this._data = o.data;
|
||||||
|
this.byteLength = o.data.length;
|
||||||
|
}
|
||||||
|
copyTo(dst) {
|
||||||
|
dst.set(this._data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function opusHead(channels, sampleRate) {
|
||||||
|
const head = new Uint8Array(19);
|
||||||
|
const view = new DataView(head.buffer);
|
||||||
|
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0);
|
||||||
|
head[8] = 1;
|
||||||
|
head[9] = channels;
|
||||||
|
view.setUint16(10, 3840, true);
|
||||||
|
view.setUint32(12, sampleRate, true);
|
||||||
|
view.setUint16(16, 0, true);
|
||||||
|
head[18] = 0;
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- EBML parser -----------------------------------------------------------
|
||||||
|
function readVarInt(buf, pos, stripMarker) {
|
||||||
|
const first = buf[pos];
|
||||||
|
if (first === 0) throw new Error('invalid varint at ' + pos);
|
||||||
|
let width = 1;
|
||||||
|
let mask = 0x80;
|
||||||
|
while (!(first & mask)) {
|
||||||
|
mask >>= 1;
|
||||||
|
width++;
|
||||||
|
}
|
||||||
|
let value = stripMarker ? first & (mask - 1) : first;
|
||||||
|
let unknown = stripMarker && (first & (mask - 1)) === mask - 1;
|
||||||
|
for (let i = 1; i < width; i++) {
|
||||||
|
value = value * 256 + buf[pos + i];
|
||||||
|
if (buf[pos + i] !== 0xff) unknown = false;
|
||||||
|
}
|
||||||
|
return { value, width, unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(buf, start, end, depth, out) {
|
||||||
|
let pos = start;
|
||||||
|
while (pos < end) {
|
||||||
|
const id = readVarInt(buf, pos, false);
|
||||||
|
const idBytes = buf.slice(pos, pos + id.width);
|
||||||
|
let idHex = 0;
|
||||||
|
for (const b of idBytes) idHex = idHex * 256 + b;
|
||||||
|
pos += id.width;
|
||||||
|
const size = readVarInt(buf, pos, true);
|
||||||
|
pos += size.width;
|
||||||
|
const dataStart = pos;
|
||||||
|
const dataEnd = size.unknown ? end : Math.min(dataStart + size.value, end);
|
||||||
|
out.push({ id: idHex, depth, dataStart, dataEnd });
|
||||||
|
const MASTER = [
|
||||||
|
0x1a45dfa3, 0x18538067, 0x1654ae6b, 0xae, 0x1f43b675, 0x1549a966,
|
||||||
|
0x114d9b74, 0x4dbb, 0xe0, 0xe1, 0x1c53bb6b, 0xbb, 0xb7,
|
||||||
|
];
|
||||||
|
if (MASTER.includes(idHex)) parse(buf, dataStart, dataEnd, depth + 1, out);
|
||||||
|
pos = dataEnd;
|
||||||
|
if (dataEnd <= dataStart && size.value === 0) continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uintAt(buf, s, e) {
|
||||||
|
let v = 0;
|
||||||
|
for (let i = s; i < e; i++) v = v * 256 + buf[i];
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
function strAt(buf, s, e) {
|
||||||
|
return Buffer.from(buf.slice(s, e)).toString('ascii').replace(/\0+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(withAudio) {
|
||||||
|
const writer = new WebMWriter({
|
||||||
|
codec: 'VP9',
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
audio: withAudio
|
||||||
|
? { sampleRate: 48000, channels: 2, codecPrivate: opusHead(2, 48000) }
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const FPS = 30;
|
||||||
|
const SECONDS = 75;
|
||||||
|
const items = [];
|
||||||
|
for (let i = 0; i < FPS * SECONDS; i++) {
|
||||||
|
items.push({
|
||||||
|
track: 1,
|
||||||
|
chunk: new EncodedVideoChunk({
|
||||||
|
timestamp: Math.round((i / FPS) * 1e6),
|
||||||
|
type: i % (FPS * 2) === 0 ? 'key' : 'delta',
|
||||||
|
data: new Uint8Array(120).fill(i & 0xff),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (withAudio) {
|
||||||
|
for (let i = 0; i < SECONDS * 10; i++) {
|
||||||
|
items.push({
|
||||||
|
track: 2,
|
||||||
|
chunk: new EncodedAudioChunk({
|
||||||
|
timestamp: Math.round(i * 0.1 * 1e6),
|
||||||
|
type: 'key',
|
||||||
|
data: new Uint8Array(40).fill(0x55),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort((a, b) =>
|
||||||
|
a.chunk.timestamp === b.chunk.timestamp
|
||||||
|
? a.track - b.track
|
||||||
|
: a.chunk.timestamp - b.chunk.timestamp
|
||||||
|
);
|
||||||
|
items.forEach((it) => writer.addFrame(it.chunk, it.track));
|
||||||
|
|
||||||
|
const blob = await writer.complete();
|
||||||
|
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
|
||||||
|
const els = [];
|
||||||
|
parse(buf, 0, buf.length, 0, els);
|
||||||
|
|
||||||
|
const label = withAudio ? 'video+audio' : 'video only';
|
||||||
|
const problems = [];
|
||||||
|
|
||||||
|
if (!els.some((e) => e.id === 0x1a45dfa3)) problems.push('no EBML header');
|
||||||
|
if (!els.some((e) => e.id === 0x18538067)) problems.push('no Segment');
|
||||||
|
|
||||||
|
const trackEntries = els.filter((e) => e.id === 0xae);
|
||||||
|
const expectedTracks = withAudio ? 2 : 1;
|
||||||
|
if (trackEntries.length !== expectedTracks)
|
||||||
|
problems.push(`expected ${expectedTracks} TrackEntry, got ${trackEntries.length}`);
|
||||||
|
|
||||||
|
const codecIds = els
|
||||||
|
.filter((e) => e.id === 0x86)
|
||||||
|
.map((e) => strAt(buf, e.dataStart, e.dataEnd));
|
||||||
|
if (!codecIds.includes('V_VP9')) problems.push('missing V_VP9, got ' + codecIds);
|
||||||
|
if (withAudio && !codecIds.includes('A_OPUS'))
|
||||||
|
problems.push('missing A_OPUS, got ' + codecIds);
|
||||||
|
|
||||||
|
const trackTypes = els
|
||||||
|
.filter((e) => e.id === 0x83)
|
||||||
|
.map((e) => uintAt(buf, e.dataStart, e.dataEnd));
|
||||||
|
if (!trackTypes.includes(1)) problems.push('no video TrackType');
|
||||||
|
if (withAudio && !trackTypes.includes(2)) problems.push('no audio TrackType');
|
||||||
|
|
||||||
|
if (withAudio) {
|
||||||
|
const priv = els.find((e) => e.id === 0x63a2);
|
||||||
|
if (!priv) problems.push('no CodecPrivate');
|
||||||
|
else {
|
||||||
|
if (priv.dataEnd - priv.dataStart !== 19)
|
||||||
|
problems.push('CodecPrivate is not 19 bytes');
|
||||||
|
if (strAt(buf, priv.dataStart, priv.dataStart + 8) !== 'OpusHead')
|
||||||
|
problems.push('CodecPrivate is not OpusHead');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters = els.filter((e) => e.id === 0x1f43b675);
|
||||||
|
if (clusters.length < 2)
|
||||||
|
problems.push(`expected multiple clusters over ${SECONDS}s, got ${clusters.length}`);
|
||||||
|
|
||||||
|
// Every SimpleBlock: track number valid, timecode inside signed 16 bit
|
||||||
|
const blocks = els.filter((e) => e.id === 0xa3);
|
||||||
|
if (blocks.length !== items.length)
|
||||||
|
problems.push(`expected ${items.length} blocks, got ${blocks.length}`);
|
||||||
|
const seenTracks = new Set();
|
||||||
|
let badTimecode = 0;
|
||||||
|
for (const b of blocks) {
|
||||||
|
const tn = readVarInt(buf, b.dataStart, true);
|
||||||
|
seenTracks.add(tn.value);
|
||||||
|
const tc = (buf[b.dataStart + tn.width] << 8) | buf[b.dataStart + tn.width + 1];
|
||||||
|
const signed = tc > 32767 ? tc - 65536 : tc;
|
||||||
|
if (signed < 0 || signed > 32767) badTimecode++;
|
||||||
|
}
|
||||||
|
if (badTimecode) problems.push(badTimecode + ' block timecodes out of range');
|
||||||
|
const expectTrackSet = withAudio ? [1, 2] : [1];
|
||||||
|
for (const t of expectTrackSet)
|
||||||
|
if (!seenTracks.has(t)) problems.push('no blocks for track ' + t);
|
||||||
|
for (const t of seenTracks)
|
||||||
|
if (!expectTrackSet.includes(t)) problems.push('unexpected track ' + t);
|
||||||
|
|
||||||
|
// Reconstruct absolute times: cluster timecode + block timecode must give
|
||||||
|
// back exactly the timestamps that went in, in order.
|
||||||
|
const rebuilt = [];
|
||||||
|
let lastClusterTime = -1;
|
||||||
|
for (const c of clusters) {
|
||||||
|
const tcEl = els.find(
|
||||||
|
(e) => e.id === 0xe7 && e.dataStart > c.dataStart && e.dataEnd <= c.dataEnd
|
||||||
|
);
|
||||||
|
if (!tcEl) {
|
||||||
|
problems.push('cluster without Timecode');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const clusterTime = uintAt(buf, tcEl.dataStart, tcEl.dataEnd);
|
||||||
|
if (clusterTime < lastClusterTime) problems.push('cluster timecodes go backwards');
|
||||||
|
lastClusterTime = clusterTime;
|
||||||
|
const inner = blocks.filter(
|
||||||
|
(b) => b.dataStart > c.dataStart && b.dataEnd <= c.dataEnd
|
||||||
|
);
|
||||||
|
if (inner.length === 0) problems.push('empty cluster');
|
||||||
|
for (const b of inner) {
|
||||||
|
const tn = readVarInt(buf, b.dataStart, true);
|
||||||
|
const raw = (buf[b.dataStart + tn.width] << 8) | buf[b.dataStart + tn.width + 1];
|
||||||
|
const rel = raw > 32767 ? raw - 65536 : raw;
|
||||||
|
if (rel < 0) problems.push('negative block timecode in cluster');
|
||||||
|
rebuilt.push({ track: tn.value, ms: clusterTime + rel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expected = items.map((it) => ({
|
||||||
|
track: it.track,
|
||||||
|
ms: Math.round(it.chunk.timestamp / 1000),
|
||||||
|
}));
|
||||||
|
if (rebuilt.length !== expected.length) {
|
||||||
|
problems.push('block count mismatch on rebuild');
|
||||||
|
} else {
|
||||||
|
let drift = 0;
|
||||||
|
for (let i = 0; i < expected.length; i++) {
|
||||||
|
if (rebuilt[i].track !== expected[i].track) {
|
||||||
|
problems.push('track order changed at block ' + i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
drift = Math.max(drift, Math.abs(rebuilt[i].ms - expected[i].ms));
|
||||||
|
}
|
||||||
|
if (drift > 1) problems.push('timestamp drift of ' + drift + 'ms');
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationEl = els.find((e) => e.id === 0x4489);
|
||||||
|
if (!durationEl) problems.push('no Duration');
|
||||||
|
else {
|
||||||
|
const dv = new DataView(buf.buffer, buf.byteOffset + durationEl.dataStart, 8);
|
||||||
|
const d = dv.getFloat64(0);
|
||||||
|
if (!(d > 0)) problems.push('Duration is ' + d);
|
||||||
|
else if (Math.abs(d - (SECONDS - 1 / FPS) * 1000) > 200)
|
||||||
|
problems.push('Duration ' + d + 'ms is not ~' + SECONDS * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[${label}] ${buf.length} bytes, ${clusters.length} clusters, ` +
|
||||||
|
`${blocks.length} blocks, tracks=${[...seenTracks].join(',')}`
|
||||||
|
);
|
||||||
|
if (problems.length) {
|
||||||
|
console.log(' FAIL:');
|
||||||
|
problems.forEach((p) => console.log(' - ' + p));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log(' OK');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const a = await run(false);
|
||||||
|
const b = await run(true);
|
||||||
|
process.exit(a && b ? 0 : 1);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user