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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:28:50 +02:00
kawa 094bfbdc60 Updated links pointing to motionity.app > motionity.kawa.zip
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 16:00:46 +02:00
kawa 414e0ee30c Updated the credits with links to the contributor's git pages
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 15:33:51 +02:00
kawa cc439c1152 Modified publish pipeline for WSL (flatpak, appimage builds)
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 15:13:42 +02:00
kawa e775ea478f Made the README.md nicer
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 14:05:41 +02:00
kawa dcb804e48e Bumped package.json version
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 13:02:26 +02:00
kawa f380249c1c Merge branch 'main' of https://git.azuze.fr/kawa/Motionity
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 12:47:42 +02:00
kawaandClaude Haiku 4.5 a87716dc5b feat: add credits modal and update docker config
- Add credits modal showing project creators and libraries used
- Replace bottom-of-canvas credits with clickable credits button
- Update docker-compose to pull pre-built image from git.azuze.fr registry
- Simplify README Docker instructions

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-13 12:26:10 +02:00
kawa 109f737c2a Update README.md
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 11:43:11 +02:00
kawa d808c715fd Added the git wkflow
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s
2026-08-13 09:04:04 +02:00
kawa 3ed8f5683f Fixed publish script+added options to separate binaries publishing.
Bumped package.json to 2.0.0
2026-08-13 08:48:34 +02:00
kawa fd0314549b Merge pull request 'Packaging/release scripts' (#1) from packaging/release-scripts into main
Reviewed-on: #1
2026-08-12 17:38:11 +02:00
kawa 89771b2555 docs: improve README with clearer structure and examples
Reorganize installation and build instructions with separate sections for
web, desktop, and Docker. Add quick start commands and move detailed
packaging info to PACKAGING.md.
2026-08-12 17:02:05 +02:00
kawaandClaude Opus 5 f5d18a10a9 build: set the package author
electron-builder warned "author is missed in the package.json" on every run. The
field ends up in installer metadata and, on Linux, the desktop entry's
maintainer.

Name only, taken from the git identity: an email here would be embedded in every
published artifact. The AppImage and Flatpak targets do not require one; deb and
rpm would, if those are ever added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:56:21 +02:00
kawaandClaude Opus 5 271107203d build: update electron to 43 and keep node_modules out of the asar
Electron 40 is affected by GHSA-9f4c-93c8-jc8g (CVSS 7.2): a sandboxed iframe
can bypass the allow-popups restriction through the OpenURL navigation path.
Motionity renders no iframes, so the practical exposure was near zero, but
Electron only patches its latest three majors — on 40 there would be no fix for
the next advisory either. npm audit now reports zero vulnerabilities.

electron/main.js needed no changes: it uses only APIs stable across 40 to 43.

While verifying the packaged output, the asar turned out to carry @ffmpeg's
transitive dependencies (node-fetch, whatwg-url, regenerator-runtime and
friends). The app loads the vendored UMD bundle from src/vendor/ffmpeg/ and
requires nothing outside electron and node builtins, so build.files now excludes
node_modules wholesale instead of naming @ffmpeg — the same result without
having to track a dependency tree that is not ours.

Verified: electron-builder 26.15.3 packages electron 43.4.0, and the asar
contains the five vendored ffmpeg files and zero node_modules entries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:49:55 +02:00
kawaandClaude Opus 5 9a4d14613f feat: replace the archive.org asm.js encoder with vendored ffmpeg.wasm
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, executed in the page, and unavailable offline. vendor.mjs now copies
ffmpeg.wasm out of node_modules, where package-lock.json pins it by hash, and
no CDN fallback is left anywhere in the app.

@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 isolation, which would break the Pixabay, Unsplash and Google Fonts
requests. That core also forces two things worth knowing:

- mainName: 'main' is mandatory. The loader defaults to proxy_main, which only
  the multi-threaded build exports, so load() compiles all 23 MB and then aborts.
- Its main() calls exit(), so an instance survives exactly one command. Reusing
  one dies with "Program terminated with exit(0)", so convertStreams builds and
  tears one down per conversion (~110 ms, and the 23 MB heap comes back in
  between). The teardown also runs on failure: an interrupted run otherwise
  leaves the loader's "running" flag set and wedges every later conversion until
  a page reload.

MP4 encodes with libx264 -crf 23 -pix_fmt yuv420p plus AAC rather than
mpeg4 -b:v 6400k. Same core, better quality per byte, and yuv420p is what makes
it play in Safari and QuickTime.

The two @ffmpeg packages are dependencies, not devDependencies, so the Docker
vendor stage can npm ci --omit=dev without pulling in electron; build.files
excludes them from the asar since src/vendor/ffmpeg/ already carries the copies
the app loads. WITH_FFMPEG=0 now means MP4/GIF export is unavailable and says
so, rather than silently fetching an encoder at run time.

Also deletes src/js/libraries/ffmpeg.min.js, an unreferenced ffmpeg.wasm loader
stub that would have fetched its core from unpkg, and prunes the stale
src/vendor/ffmpeg_asm.js from existing checkouts — src/vendor/ is packaged
whole, so it would have shipped 18.5 MB of dead weight in every installer.

Verified in Chromium against a real MediaRecorder WebM: core loads with
crossOriginIsolated false, MP4 24 KB decoding to 320x240 / 2.00 s, GIF 138 KB,
the two back to back, and the missing-core path reporting correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 15:41:40 +02:00
kawaandClaude Opus 5 a6ec6c980a fix: audit src/js and add a frame-accurate export path
Two halves of one pass; TODO-FIXES.md lists every finding.

The audit fixed, among ~90 items: MP4 export dead-ending and locking the UI
behind a stuck "Downloading..." button; height keyframes storing the width;
shadow defaults and keyframes storing undefined because fabric's get() is not
a path getter; every letter animation writing to the last letter; keyframe
times drifting on each drag because data-time was layer-relative on expanded
rows; O(n^2 log n) playback, now indexed once per frame; and a save() that
rebuilt the record canvas on every edit, now debounced.

Exports also lost audio-layer sound outright: MediaRecorder records only the
first audio track, so every source now mixes through one AudioContext into one
destination.

The new half is src/js/render.js, an offline renderer. Real-time capture was
the root cause of dropped frames on heavy scenes and smeared video layers: a
bare currentTime assignment is async, so drawing straight after it captures the
previous frame. Each frame is now seeked, awaited on 'seeked', drawn, and
pushed through a VideoEncoder; audio is mixed in one OfflineAudioContext pass
and encoded to Opus. webm-writer2.js gained a second Opus track and lost three
bugs, including a MAX_CLUSTER_DURATION_MSEC of ~58 days that overflowed the
signed 16-bit block timecode past ~32s.

Real-time capture remains the fallback: the hand-rolled muxer's output is
decoded in a <video> element before being handed over, and any failure returns
null so record() falls back transparently.

test/webm-muxer.test.js parses the muxer output with an EBML reader in plain
Node. It caught the fixed-256-byte header overflow that inspection missed.

Not verified in a browser: node --check passes on every script and the muxer
test passes, but a running Chrome held the Playwright profile lock, so the
manual pass listed at the end of TODO-FIXES.md is still outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:58:51 +02:00
kawaandClaude Opus 5 337f38d58e feat: add systemd, nginx and Caddy deployment configs
Serve src/ directly from the web server with no Node process involved. The
Caddyfile is the shortest path to TLS, which every non-localhost deployment
needs: on a plain-HTTP LAN address Chromium drops WebCodecs and blocks
IndexedDB, so export slows to real-time capture and projects stop saving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:58:07 +02:00
kawaandClaude Opus 5 7505ecdbe5 feat: add Electron desktop shell
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, rather than calling
loadFile(). Chromium exposes WebCodecs and IndexedDB only in a secure context:
over file:// the exporter would fall back to real-time MediaRecorder capture
and projects would stop saving, both silently.

The renderer runs sandboxed with no node integration, denies every permission
request, and hands http(s) navigations to the real browser.

PACKAGING.md documents all three distribution targets, including the NSIS
"Access denied" failure caused by an endpoint protection agent locking the
freshly written unsigned exe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:58:07 +02:00
kawaandClaude Opus 5 d501778ae5 build: add container image and ignore rules
Dockerfile builds a static-web-server image serving src/ with the third-party
assets vendored in a first stage, so the runtime layer carries no Node and no
shell tooling. WITH_FFMPEG=0 drops the 18.5 MB asm.js encoder for a smaller
image, at the cost of fetching it at export time.

.gitignore covers dist/, which the release scripts write on every run: without
it a fresh clone reports the build output as untracked and publish.ps1 warns
that the worktree is dirty on every build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:56:55 +02:00
kawaandClaude Opus 5 f2b9f28d14 build: add Gitea release scripts and npm entry points
scripts/build-release.ps1 packages the desktop installers; scripts/publish.ps1
pushes the container image to git.azuze.fr and attaches the installers to the
Gitea release for the same tag. The split keeps a local build free of any
credential, and lets a failed upload be retried with -BinariesOnly
-NoBinaryBuild without paying for the build again.

Two details the plain `npm run dist:*` path gets wrong for a release:

- package.json's global artifactName resolves NSIS and portable to the same
  file name, so one silently overwrites the other. The build script passes
  distinct -c.nsis.artifactName / -c.portable.artifactName.
- Artifact names now carry the tag, so publish.ps1 can glob exactly one tag's
  files and never ship a stale one. electron-builder's update metadata
  (.blockmap, latest*.yml, the NSIS .7z payload) is swept out of dist/ so it
  cannot match that glob and end up attached to the release.

The tag defaults to v<package.json version> rather than git describe, because
electron-builder stamps package.json into the app: a SHA-based tag would name
an installer whose About box disagrees with it.

Also brings scripts/server.cjs, vendor.mjs and make-icon.cjs under version
control, which the release scripts and the Docker build both call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 12:56:11 +02:00
35 changed files with 10555 additions and 2137 deletions
+13
View File
@@ -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
+72
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+160 -1
View File
@@ -1,3 +1,162 @@
<div align="center">
<img src="src/assets/logo.svg" alt="Motionity" width="72">
# 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/)
[![Demo](https://img.shields.io/badge/demo-motionity.kawa.zip-6c5ce7?style=flat-square)](https://motionity.kawa.zip/)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
[![Downloads](https://img.shields.io/badge/downloads-releases-2ea44f?style=flat-square)](https://git.azuze.fr/kawa/Motionity/releases)
---
Created by **[Alyssa X](https://github.com/alyssaxuu)** · maintained by **Kawa**
Fork of the original [Motionity](https://github.com/alyssaxuu/motionity), with bug fixes,
vendored (offline-capable) assets, desktop builds and a hardened container image.
</div>
---
## How to run
### Docker (recommended for self-hosting)
```bash
docker compose up -d # http://localhost:8080
```
Or without compose:
```bash
docker run --rm -p 8080:8080 git.azuze.fr/kawa/motionity:latest
```
Build the image yourself:
```bash
docker build -t motionity:latest . # ~86 MB on disk
docker build --build-arg WITH_FFMPEG=0 . # -23 MB, no MP4/GIF export
```
Runtime is [static-web-server](https://github.com/static-web-server/static-web-server) on Alpine — no Node, no shell, runs as UID 65534, `read_only` filesystem.
### Windows app
Grab the installer or the portable build from **[Releases](https://git.azuze.fr/kawa/Motionity/releases)**:
| File | What it is |
| --- | --- |
| `Motionity Setup <ver>.exe` | NSIS installer, per-user, choosable install dir |
| `Motionity-<ver>-x64.exe` | Portable, no install |
Build from source (on Windows):
```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
View File
@@ -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.
+27
View File
@@ -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
}
}
+43
View File
@@ -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
+47
View File
@@ -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;
}
}
+9
View File
@@ -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
+144
View File
@@ -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();
});
}
+3681
View File
File diff suppressed because it is too large Load Diff
+116
View File
@@ -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"
]
}
}
}
+754
View File
@@ -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
}
+111
View File
@@ -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)`);
+844
View File
@@ -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
}
+155
View File
@@ -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.`
);
}
});
}
+177
View File
@@ -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
View File
@@ -1,15 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<link rel="preconnect" href="https://fonts.gstatic.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<meta charset="utf-8">
<!-- 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 ref="stylesheet" href="range-slider.min.css">
<link rel="stylesheet" href="magic-check.min.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">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Primary Meta Tags -->
<title>Motionity - The web-based motion graphics editor for everyone</title>
<meta name="title" content="Motionity - The web-based motion graphics editor for everyone">
@@ -17,17 +19,17 @@
<!-- Open Graph / Facebook -->
<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: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 -->
<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: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>
<body draggable="false">
<div id="disclaimer">
@@ -35,7 +37,6 @@
<div id="emoji">🤔</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>
<a href="https://twitter.com/alyssaxuu" target="_blank" id="opt-button">Other products by the maker</a>
</div>
<div id="disc-overlay"></div>
</div>
@@ -79,6 +80,18 @@
<input class="magic-radio" type="radio" name="radio" value="image" id="image-format">
<label for="image-format">Image</label>
</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>
<div id="import-export-modal">
@@ -89,6 +102,22 @@
<p class="header-2">Export this project</p>
<div id="export-project"><img src="assets/download-icon.svg"> <span>Export</span></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="color-picker"></div>
<div id="color-picker-fill"></div>
@@ -168,7 +197,7 @@
<div id="filters">
<div id="filters-container">
<div id="filters-header">
<div id="filters-title">Filters</div>
<div class="filters-title">Filters</div>
<img src="assets/close.svg" id="filters-close">
</div>
<select id="filters-list">
@@ -183,7 +212,7 @@
<option value="Polaroid">Polaroid</option>
</select>
<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 class="filter-row">
<th class="name-col">Brightness</th>
@@ -216,7 +245,7 @@
</th>
</div>
<hr>
<div id="filters-title">Chroma key</div>
<div class="filters-title">Chroma key</div>
<div class="filter-row">
<th class="name-col">Status</th>
<th class="value-col">
@@ -242,7 +271,7 @@
</th>
</div>
<hr>
<div id="filters-title">Stylize</div>
<div class="filters-title">Stylize</div>
<div class="filter-row">
<th class="name-col">Noise</th>
<th class="value-col">
@@ -276,20 +305,24 @@
</div>
</div>
<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>
<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-video.svg" id="load-video" class="load-media">
<canvas id="canvas"></canvas>
</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="bottom-area" class="noselect">
<div id="keyframe-properties">
<div id="easing">
<p class="property-title">Keyframe easing</p>
<select id="easing">
<select id="easing-select">
<option value="linear">Linear</option>
<option value="easeInQuad">Ease in</option>
<option value="easeOutQuad">Ease out</option>
@@ -361,21 +394,25 @@
<div id="share"><img src="assets/importexport.svg"> Import & export</div>
<div id="download"><img src="assets/download-icon.svg"> Download</div>
</div>
<div id="credits-button">Credits</div>
</div>
<video id="test-video"></video>
<input id="emptyInput" value=" " style="opacity:0">
<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="https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="vendor/lottie.min.js"></script>
<script src="vendor/selection.min.js"></script>
<script src="vendor/jquery.min.js"></script>
<script src="js/libraries/sortable.min.js"></script>
<script src="js/libraries/range-slider.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="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script>
<script src="vendor/pickr.min.js"></script>
<script src="vendor/fabric.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/ui.js"></script>
<script src="js/align.js"></script>
@@ -383,6 +420,8 @@
<script src="js/database.js"></script>
<script src="js/lottie.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/functions.js"></script>
<script src="js/events.js"></script>
+26 -3
View File
@@ -78,6 +78,20 @@ function initLines() {
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) {
if (type == 'align-top') {
object.set(
@@ -122,8 +136,10 @@ function alignControls(object, type) {
function alignObject() {
const type = $(this).attr('id');
const object = canvas.getActiveObject();
console.log(canvas.getActiveObject().type);
if (canvas.getActiveObject().type == 'activeSelection') {
if (!object) {
return;
}
if (object.type == 'activeSelection') {
const tempselection = canvas.getActiveObject();
canvas.discardActiveObject();
tempselection._objects.forEach(function (object) {
@@ -157,6 +173,8 @@ function alignObject() {
);
newKeyframe('top', object, currenttime, object.get('top'), true);
}
canvas.renderAll();
save();
}
$(document).on('click', '.align', alignObject);
@@ -240,7 +258,12 @@ function centerLines(e) {
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 (
obj.get('id') == 'center_h' ||
obj.get('id') == 'center_v'
+157 -99
View File
@@ -1,92 +1,163 @@
var workerPath =
'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js';
// MP4/GIF export transcodes the captured WebM with ffmpeg.wasm.
//
// 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 blob = URL.createObjectURL(
new Blob(
[
'importScripts("' +
workerPath +
'");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"});',
],
{
type: 'application/javascript',
}
)
);
var FFMPEG_DIR = 'vendor/ffmpeg/';
var FFMPEG_CORE = FFMPEG_DIR + 'ffmpeg-core.js';
var FFMPEG_WASM = FFMPEG_DIR + 'ffmpeg-core.wasm';
var FFMPEG_WORKER = FFMPEG_DIR + 'ffmpeg-core.worker.js';
var worker = new Worker(blob);
URL.revokeObjectURL(blob);
return worker;
// One instance per conversion, deliberately not cached. The single-threaded
// 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
// between, so this is cheaper than it looks.
var ffmpegBusy = false;
function ffmpegAvailable() {
return typeof FFmpeg !== 'undefined' && typeof FFmpeg.createFFmpeg === 'function';
}
var worker;
// 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;
}
function convertStreams(videoBlob, setting) {
var aab;
var buffersReady;
var workerReady;
var posted;
var fileReader = new FileReader();
fileReader.onload = function () {
aab = this.result;
postMessage();
};
fileReader.readAsArrayBuffer(videoBlob);
if (!worker) {
worker = processInWebWorker();
async function loadFfmpeg() {
if (!ffmpegAvailable()) {
throw new Error(
'the ffmpeg.wasm loader is missing — run "npm run vendor" to populate src/vendor/'
);
}
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);
} else if (setting == 'mp4') {
var blob = new File([result.data], 'test.mp4', {
type: 'video/mp4',
});
PostBlob(blob);
// 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
// rather than a failure.
var head = await fetch(FFMPEG_WASM, { method: 'HEAD' }).catch(function () {
return null;
});
if (!head || !head.ok) {
throw new Error(
'this build ships without the ffmpeg core (WITH_FFMPEG=0), so MP4 and GIF ' +
'export are unavailable. WEBM export always works.'
);
}
var instance = FFmpeg.createFFmpeg({
corePath: absolute(FFMPEG_CORE),
wasmPath: absolute(FFMPEG_WASM),
workerPath: absolute(FFMPEG_WORKER),
// The loader defaults to the entry point of the multi-threaded core; the
// single-threaded one exports plain `main`. Without this, load() gets as
// far as compiling the 23 MB wasm and then aborts with
// "Cannot call unknown function proxy_main".
mainName: 'main',
log: false,
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) {
$('#download-real').html('Converting ' + Math.round(entry.ratio * 100) + '%');
}
},
});
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) */
}
}
};
var postMessage = function () {
posted = true;
if (setting == 'gif') {
worker.postMessage({
type: 'command',
arguments: '-i video.webm -r 24 output-10.gif'.split(' '),
files: [
{
data: new Uint8Array(aab),
name: 'video.webm',
},
],
});
} else if (setting == 'mp4') {
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',
},
],
});
}
};
ffmpegBusy = false;
}
}
function PostBlob(blob) {
@@ -97,22 +168,9 @@ function PostBlob(blob) {
a.download = blob.name || 'video';
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();
document.body.removeChild(a);
window.setTimeout(function () {
URL.revokeObjectURL(url);
}, 60000);
resetRecordingUI();
}
+126 -35
View File
@@ -50,8 +50,11 @@ function checkDB() {
'strokeUniform',
'rx',
'ry',
'cornerRadius',
'selectable',
'hasControls',
'hasBorders',
'evented',
'subTargetCheck',
'id',
'hoverCursor',
@@ -88,15 +91,24 @@ function checkDB() {
} else {
loadProject();
}
})
.catch(function (e) {
console.error('Could not open the local project database', e);
});
}
// Automatically save project (locally)
function autoSave() {
async function autoSave() {
if (checkstatus) {
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);
if (!obj) {
object.filters = [];
continue;
}
if (obj.filters) {
if (obj.filters.length > 0) {
object.filters = [];
@@ -174,7 +186,7 @@ function autoSave() {
} else {
object.filters = [];
}
});
}
const inst = canvas.toDatalessJSON([
'volume',
'audioSrc',
@@ -217,8 +229,11 @@ function autoSave() {
'strokeUniform',
'rx',
'ry',
'cornerRadius',
'selectable',
'hasControls',
'hasBorders',
'evented',
'subTargetCheck',
'id',
'hoverCursor',
@@ -250,6 +265,9 @@ function autoSave() {
activepreset: activepreset,
width: artboard.width,
height: artboard.height,
})
.catch(function (e) {
console.error('Autosave failed', e);
});
objects.forEach(function (object) {
replaceSource(canvas.getItemById(object.id), canvas);
@@ -289,17 +307,24 @@ function loadProject() {
currenttime = 0;
canvas.clipPath = null;
canvas.clear();
fabric.filterBackend = webglBackend;
if (webglBackend) {
fabric.filterBackend = webglBackend;
}
f = fabric.Image.filters;
canvas.loadFromJSON(JSON.parse(project.canvas), function () {
canvas.clipPath = artboard;
canvas.getItemById('line_h').set({ opacity: 0 });
canvas.getItemById('line_v').set({ opacity: 0 });
hideGuides(canvas);
canvas.renderAll();
$('.object-props').remove();
$('.layer').remove();
objects.forEach(function (object) {
var animatethis = false;
if (!object.animate) {
object.animate = [];
}
if (!canvas.getItemById(object.id)) {
return;
}
if (object.animate.length > 5) {
if (isSameSet(object.animate, props)) {
animatethis = true;
@@ -327,10 +352,20 @@ function loadProject() {
});
replaceSource(canvas.getItemById(object.id), canvas);
} 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));
}
});
keyframes.forEach(function (keyframe) {
if (!canvas.getItemById(keyframe.id)) {
return;
}
if (
keyframe.name != 'top' &&
keyframe.name != 'scaleY' &&
@@ -510,12 +545,18 @@ function deleteAsset(key) {
.doc({ key: key })
.get()
.then((asset) => {
if (!asset) {
return;
}
var temp = files.filter((x) => x.file == asset.src);
if (temp.length > 0) {
temp.forEach(function (file) {
deleteObject(canvas.getItemById(file.name));
const object = canvas.getItemById(file.name);
if (object) {
deleteObject(object);
}
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')
.get()
.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) {
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) {
// Rebuild rather than append: getAssets() runs again after an import
uploaded_images = [];
uploaded_videos = [];
assets.forEach(function (asset) {
if (asset.type == 'image') {
uploaded_images.push({
@@ -562,6 +615,9 @@ function getAssets() {
}
});
}
})
.catch(function (e) {
console.error('Could not read assets', e);
});
}
@@ -570,10 +626,20 @@ function readTextFile(file, callback) {
rawFile.overrideMimeType('application/json');
rawFile.open('GET', file, true);
rawFile.onreadystatechange = function () {
if (rawFile.readyState === 4 && rawFile.status == '200') {
callback(rawFile.responseText);
if (rawFile.readyState === 4) {
// A blob: URL resolves with status 0
if (rawFile.status == 200 || rawFile.status === 0) {
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);
}
@@ -582,10 +648,20 @@ async function importProject(e) {
var file = e.target.files[0];
var path = (window.URL || window.webkitURL).createObjectURL(file);
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;
if (data.project.length > 0) {
if (data.assets.length > 0) {
{
if (Array.isArray(data.assets) && data.assets.length > 0) {
data.assets.forEach(function (asset) {
delete asset.id;
db.collection('assets').add(asset);
@@ -598,10 +674,14 @@ async function importProject(e) {
$('#import-project span').html('Import');
hideModals();
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()
.then((assets) => {
var exportarr = { project: project, assets: assets };
$('<a />', {
download: 'data.json',
href:
'data:application/json,' +
encodeURIComponent(JSON.stringify(exportarr)),
})
.appendTo('body')
.click(function () {
$(this).remove();
$('#export-project span').html('Export');
})[0]
.click();
// Blob, not a data: URL - projects with media blow past the
// maximum URL length.
const url = URL.createObjectURL(
new Blob([JSON.stringify(exportarr)], {
type: 'application/json',
})
);
const a = document.createElement('a');
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');
});
} else {
alert('Empty project');
@@ -649,11 +735,16 @@ function clearProject() {
'Are you sure you want to clear this project? This action cannot be undone.'
)
) {
db.collection('projects').delete();
db.collection('assets').delete();
window.setTimeout(function () {
location.reload();
}, 1000);
Promise.all([
db.collection('projects').delete(),
db.collection('assets').delete(),
])
.catch(function (e) {
console.error('Could not clear the project', e);
})
.then(function () {
location.reload();
});
}
hideMore();
}
+150 -125
View File
@@ -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 () {
// An object is being moved in the canvas
canvas.on('object:moving', function (e) {
e.target.hasControls = false;
centerLines(e);
if (cropping) {
if (
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');
}
updateCropBounds();
crop(canvas.getItemById('cropped'));
} else if (
lockmovement &&
@@ -38,15 +100,10 @@ $(document).ready(function () {
canvas.on('object:scaling', function (e) {
e.target.hasControls = false;
centerLines(e);
// Keep the corner radius at its pixel value while the handle is dragged
syncCornerRadius(e.target);
if (cropping) {
if (
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');
}
updateCropBounds();
crop(canvas.getItemById('cropped'));
}
});
@@ -56,24 +113,15 @@ $(document).ready(function () {
e.target.hasControls = false;
centerLines(e);
if (cropping) {
if (
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');
}
updateCropBounds();
crop(canvas.getItemById('cropped'));
}
});
// An object is being rotated in the canvas
canvas.on('object:rotating', function (e) {
if (e.e.shiftKey) {
canvas.getActiveObject().snapAngle = 15;
} else {
canvas.getActiveObject().snapAngle = 0;
if (canvas.getActiveObject()) {
canvas.getActiveObject().snapAngle = e.e.shiftKey ? 15 : 0;
}
e.target.hasControls = false;
});
@@ -82,17 +130,23 @@ $(document).ready(function () {
canvas.on('object:modified', function (e) {
e.target.hasControls = true;
if (!editinggroup && !cropping) {
canvas.getActiveObject().lockMovementX = false;
canvas.getActiveObject().lockMovementY = false;
if (canvas.getActiveObject()) {
canvas.getActiveObject().lockMovementX = false;
canvas.getActiveObject().lockMovementY = false;
}
canvas.renderAll();
if (e.target.type == 'activeSelection') {
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();
e.target._objects.forEach(function (object) {
syncCornerRadius(object);
autoKeyframe(object, e, true);
});
reselect(tempselection);
} else {
syncCornerRadius(e.target);
autoKeyframe(e.target, e, false);
}
updatePanelValues();
@@ -193,8 +247,12 @@ $(document).ready(function () {
this.setViewportTransform(this.viewportTransform);
this.isDragging = false;
this.selection = true;
line_h.opacity = 0;
line_v.opacity = 0;
if (line_h) {
line_h.opacity = 0;
}
if (line_v) {
line_v.opacity = 0;
}
});
// Detect mouse over canvas (for dragging objects from the library)
@@ -214,7 +272,9 @@ $(document).ready(function () {
canvas.on('mouse:out', function (e) {
overCanvas = false;
if (wip) {
e.target.hasControls = true;
if (e.target) {
e.target.hasControls = true;
}
canvas.discardActiveObject();
wip = false;
canvas.renderAll();
@@ -320,13 +380,16 @@ $(document).ready(function () {
}
}, 1000);
}
// Redo
if (e.which === 90 && (e.ctrlKey || e.metaKey) && e.shiftKey) {
undoRedo(redo, undo, redoarr, undoarr);
}
// Undo
// Redo / undo (shift decides which; never both in one keypress)
if (e.which === 90 && (e.ctrlKey || e.metaKey)) {
undoRedo(undo, redo, undoarr, redoarr);
e.preventDefault();
if (e.shiftKey) {
if (redo.length >= 1) {
undoRedo(redo, undo, redoarr, undoarr);
}
} else if (undo.length >= 1) {
undoRedo(undo, redo, undoarr, redoarr);
}
}
// Duplicate object
if (e.which === 68 && (e.ctrlKey || e.metaKey)) {
@@ -353,51 +416,30 @@ $(document).ready(function () {
if (e.keyCode === 13 && editingproject) {
saveProjectName();
}
// Left arrow key (move object to the left)
if (e.keyCode === 37 && canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
var step = 2;
// Arrow keys nudge the selection
if (
e.keyCode >= 37 &&
e.keyCode <= 40 &&
canvas.getActiveObject() &&
!canvas.getActiveObject().isEditing &&
!focus &&
!editinglayer &&
!editingproject
) {
const obj = canvas.getActiveObject();
// Bigger step if shift is down
if (e.shiftKey) {
step = 7;
const step = e.shiftKey ? 7 : 2;
if (e.keyCode === 37) {
obj.left = obj.left - step;
} else if (e.keyCode === 38) {
obj.top = obj.top - step;
} else if (e.keyCode === 39) {
obj.left = obj.left + step;
} else {
obj.top = obj.top + step;
}
obj.left = obj.left - step;
canvas.renderAll();
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;
canvas.renderAll();
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;
canvas.renderAll();
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;
// Without this the selection box stays where the object used to be
obj.setCoords();
canvas.renderAll();
autoKeyframe(obj, { action: 'drag' }, false);
}
@@ -406,7 +448,7 @@ $(document).ready(function () {
if (
e.keyCode === 221 &&
canvas.getActiveObjects() &&
e.metaKey
(e.metaKey || e.ctrlKey)
) {
if (canvas.getActiveObjects().length == 1) {
var obj = canvas.getActiveObject();
@@ -434,7 +476,7 @@ $(document).ready(function () {
if (
e.keyCode === 219 &&
canvas.getActiveObjects() &&
e.metaKey
(e.metaKey || e.ctrlKey)
) {
if (canvas.getActiveObjects().length == 1) {
var obj = canvas.getActiveObject();
@@ -545,25 +587,24 @@ $(document).ready(function () {
// Copy event
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
if (
canvas.getActiveObject() &&
shiftkeys.length == 0 &&
!canvas.getActiveObject().isEditing
) {
if (activeObject && shiftkeys.length == 0) {
var emptyInp = document.getElementById('emptyInput');
emptyInp.select();
emptyInp.focus();
setTimeout(function () {
document.execCommand('copy');
}, 0);
clipboard = canvas.getActiveObject();
clipboard = activeObject;
cliptype = 'object';
// Copy selected keyframe(s)
} else if (
shiftkeys.length > 0 &&
!canvas.getActiveObject().isEditing
) {
} else if (shiftkeys.length > 0) {
var emptyInp = document.getElementById('emptyInput');
emptyInp.select();
emptyInp.focus();
@@ -580,7 +621,9 @@ $(document).ready(function () {
e.name == drag.attr('data-property')
);
});
clipboard.push(keyarr[0]);
if (keyarr.length > 0) {
clipboard.push(keyarr[0]);
}
});
cliptype = 'keyframe';
}
@@ -598,7 +641,9 @@ $(document).ready(function () {
} else {
for (var i = 0; i < imgs.length; i++) {
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) {
createThumbnail(imgObj, 250).then(function (data) {
saveFile(
@@ -803,27 +848,7 @@ $(document).ready(function () {
syncScrollHoz($('#timeline'), $('#seekarea'));
// Initialize layer sorting
sortable('#layer-inner-list', {
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();
}
});
initLayerSortable();
// Initialize dropdown for keyframe easing
$('#easing select').niceSelect();
@@ -936,8 +961,8 @@ $(document).ready(function () {
onmove: function (x) {
if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
obj.filters.find((x) => x.type == 'RemoveColor').distance =
if (obj.filters.find((i) => i.type == 'RemoveColor')) {
obj.filters.find((i) => i.type == 'RemoveColor').distance =
x / 100;
}
obj.applyFilters();
@@ -964,8 +989,8 @@ $(document).ready(function () {
onmove: function (x) {
if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'Noise')) {
obj.filters.find((x) => x.type == 'Noise').noise = x;
if (obj.filters.find((i) => i.type == 'Noise')) {
obj.filters.find((i) => i.type == 'Noise').noise = x;
} else {
obj.filters.push(
new f.Noise({
@@ -997,8 +1022,8 @@ $(document).ready(function () {
onmove: function (x) {
if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'Blur')) {
obj.filters.find((x) => x.type == 'Blur').blur = x / 100;
if (obj.filters.find((i) => i.type == 'Blur')) {
obj.filters.find((i) => i.type == 'Blur').blur = x / 100;
} else {
obj.filters.push(
new f.Blur({
@@ -1006,9 +1031,9 @@ $(document).ready(function () {
})
);
}
obj.applyFilters();
canvas.renderAll();
}
obj.applyFilters();
canvas.renderAll();
},
onfinish: function (x) {
save();
+1119 -1268
View File
File diff suppressed because it is too large Load Diff
+70 -40
View File
@@ -3,7 +3,6 @@ var GOOGLE_FONTS_API_KEY = 'GOOGLE_FONTS_API_KEY';
// for legacy browsers
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
var oldsrc, oldobj;
var oldtimelinepos;
var speed = 1;
@@ -58,7 +57,6 @@ var editingpanel = false;
var files = [];
var re = /(?:\.([^.]+))?$/;
var filelist = [];
var timeout;
var spacehold = false;
var spacerelease = false;
var tempselection;
@@ -94,7 +92,7 @@ var chromaslider, noiseslider, blurslider;
var isChrome =
window.chrome && Object.values(window.chrome).length !== 0;
var eyeDropper;
if (isChrome) {
if (isChrome && typeof EyeDropper !== 'undefined') {
eyeDropper = new EyeDropper();
}
var presets = [
@@ -156,20 +154,30 @@ var sliders = [];
var hovertime = 0;
var animatedtext = [];
// Get list of fonts
$.ajax({
url:
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
GOOGLE_FONTS_API_KEY +
'&sort=alpha',
type: 'GET',
dataType: 'json', // added data type
success: function (response) {
response.items.forEach(function (item) {
fonts.push(item.family);
});
},
});
// 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({
url:
'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
GOOGLE_FONTS_API_KEY +
'&sort=alpha',
type: 'GET',
dataType: 'json', // added data type
success: function (response) {
response.items.forEach(function (item) {
fonts.push(item.family);
});
},
error: function () {
console.warn('Could not load the Google Fonts list');
},
});
}
// Panel variants
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({
google: {
families: ['Syne'],
@@ -460,7 +487,11 @@ try {
var canvas2dBackend = new fabric.Canvas2dFilterBackend();
fabric.filterBackend = fabric.initFilterBackend();
fabric.filterBackend = webglBackend;
// 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;
}
// Lottie support
fabric.Lottie = fabric.util.createClass(fabric.Image, {
@@ -491,14 +522,18 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
});
this.lottieItem.addEventListener('enterFrame', (e) => {
this.canvas.requestRenderAll();
if (this.canvas) {
this.canvas.requestRenderAll();
}
});
this.lottieItem.addEventListener('DOMLoaded', () => {
this.lottieItem.goToAndStop(currenttime, false);
this.lottieItem.duration =
this.lottieItem.getDuration(false) * 1000;
this.canvas.requestRenderAll();
if (this.canvas) {
this.canvas.requestRenderAll();
}
canvas.renderAll();
canvas.fire('lottie:loaded', { any: 'payload' });
});
@@ -508,7 +543,9 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
goToSeconds: function (seconds) {
this.lottieItem.goToAndStop(seconds, false);
this.canvas.requestRenderAll();
if (this.canvas) {
this.canvas.requestRenderAll();
}
},
goToFrame: function (frame) {
this.lottieItem.goToAndStop(frame, true);
@@ -758,30 +795,23 @@ textBoxControls.mr = new fabric.Control({
// Get any object by ID
fabric.Canvas.prototype.getItemById = function (name) {
var object = null,
objects = this.getObjects();
for (var i = 0, len = this.size(); i < len; i++) {
if (objects[i].get('type') == 'group') {
if (objects[i].get('id') && objects[i].get('id') === name) {
object = objects[i];
break;
function search(list) {
for (var i = 0; i < list.length; i++) {
const item = list[i];
if (item.id && item.id === name) {
return item;
}
var wip = i;
for (var o = 0; o < objects[i]._objects.length; o++) {
if (
objects[wip]._objects[o].id &&
objects[wip]._objects[o].id === name
) {
object = objects[wip]._objects[o];
break;
// Recurse so groups nested more than one level deep are found too
if (item._objects && item._objects.length > 0) {
const found = search(item._objects);
if (found) {
return found;
}
}
} else if (objects[i].id && objects[i].id === name) {
object = objects[i];
break;
}
return null;
}
return object;
return search(this.getObjects());
};
// Create the artboard
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@ async function newLottieAnimation(x, y, json) {
strokeWidth: 0,
cursorDuration: 1,
cursorDelay: 250,
duration: duration * 1000,
duration: duration,
assetType: 'sprite',
id: 'Sprite' + layer_count,
objectCaching: false,
+211 -329
View File
@@ -1,68 +1,108 @@
const FPS = 30;
let frame = 0;
var chunks = [];
var stream;
var rec;
var track;
// Everything the current export needs to tear down when it finishes
var exportAudio = null;
function timeout(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Route every sound source of the project into a single destination node.
//
// 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 initRecorder() {
stream = document.getElementById('canvasrecord').captureStream(0);
track = stream.getVideoTracks()[0];
if (!track.requestFrame) {
track.requestFrame = () => stream.requestFrame();
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;
}
}
rec = new MediaRecorder(stream, {
bitsPerSecond: 3200000,
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') == '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.ondataavailable = function (evt) {
console.log('chunky');
chunks.push(evt.data);
};
if (background_audio != false && connect(background_audio)) {
elements.push({
element: background_audio,
start: 0,
end: duration,
trimstart: 0,
});
}
rec.start();
console.log('Recorder has been started');
rec.onstart = function () {
rec.pause();
console.log('start!');
};
if (connected) {
stream.addTrack(destination.stream.getAudioTracks()[0]);
}
return { context: ctx, elements: elements, timers: [] };
}
async function recordFrame() {
console.log(frame);
waitForEvent(rec, 'pause');
//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();
//}
// Start the scheduled audio layers relative to the start of the recording
function startExportAudio(audio) {
if (!audio) {
return;
}
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))
);
});
}
async function exportRecording() {
rec.stop();
stream.getTracks().forEach((track) => track.stop());
await waitForEvent(rec, 'stop');
return new Blob(chunks);
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
@@ -85,280 +125,122 @@ async function record() {
document.body.removeChild(link);
recording = false;
updateRecordCanvas();
} else {
if (!recording) {
recording = true;
paused = true;
await recordAnimate(0);
$('#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();
}, duration);
async function renderAnim(time) {
await recordAnimate(time * 1000);
}
}
return;
}
if (recording) {
return;
}
recording = true;
paused = true;
await recordAnimate(0);
$('#download-real').html('Rendering...');
$('#download-real').addClass('downloading');
// 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) {
const freq = frequency / 1000;
const silence = aCtx.createGain();
silence.gain.value = 0;
silence.connect(aCtx.destination);
var stopped = false;
var osc;
function onOSCend() {
if (stopped) {
return;
}
osc = aCtx.createOscillator();
osc.onended = onOSCend;
osc.connect(silence);
osc.start(0);
osc.stop(aCtx.currentTime + freq);
callback(aCtx.currentTime);
}
onOSCend();
return function () {
stopped = true;
if (osc) {
osc.onended = null;
}
};
}
const stream = document
.getElementById('canvasrecord')
.captureStream(fps);
exportAudio = buildExportAudio(stream);
const chunks = [];
const recorder = new MediaRecorder(stream, {
bitsPerSecond: 3200000,
});
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onerror = (e) => {
console.error('Recording failed', e);
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);
console.log('Finished rendering');
};
// 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) {
if (origin === null) {
origin = time;
}
const elapsed = (time - origin) * 1000;
if (elapsed >= duration) {
if (!stopping) {
stopping = true;
await recordAnimate(duration);
if (recorder.state != 'inactive') {
recorder.stop();
}
}
return;
}
await recordAnimate(elapsed);
}
recorder.start();
startExportAudio(exportAudio);
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
// Safety net: never leave the UI stuck if the oscillator clock stalls
window.setTimeout(function () {
if (recorder.state != 'inactive') {
recorder.stop();
}
}, duration + 5000);
}
/*
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();
}, 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;
}
// 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();
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 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();
*/
+515
View File
@@ -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
View File
@@ -1,10 +1,16 @@
function animateText(group, ms, play, props, cv, id) {
var starttime = p_keyframes.find((x) => x.id == id).start;
ms -= starttime;
const p_keyframe = p_keyframes.find((x) => x.id == id);
if (!group || !p_keyframe) {
return;
}
ms -= p_keyframe.start;
var length = group._objects.length;
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++) {
var index = i;
let index = i;
if (props.order == 'backward') {
index = length - i - 1;
}
@@ -12,9 +18,10 @@ function animateText(group, ms, play, props, cv, id) {
let top = group.item(index).defaultTop;
let scaleX = group.item(index).defaultScaleX;
let scaleY = group.item(index).defaultScaleY;
var delay = i * duration;
var duration = props.duration / length;
var animation = {
// Named `step` so it does not shadow the global project duration
let step = props.duration / length;
let delay = i * step;
let animation = {
opacity: 0,
top: top,
left: left,
@@ -22,7 +29,7 @@ function animateText(group, ms, play, props, cv, id) {
scaleY: scaleY,
};
if (props.typeAnim == 'letter') {
delay = i * duration - 100;
delay = i * step - 100;
} else if (props.typeAnim == 'word') {
if (group.item(index).text == ' ') {
globaldelay += 500;
@@ -30,8 +37,8 @@ function animateText(group, ms, play, props, cv, id) {
delay = globaldelay;
}
if (props.preset == 'typewriter') {
delay = i * duration;
duration = 20;
delay = i * step;
step = 20;
} else if (props.preset == 'fade in') {
} else if (props.preset == 'slide top') {
animation.top += 20;
@@ -48,14 +55,14 @@ function animateText(group, ms, play, props, cv, id) {
animation.scaleX = 1.5;
animation.scaleY = 1.5;
}
if (delay < 0) {
if (!(delay > 0)) {
delay = 0;
}
if (duration < 20) {
duration = 20;
if (!(step > 20)) {
step = 20;
}
var start = false;
var instance = anime({
let start = false;
let instance = anime({
targets: animation,
delay: delay,
opacity: 1,
@@ -63,7 +70,7 @@ function animateText(group, ms, play, props, cv, id) {
top: top,
scaleX: scaleX,
scaleY: scaleY,
duration: duration,
duration: step,
easing: props.easing,
autoplay: play,
update: function () {
@@ -228,8 +235,7 @@ class AnimatedText {
var obj = cv.getItemById(this.id);
var left = obj.left;
var top = obj.top;
var scaleX = obj,
scaleX;
var scaleX = obj.scaleX;
var scaleY = obj.scaleY;
var angle = obj.angle;
var start = p_keyframes.find((x) => x.id == this.id).start;
@@ -260,10 +266,16 @@ class AnimatedText {
cv,
this.id
);
animate(currenttime, false);
animate(false, currenttime);
save();
}
assignTo(id, text, props) {
this.id = id;
if (text !== undefined) {
this.text = text;
}
if (props !== undefined) {
this.props = props;
}
}
}
+197 -97
View File
@@ -1,5 +1,8 @@
// Update panel (when selecting / de-selecting objects)
function updatePanel(selection) {
if (selection && !canvas.getActiveObject()) {
selection = false;
}
if (!selection) {
$('#align').addClass('align-off');
$('#object-specific').html(canvas_panel);
@@ -330,17 +333,19 @@ function convertToHex(nonHexColorString) {
function updateStrokeValues() {
const object = canvas.getActiveObject();
if (!object) {
return;
}
$('.line-join-active').removeClass('line-join-active');
if (
object.get('strokeDashArray') == false &&
object.get('strokeWidth') == 0
) {
const dash = object.get('strokeDashArray');
const hasDash = Array.isArray(dash) && dash.length > 0;
if (!hasDash && object.get('strokeWidth') == 0) {
$('#miter').addClass('line-join-active');
$('#miter img').attr('src', 'assets/miter-active.svg');
} else if (object.get('strokeDashArray') == false) {
} else if (!hasDash) {
$('#bevel').addClass('line-join-active');
$('#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 img').attr('src', 'assets/round-active.svg');
} else {
@@ -350,39 +355,39 @@ function updateStrokeValues() {
}
function toggleAnimationOrder() {
var object = canvas.getActiveObject();
const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active').removeClass(
'order-toggle-item-active'
);
if ($(this).attr('id') == 'order-backward') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ order: 'backward' }, canvas);
text.setProp({ order: 'backward' });
} else if ($(this).attr('id') == 'order-forward') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ order: 'forward' }, canvas);
text.setProp({ order: 'forward' });
}
$(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();
}
function toggleAnimationType() {
var object = canvas.getActiveObject();
const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active-2').removeClass(
'order-toggle-item-active-2'
);
if ($(this).attr('id') == 'type-words') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'word' }, canvas);
text.setProp({ typeAnim: 'word' });
} else if ($(this).attr('id') == 'type-letters') {
animatedtext
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'letter' }, canvas);
text.setProp({ typeAnim: 'letter' });
}
$(this).addClass('order-toggle-item-active-2');
animate(currenttime, false);
animate(false, currenttime);
save();
}
$(document).on(
@@ -473,7 +478,7 @@ function updatePanelValues() {
var tempstore = false;
var object = canvas.getActiveObject();
if (
canvas.getActiveObjects.length > 1 ||
canvas.getActiveObjects().length > 1 ||
object.get('type') == 'activeSelection'
) {
object = object.toGroup();
@@ -565,7 +570,7 @@ function updatePanelValues() {
o_slider.setValue(object.get('opacity') * 100);
if (object.get('type') == 'rect') {
$('#object-corners input').val(
parseFloat(object.get('rx').toFixed(2))
parseFloat(getCornerRadius(object).toFixed(2))
);
colormode = 'fill';
o_fill.setColor(object.get('fill'));
@@ -590,7 +595,8 @@ function updatePanelValues() {
// Update opacity input
function updateInputs(id) {
if (canvas.getActiveObject().get('assetType') == 'audio') {
const active = canvas.getActiveObject();
if (!active || active.get('assetType') == 'audio') {
return false;
}
if ($('#object-o input').val() > 100) {
@@ -604,7 +610,7 @@ function updateInputs(id) {
id == 'object-color-fill-opacity'
) {
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) {
$('#object-color-fill-opacity input').val(0);
}
@@ -628,7 +634,7 @@ function updateInputs(id) {
id == 'object-color-stroke-opacity'
) {
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) {
$('#object-color-stroke-opacity input').val(0);
}
@@ -652,7 +658,7 @@ function updateInputs(id) {
id == 'object-color-shadow-opacity'
) {
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) {
$('#object-color-shadow-opacity input').val(0);
}
@@ -673,7 +679,6 @@ function updateInputs(id) {
// Update object position based on panel input values
function updateObjectValues(type) {
autoSave();
if (canvas.getActiveObjects().length > 0) {
if ($(this).find('input').val() || type) {
var object = canvas.getActiveObject();
@@ -832,14 +837,11 @@ function updateObjectValues(type) {
}
}
function setTextAnimation() {
var object = canvas.getActiveObject();
animatedtext
.find((x) => x.id == object.id)
.reset(
$(this).parent().find('input').val(),
animatedtext.find((x) => x.id == object.id).props,
canvas
);
const text = activeAnimatedText();
if (!text) {
return;
}
text.reset($(this).parent().find('input').val(), text.props, canvas);
}
$(document).on('input', '.property-input', updateObjectValues);
@@ -850,8 +852,10 @@ $(document).on('click', '#animatedset', setTextAnimation);
// Toggle picker (maybe it could be condensed?)
function togglePicker() {
const object = canvas.getActiveObject();
if (!object && $(this).attr('id') != 'canvas-color') {
return;
}
if (!o_fill.isOpen()) {
newcolorkeyframe = true;
if ($(this).attr('id') == 'object-color-fill') {
colormode = 'fill';
o_fill.setColor(object.get('fill'));
@@ -879,7 +883,6 @@ function togglePicker() {
colormode = 'shadow';
o_fill.setColor(object.shadow.color);
}
newcolorkeyframe = false;
o_fill.show();
} else {
o_fill.hide();
@@ -917,6 +920,7 @@ function populateGrid(type) {
});
} else if (type == 'image-tool') {
$('#images-grid').html('');
$('#categories').html('');
image_categories.forEach(function (category) {
$('#categories').append(
"<div class='category' data-name='" +
@@ -930,6 +934,7 @@ function populateGrid(type) {
});
} else if (type == 'video-tool') {
$('#images-grid').html('');
$('#categories').html('');
video_categories.forEach(function (category) {
$('#categories').append(
"<div class='category' data-name='" +
@@ -959,7 +964,7 @@ function populateGrid(type) {
item.key +
"'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" +
item.thumb +
"'</div>"
"'></div>"
);
}
});
@@ -999,6 +1004,7 @@ function populateGrid(type) {
}
} else if (type == 'audio-tool') {
var flag = false;
$('#audio-list').html('');
audio_items.forEach(function (item) {
if (item.src == background_key) {
flag = true;
@@ -1015,7 +1021,7 @@ function populateGrid(type) {
item.desc +
"</a><div class='audio-info-duration'>" +
item.duration +
'</div></div></div></div>'
'</div></div></div>'
);
} else {
$('#audio-list').append(
@@ -1031,7 +1037,7 @@ function populateGrid(type) {
item.desc +
"</a><div class='audio-info-duration'>" +
item.duration +
'</div></div></div></div>'
'</div></div></div>'
);
}
});
@@ -1057,7 +1063,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1074,7 +1080,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1091,7 +1097,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1108,7 +1114,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1125,7 +1131,7 @@ function populateGrid(type) {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
text.fontname +
"' style='font-family: " +
text.fontname +
@@ -1175,27 +1181,27 @@ function updateBrowser(type) {
if (type == 'image-tool') {
$('#browser-container').html(image_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'shape-tool') {
$('#browser-container').html(shape_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'video-tool') {
$('#browser-container').html(video_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'text-tool') {
$('#browser-container').html(text_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'upload-tool') {
$('#browser-container').html(upload_browser);
populateGrid('images-tab');
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'audio-tool') {
$('#browser-container').html(audio_browser);
populateGrid(type);
$('#browser').on('scroll', scrollBottom);
$('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
}
}
@@ -1213,12 +1219,13 @@ $(document).on(
// Switch tool
function switchTool(e) {
$('#browser').removeClass('collapsed');
$('#canvas-area').removeClass('canvas-full');
if ($(this).attr('id') == 'more-tool') {
showMore();
return false;
}
$('#browser').removeClass('collapsed');
$('#behind-browser').removeClass('collapsed');
applyPanelWidths();
resizeCanvas();
var act = $('.tool-active');
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
function replaceObject(src, object) {
if (!src || !object) {
return;
}
var img = new Image();
var width = object.width;
var height = object.height;
oldsrc = object._originalElement.currentSrc;
const element = object.getElement();
oldsrc = element ? element.currentSrc || element.src : null;
oldobj = object;
img.onload = function () {
object.setElement(img);
@@ -1271,6 +1282,9 @@ function replaceObject(src, object) {
object.set('height', height);
canvas.renderAll();
};
img.onerror = function () {
console.warn('Could not load replacement image');
};
img.src = src;
}
@@ -1279,6 +1293,10 @@ function dragObject(e) {
if (e.which == 3) {
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();
drag.css({
background: 'transparent',
@@ -1291,7 +1309,7 @@ function dragObject(e) {
zIndex: 9999999,
left: $(this).offset().left,
top: $(this).offset().top,
width: canvas.getZoom() * drag.width(),
width: canvas.getZoom() * sourcewidth,
pointerEvents: 'none',
opacity: 0,
});
@@ -1339,7 +1357,9 @@ function dragObject(e) {
(replacing && !e.ctrlKey)
) {
drag.css('visibility', 'visible');
replaceObject(oldsrc, oldobj);
if (oldsrc && oldobj) {
replaceObject(oldsrc, oldobj);
}
replacing = false;
canvas.discardActiveObject();
$('#replace-image').removeClass('replace-active');
@@ -1361,7 +1381,21 @@ function dragObject(e) {
$('#properties').removeClass('noselect');
$('#controls').removeClass('noselect');
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;
canvasy = canvas.getPointer(e).y;
var xpos = canvasx + offsetx - artboard.get('left');
@@ -1561,23 +1595,42 @@ function dragObject(e) {
}
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('mousedown', '.video-grid-item', dragObject);
$(document).on('mousedown', '.grid-item', dragObject);
$(document).on('mousedown', '.grid-emoji-item', dragObject);
$(document).on('mousedown', '.add-text', dragObject);
$(document).on('mousedown click mouseup', '.credit', function (e) {
e.stopPropagation();
});
$(document).on('pointerdown', '.image-grid-item', dragObject);
$(document).on('pointerdown', '.video-grid-item', dragObject);
$(document).on('pointerdown', '.grid-item', dragObject);
$(document).on('pointerdown', '.grid-emoji-item', dragObject);
$(document).on('pointerdown', '.add-text', dragObject);
$(document).on(
'pointerdown mousedown click mouseup',
'.credit',
function (e) {
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
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');
$('#behind-browser').addClass('collapsed');
$('#canvas-area').addClass('canvas-full');
var act = $('.tool-active');
if (act.attr('id') == 'image-tool') {
act.find('img').attr('src', 'assets/image.svg');
} else if (act.attr('id') == 'text-tool') {
@@ -1592,6 +1645,7 @@ function collapsePanel() {
act.find('img').attr('src', 'assets/uploads.svg');
}
$('.tool-active').removeClass('tool-active');
applyPanelWidths();
resizeCanvas();
}
$(document).on('click', '#collapse', collapsePanel);
@@ -1613,18 +1667,29 @@ function 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() {
var object = canvas.getActiveObject();
animatedtext
.find((x) => x.id == object.id)
.setProp({ preset: $(this).val() }, canvas);
const text = activeAnimatedText();
if (!text) {
return;
}
text.setProp({ preset: $(this).val() });
animate(false, currenttime);
save();
}
function setTextEasing() {
var object = canvas.getActiveObject();
animatedtext
.find((x) => x.id == object.id)
.setProp({ easing: $(this).val() }, canvas);
const text = activeAnimatedText();
if (!text) {
return;
}
text.setProp({ easing: $(this).val() });
animate(false, currenttime);
save();
}
$(document).on('change', '#preset-picker', setTextPreset);
@@ -1651,11 +1716,14 @@ function saveLayerName() {
if ($('.name-active').val() == '') {
$('.name-active').val('Untitled layer');
}
objects.find(
const entry = objects.find(
(x) =>
x.id == $('.name-active').parent().parent().attr('data-object')
).label = $('.name-active').val();
save();
);
if (entry) {
entry.label = $('.name-active').val();
save();
}
$('.name-active').removeClass('name-active');
if (window.getSelection) {
if (window.getSelection().empty) {
@@ -1726,6 +1794,14 @@ function 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() {
var value = $(this).val().toLowerCase();
if (value == '') {
@@ -1750,6 +1826,9 @@ function fancyTimeFormat(duration) {
function loadMoreMedia() {
var value = $('#browser-search input').val();
if (!HAS_PIXABAY_KEY) {
return;
}
if (value != '' && page != false) {
page += 1;
if ($('#image-tool').hasClass('tool-active')) {
@@ -1772,7 +1851,7 @@ function loadMoreMedia() {
hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL +
"'</div>"
"'></div>"
);
});
} else {
@@ -1800,7 +1879,7 @@ function loadMoreMedia() {
hit.user +
"</a><div id='time-video'>" +
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
@@ -1821,6 +1900,15 @@ function loadMoreMedia() {
function search() {
page = 1;
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')) {
var URL =
'https://pixabay.com/api/?key=' +
@@ -1845,7 +1933,7 @@ function search() {
hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL +
"'</div>"
"'></div>"
);
});
} else {
@@ -1883,7 +1971,7 @@ function search() {
hit.user +
"</a><div id='time-video'>" +
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){
$(".image-grid-item[data-src='" + video + "']")
@@ -1963,7 +2051,7 @@ function search() {
},
});
$('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" +
"<div class='item-text add-text noselect' data-font='" +
font +
"' style='font-family: " +
font +
@@ -2069,10 +2157,7 @@ function checkFilter() {
resetFilters();
if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
if (
canvas.getActiveObjects().length == 1 &&
(obj.type == 'image' || obj.type == 'video')
) {
if (canvas.getActiveObjects().length == 1 && obj.filters) {
var value = 'none';
if (obj.filters.length > 0) {
obj.filters.forEach(function (filter) {
@@ -2224,6 +2309,9 @@ $(document).on('click', '#reset-filters', removeFilters);
function updateChromaValues() {
if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
if (!obj.filters) {
return;
}
if ($('.status-active').attr('id') == 'status-on') {
if (obj.filters.find((x) => x.type == 'RemoveColor')) {
obj.filters.find((x) => x.type == 'RemoveColor').distance =
@@ -2264,7 +2352,8 @@ function updateChromaUI() {
$('.status-active').removeClass('status-active');
$('#status-on').addClass('status-active');
chromaslider.setValue(
obj.filters.find((x) => x.type == 'RemoveColor').distance
obj.filters.find((x) => x.type == 'RemoveColor').distance *
100
);
$('#chroma-color input').val(
obj.filters.find((x) => x.type == 'RemoveColor').color
@@ -2278,7 +2367,7 @@ function updateChromaUI() {
$('#status-off').addClass('status-active');
chromaslider.setValue(1);
$('#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() {
var filething = $('#filepick3').get(0).files;
if (!filething || filething.length == 0) {
return;
}
var reader = new FileReader();
reader.onload = function (event) {
newLottieAnimation(
artboard.get('left') + artboard.get('width') / 2,
artboard.get('top') + artboard.get('height') / 2,
event.target.result
);
try {
newLottieAnimation(
artboard.get('left') + artboard.get('width') / 2,
artboard.get('top') + artboard.get('height') / 2,
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));
}
+126 -17
View File
@@ -596,7 +596,12 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
buffer.writeEBMLVarInt(4); // Size field
ebml.dataOffset = buffer.pos + bufferFileOffset;
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
ebml.dataOffset = buffer.pos + bufferFileOffset;
buffer.writeBytes(ebml.data);
@@ -630,7 +635,8 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
*/
let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
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,
firstTimestampEver = true, earliestTimestamp = 0,
@@ -649,6 +655,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
// (optional)
codec: 'VP8', // Codec to write to webm file
// Optional second track holding Opus audio. Supply:
// {sampleRate, channels, codecPrivate: Uint8Array (OpusHead)}
audio: null,
},
seekPoints = {
@@ -787,9 +796,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
}
];
let tracks = {
'id': 0x1654ae6b, // Tracks
'data': [{
let trackEntries = [{
'id': 0xae, // TrackEntry
'data': [
{
@@ -868,7 +875,87 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
'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 = {
@@ -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]);
blobBuffer.write(bufferStream.getAsDataArray());
@@ -1036,7 +1125,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
* @param {Frame} frame
*/
function addFrameToCluster(frame) {
frame.trackNumber = DEFAULT_TRACK_NUMBER;
frame.trackNumber = frame.trackNumber || DEFAULT_TRACK_NUMBER;
var time = frame.intime / 1000;
if (firstTimestampEver) {
earliestTimestamp = time;
@@ -1045,19 +1134,30 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
} else {
time = time - earliestTimestamp;
}
lastTimeCode = time;
if (clusterDuration == 0) clusterStartTime = time;
if (time > lastTimeCode) {
lastTimeCode = 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.timecode = Math.round(clusterDuration);
frame.timecode = Math.round(time - clusterStartTime);
clusterFrameBuffer.push(frame);
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.
*
*/
this.addFrame = function(frame) {
this.addFrame = function(frame, trackNumber) {
if (!writtenHeader) {
videoWidth = options.width;
videoHeight = options.height;
writeHeader();
}
if (frame.constructor.name == 'EncodedVideoChunk') {
const name = frame.constructor.name;
if (name == 'EncodedVideoChunk' || name == 'EncodedAudioChunk') {
let frameData = new Uint8Array(frame.byteLength);
frame.copyTo(frameData);
addFrameToCluster({
frame: frameData,
intime: frame.timestamp,
type: frame.type,
trackNumber: trackNumber ||
(name == 'EncodedAudioChunk' ? AUDIO_TRACK_NUMBER :
DEFAULT_TRACK_NUMBER),
});
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.
*
+299 -80
View File
@@ -7,6 +7,14 @@
--input-color: #22233e;
--accent-color: #166ef1;
--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 {
@@ -41,7 +49,7 @@ body {
font-size: 14px;
font-weight: 600;
background: var(--panel-back);
width: 76px;
width: var(--rail-w);
border-right: 1px solid var(--panel-stroke);
box-sizing: border-box;
}
@@ -204,6 +212,27 @@ body {
.hand-active:hover {
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-canvas {
position: absolute;
@@ -212,58 +241,6 @@ body {
z-index: 9999999;
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 {
display: none !important;
}
@@ -275,7 +252,7 @@ body {
#toolbar {
position: absolute;
height: calc(100% - 340px);
width: 76px;
width: var(--rail-w);
background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke);
left: 0px;
@@ -339,8 +316,8 @@ body {
#behind-browser {
position: absolute;
height: 100%;
width: 299px;
left: 76px;
width: var(--browser-w);
left: var(--rail-w);
background: var(--panel-back);
border-right: 1px solid var(--panel-stroke);
z-index: 1;
@@ -349,10 +326,10 @@ body {
#browser {
position: absolute;
height: calc(100% - 450px);
width: 299px;
width: var(--browser-w);
background-color: var(--panel-back);
border-right: 1px solid var(--panel-stroke);
left: 76px;
left: var(--rail-w);
top: 110px;
box-sizing: border-box;
z-index: 999999;
@@ -364,7 +341,7 @@ body {
display: none !important;
}
#browser-container {
width: 260px;
width: calc(var(--browser-w) - 39px);
margin-left: auto;
margin-right: auto;
height: 100%;
@@ -390,7 +367,7 @@ body {
}
.image-grid-item,
.video-grid-item {
width: 120px;
width: 100%;
margin-bottom: 15px;
position: relative;
}
@@ -419,7 +396,7 @@ body {
text-align: right;
text-decoration: none;
text-shadow: 0px 1px 5px #000000;
width: 110px;
width: calc(100% - 10px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -467,7 +444,9 @@ body {
grid-gap: 15px;
width: 100%;
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;
}
.scroll-row:before {
@@ -492,7 +471,7 @@ body {
#search-fixed {
position: fixed;
top: 0px;
width: 279px;
width: calc(var(--browser-w) - 20px);
padding-left: 19px;
margin-left: -19px;
z-index: 999999;
@@ -734,7 +713,7 @@ body {
height: 28px;
line-height: 28px;
}
#item-text {
.item-text {
font-size: 16px;
font-weight: 500;
height: 34px;
@@ -807,12 +786,13 @@ body {
position: absolute;
right: 0px;
top: 0px;
width: 300px;
width: var(--props-w);
height: calc(100% - 340px);
background-color: var(--panel-back);
border-left: 1px solid var(--panel-stroke);
z-index: 999;
overflow-y: overlay;
overflow-x: hidden;
}
#properties-overlay {
width: 100%;
@@ -861,9 +841,12 @@ hr {
background-color: var(--panel-stroke);
margin-bottom: 20px;
}
#properties hr {
width: calc(var(--props-w) - 40px);
}
/* Property sections */
.panel-section {
width: 260px;
width: calc(var(--props-w) - 40px);
margin-left: auto;
margin-right: auto;
}
@@ -900,8 +883,17 @@ th {
width: 189px;
display: flex;
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;
}
/* 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 */
.nice-select,
.list,
@@ -1375,6 +1367,49 @@ input[type="number"] {
display: block;
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 {
color: var(--secondary-text-color);
font-family: Inter;
@@ -1418,7 +1453,8 @@ input[type="number"] {
/* Download modal */
#download-modal {
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);
border: 1px solid var(--panel-stroke);
border-radius: 5px;
@@ -1463,6 +1499,28 @@ input[type="number"] {
margin-left: 10px;
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 {
background-color: var(--accent-color) !important;
border: 0px !important;
@@ -1515,16 +1573,12 @@ label span {
#canvas-area {
position: absolute;
top: 0px;
left: 375px;
left: calc(var(--rail-w) + var(--browser-w));
height: calc(100% - 342px);
width: calc(100% - 675px);
width: calc(100% - var(--rail-w) - var(--browser-w) - var(--props-w));
box-sizing: border-box;
overflow: hidden;
}
.canvas-full {
left: 76px !important;
width: calc(100% - 376px) !important;
}
.canvas-container {
left: 0px;
top: 0px;
@@ -1579,7 +1633,7 @@ canvas {
#layer-list {
position: absolute;
height: 245px;
width: 375px;
width: var(--layers-w);
left: 0px;
bottom: 60px;
background-color: var(--panel-back);
@@ -1626,6 +1680,46 @@ layer:nth-child(even) .properties {
font-size: 14px;
text-indent: 25px;
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 {
background: transparent;
@@ -1741,7 +1835,7 @@ layer:nth-child(even) .properties {
color: var(--secondary-text-color);
line-height: 40px;
height: 40px;
width: 375px;
width: var(--layers-w);
position: fixed;
margin-top: -35px;
background-color: var(--panel-back);
@@ -1765,10 +1859,10 @@ layer:nth-child(even) .properties {
#timearea {
position: absolute;
bottom: 60px;
left: 375px;
left: var(--layers-w);
height: 245px;
background-color: var(--main-back);
width: calc(100% - 375px);
width: calc(100% - var(--layers-w));
border-top: 1px solid var(--panel-stroke);
z-index: 999;
}
@@ -1783,10 +1877,108 @@ layer:nth-child(even) .properties {
cursor: ns-resize;
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 {
height: 100%;
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-y: hidden;
position: fixed;
@@ -2020,6 +2212,33 @@ layer:nth-child(even) .properties {
z-index: 99999999;
pointer-events: all;
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 {
outline: 3px solid rgba(255, 255, 255, 0.1);
@@ -2438,10 +2657,10 @@ layer:nth-child(even) .properties {
margin-top: -45px;
box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%);
}
#filters-header #filters-title {
#filters-header .filters-title {
margin-top: 0px!important;
}
#filters-title {
.filters-title {
color: var(--main-text-color);
font-size: 14px;
font-weight: 500;
+286
View File
@@ -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);
})();