Packaging/release scripts #1

Merged
kawa merged 9 commits from packaging/release-scripts into main 2026-08-12 17:38:12 +02:00
34 changed files with 8571 additions and 1976 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
+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.
+215
View File
@@ -0,0 +1,215 @@
# 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` | Windows for `.exe`, Linux (or WSL2) for AppImage/Flatpak |
| 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
```
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).
### 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. Exclude the repository's `dist/` directory in the endpoint protection agent.
2. Build on a machine or CI runner without that agent.
3. Ship `dist/win-unpacked/``electron-builder --win dir` is unaffected.
### 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?
**AppImage: yes.** electron-builder produces the squashfs itself, so no FUSE is
needed at build time. To *run* the result inside WSL you need `libfuse2`
(or `./Motionity-1.0.0-x64.AppImage --appimage-extract-and-run`), and WSLg on
Windows 11 gives you the GUI.
**Flatpak: technically yes, practically annoying.** `flatpak-builder` runs under
WSL2 (the kernel has the user namespaces and `/dev/fuse` that bubblewrap needs),
but you must install the runtimes by hand first and there is no
`xdg-desktop-portal` to fall back on. If it fights you, build it in a Linux
container instead — it is the same command with fewer moving parts.
**`.exe`: no.** Build it on the Windows side. Cross-building NSIS from Linux
needs Wine and rules out signing.
This machine currently has no WSL distro other than `docker-desktop`, so the
Linux targets have not been run here. Setup, from PowerShell:
```powershell
wsl --install -d Ubuntu
```
Then inside Ubuntu:
```bash
sudo apt update
sudo apt install -y nodejs npm libfuse2 # AppImage
sudo apt install -y flatpak flatpak-builder elfutils # Flatpak
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
cd /mnt/c/Users/<you>/git\ azuze/motionity-2
npm install
npm run dist:linux
```
Note that building on `/mnt/c` is slow. Copying the tree into the WSL
filesystem (`~/motionity`) is several times faster.
## 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.
+33 -1
View File
@@ -1,3 +1,35 @@
# Motionity # Motionity
This is a fork of the original project aiming to fix issues and add features. Web-based motion graphics editor with keyframing, masking, filters and text animations.
This is a fork of the original [Motionity](https://github.com/alyssaxuu/motionity) by [@alyssaxuu](https://github.com/alyssaxuu), with bug fixes and enhancements.
## Quick Start
**Web (localhost only):**
```bash
npm install
npm run vendor
npm start # http://127.0.0.1:8080
```
**Desktop:**
```bash
npm install
npm run vendor
npm run dev # Electron app
```
**Docker:**
```bash
npm run docker:build
npm run docker:run # http://localhost:8080
```
Full build instructions (Windows installers, Linux AppImage/Flatpak) in [PACKAGING.md](PACKAGING.md).
## Notes
- `ffmpeg.wasm` is vendored; `npm install` + `npm run vendor` are required
- WebCodecs and IndexedDB only work in secure context (`http://localhost` OK, plain HTTP over LAN is not)
- Serve over TLS for remote access
+317
View File
@@ -0,0 +1,317 @@
# 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
+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;
}
}
+15
View File
@@ -0,0 +1,15 @@
services:
motionity:
build:
context: .
# WITH_FFMPEG: "0" trims 18.5 MB by fetching the MP4/GIF encoder from
# archive.org at runtime instead of shipping it.
args:
WITH_FFMPEG: "1"
image: 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
+109
View File
@@ -0,0 +1,109 @@
{
"name": "motionity",
"productName": "Motionity",
"version": "1.0.0",
"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",
"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": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/publish.ps1 -PublishRelease",
"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",
"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"
]
}
}
}
+222
View File
@@ -0,0 +1,222 @@
#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). Nothing here cross-builds.
.EXAMPLE
./scripts/build-release.ps1
Build every target at v<package.json version>.
.EXAMPLE
./scripts/build-release.ps1 -Targets win -Tag v1.1.0
Windows installers only, named v1.1.0.
.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, "linux" is AppImage + Flatpak.
[ValidateSet("win", "linux")]
[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,
# Remove dist/ before building.
[switch]$Clean
)
$ErrorActionPreference = "Stop"
function Invoke-Checked {
param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$Args)
Write-Host " > $Exe $($Args -join ' ')" -ForegroundColor DarkGray
& $Exe @Args
if ($LASTEXITCODE -ne 0) {
throw "'$Exe $($Args -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}'
}
$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 ""
# electron-builder produces AppImage and Flatpak with Linux-only tooling
# (appimagetool, flatpak-builder). Warned rather than blocked: the same script
# runs under pwsh on a Linux box or in WSL, which is where that target belongs.
if ($Targets -contains "linux" -and $env:OS -eq "Windows_NT") {
Write-Warning "the linux target needs a Linux host or WSL — electron-builder cannot produce AppImage or Flatpak on Windows (PACKAGING.md has the WSL setup)."
}
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).'
}
foreach ($target in $Targets) {
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")"
)
}
"linux" {
$builderArgs = @(
"--linux", "AppImage", "flatpak", "--publish", "never",
"-c.appImage.artifactName=$(Get-ArtifactName "$prefix-linux-x86_64")",
"-c.flatpak.artifactName=$(Get-ArtifactName "$prefix-linux-x86_64")"
)
}
}
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)
if (-not $built.Count) {
throw "electron-builder reported success but no $prefix-* artifact landed in $distDir."
}
# --- Checksums ------------------------------------------------------------
Write-Host "Writing checksums..." -ForegroundColor Cyan
$sumsPath = Join-Path $distDir "SHA256SUMS.txt"
$lines = foreach ($f in $built) {
"$((Get-FileHash -Algorithm SHA256 $f.FullName).Hash.ToLower()) $($f.Name)"
}
# 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
}
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)`);
+456
View File
@@ -0,0 +1,456 @@
#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).
-BinariesOnly ships just the installers: no docker build, no docker login, no
image push, and the release upload is implied. That is also the mode to use on
a Linux host or in WSL, where the AppImage and Flatpak targets actually build.
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 -Targets 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 -NoBinaryBuild -Tag v1.1.0
Retry a failed upload: attach the installers already in dist/ without rebuilding.
.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.
[ValidateSet("win", "linux")]
[string[]]$Targets = @("win", "linux"),
[switch]$SkipVendor,
# 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.
[switch]$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,
# Replace release attachments that already exist under the same name.
[switch]$Force
)
$ErrorActionPreference = "Stop"
function Invoke-Checked {
param([Parameter(Mandatory)][string]$Exe, [Parameter(Mandatory)][string[]]$Args)
Write-Host " > $Exe $($Args -join ' ')" -ForegroundColor DarkGray
& $Exe @Args
if ($LASTEXITCODE -ne 0) {
throw "'$Exe $($Args -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 Publish-BinaryRelease {
<#
Attach the installers to the release for $Tag, creating that release if it
does not exist yet. Re-uploading the same file name is a delete + upload,
which needs -Force: overwriting an asset someone may already have linked is
not something to do silently.
#>
param(
[Parameter(Mandatory)][string]$ApiRoot,
[Parameter(Mandatory)][string]$RepoPath,
[Parameter(Mandatory)][string]$Tag,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][string[]]$Artifacts,
[switch]$Force
)
$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 = "Desktop installers — Windows NSIS + portable, Linux AppImage + Flatpak — with SHA256SUMS.txt. Container image: ${Registry}/${Owner}/${Image}:$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
}
foreach ($path in $Artifacts) {
$name = Split-Path -Leaf $path
$existing = $release.assets | Where-Object { $_.name -eq $name }
if ($existing) {
if (-not $Force) {
throw "release $Tag already has an attachment named '$name' — pass -Force to replace it."
}
Write-Host " replacing existing attachment '$name'..." -ForegroundColor DarkGray
Invoke-GiteaApi -Method DELETE -Token $Token `
-Uri "$releasesUri/$($release.id)/assets/$($existing.id)" | Out-Null
}
$encoded = [System.Uri]::EscapeDataString($name)
Send-ReleaseAsset -Token $Token -Path $path `
-Uri "$releasesUri/$($release.id)/assets?name=$encoded"
}
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 ------------------------------------------------------
if ($BinariesOnly -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 ($BinariesOnly) {
# 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
}
$pushImage = -not $BinariesOnly
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 ', ' })"
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 = @()
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.
$oldest = (Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File |
Sort-Object LastWriteTime | Select-Object -First 1)
if (-not $oldest) {
throw "no installers matching motionity-$Tag-* in $distDir — 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 $oldest.LastWriteTime
}
if ($newer) {
Write-Warning "$($oldest.Name) predates $($newer.Count) source file(s) — the installers may not contain your latest changes (newest: $(($newer | Sort-Object LastWriteTime -Descending)[0].Name))."
}
}
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.
& (Join-Path $PSScriptRoot "build-release.ps1") -Tag $Tag -Targets $Targets -SkipVendor:$SkipVendor
}
$artifacts = @(Get-ChildItem $distDir -Filter "motionity-$Tag-*" -File | ForEach-Object FullName)
if (-not $artifacts.Count) { throw "no installers for $Tag found in $distDir." }
$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) {
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"
$releaseUrl = Publish-BinaryRelease -ApiRoot $apiRoot -RepoPath $ReleaseRepo -Tag $Tag `
-Token $Password -Artifacts $artifacts -Force:$Force
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) {
$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 }
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.`);
}
+34 -15
View File
@@ -1,15 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="preconnect" href="https://fonts.gstatic.com"> <meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <!-- Third-party assets live in vendor/ so the app works offline. -->
<!-- Run `npm run vendor` to populate that directory. -->
<link rel="stylesheet" href="vendor/inter.css">
<link rel="stylesheet" href="nice-select.css"> <link rel="stylesheet" href="nice-select.css">
<link ref="stylesheet" href="range-slider.min.css"> <link ref="stylesheet" href="range-slider.min.css">
<link rel="stylesheet" href="magic-check.min.css"> <link rel="stylesheet" href="magic-check.min.css">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css"/> <link rel="stylesheet" href="vendor/pickr-nano.min.css"/>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<title>Motionity - The web-based motion graphics editor for everyone</title> <title>Motionity - The web-based motion graphics editor for everyone</title>
<meta name="title" content="Motionity - The web-based motion graphics editor for everyone"> <meta name="title" content="Motionity - The web-based motion graphics editor for everyone">
@@ -79,6 +81,18 @@
<input class="magic-radio" type="radio" name="radio" value="image" id="image-format"> <input class="magic-radio" type="radio" name="radio" value="image" id="image-format">
<label for="image-format">Image</label> <label for="image-format">Image</label>
</div> </div>
<div id="framerate-row">
<p class="subheader">Frame rate</p>
<select id="framerate">
<option value="12">12 fps</option>
<option value="15">15 fps</option>
<option value="24">24 fps</option>
<option value="25">25 fps</option>
<option value="30" selected>30 fps</option>
<option value="50">50 fps</option>
<option value="60">60 fps</option>
</select>
</div>
<div id="download-real">Download</div> <div id="download-real">Download</div>
</div> </div>
<div id="import-export-modal"> <div id="import-export-modal">
@@ -168,7 +182,7 @@
<div id="filters"> <div id="filters">
<div id="filters-container"> <div id="filters-container">
<div id="filters-header"> <div id="filters-header">
<div id="filters-title">Filters</div> <div class="filters-title">Filters</div>
<img src="assets/close.svg" id="filters-close"> <img src="assets/close.svg" id="filters-close">
</div> </div>
<select id="filters-list"> <select id="filters-list">
@@ -183,7 +197,7 @@
<option value="Polaroid">Polaroid</option> <option value="Polaroid">Polaroid</option>
</select> </select>
<hr> <hr>
<div id="filters-title">Adjustments</div> <div class="filters-title">Adjustments</div>
<div id="reset-filters"><img src="assets/repeat.svg"> Reset</div> <div id="reset-filters"><img src="assets/repeat.svg"> Reset</div>
<div class="filter-row"> <div class="filter-row">
<th class="name-col">Brightness</th> <th class="name-col">Brightness</th>
@@ -216,7 +230,7 @@
</th> </th>
</div> </div>
<hr> <hr>
<div id="filters-title">Chroma key</div> <div class="filters-title">Chroma key</div>
<div class="filter-row"> <div class="filter-row">
<th class="name-col">Status</th> <th class="name-col">Status</th>
<th class="value-col"> <th class="value-col">
@@ -242,7 +256,7 @@
</th> </th>
</div> </div>
<hr> <hr>
<div id="filters-title">Stylize</div> <div class="filters-title">Stylize</div>
<div class="filter-row"> <div class="filter-row">
<th class="name-col">Noise</th> <th class="name-col">Noise</th>
<th class="value-col"> <th class="value-col">
@@ -289,7 +303,7 @@
<div id="keyframe-properties"> <div id="keyframe-properties">
<div id="easing"> <div id="easing">
<p class="property-title">Keyframe easing</p> <p class="property-title">Keyframe easing</p>
<select id="easing"> <select id="easing-select">
<option value="linear">Linear</option> <option value="linear">Linear</option>
<option value="easeInQuad">Ease in</option> <option value="easeInQuad">Ease in</option>
<option value="easeOutQuad">Ease out</option> <option value="easeOutQuad">Ease out</option>
@@ -366,16 +380,19 @@
<video id="test-video"></video> <video id="test-video"></video>
<input id="emptyInput" value=" " style="opacity:0"> <input id="emptyInput" value=" " style="opacity:0">
<script src="js/libraries/localbase.js"></script> <script src="js/libraries/localbase.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js"></script> <script src="vendor/lottie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js"></script> <script src="vendor/selection.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> <script src="vendor/jquery.min.js"></script>
<script src="js/libraries/sortable.min.js"></script> <script src="js/libraries/sortable.min.js"></script>
<script src="js/libraries/range-slider.min.js"></script> <script src="js/libraries/range-slider.min.js"></script>
<script src="js/libraries/jquery.nice-select.min.js"></script> <script src="js/libraries/jquery.nice-select.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js"></script> <script src="vendor/pickr.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script> <script src="vendor/fabric.min.js"></script>
<script src="js/libraries/anime.min.js"></script> <script src="js/libraries/anime.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script> <script src="vendor/webfont.js"></script>
<!-- ffmpeg.wasm loader; the 23 MB core beside it is fetched on first
MP4/GIF export, not at page load. -->
<script src="vendor/ffmpeg/ffmpeg.min.js"></script>
<script src="js/init.js"></script> <script src="js/init.js"></script>
<script src="js/ui.js"></script> <script src="js/ui.js"></script>
<script src="js/align.js"></script> <script src="js/align.js"></script>
@@ -383,6 +400,8 @@
<script src="js/database.js"></script> <script src="js/database.js"></script>
<script src="js/lottie.js"></script> <script src="js/lottie.js"></script>
<script src="js/text.js"></script> <script src="js/text.js"></script>
<script src="js/webm-writer2.js"></script>
<script src="js/render.js"></script>
<script src="js/recorder.js"></script> <script src="js/recorder.js"></script>
<script src="js/functions.js"></script> <script src="js/functions.js"></script>
<script src="js/events.js"></script> <script src="js/events.js"></script>
+26 -3
View File
@@ -78,6 +78,20 @@ function initLines() {
canvas.add(line_v); canvas.add(line_v);
} }
// Hide the alignment guides after a canvas reload.
// They can be missing entirely in projects saved before they existed, so
// never dereference them directly.
function hideGuides(inst) {
const h = inst.getItemById('line_h');
const v = inst.getItemById('line_v');
if (h) {
h.set({ opacity: 0 });
}
if (v) {
v.set({ opacity: 0 });
}
}
function alignControls(object, type) { function alignControls(object, type) {
if (type == 'align-top') { if (type == 'align-top') {
object.set( object.set(
@@ -122,8 +136,10 @@ function alignControls(object, type) {
function alignObject() { function alignObject() {
const type = $(this).attr('id'); const type = $(this).attr('id');
const object = canvas.getActiveObject(); const object = canvas.getActiveObject();
console.log(canvas.getActiveObject().type); if (!object) {
if (canvas.getActiveObject().type == 'activeSelection') { return;
}
if (object.type == 'activeSelection') {
const tempselection = canvas.getActiveObject(); const tempselection = canvas.getActiveObject();
canvas.discardActiveObject(); canvas.discardActiveObject();
tempselection._objects.forEach(function (object) { tempselection._objects.forEach(function (object) {
@@ -157,6 +173,8 @@ function alignObject() {
); );
newKeyframe('top', object, currenttime, object.get('top'), true); newKeyframe('top', object, currenttime, object.get('top'), true);
} }
canvas.renderAll();
save();
} }
$(document).on('click', '.align', alignObject); $(document).on('click', '.align', alignObject);
@@ -240,7 +258,12 @@ function centerLines(e) {
canvas.renderAll(); canvas.renderAll();
} }
} }
if (obj != e.target && obj != line_h && obj != line_v) { if (
obj != e.target &&
obj != line_h &&
obj != line_v &&
obj.visible !== false
) {
if ( if (
obj.get('id') == 'center_h' || obj.get('id') == 'center_h' ||
obj.get('id') == 'center_v' obj.get('id') == 'center_v'
+153 -95
View File
@@ -1,92 +1,163 @@
var workerPath = // MP4/GIF export transcodes the captured WebM with ffmpeg.wasm.
'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js'; //
// Everything is served from vendor/ffmpeg/, vendored out of node_modules by
// `npm run vendor` and pinned by package-lock.json. There is deliberately no
// CDN fallback: the asm.js build this replaced was fetched from a public
// archive.org mirror with no integrity check, so a changed object there would
// have run in the page unnoticed.
//
// The core is the single-threaded build. The default multi-threaded one needs
// SharedArrayBuffer, which requires COOP/COEP isolation, which would break the
// Pixabay, Unsplash and Google Fonts requests the editor makes.
function processInWebWorker() { var FFMPEG_DIR = 'vendor/ffmpeg/';
var blob = URL.createObjectURL( var FFMPEG_CORE = FFMPEG_DIR + 'ffmpeg-core.js';
new Blob( var FFMPEG_WASM = FFMPEG_DIR + 'ffmpeg-core.wasm';
[ var FFMPEG_WORKER = FFMPEG_DIR + 'ffmpeg-core.worker.js';
'importScripts("' +
workerPath +
'");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 worker = new Worker(blob); // One instance per conversion, deliberately not cached. The single-threaded
URL.revokeObjectURL(blob); // core's `main` calls exit() when the command finishes, which tears the wasm
return worker; // 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) { async function loadFfmpeg() {
var aab; if (!ffmpegAvailable()) {
var buffersReady; throw new Error(
var workerReady; 'the ffmpeg.wasm loader is missing — run "npm run vendor" to populate src/vendor/'
var posted; );
var fileReader = new FileReader();
fileReader.onload = function () {
aab = this.result;
postMessage();
};
fileReader.readAsArrayBuffer(videoBlob);
if (!worker) {
worker = processInWebWorker();
} }
worker.onmessage = function (event) {
var message = event.data; // A build made with WITH_FFMPEG=0 ships the loader but not the 23 MB core,
if (message.type == 'ready') { // so check before paying for the load and report it as a build choice
workerReady = true; // rather than a failure.
if (buffersReady) postMessage(); var head = await fetch(FFMPEG_WASM, { method: 'HEAD' }).catch(function () {
} else if (message.type == 'done') { return null;
var result = message.data[0];
if (setting == 'gif') {
var blob = new File([result.data], 'test.gif', {
type: 'image/gif',
}); });
PostBlob(blob); if (!head || !head.ok) {
} else if (setting == 'mp4') { throw new Error(
var blob = new File([result.data], 'test.mp4', { 'this build ships without the ffmpeg core (WITH_FFMPEG=0), so MP4 and GIF ' +
type: 'video/mp4', 'export are unavailable. WEBM export always works.'
}); );
PostBlob(blob);
} }
}
}; var instance = FFmpeg.createFFmpeg({
var postMessage = function () { corePath: absolute(FFMPEG_CORE),
posted = true; wasmPath: absolute(FFMPEG_WASM),
if (setting == 'gif') { workerPath: absolute(FFMPEG_WORKER),
worker.postMessage({ // The loader defaults to the entry point of the multi-threaded core; the
type: 'command', // single-threaded one exports plain `main`. Without this, load() gets as
arguments: '-i video.webm -r 24 output-10.gif'.split(' '), // far as compiling the 23 MB wasm and then aborts with
files: [ // "Cannot call unknown function proxy_main".
{ mainName: 'main',
data: new Uint8Array(aab), log: false,
name: 'video.webm', logger: function (entry) {
if (entry.type === 'fferr') console.debug('[ffmpeg]', entry.message);
}, },
], progress: function (entry) {
}); if (typeof entry.ratio === 'number' && entry.ratio >= 0 && entry.ratio <= 1) {
} else if (setting == 'mp4') { $('#download-real').html('Converting ' + Math.round(entry.ratio * 100) + '%');
worker.postMessage({ }
type: 'command',
arguments:
'-i video.webm -c:v mpeg4 -b:v 6400k -strict experimental output.mp4'.split(
' '
),
files: [
{
data: new Uint8Array(aab),
name: 'video.webm',
}, },
],
}); });
await instance.load();
return instance;
}
// The recording was made at this rate, so the transcode has to keep it: a
// different -r would duplicate or drop frames and drift the timing.
function conversionArgs(setting, fps) {
if (setting === 'gif') {
return ['-i', 'input.webm', '-r', String(fps), 'output.gif'];
}
// libx264 rather than the mpeg4 the asm.js path used: same core, far better
// quality per byte, and it plays in Safari and QuickTime. yuv420p is what
// makes that true — x264 defaults to yuv444p here, which they refuse.
return [
'-i', 'input.webm',
'-c:v', 'libx264',
'-preset', 'ultrafast',
'-crf', '23',
'-pix_fmt', 'yuv420p',
'-r', String(fps),
'-c:a', 'aac',
'-b:a', '192k',
'output.mp4',
];
}
async function convertStreams(videoBlob, setting) {
var outputName = setting === 'gif' ? 'output.gif' : 'output.mp4';
var mimeType = setting === 'gif' ? 'image/gif' : 'video/mp4';
function convertFailed(reason) {
console.error('Conversion failed: ' + reason);
alert(
'Sorry, the ' +
setting.toUpperCase() +
' conversion failed. The WEBM format is always available.\n\n' +
reason
);
resetRecordingUI();
}
if (setting !== 'gif' && setting !== 'mp4') {
convertFailed('unknown output format "' + setting + '"');
return;
}
if (ffmpegBusy) {
convertFailed('a conversion is already running — wait for it to finish');
return;
}
ffmpegBusy = true;
var ffmpeg = null;
try {
$('#download-real').html('Loading converter...');
ffmpeg = await loadFfmpeg();
ffmpeg.FS('writeFile', 'input.webm', new Uint8Array(await videoBlob.arrayBuffer()));
$('#download-real').html('Converting 0%');
await ffmpeg.run.apply(ffmpeg, conversionArgs(setting, getExportFramerate()));
var data = ffmpeg.FS('readFile', outputName);
if (!data || !data.length) {
convertFailed('the encoder returned no data');
return;
}
// Copy out of the wasm heap before unlinking: the view would otherwise be
// backed by memory ffmpeg is free to reuse.
PostBlob(new File([data.slice()], 'video.' + setting, { type: mimeType }));
} catch (err) {
convertFailed((err && err.message) || String(err));
} finally {
// Tear the instance down either way. After a success the runtime has
// already exited and is unusable; after a failure the loader's internal
// "running" flag would otherwise stay set and every later conversion would
// fail with "can only run one command at a time" until a page reload.
// Dropping the reference also returns the 23 MB heap and whatever MEMFS
// still holds, which for a long export is most of the memory in play.
if (ffmpeg) {
try {
ffmpeg.exit();
} catch (e) {
/* already torn down by its own exit(0) */
}
}
ffmpegBusy = false;
} }
};
} }
function PostBlob(blob) { function PostBlob(blob) {
@@ -97,22 +168,9 @@ function PostBlob(blob) {
a.download = blob.name || 'video'; a.download = blob.name || 'video';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
recording = false; document.body.removeChild(a);
currenttime = 0; window.setTimeout(function () {
animate(false, 0); URL.revokeObjectURL(url);
$('#seekbar').offset({ }, 60000);
left: resetRecordingUI();
offset_left +
$('#inner-timeline').offset().left +
currenttime / timelinetime,
});
canvas.renderAll();
resizeCanvas();
if (background_audio != false) {
background_audio.pause();
background_audio = new Audio(background_audio.src);
}
$('#download-real').html('Download');
$('#download-real').removeClass('downloading');
updateRecordCanvas();
} }
+108 -30
View File
@@ -88,15 +88,24 @@ function checkDB() {
} else { } else {
loadProject(); loadProject();
} }
})
.catch(function (e) {
console.error('Could not open the local project database', e);
}); });
} }
// Automatically save project (locally) // Automatically save project (locally)
function autoSave() { async function autoSave() {
if (checkstatus) { if (checkstatus) {
canvas.clipPath = null; canvas.clipPath = null;
objects.forEach(async function (object) { // Sequential, not forEach(async): toDatalessJSON below must not run
// while filters are still being stripped off the objects.
for (const object of objects) {
var obj = canvas.getItemById(object.id); var obj = canvas.getItemById(object.id);
if (!obj) {
object.filters = [];
continue;
}
if (obj.filters) { if (obj.filters) {
if (obj.filters.length > 0) { if (obj.filters.length > 0) {
object.filters = []; object.filters = [];
@@ -174,7 +183,7 @@ function autoSave() {
} else { } else {
object.filters = []; object.filters = [];
} }
}); }
const inst = canvas.toDatalessJSON([ const inst = canvas.toDatalessJSON([
'volume', 'volume',
'audioSrc', 'audioSrc',
@@ -250,6 +259,9 @@ function autoSave() {
activepreset: activepreset, activepreset: activepreset,
width: artboard.width, width: artboard.width,
height: artboard.height, height: artboard.height,
})
.catch(function (e) {
console.error('Autosave failed', e);
}); });
objects.forEach(function (object) { objects.forEach(function (object) {
replaceSource(canvas.getItemById(object.id), canvas); replaceSource(canvas.getItemById(object.id), canvas);
@@ -289,17 +301,24 @@ function loadProject() {
currenttime = 0; currenttime = 0;
canvas.clipPath = null; canvas.clipPath = null;
canvas.clear(); canvas.clear();
if (webglBackend) {
fabric.filterBackend = webglBackend; fabric.filterBackend = webglBackend;
}
f = fabric.Image.filters; f = fabric.Image.filters;
canvas.loadFromJSON(JSON.parse(project.canvas), function () { canvas.loadFromJSON(JSON.parse(project.canvas), function () {
canvas.clipPath = artboard; canvas.clipPath = artboard;
canvas.getItemById('line_h').set({ opacity: 0 }); hideGuides(canvas);
canvas.getItemById('line_v').set({ opacity: 0 });
canvas.renderAll(); canvas.renderAll();
$('.object-props').remove(); $('.object-props').remove();
$('.layer').remove(); $('.layer').remove();
objects.forEach(function (object) { objects.forEach(function (object) {
var animatethis = false; var animatethis = false;
if (!object.animate) {
object.animate = [];
}
if (!canvas.getItemById(object.id)) {
return;
}
if (object.animate.length > 5) { if (object.animate.length > 5) {
if (isSameSet(object.animate, props)) { if (isSameSet(object.animate, props)) {
animatethis = true; animatethis = true;
@@ -331,6 +350,9 @@ function loadProject() {
} }
}); });
keyframes.forEach(function (keyframe) { keyframes.forEach(function (keyframe) {
if (!canvas.getItemById(keyframe.id)) {
return;
}
if ( if (
keyframe.name != 'top' && keyframe.name != 'top' &&
keyframe.name != 'scaleY' && keyframe.name != 'scaleY' &&
@@ -510,12 +532,18 @@ function deleteAsset(key) {
.doc({ key: key }) .doc({ key: key })
.get() .get()
.then((asset) => { .then((asset) => {
if (!asset) {
return;
}
var temp = files.filter((x) => x.file == asset.src); var temp = files.filter((x) => x.file == asset.src);
if (temp.length > 0) { if (temp.length > 0) {
temp.forEach(function (file) { temp.forEach(function (file) {
deleteObject(canvas.getItemById(file.name)); const object = canvas.getItemById(file.name);
if (object) {
deleteObject(object);
}
files = $.grep(files, function (a) { files = $.grep(files, function (a) {
return a != file; return a !== file;
}); });
}); });
} }
@@ -534,14 +562,26 @@ function deleteAsset(key) {
}); });
} }
function getAssets() { function getAssets(attempt) {
attempt = attempt || 0;
db.collection('assets') db.collection('assets')
.get() .get()
.then((assets) => { .then((assets) => {
// Sometimes the assets aren't ready when importing, really annoying // Sometimes the assets aren't ready when importing, really annoying.
// Retry on a timer with a hard cap - a bare synchronous re-call spins
// the CPU forever when the collection never resolves.
if (assets === undefined) { if (assets === undefined) {
getAssets(); if (attempt < 20) {
window.setTimeout(function () {
getAssets(attempt + 1);
}, 250);
} else {
console.error('Could not read assets from the local database');
}
} else if (assets.length > 0) { } else if (assets.length > 0) {
// Rebuild rather than append: getAssets() runs again after an import
uploaded_images = [];
uploaded_videos = [];
assets.forEach(function (asset) { assets.forEach(function (asset) {
if (asset.type == 'image') { if (asset.type == 'image') {
uploaded_images.push({ uploaded_images.push({
@@ -562,6 +602,9 @@ function getAssets() {
} }
}); });
} }
})
.catch(function (e) {
console.error('Could not read assets', e);
}); });
} }
@@ -570,9 +613,19 @@ function readTextFile(file, callback) {
rawFile.overrideMimeType('application/json'); rawFile.overrideMimeType('application/json');
rawFile.open('GET', file, true); rawFile.open('GET', file, true);
rawFile.onreadystatechange = function () { rawFile.onreadystatechange = function () {
if (rawFile.readyState === 4 && rawFile.status == '200') { if (rawFile.readyState === 4) {
// A blob: URL resolves with status 0
if (rawFile.status == 200 || rawFile.status === 0) {
callback(rawFile.responseText); callback(rawFile.responseText);
} else {
alert('Could not read the file');
$('#import-project span').html('Import');
} }
}
};
rawFile.onerror = function () {
alert('Could not read the file');
$('#import-project span').html('Import');
}; };
rawFile.send(null); rawFile.send(null);
} }
@@ -582,10 +635,20 @@ async function importProject(e) {
var file = e.target.files[0]; var file = e.target.files[0];
var path = (window.URL || window.webkitURL).createObjectURL(file); var path = (window.URL || window.webkitURL).createObjectURL(file);
readTextFile(path, function (text) { readTextFile(path, function (text) {
var data = JSON.parse(text); var data;
try {
data = JSON.parse(text);
} catch (e) {
data = null;
}
if (!data || !Array.isArray(data.project) || data.project.length == 0) {
alert('Wrong file type');
$('#import-project span').html('Import');
return;
}
delete data.project[0].id; delete data.project[0].id;
if (data.project.length > 0) { {
if (data.assets.length > 0) { if (Array.isArray(data.assets) && data.assets.length > 0) {
data.assets.forEach(function (asset) { data.assets.forEach(function (asset) {
delete asset.id; delete asset.id;
db.collection('assets').add(asset); db.collection('assets').add(asset);
@@ -598,10 +661,14 @@ async function importProject(e) {
$('#import-project span').html('Import'); $('#import-project span').html('Import');
hideModals(); hideModals();
loadProject(); loadProject();
})
.catch(function (e) {
console.error('Import failed', e);
alert('Import failed');
$('#import-project span').html('Import');
}); });
} else {
alert('Wrong file type');
} }
window.URL.revokeObjectURL(path);
}); });
} }
@@ -619,18 +686,24 @@ function exportProject() {
.get() .get()
.then((assets) => { .then((assets) => {
var exportarr = { project: project, assets: assets }; var exportarr = { project: project, assets: assets };
$('<a />', { // Blob, not a data: URL - projects with media blow past the
download: 'data.json', // maximum URL length.
href: const url = URL.createObjectURL(
'data:application/json,' + new Blob([JSON.stringify(exportarr)], {
encodeURIComponent(JSON.stringify(exportarr)), type: 'application/json',
}) })
.appendTo('body') );
.click(function () { const a = document.createElement('a');
$(this).remove(); a.style.display = 'none';
a.href = url;
a.download = 'data.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(function () {
URL.revokeObjectURL(url);
}, 60000);
$('#export-project span').html('Export'); $('#export-project span').html('Export');
})[0]
.click();
}); });
} else { } else {
alert('Empty project'); alert('Empty project');
@@ -649,11 +722,16 @@ function clearProject() {
'Are you sure you want to clear this project? This action cannot be undone.' 'Are you sure you want to clear this project? This action cannot be undone.'
) )
) { ) {
db.collection('projects').delete(); Promise.all([
db.collection('assets').delete(); db.collection('projects').delete(),
window.setTimeout(function () { db.collection('assets').delete(),
])
.catch(function (e) {
console.error('Could not clear the project', e);
})
.then(function () {
location.reload(); location.reload();
}, 1000); });
} }
hideMore(); hideMore();
} }
+132 -113
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 () { $(document).ready(function () {
// An object is being moved in the canvas // An object is being moved in the canvas
canvas.on('object:moving', function (e) { canvas.on('object:moving', function (e) {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
if (cropping) { if (cropping) {
if ( updateCropBounds();
canvas.getItemById('crop').isContainedWithinObject(cropobj)
) {
cropleft = canvas.getItemById('crop').get('left');
croptop = canvas.getItemById('crop').get('top');
cropscalex = canvas.getItemById('crop').get('scaleX');
cropscaley = canvas.getItemById('crop').get('scaleY');
}
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} else if ( } else if (
lockmovement && lockmovement &&
@@ -39,14 +101,7 @@ $(document).ready(function () {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
if (cropping) { if (cropping) {
if ( updateCropBounds();
canvas.getItemById('crop').isContainedWithinObject(cropobj)
) {
cropleft = canvas.getItemById('crop').get('left');
croptop = canvas.getItemById('crop').get('top');
cropscalex = canvas.getItemById('crop').get('scaleX');
cropscaley = canvas.getItemById('crop').get('scaleY');
}
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} }
}); });
@@ -56,24 +111,15 @@ $(document).ready(function () {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
if (cropping) { if (cropping) {
if ( updateCropBounds();
canvas.getItemById('crop').isContainedWithinObject(cropobj)
) {
cropleft = canvas.getItemById('crop').get('left');
croptop = canvas.getItemById('crop').get('top');
cropscalex = canvas.getItemById('crop').get('scaleX');
cropscaley = canvas.getItemById('crop').get('scaleY');
}
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} }
}); });
// An object is being rotated in the canvas // An object is being rotated in the canvas
canvas.on('object:rotating', function (e) { canvas.on('object:rotating', function (e) {
if (e.e.shiftKey) { if (canvas.getActiveObject()) {
canvas.getActiveObject().snapAngle = 15; canvas.getActiveObject().snapAngle = e.e.shiftKey ? 15 : 0;
} else {
canvas.getActiveObject().snapAngle = 0;
} }
e.target.hasControls = false; e.target.hasControls = false;
}); });
@@ -82,8 +128,10 @@ $(document).ready(function () {
canvas.on('object:modified', function (e) { canvas.on('object:modified', function (e) {
e.target.hasControls = true; e.target.hasControls = true;
if (!editinggroup && !cropping) { if (!editinggroup && !cropping) {
if (canvas.getActiveObject()) {
canvas.getActiveObject().lockMovementX = false; canvas.getActiveObject().lockMovementX = false;
canvas.getActiveObject().lockMovementY = false; canvas.getActiveObject().lockMovementY = false;
}
canvas.renderAll(); canvas.renderAll();
if (e.target.type == 'activeSelection') { if (e.target.type == 'activeSelection') {
const tempselection = canvas.getActiveObject(); const tempselection = canvas.getActiveObject();
@@ -193,8 +241,12 @@ $(document).ready(function () {
this.setViewportTransform(this.viewportTransform); this.setViewportTransform(this.viewportTransform);
this.isDragging = false; this.isDragging = false;
this.selection = true; this.selection = true;
if (line_h) {
line_h.opacity = 0; line_h.opacity = 0;
}
if (line_v) {
line_v.opacity = 0; line_v.opacity = 0;
}
}); });
// Detect mouse over canvas (for dragging objects from the library) // Detect mouse over canvas (for dragging objects from the library)
@@ -214,7 +266,9 @@ $(document).ready(function () {
canvas.on('mouse:out', function (e) { canvas.on('mouse:out', function (e) {
overCanvas = false; overCanvas = false;
if (wip) { if (wip) {
if (e.target) {
e.target.hasControls = true; e.target.hasControls = true;
}
canvas.discardActiveObject(); canvas.discardActiveObject();
wip = false; wip = false;
canvas.renderAll(); canvas.renderAll();
@@ -320,14 +374,17 @@ $(document).ready(function () {
} }
}, 1000); }, 1000);
} }
// Redo // Redo / undo (shift decides which; never both in one keypress)
if (e.which === 90 && (e.ctrlKey || e.metaKey) && e.shiftKey) { if (e.which === 90 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (e.shiftKey) {
if (redo.length >= 1) {
undoRedo(redo, undo, redoarr, undoarr); undoRedo(redo, undo, redoarr, undoarr);
} }
// Undo } else if (undo.length >= 1) {
if (e.which === 90 && (e.ctrlKey || e.metaKey)) {
undoRedo(undo, redo, undoarr, redoarr); undoRedo(undo, redo, undoarr, redoarr);
} }
}
// Duplicate object // Duplicate object
if (e.which === 68 && (e.ctrlKey || e.metaKey)) { if (e.which === 68 && (e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
@@ -353,51 +410,30 @@ $(document).ready(function () {
if (e.keyCode === 13 && editingproject) { if (e.keyCode === 13 && editingproject) {
saveProjectName(); saveProjectName();
} }
// Left arrow key (move object to the left) // Arrow keys nudge the selection
if (e.keyCode === 37 && canvas.getActiveObject()) { if (
var obj = canvas.getActiveObject(); e.keyCode >= 37 &&
var step = 2; e.keyCode <= 40 &&
canvas.getActiveObject() &&
!canvas.getActiveObject().isEditing &&
!focus &&
!editinglayer &&
!editingproject
) {
const obj = canvas.getActiveObject();
// Bigger step if shift is down // Bigger step if shift is down
if (e.shiftKey) { const step = e.shiftKey ? 7 : 2;
step = 7; if (e.keyCode === 37) {
}
obj.left = obj.left - step; obj.left = obj.left - step;
canvas.renderAll(); } else if (e.keyCode === 38) {
autoKeyframe(obj, { action: 'drag' }, false);
}
// Up arrow key (move object up)
if (e.keyCode === 38 && canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
var step = 2;
// Bigger step if shift is down
if (e.shiftKey) {
step = 7;
}
obj.top = obj.top - step; obj.top = obj.top - step;
canvas.renderAll(); } else if (e.keyCode === 39) {
autoKeyframe(obj, { action: 'drag' }, false);
}
// Right arrow key (move object to the right)
if (e.keyCode === 39 && canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
var step = 2;
// Bigger step if shift is down
if (e.shiftKey) {
step = 7;
}
obj.left = obj.left + step; obj.left = obj.left + step;
canvas.renderAll(); } else {
autoKeyframe(obj, { action: 'drag' }, false);
}
// Down arrow key (move object down)
if (e.keyCode === 40 && canvas.getActiveObject()) {
var obj = canvas.getActiveObject();
var step = 2;
// Bigger step if shift is down
if (e.shiftKey) {
step = 7;
}
obj.top = obj.top + step; obj.top = obj.top + step;
}
// Without this the selection box stays where the object used to be
obj.setCoords();
canvas.renderAll(); canvas.renderAll();
autoKeyframe(obj, { action: 'drag' }, false); autoKeyframe(obj, { action: 'drag' }, false);
} }
@@ -406,7 +442,7 @@ $(document).ready(function () {
if ( if (
e.keyCode === 221 && e.keyCode === 221 &&
canvas.getActiveObjects() && canvas.getActiveObjects() &&
e.metaKey (e.metaKey || e.ctrlKey)
) { ) {
if (canvas.getActiveObjects().length == 1) { if (canvas.getActiveObjects().length == 1) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
@@ -434,7 +470,7 @@ $(document).ready(function () {
if ( if (
e.keyCode === 219 && e.keyCode === 219 &&
canvas.getActiveObjects() && canvas.getActiveObjects() &&
e.metaKey (e.metaKey || e.ctrlKey)
) { ) {
if (canvas.getActiveObjects().length == 1) { if (canvas.getActiveObjects().length == 1) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
@@ -545,25 +581,24 @@ $(document).ready(function () {
// Copy event // Copy event
window.addEventListener('copy', function (e) { window.addEventListener('copy', function (e) {
// Selecting keyframes clears the canvas selection, so there is often no
// active object at all here - never dereference it unguarded.
const activeObject = canvas.getActiveObject();
if (activeObject && activeObject.isEditing) {
return;
}
// Copy selected object // Copy selected object
if ( if (activeObject && shiftkeys.length == 0) {
canvas.getActiveObject() &&
shiftkeys.length == 0 &&
!canvas.getActiveObject().isEditing
) {
var emptyInp = document.getElementById('emptyInput'); var emptyInp = document.getElementById('emptyInput');
emptyInp.select(); emptyInp.select();
emptyInp.focus(); emptyInp.focus();
setTimeout(function () { setTimeout(function () {
document.execCommand('copy'); document.execCommand('copy');
}, 0); }, 0);
clipboard = canvas.getActiveObject(); clipboard = activeObject;
cliptype = 'object'; cliptype = 'object';
// Copy selected keyframe(s) // Copy selected keyframe(s)
} else if ( } else if (shiftkeys.length > 0) {
shiftkeys.length > 0 &&
!canvas.getActiveObject().isEditing
) {
var emptyInp = document.getElementById('emptyInput'); var emptyInp = document.getElementById('emptyInput');
emptyInp.select(); emptyInp.select();
emptyInp.focus(); emptyInp.focus();
@@ -580,7 +615,9 @@ $(document).ready(function () {
e.name == drag.attr('data-property') e.name == drag.attr('data-property')
); );
}); });
if (keyarr.length > 0) {
clipboard.push(keyarr[0]); clipboard.push(keyarr[0]);
}
}); });
cliptype = 'keyframe'; cliptype = 'keyframe';
} }
@@ -598,7 +635,9 @@ $(document).ready(function () {
} else { } else {
for (var i = 0; i < imgs.length; i++) { for (var i = 0; i < imgs.length; i++) {
if (imgs[i].type.indexOf('image') == -1) continue; if (imgs[i].type.indexOf('image') == -1) continue;
var imgObj = imgs[i].getAsFile(); // `let` so each async thumbnail callback keeps its own file
let imgObj = imgs[i].getAsFile();
if (!imgObj) continue;
if (imgObj.size / 1024 / 1024 <= 10) { if (imgObj.size / 1024 / 1024 <= 10) {
createThumbnail(imgObj, 250).then(function (data) { createThumbnail(imgObj, 250).then(function (data) {
saveFile( saveFile(
@@ -803,27 +842,7 @@ $(document).ready(function () {
syncScrollHoz($('#timeline'), $('#seekarea')); syncScrollHoz($('#timeline'), $('#seekarea'));
// Initialize layer sorting // Initialize layer sorting
sortable('#layer-inner-list', { initLayerSortable();
customDragImage: (draggedElement, elementOffset, event) => {
return {
element: document.getElementById('nothing'),
posX: event.pageX - elementOffset.left,
posY: event.pageY - elementOffset.top,
};
},
})[0].addEventListener('sortstop', function (e) {
const id = $(e.detail.item).attr('data-object');
const previd = $(e.detail.item).prev().attr('data-object');
if ($('.sortable-dragging').length == 1) {
$('.sortable-dragging').remove();
if (previd == undefined) {
$('#inner-timeline').prepend($('#' + id));
} else {
$('#' + id).insertAfter($('#' + previd));
}
orderLayers();
}
});
// Initialize dropdown for keyframe easing // Initialize dropdown for keyframe easing
$('#easing select').niceSelect(); $('#easing select').niceSelect();
@@ -936,8 +955,8 @@ $(document).ready(function () {
onmove: function (x) { onmove: function (x) {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'RemoveColor')) { if (obj.filters.find((i) => i.type == 'RemoveColor')) {
obj.filters.find((x) => x.type == 'RemoveColor').distance = obj.filters.find((i) => i.type == 'RemoveColor').distance =
x / 100; x / 100;
} }
obj.applyFilters(); obj.applyFilters();
@@ -964,8 +983,8 @@ $(document).ready(function () {
onmove: function (x) { onmove: function (x) {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'Noise')) { if (obj.filters.find((i) => i.type == 'Noise')) {
obj.filters.find((x) => x.type == 'Noise').noise = x; obj.filters.find((i) => i.type == 'Noise').noise = x;
} else { } else {
obj.filters.push( obj.filters.push(
new f.Noise({ new f.Noise({
@@ -997,8 +1016,8 @@ $(document).ready(function () {
onmove: function (x) { onmove: function (x) {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'Blur')) { if (obj.filters.find((i) => i.type == 'Blur')) {
obj.filters.find((x) => x.type == 'Blur').blur = x / 100; obj.filters.find((i) => i.type == 'Blur').blur = x / 100;
} else { } else {
obj.filters.push( obj.filters.push(
new f.Blur({ new f.Blur({
@@ -1006,9 +1025,9 @@ $(document).ready(function () {
}) })
); );
} }
}
obj.applyFilters(); obj.applyFilters();
canvas.renderAll(); canvas.renderAll();
}
}, },
onfinish: function (x) { onfinish: function (x) {
save(); save();
+734 -1168
View File
File diff suppressed because it is too large Load Diff
+56 -26
View File
@@ -3,7 +3,6 @@ var GOOGLE_FONTS_API_KEY = 'GOOGLE_FONTS_API_KEY';
// for legacy browsers // for legacy browsers
const AudioContext = window.AudioContext || window.webkitAudioContext; const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
var oldsrc, oldobj; var oldsrc, oldobj;
var oldtimelinepos; var oldtimelinepos;
var speed = 1; var speed = 1;
@@ -58,7 +57,6 @@ var editingpanel = false;
var files = []; var files = [];
var re = /(?:\.([^.]+))?$/; var re = /(?:\.([^.]+))?$/;
var filelist = []; var filelist = [];
var timeout;
var spacehold = false; var spacehold = false;
var spacerelease = false; var spacerelease = false;
var tempselection; var tempselection;
@@ -94,7 +92,7 @@ var chromaslider, noiseslider, blurslider;
var isChrome = var isChrome =
window.chrome && Object.values(window.chrome).length !== 0; window.chrome && Object.values(window.chrome).length !== 0;
var eyeDropper; var eyeDropper;
if (isChrome) { if (isChrome && typeof EyeDropper !== 'undefined') {
eyeDropper = new EyeDropper(); eyeDropper = new EyeDropper();
} }
var presets = [ var presets = [
@@ -156,8 +154,14 @@ var sliders = [];
var hovertime = 0; var hovertime = 0;
var animatedtext = []; var animatedtext = [];
// Get list of fonts // Get list of fonts.
$.ajax({ // 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: url:
'https://www.googleapis.com/webfonts/v1/webfonts?key=' + 'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
GOOGLE_FONTS_API_KEY + GOOGLE_FONTS_API_KEY +
@@ -169,7 +173,11 @@ $.ajax({
fonts.push(item.family); fonts.push(item.family);
}); });
}, },
}); error: function () {
console.warn('Could not load the Google Fonts list');
},
});
}
// Panel variants // Panel variants
const canvas_panel = const canvas_panel =
@@ -444,6 +452,25 @@ var text_items = {
], ],
}; };
// Without a Google Fonts key the font pickers would be empty, so fall back
// to the families that are already bundled in the text browser.
// (Declared here because it reads text_items, defined just above.)
if (!HAS_FONTS_KEY) {
Object.keys(text_items).forEach(function (group) {
text_items[group].forEach(function (item) {
if (fonts.indexOf(item.fontname) == -1) {
fonts.push(item.fontname);
}
});
});
['Inter', 'Syne'].forEach(function (name) {
if (fonts.indexOf(name) == -1) {
fonts.push(name);
}
});
fonts.sort();
}
WebFont.load({ WebFont.load({
google: { google: {
families: ['Syne'], families: ['Syne'],
@@ -460,7 +487,11 @@ try {
var canvas2dBackend = new fabric.Canvas2dFilterBackend(); var canvas2dBackend = new fabric.Canvas2dFilterBackend();
fabric.filterBackend = fabric.initFilterBackend(); fabric.filterBackend = fabric.initFilterBackend();
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 // Lottie support
fabric.Lottie = fabric.util.createClass(fabric.Image, { fabric.Lottie = fabric.util.createClass(fabric.Image, {
@@ -491,14 +522,18 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
}); });
this.lottieItem.addEventListener('enterFrame', (e) => { this.lottieItem.addEventListener('enterFrame', (e) => {
if (this.canvas) {
this.canvas.requestRenderAll(); this.canvas.requestRenderAll();
}
}); });
this.lottieItem.addEventListener('DOMLoaded', () => { this.lottieItem.addEventListener('DOMLoaded', () => {
this.lottieItem.goToAndStop(currenttime, false); this.lottieItem.goToAndStop(currenttime, false);
this.lottieItem.duration = this.lottieItem.duration =
this.lottieItem.getDuration(false) * 1000; this.lottieItem.getDuration(false) * 1000;
if (this.canvas) {
this.canvas.requestRenderAll(); this.canvas.requestRenderAll();
}
canvas.renderAll(); canvas.renderAll();
canvas.fire('lottie:loaded', { any: 'payload' }); canvas.fire('lottie:loaded', { any: 'payload' });
}); });
@@ -508,7 +543,9 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
goToSeconds: function (seconds) { goToSeconds: function (seconds) {
this.lottieItem.goToAndStop(seconds, false); this.lottieItem.goToAndStop(seconds, false);
if (this.canvas) {
this.canvas.requestRenderAll(); this.canvas.requestRenderAll();
}
}, },
goToFrame: function (frame) { goToFrame: function (frame) {
this.lottieItem.goToAndStop(frame, true); this.lottieItem.goToAndStop(frame, true);
@@ -758,30 +795,23 @@ textBoxControls.mr = new fabric.Control({
// Get any object by ID // Get any object by ID
fabric.Canvas.prototype.getItemById = function (name) { fabric.Canvas.prototype.getItemById = function (name) {
var object = null, function search(list) {
objects = this.getObjects(); for (var i = 0; i < list.length; i++) {
for (var i = 0, len = this.size(); i < len; i++) { const item = list[i];
if (objects[i].get('type') == 'group') { if (item.id && item.id === name) {
if (objects[i].get('id') && objects[i].get('id') === name) { return item;
object = objects[i];
break;
} }
var wip = i; // Recurse so groups nested more than one level deep are found too
for (var o = 0; o < objects[i]._objects.length; o++) { if (item._objects && item._objects.length > 0) {
if ( const found = search(item._objects);
objects[wip]._objects[o].id && if (found) {
objects[wip]._objects[o].id === name return found;
) {
object = objects[wip]._objects[o];
break;
} }
} }
} else if (objects[i].id && objects[i].id === name) {
object = objects[i];
break;
} }
return null;
} }
return object; return search(this.getObjects());
}; };
// Create the artboard // Create the artboard
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, strokeWidth: 0,
cursorDuration: 1, cursorDuration: 1,
cursorDelay: 250, cursorDelay: 250,
duration: duration * 1000, duration: duration,
assetType: 'sprite', assetType: 'sprite',
id: 'Sprite' + layer_count, id: 'Sprite' + layer_count,
objectCaching: false, objectCaching: false,
+177 -295
View File
@@ -1,68 +1,108 @@
const FPS = 30; // Everything the current export needs to tear down when it finishes
let frame = 0; var exportAudio = null;
var chunks = [];
var stream;
var rec;
var track;
function timeout(ms) { // Route every sound source of the project into a single destination node.
return new Promise((resolve) => setTimeout(resolve, ms)); //
} // This has to be one shared AudioContext with one destination:
// - MediaRecorder only records the first audio track of a stream, so
// adding one track per source silently dropped all but one of them,
// - a context per source was never closed, and browsers cap how many a
// page may hold.
function buildExportAudio(stream) {
const ctx = new AudioContext();
const destination = ctx.createMediaStreamDestination();
const elements = [];
var connected = false;
function initRecorder() { function connect(element) {
stream = document.getElementById('canvasrecord').captureStream(0); try {
track = stream.getVideoTracks()[0]; ctx.createMediaElementSource(element).connect(destination);
connected = true;
if (!track.requestFrame) { return true;
track.requestFrame = () => stream.requestFrame(); } 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, { objects.forEach(function (object) {
bitsPerSecond: 3200000, 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) { if (background_audio != false && connect(background_audio)) {
console.log('chunky'); elements.push({
chunks.push(evt.data); element: background_audio,
}; start: 0,
end: duration,
trimstart: 0,
});
}
rec.start(); if (connected) {
stream.addTrack(destination.stream.getAudioTracks()[0]);
console.log('Recorder has been started'); }
return { context: ctx, elements: elements, timers: [] };
rec.onstart = function () {
rec.pause();
console.log('start!');
};
} }
async function recordFrame() { // Start the scheduled audio layers relative to the start of the recording
console.log(frame); function startExportAudio(audio) {
if (!audio) {
waitForEvent(rec, 'pause'); return;
}
//rec.onpause = async function(e) { audio.elements.forEach(function (item) {
item.element.currentTime = item.trimstart / 1000;
// wake up the recorder audio.timers.push(
rec.resume(); window.setTimeout(function () {
recordAnimate(false, (frame / FPS) * 1000); item.element.play();
//animate(false, (frame/FPS)*1000) }, Math.max(0, item.start))
// force write the frame );
track.requestFrame(); audio.timers.push(
window.setTimeout(function () {
// wait until our frame-time elapsed item.element.pause();
await timeout(1000 / FPS); }, Math.max(0, item.end))
);
// sleep recorder });
rec.pause();
//}
} }
async function exportRecording() { function stopExportAudio(audio) {
rec.stop(); if (!audio) {
stream.getTracks().forEach((track) => track.stop()); return;
await waitForEvent(rec, 'stop'); }
return new Blob(chunks); audio.timers.forEach(window.clearTimeout);
audio.timers = [];
audio.elements.forEach(function (item) {
item.element.pause();
});
if (audio.context && audio.context.state != 'closed') {
audio.context.close();
}
} }
// Record canvas // Record canvas
@@ -85,280 +125,122 @@ async function record() {
document.body.removeChild(link); document.body.removeChild(link);
recording = false; recording = false;
updateRecordCanvas(); updateRecordCanvas();
} else { return;
if (!recording) { }
if (recording) {
return;
}
recording = true; recording = true;
paused = true; paused = true;
await recordAnimate(0); await recordAnimate(0);
$('#download-real').html('Rendering...'); $('#download-real').html('Rendering...');
$('#download-real').addClass('downloading'); $('#download-real').addClass('downloading');
var fps = 60;
var aCtx = new AudioContext(); // Preferred path: render every frame offline, with no clock attached, so
// nothing is dropped and video layers land on the exact frame.
if (frameAccurateSupported()) {
const blob = await renderFrameAccurate();
if (blob) {
deliverRecording(blob);
return;
}
console.warn('Falling back to the real-time encoder');
$('#download-real').html('Rendering...');
await updateRecordCanvas();
await recordAnimate(0);
}
const fps = getExportFramerate();
const aCtx = new AudioContext();
// requestAnimationFrame is throttled in background tabs, an oscillator is
// not, so the render keeps running while the tab is hidden.
function audioTimerLoop(callback, frequency) { function audioTimerLoop(callback, frequency) {
var freq = frequency / 1000; const freq = frequency / 1000;
var silence = aCtx.createGain(); const silence = aCtx.createGain();
silence.gain.value = 0; silence.gain.value = 0;
silence.connect(aCtx.destination); silence.connect(aCtx.destination);
onOSCend();
var stopped = false; var stopped = false;
var osc;
function onOSCend() { function onOSCend() {
if (stopped) {
return;
}
osc = aCtx.createOscillator(); osc = aCtx.createOscillator();
osc.onended = onOSCend; osc.onended = onOSCend;
osc.connect(silence); osc.connect(silence);
osc.start(0); osc.start(0);
osc.stop(aCtx.currentTime + freq); osc.stop(aCtx.currentTime + freq);
callback(aCtx.currentTime); callback(aCtx.currentTime);
if (stopped) {
osc.onended = function () {
return;
};
}
} }
onOSCend();
return function () { return function () {
stopped = true; stopped = true;
if (osc) {
osc.onended = null;
}
}; };
} }
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
var stream = document const stream = document
.getElementById('canvasrecord') .getElementById('canvasrecord')
.captureStream(fps); .captureStream(fps);
objects.forEach(function (object) { exportAudio = buildExportAudio(stream);
if (
canvasrecord.getItemById(object.id).get('assetType') && const chunks = [];
canvasrecord.getItemById(object.id).get('assetType') == const recorder = new MediaRecorder(stream, {
'video'
) {
var audio = $(
canvasrecord.getItemById(object.id).getElement()
)[0];
var audioContext = new AudioContext();
var audioSource =
audioContext.createMediaElementSource(audio);
var audioDestination =
audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
stream.addTrack(
audioDestination.stream.getAudioTracks()[0]
);
}
});
if (background_audio != false) {
var audioContext = new AudioContext();
var audioSource =
audioContext.createMediaElementSource(background_audio);
var audioDestination =
audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
background_audio.currentTime = 0;
background_audio.play();
}
let chunks = [];
var recorder = new MediaRecorder(stream, {
bitsPerSecond: 3200000, bitsPerSecond: 3200000,
}); });
recorder.ondataavailable = (e) => chunks.push(e.data); recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = (e) => { recorder.onerror = (e) => {
console.error('Recording failed', e);
stopAnim(); stopAnim();
stopExportAudio(exportAudio);
exportAudio = null;
aCtx.close();
resetRecordingUI();
};
recorder.onstop = () => {
stopAnim();
stopExportAudio(exportAudio);
exportAudio = null;
stream.getTracks().forEach((track) => track.stop());
aCtx.close();
downloadRecording(chunks); downloadRecording(chunks);
animate(false, 0);
$('#seekbar').offset({
left:
offset_left +
$('#inner-timeline').offset().left +
currenttime / timelinetime,
});
canvas.renderAll();
console.log('Finished rendering'); console.log('Finished rendering');
}; };
recorder.start();
setTimeout(function () {
recorder.stop();
}, duration);
// The capture is real time, so the animation clock is driven off the audio
// clock and the recording is stopped once it has covered the timeline.
var origin = null;
var stopping = false;
async function renderAnim(time) { async function renderAnim(time) {
await recordAnimate(time * 1000); if (origin === null) {
origin = time;
}
const elapsed = (time - origin) * 1000;
if (elapsed >= duration) {
if (!stopping) {
stopping = true;
await recordAnimate(duration);
if (recorder.state != 'inactive') {
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) { function animateText(group, ms, play, props, cv, id) {
var starttime = p_keyframes.find((x) => x.id == id).start; const p_keyframe = p_keyframes.find((x) => x.id == id);
ms -= starttime; if (!group || !p_keyframe) {
return;
}
ms -= p_keyframe.start;
var length = group._objects.length; var length = group._objects.length;
var globaldelay = 0; var globaldelay = 0;
// Every letter needs its own binding: the anime callbacks below run long
// after the loop has finished, so `var` would make them all share the last
// letter's state.
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
var index = i; let index = i;
if (props.order == 'backward') { if (props.order == 'backward') {
index = length - i - 1; index = length - i - 1;
} }
@@ -12,9 +18,10 @@ function animateText(group, ms, play, props, cv, id) {
let top = group.item(index).defaultTop; let top = group.item(index).defaultTop;
let scaleX = group.item(index).defaultScaleX; let scaleX = group.item(index).defaultScaleX;
let scaleY = group.item(index).defaultScaleY; let scaleY = group.item(index).defaultScaleY;
var delay = i * duration; // Named `step` so it does not shadow the global project duration
var duration = props.duration / length; let step = props.duration / length;
var animation = { let delay = i * step;
let animation = {
opacity: 0, opacity: 0,
top: top, top: top,
left: left, left: left,
@@ -22,7 +29,7 @@ function animateText(group, ms, play, props, cv, id) {
scaleY: scaleY, scaleY: scaleY,
}; };
if (props.typeAnim == 'letter') { if (props.typeAnim == 'letter') {
delay = i * duration - 100; delay = i * step - 100;
} else if (props.typeAnim == 'word') { } else if (props.typeAnim == 'word') {
if (group.item(index).text == ' ') { if (group.item(index).text == ' ') {
globaldelay += 500; globaldelay += 500;
@@ -30,8 +37,8 @@ function animateText(group, ms, play, props, cv, id) {
delay = globaldelay; delay = globaldelay;
} }
if (props.preset == 'typewriter') { if (props.preset == 'typewriter') {
delay = i * duration; delay = i * step;
duration = 20; step = 20;
} else if (props.preset == 'fade in') { } else if (props.preset == 'fade in') {
} else if (props.preset == 'slide top') { } else if (props.preset == 'slide top') {
animation.top += 20; animation.top += 20;
@@ -48,14 +55,14 @@ function animateText(group, ms, play, props, cv, id) {
animation.scaleX = 1.5; animation.scaleX = 1.5;
animation.scaleY = 1.5; animation.scaleY = 1.5;
} }
if (delay < 0) { if (!(delay > 0)) {
delay = 0; delay = 0;
} }
if (duration < 20) { if (!(step > 20)) {
duration = 20; step = 20;
} }
var start = false; let start = false;
var instance = anime({ let instance = anime({
targets: animation, targets: animation,
delay: delay, delay: delay,
opacity: 1, opacity: 1,
@@ -63,7 +70,7 @@ function animateText(group, ms, play, props, cv, id) {
top: top, top: top,
scaleX: scaleX, scaleX: scaleX,
scaleY: scaleY, scaleY: scaleY,
duration: duration, duration: step,
easing: props.easing, easing: props.easing,
autoplay: play, autoplay: play,
update: function () { update: function () {
@@ -228,8 +235,7 @@ class AnimatedText {
var obj = cv.getItemById(this.id); var obj = cv.getItemById(this.id);
var left = obj.left; var left = obj.left;
var top = obj.top; var top = obj.top;
var scaleX = obj, var scaleX = obj.scaleX;
scaleX;
var scaleY = obj.scaleY; var scaleY = obj.scaleY;
var angle = obj.angle; var angle = obj.angle;
var start = p_keyframes.find((x) => x.id == this.id).start; var start = p_keyframes.find((x) => x.id == this.id).start;
@@ -260,10 +266,16 @@ class AnimatedText {
cv, cv,
this.id this.id
); );
animate(currenttime, false); animate(false, currenttime);
save(); save();
} }
assignTo(id, text, props) { assignTo(id, text, props) {
this.id = id; this.id = id;
if (text !== undefined) {
this.text = text;
}
if (props !== undefined) {
this.props = props;
}
} }
} }
+166 -83
View File
@@ -1,5 +1,8 @@
// Update panel (when selecting / de-selecting objects) // Update panel (when selecting / de-selecting objects)
function updatePanel(selection) { function updatePanel(selection) {
if (selection && !canvas.getActiveObject()) {
selection = false;
}
if (!selection) { if (!selection) {
$('#align').addClass('align-off'); $('#align').addClass('align-off');
$('#object-specific').html(canvas_panel); $('#object-specific').html(canvas_panel);
@@ -330,17 +333,19 @@ function convertToHex(nonHexColorString) {
function updateStrokeValues() { function updateStrokeValues() {
const object = canvas.getActiveObject(); const object = canvas.getActiveObject();
if (!object) {
return;
}
$('.line-join-active').removeClass('line-join-active'); $('.line-join-active').removeClass('line-join-active');
if ( const dash = object.get('strokeDashArray');
object.get('strokeDashArray') == false && const hasDash = Array.isArray(dash) && dash.length > 0;
object.get('strokeWidth') == 0 if (!hasDash && object.get('strokeWidth') == 0) {
) {
$('#miter').addClass('line-join-active'); $('#miter').addClass('line-join-active');
$('#miter img').attr('src', 'assets/miter-active.svg'); $('#miter img').attr('src', 'assets/miter-active.svg');
} else if (object.get('strokeDashArray') == false) { } else if (!hasDash) {
$('#bevel').addClass('line-join-active'); $('#bevel').addClass('line-join-active');
$('#bevel img').attr('src', 'assets/bevel-active.svg'); $('#bevel img').attr('src', 'assets/bevel-active.svg');
} else if (object.get('strokeDashArray') == [10, 5]) { } else if (dash[0] == 10 && dash[1] == 5) {
$('#round').addClass('line-join-active'); $('#round').addClass('line-join-active');
$('#round img').attr('src', 'assets/round-active.svg'); $('#round img').attr('src', 'assets/round-active.svg');
} else { } else {
@@ -350,39 +355,39 @@ function updateStrokeValues() {
} }
function toggleAnimationOrder() { function toggleAnimationOrder() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active').removeClass( $('.order-toggle-item-active').removeClass(
'order-toggle-item-active' 'order-toggle-item-active'
); );
if ($(this).attr('id') == 'order-backward') { if ($(this).attr('id') == 'order-backward') {
animatedtext text.setProp({ order: 'backward' });
.find((x) => x.id == object.id)
.setProp({ order: 'backward' }, canvas);
} else if ($(this).attr('id') == 'order-forward') { } else if ($(this).attr('id') == 'order-forward') {
animatedtext text.setProp({ order: 'forward' });
.find((x) => x.id == object.id)
.setProp({ order: 'forward' }, canvas);
} }
$(this).addClass('order-toggle-item-active'); $(this).addClass('order-toggle-item-active');
animate(currenttime, false); // animate(play, time) - the arguments used to be the other way round, so
// the canvas never refreshed after changing a text animation.
animate(false, currenttime);
save(); save();
} }
function toggleAnimationType() { function toggleAnimationType() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active-2').removeClass( $('.order-toggle-item-active-2').removeClass(
'order-toggle-item-active-2' 'order-toggle-item-active-2'
); );
if ($(this).attr('id') == 'type-words') { if ($(this).attr('id') == 'type-words') {
animatedtext text.setProp({ typeAnim: 'word' });
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'word' }, canvas);
} else if ($(this).attr('id') == 'type-letters') { } else if ($(this).attr('id') == 'type-letters') {
animatedtext text.setProp({ typeAnim: 'letter' });
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'letter' }, canvas);
} }
$(this).addClass('order-toggle-item-active-2'); $(this).addClass('order-toggle-item-active-2');
animate(currenttime, false); animate(false, currenttime);
save(); save();
} }
$(document).on( $(document).on(
@@ -473,7 +478,7 @@ function updatePanelValues() {
var tempstore = false; var tempstore = false;
var object = canvas.getActiveObject(); var object = canvas.getActiveObject();
if ( if (
canvas.getActiveObjects.length > 1 || canvas.getActiveObjects().length > 1 ||
object.get('type') == 'activeSelection' object.get('type') == 'activeSelection'
) { ) {
object = object.toGroup(); object = object.toGroup();
@@ -590,7 +595,8 @@ function updatePanelValues() {
// Update opacity input // Update opacity input
function updateInputs(id) { function updateInputs(id) {
if (canvas.getActiveObject().get('assetType') == 'audio') { const active = canvas.getActiveObject();
if (!active || active.get('assetType') == 'audio') {
return false; return false;
} }
if ($('#object-o input').val() > 100) { if ($('#object-o input').val() > 100) {
@@ -604,7 +610,7 @@ function updateInputs(id) {
id == 'object-color-fill-opacity' id == 'object-color-fill-opacity'
) { ) {
if ($('#object-color-fill-opacity input').val() > 100) { if ($('#object-color-fill-opacity input').val() > 100) {
$('#object-color-fill-opacity').val(100); $('#object-color-fill-opacity input').val(100);
} else if ($('#object-color-fill-opacity input').val() < 0) { } else if ($('#object-color-fill-opacity input').val() < 0) {
$('#object-color-fill-opacity input').val(0); $('#object-color-fill-opacity input').val(0);
} }
@@ -628,7 +634,7 @@ function updateInputs(id) {
id == 'object-color-stroke-opacity' id == 'object-color-stroke-opacity'
) { ) {
if ($('#object-color-stroke-opacity input').val() > 100) { if ($('#object-color-stroke-opacity input').val() > 100) {
$('#object-color-stroke-opacity').val(100); $('#object-color-stroke-opacity input').val(100);
} else if ($('#object-color-stroke-opacity input').val() < 0) { } else if ($('#object-color-stroke-opacity input').val() < 0) {
$('#object-color-stroke-opacity input').val(0); $('#object-color-stroke-opacity input').val(0);
} }
@@ -652,7 +658,7 @@ function updateInputs(id) {
id == 'object-color-shadow-opacity' id == 'object-color-shadow-opacity'
) { ) {
if ($('#object-color-shadow-opacity input').val() > 100) { if ($('#object-color-shadow-opacity input').val() > 100) {
$('#object-color-shadow-opacity').val(100); $('#object-color-shadow-opacity input').val(100);
} else if ($('#object-color-shadow-opacity input').val() < 0) { } else if ($('#object-color-shadow-opacity input').val() < 0) {
$('#object-color-shadow-opacity input').val(0); $('#object-color-shadow-opacity input').val(0);
} }
@@ -673,7 +679,6 @@ function updateInputs(id) {
// Update object position based on panel input values // Update object position based on panel input values
function updateObjectValues(type) { function updateObjectValues(type) {
autoSave();
if (canvas.getActiveObjects().length > 0) { if (canvas.getActiveObjects().length > 0) {
if ($(this).find('input').val() || type) { if ($(this).find('input').val() || type) {
var object = canvas.getActiveObject(); var object = canvas.getActiveObject();
@@ -832,14 +837,11 @@ function updateObjectValues(type) {
} }
} }
function setTextAnimation() { function setTextAnimation() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
animatedtext if (!text) {
.find((x) => x.id == object.id) return;
.reset( }
$(this).parent().find('input').val(), text.reset($(this).parent().find('input').val(), text.props, canvas);
animatedtext.find((x) => x.id == object.id).props,
canvas
);
} }
$(document).on('input', '.property-input', updateObjectValues); $(document).on('input', '.property-input', updateObjectValues);
@@ -850,8 +852,10 @@ $(document).on('click', '#animatedset', setTextAnimation);
// Toggle picker (maybe it could be condensed?) // Toggle picker (maybe it could be condensed?)
function togglePicker() { function togglePicker() {
const object = canvas.getActiveObject(); const object = canvas.getActiveObject();
if (!object && $(this).attr('id') != 'canvas-color') {
return;
}
if (!o_fill.isOpen()) { if (!o_fill.isOpen()) {
newcolorkeyframe = true;
if ($(this).attr('id') == 'object-color-fill') { if ($(this).attr('id') == 'object-color-fill') {
colormode = 'fill'; colormode = 'fill';
o_fill.setColor(object.get('fill')); o_fill.setColor(object.get('fill'));
@@ -879,7 +883,6 @@ function togglePicker() {
colormode = 'shadow'; colormode = 'shadow';
o_fill.setColor(object.shadow.color); o_fill.setColor(object.shadow.color);
} }
newcolorkeyframe = false;
o_fill.show(); o_fill.show();
} else { } else {
o_fill.hide(); o_fill.hide();
@@ -917,6 +920,7 @@ function populateGrid(type) {
}); });
} else if (type == 'image-tool') { } else if (type == 'image-tool') {
$('#images-grid').html(''); $('#images-grid').html('');
$('#categories').html('');
image_categories.forEach(function (category) { image_categories.forEach(function (category) {
$('#categories').append( $('#categories').append(
"<div class='category' data-name='" + "<div class='category' data-name='" +
@@ -930,6 +934,7 @@ function populateGrid(type) {
}); });
} else if (type == 'video-tool') { } else if (type == 'video-tool') {
$('#images-grid').html(''); $('#images-grid').html('');
$('#categories').html('');
video_categories.forEach(function (category) { video_categories.forEach(function (category) {
$('#categories').append( $('#categories').append(
"<div class='category' data-name='" + "<div class='category' data-name='" +
@@ -959,7 +964,7 @@ function populateGrid(type) {
item.key + item.key +
"'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" + "'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" +
item.thumb + item.thumb +
"'</div>" "'></div>"
); );
} }
}); });
@@ -999,6 +1004,7 @@ function populateGrid(type) {
} }
} else if (type == 'audio-tool') { } else if (type == 'audio-tool') {
var flag = false; var flag = false;
$('#audio-list').html('');
audio_items.forEach(function (item) { audio_items.forEach(function (item) {
if (item.src == background_key) { if (item.src == background_key) {
flag = true; flag = true;
@@ -1015,7 +1021,7 @@ function populateGrid(type) {
item.desc + item.desc +
"</a><div class='audio-info-duration'>" + "</a><div class='audio-info-duration'>" +
item.duration + item.duration +
'</div></div></div></div>' '</div></div></div>'
); );
} else { } else {
$('#audio-list').append( $('#audio-list').append(
@@ -1031,7 +1037,7 @@ function populateGrid(type) {
item.desc + item.desc +
"</a><div class='audio-info-duration'>" + "</a><div class='audio-info-duration'>" +
item.duration + item.duration +
'</div></div></div></div>' '</div></div></div>'
); );
} }
}); });
@@ -1057,7 +1063,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1074,7 +1080,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1091,7 +1097,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1108,7 +1114,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1125,7 +1131,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1175,27 +1181,27 @@ function updateBrowser(type) {
if (type == 'image-tool') { if (type == 'image-tool') {
$('#browser-container').html(image_browser); $('#browser-container').html(image_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'shape-tool') { } else if (type == 'shape-tool') {
$('#browser-container').html(shape_browser); $('#browser-container').html(shape_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'video-tool') { } else if (type == 'video-tool') {
$('#browser-container').html(video_browser); $('#browser-container').html(video_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'text-tool') { } else if (type == 'text-tool') {
$('#browser-container').html(text_browser); $('#browser-container').html(text_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'upload-tool') { } else if (type == 'upload-tool') {
$('#browser-container').html(upload_browser); $('#browser-container').html(upload_browser);
populateGrid('images-tab'); populateGrid('images-tab');
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'audio-tool') { } else if (type == 'audio-tool') {
$('#browser-container').html(audio_browser); $('#browser-container').html(audio_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} }
} }
@@ -1260,10 +1266,14 @@ $(document).on('click', '.tool:not(.tool-active)', switchTool);
// Replace image or video by dragging on top and holding a key // Replace image or video by dragging on top and holding a key
function replaceObject(src, object) { function replaceObject(src, object) {
if (!src || !object) {
return;
}
var img = new Image(); var img = new Image();
var width = object.width; var width = object.width;
var height = object.height; var height = object.height;
oldsrc = object._originalElement.currentSrc; const element = object.getElement();
oldsrc = element ? element.currentSrc || element.src : null;
oldobj = object; oldobj = object;
img.onload = function () { img.onload = function () {
object.setElement(img); object.setElement(img);
@@ -1271,6 +1281,9 @@ function replaceObject(src, object) {
object.set('height', height); object.set('height', height);
canvas.renderAll(); canvas.renderAll();
}; };
img.onerror = function () {
console.warn('Could not load replacement image');
};
img.src = src; img.src = src;
} }
@@ -1339,7 +1352,9 @@ function dragObject(e) {
(replacing && !e.ctrlKey) (replacing && !e.ctrlKey)
) { ) {
drag.css('visibility', 'visible'); drag.css('visibility', 'visible');
if (oldsrc && oldobj) {
replaceObject(oldsrc, oldobj); replaceObject(oldsrc, oldobj);
}
replacing = false; replacing = false;
canvas.discardActiveObject(); canvas.discardActiveObject();
$('#replace-image').removeClass('replace-active'); $('#replace-image').removeClass('replace-active');
@@ -1361,7 +1376,21 @@ function dragObject(e) {
$('#properties').removeClass('noselect'); $('#properties').removeClass('noselect');
$('#controls').removeClass('noselect'); $('#controls').removeClass('noselect');
draggingPanel = false; draggingPanel = false;
$('body').off('mousemove', dragging).off('mouseup', released); // An aborted drag (pointercancel, a stray native dragstart, or the window
// losing focus) carries no pointer position, so there is nowhere to drop.
// Undo any in-progress replacement and bin the ghost.
if (!e || e.type != 'pointerup') {
if (replacing) {
if (oldsrc && oldobj) {
replaceObject(oldsrc, oldobj);
}
replacing = false;
canvas.discardActiveObject();
canvas.renderAll();
}
drag.remove();
return false;
}
canvasx = canvas.getPointer(e).x; canvasx = canvas.getPointer(e).x;
canvasy = canvas.getPointer(e).y; canvasy = canvas.getPointer(e).y;
var xpos = canvasx + offsetx - artboard.get('left'); var xpos = canvasx + offsetx - artboard.get('left');
@@ -1561,16 +1590,32 @@ function dragObject(e) {
} }
drag.remove(); drag.remove();
} }
$('body').on('mouseup', released).on('mousemove', dragging); // No pointer capture here (unlike the timeline drags): the drop target is
// worked out from the canvas hover state, and capturing would retarget those
// events to the panel item and leave overCanvas permanently false.
bindPointerDrag(e, null, dragging, released);
} }
$(document).on('mousedown', '.image-grid-item', dragObject); $(document).on('pointerdown', '.image-grid-item', dragObject);
$(document).on('mousedown', '.video-grid-item', dragObject); $(document).on('pointerdown', '.video-grid-item', dragObject);
$(document).on('mousedown', '.grid-item', dragObject); $(document).on('pointerdown', '.grid-item', dragObject);
$(document).on('mousedown', '.grid-emoji-item', dragObject); $(document).on('pointerdown', '.grid-emoji-item', dragObject);
$(document).on('mousedown', '.add-text', dragObject); $(document).on('pointerdown', '.add-text', dragObject);
$(document).on('mousedown click mouseup', '.credit', function (e) { $(document).on(
'pointerdown mousedown click mouseup',
'.credit',
function (e) {
e.stopPropagation(); e.stopPropagation();
}); }
);
// Panel items must never become native drag sources - a native drag swallows
// the pointerup and leaves the dragged ghost stuck to the cursor
$(document).on(
'dragstart',
'.image-grid-item, .video-grid-item, .grid-item, .grid-emoji-item, .add-text, .credit',
function (e) {
e.preventDefault();
}
);
// Collapse library // Collapse library
function collapsePanel() { function collapsePanel() {
@@ -1613,18 +1658,29 @@ function setPreset() {
} }
$(document).on('change', '#preset', setPreset); $(document).on('change', '#preset', setPreset);
function activeAnimatedText() {
const object = canvas.getActiveObject();
if (!object) {
return null;
}
return animatedtext.find((x) => x.id == object.id) || null;
}
function setTextPreset() { function setTextPreset() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
animatedtext if (!text) {
.find((x) => x.id == object.id) return;
.setProp({ preset: $(this).val() }, canvas); }
text.setProp({ preset: $(this).val() });
animate(false, currenttime);
save(); save();
} }
function setTextEasing() { function setTextEasing() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
animatedtext if (!text) {
.find((x) => x.id == object.id) return;
.setProp({ easing: $(this).val() }, canvas); }
text.setProp({ easing: $(this).val() });
animate(false, currenttime);
save(); save();
} }
$(document).on('change', '#preset-picker', setTextPreset); $(document).on('change', '#preset-picker', setTextPreset);
@@ -1651,11 +1707,14 @@ function saveLayerName() {
if ($('.name-active').val() == '') { if ($('.name-active').val() == '') {
$('.name-active').val('Untitled layer'); $('.name-active').val('Untitled layer');
} }
objects.find( const entry = objects.find(
(x) => (x) =>
x.id == $('.name-active').parent().parent().attr('data-object') x.id == $('.name-active').parent().parent().attr('data-object')
).label = $('.name-active').val(); );
if (entry) {
entry.label = $('.name-active').val();
save(); save();
}
$('.name-active').removeClass('name-active'); $('.name-active').removeClass('name-active');
if (window.getSelection) { if (window.getSelection) {
if (window.getSelection().empty) { if (window.getSelection().empty) {
@@ -1750,6 +1809,9 @@ function fancyTimeFormat(duration) {
function loadMoreMedia() { function loadMoreMedia() {
var value = $('#browser-search input').val(); var value = $('#browser-search input').val();
if (!HAS_PIXABAY_KEY) {
return;
}
if (value != '' && page != false) { if (value != '' && page != false) {
page += 1; page += 1;
if ($('#image-tool').hasClass('tool-active')) { if ($('#image-tool').hasClass('tool-active')) {
@@ -1772,7 +1834,7 @@ function loadMoreMedia() {
hit.user + hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" + "</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL + hit.webformatURL +
"'</div>" "'></div>"
); );
}); });
} else { } else {
@@ -1800,7 +1862,7 @@ function loadMoreMedia() {
hit.user + hit.user +
"</a><div id='time-video'>" + "</a><div id='time-video'>" +
fancyTimeFormat(hit.duration) + fancyTimeFormat(hit.duration) +
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>" "</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
); );
createVideoThumbnail(video, 250, 0, true).then(function ( createVideoThumbnail(video, 250, 0, true).then(function (
data data
@@ -1821,6 +1883,15 @@ function loadMoreMedia() {
function search() { function search() {
page = 1; page = 1;
var value = $('#browser-search input').val(); var value = $('#browser-search input').val();
const pixabaySearch =
$('#image-tool').hasClass('tool-active') ||
$('#video-tool').hasClass('tool-active');
if (pixabaySearch && !HAS_PIXABAY_KEY) {
$('#shapes-cont').html(
"<div id='no-results'>Image and video search needs a Pixabay API key. Set API_KEY in js/init.js.</div>"
);
return;
}
if ($('#image-tool').hasClass('tool-active')) { if ($('#image-tool').hasClass('tool-active')) {
var URL = var URL =
'https://pixabay.com/api/?key=' + 'https://pixabay.com/api/?key=' +
@@ -1845,7 +1916,7 @@ function search() {
hit.user + hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" + "</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL + hit.webformatURL +
"'</div>" "'></div>"
); );
}); });
} else { } else {
@@ -1883,7 +1954,7 @@ function search() {
hit.user + hit.user +
"</a><div id='time-video'>" + "</a><div id='time-video'>" +
fancyTimeFormat(hit.duration) + fancyTimeFormat(hit.duration) +
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>" "</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
); );
//createVideoThumbnail(video, 250, 0, true).then(function(data){ //createVideoThumbnail(video, 250, 0, true).then(function(data){
$(".image-grid-item[data-src='" + video + "']") $(".image-grid-item[data-src='" + video + "']")
@@ -1963,7 +2034,7 @@ function search() {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
font + font +
"' style='font-family: " + "' style='font-family: " +
font + font +
@@ -2069,10 +2140,7 @@ function checkFilter() {
resetFilters(); resetFilters();
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if ( if (canvas.getActiveObjects().length == 1 && obj.filters) {
canvas.getActiveObjects().length == 1 &&
(obj.type == 'image' || obj.type == 'video')
) {
var value = 'none'; var value = 'none';
if (obj.filters.length > 0) { if (obj.filters.length > 0) {
obj.filters.forEach(function (filter) { obj.filters.forEach(function (filter) {
@@ -2224,6 +2292,9 @@ $(document).on('click', '#reset-filters', removeFilters);
function updateChromaValues() { function updateChromaValues() {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (!obj.filters) {
return;
}
if ($('.status-active').attr('id') == 'status-on') { if ($('.status-active').attr('id') == 'status-on') {
if (obj.filters.find((x) => x.type == 'RemoveColor')) { if (obj.filters.find((x) => x.type == 'RemoveColor')) {
obj.filters.find((x) => x.type == 'RemoveColor').distance = obj.filters.find((x) => x.type == 'RemoveColor').distance =
@@ -2264,7 +2335,8 @@ function updateChromaUI() {
$('.status-active').removeClass('status-active'); $('.status-active').removeClass('status-active');
$('#status-on').addClass('status-active'); $('#status-on').addClass('status-active');
chromaslider.setValue( chromaslider.setValue(
obj.filters.find((x) => x.type == 'RemoveColor').distance obj.filters.find((x) => x.type == 'RemoveColor').distance *
100
); );
$('#chroma-color input').val( $('#chroma-color input').val(
obj.filters.find((x) => x.type == 'RemoveColor').color obj.filters.find((x) => x.type == 'RemoveColor').color
@@ -2278,7 +2350,7 @@ function updateChromaUI() {
$('#status-off').addClass('status-active'); $('#status-off').addClass('status-active');
chromaslider.setValue(1); chromaslider.setValue(1);
$('#chroma-color input').val('#FFFFFF'); $('#chroma-color input').val('#FFFFFF');
$('#color-chroma-side').css('background-color', '#FFFFF'); $('#color-chroma-side').css('background-color', '#FFFFFF');
} }
} }
} }
@@ -2350,13 +2422,24 @@ function hideMore() {
function handleLottieUpload() { function handleLottieUpload() {
var filething = $('#filepick3').get(0).files; var filething = $('#filepick3').get(0).files;
if (!filething || filething.length == 0) {
return;
}
var reader = new FileReader(); var reader = new FileReader();
reader.onload = function (event) { reader.onload = function (event) {
try {
newLottieAnimation( newLottieAnimation(
artboard.get('left') + artboard.get('width') / 2, artboard.get('left') + artboard.get('width') / 2,
artboard.get('top') + artboard.get('height') / 2, artboard.get('top') + artboard.get('height') / 2,
event.target.result event.target.result
); );
} catch (e) {
console.error(e);
alert('That does not look like a valid Lottie file');
}
};
reader.onerror = function () {
alert('Could not read the file');
}; };
reader.readAsDataURL(filething.item(0)); reader.readAsDataURL(filething.item(0));
} }
+125 -16
View File
@@ -596,7 +596,12 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
buffer.writeEBMLVarInt(4); // Size field buffer.writeEBMLVarInt(4); // Size field
ebml.dataOffset = buffer.pos + bufferFileOffset; ebml.dataOffset = buffer.pos + bufferFileOffset;
buffer.writeFloatBE(ebml.data.value); buffer.writeFloatBE(ebml.data.value);
} else if (ebml.data instanceof Uint8Array) { } else if (
ebml.data instanceof Uint8Array ||
(ArrayBuffer.isView(ebml.data) &&
ebml.data.BYTES_PER_ELEMENT === 1)) {
// isView as well as instanceof: a byte array that crossed a realm
// boundary (worker, vm context) fails the instanceof check
buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field
ebml.dataOffset = buffer.pos + bufferFileOffset; ebml.dataOffset = buffer.pos + bufferFileOffset;
buffer.writeBytes(ebml.data); buffer.writeBytes(ebml.data);
@@ -630,7 +635,8 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
*/ */
let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) { let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
return function(options) { return function(options) {
let MAX_CLUSTER_DURATION_MSEC = 5000000, DEFAULT_TRACK_NUMBER = 1, let MAX_CLUSTER_DURATION_MSEC = 5000, DEFAULT_TRACK_NUMBER = 1,
AUDIO_TRACK_NUMBER = 2,
writtenHeader = false, videoWidth = 0, videoHeight = 0, writtenHeader = false, videoWidth = 0, videoHeight = 0,
firstTimestampEver = true, earliestTimestamp = 0, firstTimestampEver = true, earliestTimestamp = 0,
@@ -649,6 +655,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
// (optional) // (optional)
codec: 'VP8', // Codec to write to webm file codec: 'VP8', // Codec to write to webm file
// Optional second track holding Opus audio. Supply:
// {sampleRate, channels, codecPrivate: Uint8Array (OpusHead)}
audio: null,
}, },
seekPoints = { seekPoints = {
@@ -787,9 +796,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
} }
]; ];
let tracks = { let trackEntries = [{
'id': 0x1654ae6b, // Tracks
'data': [{
'id': 0xae, // TrackEntry 'id': 0xae, // TrackEntry
'data': [ 'data': [
{ {
@@ -868,7 +875,87 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
'data': options.codec 'data': options.codec
},*/ },*/
] ]
}] }];
// Optional Opus audio track.
// CodecPrivate must be an OpusHead block, and Opus needs the pre-roll
// hints or players will start it with audible artefacts.
if (options.audio) {
trackEntries.push({
'id': 0xae, // TrackEntry
'data': [
{
'id': 0xd7, // TrackNumber
'data': AUDIO_TRACK_NUMBER
},
{
'id': 0x73c5, // TrackUID
'data': AUDIO_TRACK_NUMBER
},
{
'id': 0x83, // TrackType (2 = audio)
'data': 2
},
{
'id': 0x9c, // FlagLacing
'data': 0
},
{
'id': 0x22b59c, // Language
'data': 'und'
},
{
'id': 0xb9, // FlagEnabled
'data': 1
},
{
'id': 0x88, // FlagDefault
'data': 1
},
{
'id': 0x55aa, // FlagForced
'data': 0
},
{
'id': 0x86, // CodecID
'data': 'A_OPUS'
},
{
'id': 0x63A2, // CodecPrivate
'data': options.audio.codecPrivate
},
{
'id': 0x56AA, // CodecDelay (ns)
'data': 6500000
},
{
'id': 0x56BB, // SeekPreRoll (ns)
'data': 80000000
},
{
'id': 0xe1, // Audio
'data': [
{
'id': 0xb5, // SamplingFrequency
'data': new EBMLFloat64(options.audio.sampleRate)
},
{
'id': 0x9f, // Channels
'data': options.audio.channels
},
{
'id': 0x6264, // BitDepth
'data': 32
}
]
}
]
});
}
let tracks = {
'id': 0x1654ae6b, // Tracks
'data': trackEntries
}; };
ebmlSegment = { ebmlSegment = {
@@ -881,7 +968,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
] ]
}; };
let bufferStream = new ArrayBufferDataStream(256); // Has to fit the header, SeekHead, SegmentInfo and every TrackEntry.
// 256 was only ever enough for a single video track.
let bufferStream = new ArrayBufferDataStream(1024);
writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]); writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]);
blobBuffer.write(bufferStream.getAsDataArray()); blobBuffer.write(bufferStream.getAsDataArray());
@@ -1036,7 +1125,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
* @param {Frame} frame * @param {Frame} frame
*/ */
function addFrameToCluster(frame) { function addFrameToCluster(frame) {
frame.trackNumber = DEFAULT_TRACK_NUMBER; frame.trackNumber = frame.trackNumber || DEFAULT_TRACK_NUMBER;
var time = frame.intime / 1000; var time = frame.intime / 1000;
if (firstTimestampEver) { if (firstTimestampEver) {
earliestTimestamp = time; earliestTimestamp = time;
@@ -1045,19 +1134,30 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
} else { } else {
time = time - earliestTimestamp; time = time - earliestTimestamp;
} }
if (time > lastTimeCode) {
lastTimeCode = time; lastTimeCode = time;
if (clusterDuration == 0) clusterStartTime = time; }
// Start a new cluster on a video keyframe once the current one has
// grown past the limit. SimpleBlock timecodes are a signed 16 bit
// offset from the cluster, so clusters cannot span more than ~32s.
const isVideoKeyframe =
frame.trackNumber == DEFAULT_TRACK_NUMBER && frame.type == 'key';
if (
clusterFrameBuffer.length > 0 && isVideoKeyframe &&
time - clusterStartTime >= MAX_CLUSTER_DURATION_MSEC) {
flushClusterFrameBuffer();
}
if (clusterFrameBuffer.length === 0) {
clusterStartTime = time;
}
// Frame timecodes are relative to the start of their cluster: // Frame timecodes are relative to the start of their cluster:
// frame.timecode = Math.round(clusterDuration);
frame.timecode = Math.round(time - clusterStartTime); frame.timecode = Math.round(time - clusterStartTime);
clusterFrameBuffer.push(frame); clusterFrameBuffer.push(frame);
clusterDuration = frame.timecode + 1; clusterDuration = frame.timecode + 1;
if (clusterDuration >= MAX_CLUSTER_DURATION_MSEC) {
flushClusterFrameBuffer();
}
} }
/** /**
@@ -1106,24 +1206,33 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
* toDataUrl() on an image yourself. * toDataUrl() on an image yourself.
* *
*/ */
this.addFrame = function(frame) { this.addFrame = function(frame, trackNumber) {
if (!writtenHeader) { if (!writtenHeader) {
videoWidth = options.width; videoWidth = options.width;
videoHeight = options.height; videoHeight = options.height;
writeHeader(); writeHeader();
} }
if (frame.constructor.name == 'EncodedVideoChunk') { const name = frame.constructor.name;
if (name == 'EncodedVideoChunk' || name == 'EncodedAudioChunk') {
let frameData = new Uint8Array(frame.byteLength); let frameData = new Uint8Array(frame.byteLength);
frame.copyTo(frameData); frame.copyTo(frameData);
addFrameToCluster({ addFrameToCluster({
frame: frameData, frame: frameData,
intime: frame.timestamp, intime: frame.timestamp,
type: frame.type, type: frame.type,
trackNumber: trackNumber ||
(name == 'EncodedAudioChunk' ? AUDIO_TRACK_NUMBER :
DEFAULT_TRACK_NUMBER),
}); });
return; return;
} }
}; };
// Encoded audio for the optional second track
this.addAudioChunk = function(chunk) {
this.addFrame(chunk, AUDIO_TRACK_NUMBER);
};
/** /**
* Finish writing the video and return a Promise to signal completion. * Finish writing the video and return a Promise to signal completion.
* *
+94 -4
View File
@@ -734,7 +734,7 @@ body {
height: 28px; height: 28px;
line-height: 28px; line-height: 28px;
} }
#item-text { .item-text {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
height: 34px; height: 34px;
@@ -1418,7 +1418,8 @@ input[type="number"] {
/* Download modal */ /* Download modal */
#download-modal { #download-modal {
width: 300px; width: 300px;
height: 255px; /* Grows with its content: the frame rate row is hidden for image exports */
padding-bottom: 20px;
background-color: var(--panel-back); background-color: var(--panel-back);
border: 1px solid var(--panel-stroke); border: 1px solid var(--panel-stroke);
border-radius: 5px; border-radius: 5px;
@@ -1463,6 +1464,28 @@ input[type="number"] {
margin-left: 10px; margin-left: 10px;
margin-bottom: 20px; margin-bottom: 20px;
} }
#framerate-row {
margin-bottom: 20px;
}
#framerate-row .subheader {
margin-bottom: 8px;
}
#framerate {
background-color: var(--input-color);
border: none;
border-radius: 5px;
color: var(--main-text-color);
font-family: Inter;
font-size: 14px;
height: 35px;
outline: none;
padding-left: 11px;
width: calc(100% - 40px);
margin-left: 20px;
}
#framerate:hover {
cursor: pointer;
}
.magic-radio:checked + label:before { .magic-radio:checked + label:before {
background-color: var(--accent-color) !important; background-color: var(--accent-color) !important;
border: 0px !important; border: 0px !important;
@@ -1626,6 +1649,46 @@ layer:nth-child(even) .properties {
font-size: 14px; font-size: 14px;
text-indent: 25px; text-indent: 25px;
display: inline-block; display: inline-block;
position: relative;
}
.layer-handle {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
width: 9px;
height: 16px;
text-indent: 0;
opacity: 0.3;
cursor: grab;
background-image: radial-gradient(
currentColor 1px,
transparent 1.5px
);
background-size: 4px 5px;
background-position: 1px 2px;
transition: opacity 0.15s ease;
}
.layer:hover .layer-handle {
opacity: 0.7;
}
.layer .layer-handle:hover {
opacity: 1;
}
.layer-handle:active {
cursor: grabbing;
}
.layer-handle[draggable="false"] {
cursor: not-allowed;
opacity: 0.15 !important;
}
.sortable-placeholder {
height: 35px;
margin-bottom: 2px;
box-sizing: border-box;
border: 1px dashed var(--accent-color);
border-radius: 3px;
background: rgba(0, 122, 255, 0.12);
} }
.layer-custom-name { .layer-custom-name {
background: transparent; background: transparent;
@@ -2020,6 +2083,33 @@ layer:nth-child(even) .properties {
z-index: 99999999; z-index: 99999999;
pointer-events: all; pointer-events: all;
top: 0px; top: 0px;
/* Never let the browser start a native drag or a selection on the seekbar */
-webkit-user-drag: none;
-webkit-user-select: none;
user-select: none;
touch-action: none;
}
#seekbar:after,
#seek-hover {
-webkit-user-drag: none;
}
/* Same for the other timeline drag targets */
.keyframe,
.main-row,
.row-el,
.trim-row,
#timeline-handle {
-webkit-user-drag: none;
touch-action: none;
}
/* And for the library panel items dragged onto the canvas */
.image-grid-item,
.video-grid-item,
.grid-item,
.grid-emoji-item,
.add-text,
.credit {
-webkit-user-drag: none;
} }
#seekbar:hover { #seekbar:hover {
outline: 3px solid rgba(255, 255, 255, 0.1); outline: 3px solid rgba(255, 255, 255, 0.1);
@@ -2438,10 +2528,10 @@ layer:nth-child(even) .properties {
margin-top: -45px; margin-top: -45px;
box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%); box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%);
} }
#filters-header #filters-title { #filters-header .filters-title {
margin-top: 0px!important; margin-top: 0px!important;
} }
#filters-title { .filters-title {
color: var(--main-text-color); color: var(--main-text-color);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
+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);
})();