diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5380e88 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2b6ffa --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5fed10a --- /dev/null +++ b/Dockerfile @@ -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. diff --git a/PACKAGING.md b/PACKAGING.md new file mode 100644 index 0000000..b4c6726 --- /dev/null +++ b/PACKAGING.md @@ -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//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. diff --git a/README.md b/README.md index 104e491..74a8822 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ # 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 diff --git a/TODO-FIXES.md b/TODO-FIXES.md new file mode 100644 index 0000000..097a382 --- /dev/null +++ b/TODO-FIXES.md @@ -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 `` in `src/js/ui.js`. +- [x] **Duplicate DOM ids** — `id="easing"` on both wrapper and ` +
+

Frame rate

+ +
Download
@@ -168,7 +182,7 @@
-
Filters
+
Filters

-
Adjustments
+
Adjustments
Reset
Brightness @@ -216,7 +230,7 @@

-
Chroma key
+
Chroma key
Status @@ -242,7 +256,7 @@

-
Stylize
+
Stylize
Noise @@ -289,7 +303,7 @@

Keyframe easing

- @@ -366,16 +380,19 @@ - - - + + + - - + + - + + + @@ -383,6 +400,8 @@ + + diff --git a/src/js/align.js b/src/js/align.js index c59eaa6..c078f8e 100644 --- a/src/js/align.js +++ b/src/js/align.js @@ -78,6 +78,20 @@ function initLines() { canvas.add(line_v); } +// Hide the alignment guides after a canvas reload. +// They can be missing entirely in projects saved before they existed, so +// never dereference them directly. +function hideGuides(inst) { + const h = inst.getItemById('line_h'); + const v = inst.getItemById('line_v'); + if (h) { + h.set({ opacity: 0 }); + } + if (v) { + v.set({ opacity: 0 }); + } +} + function alignControls(object, type) { if (type == 'align-top') { object.set( @@ -122,8 +136,10 @@ function alignControls(object, type) { function alignObject() { const type = $(this).attr('id'); const object = canvas.getActiveObject(); - console.log(canvas.getActiveObject().type); - if (canvas.getActiveObject().type == 'activeSelection') { + if (!object) { + return; + } + if (object.type == 'activeSelection') { const tempselection = canvas.getActiveObject(); canvas.discardActiveObject(); tempselection._objects.forEach(function (object) { @@ -157,6 +173,8 @@ function alignObject() { ); newKeyframe('top', object, currenttime, object.get('top'), true); } + canvas.renderAll(); + save(); } $(document).on('click', '.align', alignObject); @@ -240,7 +258,12 @@ function centerLines(e) { canvas.renderAll(); } } - if (obj != e.target && obj != line_h && obj != line_v) { + if ( + obj != e.target && + obj != line_h && + obj != line_v && + obj.visible !== false + ) { if ( obj.get('id') == 'center_h' || obj.get('id') == 'center_v' diff --git a/src/js/converter.js b/src/js/converter.js index 83e2668..d4e9330 100644 --- a/src/js/converter.js +++ b/src/js/converter.js @@ -1,92 +1,163 @@ -var workerPath = - 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js'; +// MP4/GIF export transcodes the captured WebM with ffmpeg.wasm. +// +// Everything is served from vendor/ffmpeg/, vendored out of node_modules by +// `npm run vendor` and pinned by package-lock.json. There is deliberately no +// CDN fallback: the asm.js build this replaced was fetched from a public +// archive.org mirror with no integrity check, so a changed object there would +// have run in the page unnoticed. +// +// The core is the single-threaded build. The default multi-threaded one needs +// SharedArrayBuffer, which requires COOP/COEP isolation, which would break the +// Pixabay, Unsplash and Google Fonts requests the editor makes. -function processInWebWorker() { - var blob = URL.createObjectURL( - new Blob( - [ - 'importScripts("' + - workerPath + - '");var now = Date.now;function print(text) {postMessage({"type" : "stdout","data" : text});};onmessage = function(event) {var message = event.data;if (message.type === "command") {var Module = {print: print,printErr: print,files: message.files || [],arguments: message.arguments || [],TOTAL_MEMORY: message.TOTAL_MEMORY||536870912 || false};postMessage({"type" : "start","data" : Module.arguments.join(" ")});postMessage({"type" : "stdout","data" : "Received command: " +Module.arguments.join(" ") +((Module.TOTAL_MEMORY ) ? ". Processing with " + Module.TOTAL_MEMORY + " bits." : "")});var time = now();var result = ffmpeg_run(Module);var totalTime = now() - time;postMessage({"type" : "stdout","data" : "Finished processing (took " + totalTime + "ms)"});postMessage({"type" : "done","data" : result,"time" : totalTime});}};postMessage({"type" : "ready"});', - ], - { - type: 'application/javascript', - } - ) - ); +var FFMPEG_DIR = 'vendor/ffmpeg/'; +var FFMPEG_CORE = FFMPEG_DIR + 'ffmpeg-core.js'; +var FFMPEG_WASM = FFMPEG_DIR + 'ffmpeg-core.wasm'; +var FFMPEG_WORKER = FFMPEG_DIR + 'ffmpeg-core.worker.js'; - var worker = new Worker(blob); - URL.revokeObjectURL(blob); - return worker; +// One instance per conversion, deliberately not cached. The single-threaded +// core's `main` calls exit() when the command finishes, which tears the wasm +// runtime down: a second run on the same instance dies with "Program terminated +// with exit(0)". Reloading costs about 110ms and returns the 23 MB heap in +// between, so this is cheaper than it looks. +var ffmpegBusy = false; + +function ffmpegAvailable() { + return typeof FFmpeg !== 'undefined' && typeof FFmpeg.createFFmpeg === 'function'; } -var worker; +// The loader resolves the core through `new URL(corePath, import.meta.url)`, +// which points at the bundle rather than the page once it is minified. Passing +// all three paths absolute skips that resolution entirely. +function absolute(path) { + return new URL(path, location.href).href; +} -function convertStreams(videoBlob, setting) { - var aab; - var buffersReady; - var workerReady; - var posted; - - var fileReader = new FileReader(); - fileReader.onload = function () { - aab = this.result; - postMessage(); - }; - fileReader.readAsArrayBuffer(videoBlob); - - if (!worker) { - worker = processInWebWorker(); +async function loadFfmpeg() { + if (!ffmpegAvailable()) { + throw new Error( + 'the ffmpeg.wasm loader is missing — run "npm run vendor" to populate src/vendor/' + ); } - worker.onmessage = function (event) { - var message = event.data; - if (message.type == 'ready') { - workerReady = true; - if (buffersReady) postMessage(); - } else if (message.type == 'done') { - var result = message.data[0]; - if (setting == 'gif') { - var blob = new File([result.data], 'test.gif', { - type: 'image/gif', - }); - PostBlob(blob); - } else if (setting == 'mp4') { - var blob = new File([result.data], 'test.mp4', { - type: 'video/mp4', - }); - PostBlob(blob); + + // A build made with WITH_FFMPEG=0 ships the loader but not the 23 MB core, + // so check before paying for the load and report it as a build choice + // rather than a failure. + var head = await fetch(FFMPEG_WASM, { method: 'HEAD' }).catch(function () { + return null; + }); + if (!head || !head.ok) { + throw new Error( + 'this build ships without the ffmpeg core (WITH_FFMPEG=0), so MP4 and GIF ' + + 'export are unavailable. WEBM export always works.' + ); + } + + var instance = FFmpeg.createFFmpeg({ + corePath: absolute(FFMPEG_CORE), + wasmPath: absolute(FFMPEG_WASM), + workerPath: absolute(FFMPEG_WORKER), + // The loader defaults to the entry point of the multi-threaded core; the + // single-threaded one exports plain `main`. Without this, load() gets as + // far as compiling the 23 MB wasm and then aborts with + // "Cannot call unknown function proxy_main". + mainName: 'main', + log: false, + logger: function (entry) { + if (entry.type === 'fferr') console.debug('[ffmpeg]', entry.message); + }, + progress: function (entry) { + if (typeof entry.ratio === 'number' && entry.ratio >= 0 && entry.ratio <= 1) { + $('#download-real').html('Converting ' + Math.round(entry.ratio * 100) + '%'); + } + }, + }); + await instance.load(); + return instance; +} + +// The recording was made at this rate, so the transcode has to keep it: a +// different -r would duplicate or drop frames and drift the timing. +function conversionArgs(setting, fps) { + if (setting === 'gif') { + return ['-i', 'input.webm', '-r', String(fps), 'output.gif']; + } + // libx264 rather than the mpeg4 the asm.js path used: same core, far better + // quality per byte, and it plays in Safari and QuickTime. yuv420p is what + // makes that true — x264 defaults to yuv444p here, which they refuse. + return [ + '-i', 'input.webm', + '-c:v', 'libx264', + '-preset', 'ultrafast', + '-crf', '23', + '-pix_fmt', 'yuv420p', + '-r', String(fps), + '-c:a', 'aac', + '-b:a', '192k', + 'output.mp4', + ]; +} + +async function convertStreams(videoBlob, setting) { + var outputName = setting === 'gif' ? 'output.gif' : 'output.mp4'; + var mimeType = setting === 'gif' ? 'image/gif' : 'video/mp4'; + + function convertFailed(reason) { + console.error('Conversion failed: ' + reason); + alert( + 'Sorry, the ' + + setting.toUpperCase() + + ' conversion failed. The WEBM format is always available.\n\n' + + reason + ); + resetRecordingUI(); + } + + if (setting !== 'gif' && setting !== 'mp4') { + convertFailed('unknown output format "' + setting + '"'); + return; + } + if (ffmpegBusy) { + convertFailed('a conversion is already running — wait for it to finish'); + return; + } + + ffmpegBusy = true; + var ffmpeg = null; + try { + $('#download-real').html('Loading converter...'); + ffmpeg = await loadFfmpeg(); + + ffmpeg.FS('writeFile', 'input.webm', new Uint8Array(await videoBlob.arrayBuffer())); + $('#download-real').html('Converting 0%'); + await ffmpeg.run.apply(ffmpeg, conversionArgs(setting, getExportFramerate())); + + var data = ffmpeg.FS('readFile', outputName); + if (!data || !data.length) { + convertFailed('the encoder returned no data'); + return; + } + // Copy out of the wasm heap before unlinking: the view would otherwise be + // backed by memory ffmpeg is free to reuse. + PostBlob(new File([data.slice()], 'video.' + setting, { type: mimeType })); + } catch (err) { + convertFailed((err && err.message) || String(err)); + } finally { + // Tear the instance down either way. After a success the runtime has + // already exited and is unusable; after a failure the loader's internal + // "running" flag would otherwise stay set and every later conversion would + // fail with "can only run one command at a time" until a page reload. + // Dropping the reference also returns the 23 MB heap and whatever MEMFS + // still holds, which for a long export is most of the memory in play. + if (ffmpeg) { + try { + ffmpeg.exit(); + } catch (e) { + /* already torn down by its own exit(0) */ } } - }; - var postMessage = function () { - posted = true; - if (setting == 'gif') { - worker.postMessage({ - type: 'command', - arguments: '-i video.webm -r 24 output-10.gif'.split(' '), - files: [ - { - data: new Uint8Array(aab), - name: 'video.webm', - }, - ], - }); - } else if (setting == 'mp4') { - worker.postMessage({ - type: 'command', - arguments: - '-i video.webm -c:v mpeg4 -b:v 6400k -strict experimental output.mp4'.split( - ' ' - ), - files: [ - { - data: new Uint8Array(aab), - name: 'video.webm', - }, - ], - }); - } - }; + ffmpegBusy = false; + } } function PostBlob(blob) { @@ -97,22 +168,9 @@ function PostBlob(blob) { a.download = blob.name || 'video'; document.body.appendChild(a); a.click(); - recording = false; - currenttime = 0; - animate(false, 0); - $('#seekbar').offset({ - left: - offset_left + - $('#inner-timeline').offset().left + - currenttime / timelinetime, - }); - canvas.renderAll(); - resizeCanvas(); - if (background_audio != false) { - background_audio.pause(); - background_audio = new Audio(background_audio.src); - } - $('#download-real').html('Download'); - $('#download-real').removeClass('downloading'); - updateRecordCanvas(); + document.body.removeChild(a); + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + resetRecordingUI(); } diff --git a/src/js/database.js b/src/js/database.js index c36ac33..a52c770 100644 --- a/src/js/database.js +++ b/src/js/database.js @@ -88,15 +88,24 @@ function checkDB() { } else { loadProject(); } + }) + .catch(function (e) { + console.error('Could not open the local project database', e); }); } // Automatically save project (locally) -function autoSave() { +async function autoSave() { if (checkstatus) { canvas.clipPath = null; - objects.forEach(async function (object) { + // Sequential, not forEach(async): toDatalessJSON below must not run + // while filters are still being stripped off the objects. + for (const object of objects) { var obj = canvas.getItemById(object.id); + if (!obj) { + object.filters = []; + continue; + } if (obj.filters) { if (obj.filters.length > 0) { object.filters = []; @@ -174,7 +183,7 @@ function autoSave() { } else { object.filters = []; } - }); + } const inst = canvas.toDatalessJSON([ 'volume', 'audioSrc', @@ -250,6 +259,9 @@ function autoSave() { activepreset: activepreset, width: artboard.width, height: artboard.height, + }) + .catch(function (e) { + console.error('Autosave failed', e); }); objects.forEach(function (object) { replaceSource(canvas.getItemById(object.id), canvas); @@ -289,17 +301,24 @@ function loadProject() { currenttime = 0; canvas.clipPath = null; canvas.clear(); - fabric.filterBackend = webglBackend; + if (webglBackend) { + fabric.filterBackend = webglBackend; + } f = fabric.Image.filters; canvas.loadFromJSON(JSON.parse(project.canvas), function () { canvas.clipPath = artboard; - canvas.getItemById('line_h').set({ opacity: 0 }); - canvas.getItemById('line_v').set({ opacity: 0 }); + hideGuides(canvas); canvas.renderAll(); $('.object-props').remove(); $('.layer').remove(); objects.forEach(function (object) { var animatethis = false; + if (!object.animate) { + object.animate = []; + } + if (!canvas.getItemById(object.id)) { + return; + } if (object.animate.length > 5) { if (isSameSet(object.animate, props)) { animatethis = true; @@ -331,6 +350,9 @@ function loadProject() { } }); keyframes.forEach(function (keyframe) { + if (!canvas.getItemById(keyframe.id)) { + return; + } if ( keyframe.name != 'top' && keyframe.name != 'scaleY' && @@ -510,12 +532,18 @@ function deleteAsset(key) { .doc({ key: key }) .get() .then((asset) => { + if (!asset) { + return; + } var temp = files.filter((x) => x.file == asset.src); if (temp.length > 0) { temp.forEach(function (file) { - deleteObject(canvas.getItemById(file.name)); + const object = canvas.getItemById(file.name); + if (object) { + deleteObject(object); + } files = $.grep(files, function (a) { - return a != file; + return a !== file; }); }); } @@ -534,14 +562,26 @@ function deleteAsset(key) { }); } -function getAssets() { +function getAssets(attempt) { + attempt = attempt || 0; db.collection('assets') .get() .then((assets) => { - // Sometimes the assets aren't ready when importing, really annoying + // Sometimes the assets aren't ready when importing, really annoying. + // Retry on a timer with a hard cap - a bare synchronous re-call spins + // the CPU forever when the collection never resolves. if (assets === undefined) { - getAssets(); + if (attempt < 20) { + window.setTimeout(function () { + getAssets(attempt + 1); + }, 250); + } else { + console.error('Could not read assets from the local database'); + } } else if (assets.length > 0) { + // Rebuild rather than append: getAssets() runs again after an import + uploaded_images = []; + uploaded_videos = []; assets.forEach(function (asset) { if (asset.type == 'image') { uploaded_images.push({ @@ -562,6 +602,9 @@ function getAssets() { } }); } + }) + .catch(function (e) { + console.error('Could not read assets', e); }); } @@ -570,10 +613,20 @@ function readTextFile(file, callback) { rawFile.overrideMimeType('application/json'); rawFile.open('GET', file, true); rawFile.onreadystatechange = function () { - if (rawFile.readyState === 4 && rawFile.status == '200') { - callback(rawFile.responseText); + if (rawFile.readyState === 4) { + // A blob: URL resolves with status 0 + if (rawFile.status == 200 || rawFile.status === 0) { + callback(rawFile.responseText); + } else { + alert('Could not read the file'); + $('#import-project span').html('Import'); + } } }; + rawFile.onerror = function () { + alert('Could not read the file'); + $('#import-project span').html('Import'); + }; rawFile.send(null); } @@ -582,10 +635,20 @@ async function importProject(e) { var file = e.target.files[0]; var path = (window.URL || window.webkitURL).createObjectURL(file); readTextFile(path, function (text) { - var data = JSON.parse(text); + var data; + try { + data = JSON.parse(text); + } catch (e) { + data = null; + } + if (!data || !Array.isArray(data.project) || data.project.length == 0) { + alert('Wrong file type'); + $('#import-project span').html('Import'); + return; + } delete data.project[0].id; - if (data.project.length > 0) { - if (data.assets.length > 0) { + { + if (Array.isArray(data.assets) && data.assets.length > 0) { data.assets.forEach(function (asset) { delete asset.id; db.collection('assets').add(asset); @@ -598,10 +661,14 @@ async function importProject(e) { $('#import-project span').html('Import'); hideModals(); loadProject(); + }) + .catch(function (e) { + console.error('Import failed', e); + alert('Import failed'); + $('#import-project span').html('Import'); }); - } else { - alert('Wrong file type'); } + window.URL.revokeObjectURL(path); }); } @@ -619,18 +686,24 @@ function exportProject() { .get() .then((assets) => { var exportarr = { project: project, assets: assets }; - $('', { - download: 'data.json', - href: - 'data:application/json,' + - encodeURIComponent(JSON.stringify(exportarr)), - }) - .appendTo('body') - .click(function () { - $(this).remove(); - $('#export-project span').html('Export'); - })[0] - .click(); + // Blob, not a data: URL - projects with media blow past the + // maximum URL length. + const url = URL.createObjectURL( + new Blob([JSON.stringify(exportarr)], { + type: 'application/json', + }) + ); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = 'data.json'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + $('#export-project span').html('Export'); }); } else { alert('Empty project'); @@ -649,11 +722,16 @@ function clearProject() { 'Are you sure you want to clear this project? This action cannot be undone.' ) ) { - db.collection('projects').delete(); - db.collection('assets').delete(); - window.setTimeout(function () { - location.reload(); - }, 1000); + Promise.all([ + db.collection('projects').delete(), + db.collection('assets').delete(), + ]) + .catch(function (e) { + console.error('Could not clear the project', e); + }) + .then(function () { + location.reload(); + }); } hideMore(); } diff --git a/src/js/events.js b/src/js/events.js index 8dbed56..0036fab 100644 --- a/src/js/events.js +++ b/src/js/events.js @@ -1,17 +1,79 @@ +// Remember the last valid crop rectangle so it can be restored if the user +// drags it outside the image. +function updateCropBounds() { + const cropUI = canvas.getItemById('crop'); + if (!cropUI || !cropobj) { + return; + } + if (cropUI.isContainedWithinObject(cropobj)) { + cropleft = cropUI.get('left'); + croptop = cropUI.get('top'); + cropscalex = cropUI.get('scaleX'); + cropscaley = cropUI.get('scaleY'); + } +} + +// Drag-to-reorder for the layer list. html5sortable only wires up the +// children present when it runs, so this is re-run every time a layer is +// added. The sortstop handler is bound only once. +let layerSortBound = false; +function initLayerSortable() { + const list = document.getElementById('layer-inner-list'); + if (!list) { + return; + } + const sortableList = sortable(list, { + handle: '.layer-handle', + customDragImage: (draggedElement, elementOffset, event) => { + return { + element: document.getElementById('nothing'), + posX: event.pageX - elementOffset.left, + posY: event.pageY - elementOffset.top, + }; + }, + })[0]; + + // Re-initializing marks every handle draggable again, so locked layers + // have to be opted back out. + $('#layer-inner-list .layer').each(function () { + if ($(this).find('.lock').hasClass('locked')) { + $(this).find('.layer-handle').attr('draggable', false); + } + }); + + if (layerSortBound) { + return; + } + layerSortBound = true; + sortableList.addEventListener('sortupdate', function () { + syncTimelineOrder(); + orderLayers(); + }); +} + +// Re-order the timeline rows to match the layer list +function syncTimelineOrder() { + const timeline = document.getElementById('inner-timeline'); + if (!timeline) { + return; + } + $('#layer-inner-list .layer').each(function () { + const row = document.getElementById( + $(this).attr('data-object') + ); + if (row) { + timeline.appendChild(row); + } + }); +} + $(document).ready(function () { // An object is being moved in the canvas canvas.on('object:moving', function (e) { e.target.hasControls = false; centerLines(e); if (cropping) { - if ( - canvas.getItemById('crop').isContainedWithinObject(cropobj) - ) { - cropleft = canvas.getItemById('crop').get('left'); - croptop = canvas.getItemById('crop').get('top'); - cropscalex = canvas.getItemById('crop').get('scaleX'); - cropscaley = canvas.getItemById('crop').get('scaleY'); - } + updateCropBounds(); crop(canvas.getItemById('cropped')); } else if ( lockmovement && @@ -39,14 +101,7 @@ $(document).ready(function () { e.target.hasControls = false; centerLines(e); if (cropping) { - if ( - canvas.getItemById('crop').isContainedWithinObject(cropobj) - ) { - cropleft = canvas.getItemById('crop').get('left'); - croptop = canvas.getItemById('crop').get('top'); - cropscalex = canvas.getItemById('crop').get('scaleX'); - cropscaley = canvas.getItemById('crop').get('scaleY'); - } + updateCropBounds(); crop(canvas.getItemById('cropped')); } }); @@ -56,24 +111,15 @@ $(document).ready(function () { e.target.hasControls = false; centerLines(e); if (cropping) { - if ( - canvas.getItemById('crop').isContainedWithinObject(cropobj) - ) { - cropleft = canvas.getItemById('crop').get('left'); - croptop = canvas.getItemById('crop').get('top'); - cropscalex = canvas.getItemById('crop').get('scaleX'); - cropscaley = canvas.getItemById('crop').get('scaleY'); - } + updateCropBounds(); crop(canvas.getItemById('cropped')); } }); // An object is being rotated in the canvas canvas.on('object:rotating', function (e) { - if (e.e.shiftKey) { - canvas.getActiveObject().snapAngle = 15; - } else { - canvas.getActiveObject().snapAngle = 0; + if (canvas.getActiveObject()) { + canvas.getActiveObject().snapAngle = e.e.shiftKey ? 15 : 0; } e.target.hasControls = false; }); @@ -82,8 +128,10 @@ $(document).ready(function () { canvas.on('object:modified', function (e) { e.target.hasControls = true; if (!editinggroup && !cropping) { - canvas.getActiveObject().lockMovementX = false; - canvas.getActiveObject().lockMovementY = false; + if (canvas.getActiveObject()) { + canvas.getActiveObject().lockMovementX = false; + canvas.getActiveObject().lockMovementY = false; + } canvas.renderAll(); if (e.target.type == 'activeSelection') { const tempselection = canvas.getActiveObject(); @@ -193,8 +241,12 @@ $(document).ready(function () { this.setViewportTransform(this.viewportTransform); this.isDragging = false; this.selection = true; - line_h.opacity = 0; - line_v.opacity = 0; + if (line_h) { + line_h.opacity = 0; + } + if (line_v) { + line_v.opacity = 0; + } }); // Detect mouse over canvas (for dragging objects from the library) @@ -214,7 +266,9 @@ $(document).ready(function () { canvas.on('mouse:out', function (e) { overCanvas = false; if (wip) { - e.target.hasControls = true; + if (e.target) { + e.target.hasControls = true; + } canvas.discardActiveObject(); wip = false; canvas.renderAll(); @@ -320,13 +374,16 @@ $(document).ready(function () { } }, 1000); } - // Redo - if (e.which === 90 && (e.ctrlKey || e.metaKey) && e.shiftKey) { - undoRedo(redo, undo, redoarr, undoarr); - } - // Undo + // Redo / undo (shift decides which; never both in one keypress) if (e.which === 90 && (e.ctrlKey || e.metaKey)) { - undoRedo(undo, redo, undoarr, redoarr); + e.preventDefault(); + if (e.shiftKey) { + if (redo.length >= 1) { + undoRedo(redo, undo, redoarr, undoarr); + } + } else if (undo.length >= 1) { + undoRedo(undo, redo, undoarr, redoarr); + } } // Duplicate object if (e.which === 68 && (e.ctrlKey || e.metaKey)) { @@ -353,51 +410,30 @@ $(document).ready(function () { if (e.keyCode === 13 && editingproject) { saveProjectName(); } - // Left arrow key (move object to the left) - if (e.keyCode === 37 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; + // Arrow keys nudge the selection + if ( + e.keyCode >= 37 && + e.keyCode <= 40 && + canvas.getActiveObject() && + !canvas.getActiveObject().isEditing && + !focus && + !editinglayer && + !editingproject + ) { + const obj = canvas.getActiveObject(); // Bigger step if shift is down - if (e.shiftKey) { - step = 7; + const step = e.shiftKey ? 7 : 2; + if (e.keyCode === 37) { + obj.left = obj.left - step; + } else if (e.keyCode === 38) { + obj.top = obj.top - step; + } else if (e.keyCode === 39) { + obj.left = obj.left + step; + } else { + obj.top = obj.top + step; } - obj.left = obj.left - step; - canvas.renderAll(); - autoKeyframe(obj, { action: 'drag' }, false); - } - // Up arrow key (move object up) - if (e.keyCode === 38 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; - // Bigger step if shift is down - if (e.shiftKey) { - step = 7; - } - obj.top = obj.top - step; - canvas.renderAll(); - autoKeyframe(obj, { action: 'drag' }, false); - } - // Right arrow key (move object to the right) - if (e.keyCode === 39 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; - // Bigger step if shift is down - if (e.shiftKey) { - step = 7; - } - obj.left = obj.left + step; - canvas.renderAll(); - autoKeyframe(obj, { action: 'drag' }, false); - } - // Down arrow key (move object down) - if (e.keyCode === 40 && canvas.getActiveObject()) { - var obj = canvas.getActiveObject(); - var step = 2; - // Bigger step if shift is down - if (e.shiftKey) { - step = 7; - } - obj.top = obj.top + step; + // Without this the selection box stays where the object used to be + obj.setCoords(); canvas.renderAll(); autoKeyframe(obj, { action: 'drag' }, false); } @@ -406,7 +442,7 @@ $(document).ready(function () { if ( e.keyCode === 221 && canvas.getActiveObjects() && - e.metaKey + (e.metaKey || e.ctrlKey) ) { if (canvas.getActiveObjects().length == 1) { var obj = canvas.getActiveObject(); @@ -434,7 +470,7 @@ $(document).ready(function () { if ( e.keyCode === 219 && canvas.getActiveObjects() && - e.metaKey + (e.metaKey || e.ctrlKey) ) { if (canvas.getActiveObjects().length == 1) { var obj = canvas.getActiveObject(); @@ -545,25 +581,24 @@ $(document).ready(function () { // Copy event window.addEventListener('copy', function (e) { + // Selecting keyframes clears the canvas selection, so there is often no + // active object at all here - never dereference it unguarded. + const activeObject = canvas.getActiveObject(); + if (activeObject && activeObject.isEditing) { + return; + } // Copy selected object - if ( - canvas.getActiveObject() && - shiftkeys.length == 0 && - !canvas.getActiveObject().isEditing - ) { + if (activeObject && shiftkeys.length == 0) { var emptyInp = document.getElementById('emptyInput'); emptyInp.select(); emptyInp.focus(); setTimeout(function () { document.execCommand('copy'); }, 0); - clipboard = canvas.getActiveObject(); + clipboard = activeObject; cliptype = 'object'; // Copy selected keyframe(s) - } else if ( - shiftkeys.length > 0 && - !canvas.getActiveObject().isEditing - ) { + } else if (shiftkeys.length > 0) { var emptyInp = document.getElementById('emptyInput'); emptyInp.select(); emptyInp.focus(); @@ -580,7 +615,9 @@ $(document).ready(function () { e.name == drag.attr('data-property') ); }); - clipboard.push(keyarr[0]); + if (keyarr.length > 0) { + clipboard.push(keyarr[0]); + } }); cliptype = 'keyframe'; } @@ -598,7 +635,9 @@ $(document).ready(function () { } else { for (var i = 0; i < imgs.length; i++) { if (imgs[i].type.indexOf('image') == -1) continue; - var imgObj = imgs[i].getAsFile(); + // `let` so each async thumbnail callback keeps its own file + let imgObj = imgs[i].getAsFile(); + if (!imgObj) continue; if (imgObj.size / 1024 / 1024 <= 10) { createThumbnail(imgObj, 250).then(function (data) { saveFile( @@ -803,27 +842,7 @@ $(document).ready(function () { syncScrollHoz($('#timeline'), $('#seekarea')); // Initialize layer sorting - sortable('#layer-inner-list', { - customDragImage: (draggedElement, elementOffset, event) => { - return { - element: document.getElementById('nothing'), - posX: event.pageX - elementOffset.left, - posY: event.pageY - elementOffset.top, - }; - }, - })[0].addEventListener('sortstop', function (e) { - const id = $(e.detail.item).attr('data-object'); - const previd = $(e.detail.item).prev().attr('data-object'); - if ($('.sortable-dragging').length == 1) { - $('.sortable-dragging').remove(); - if (previd == undefined) { - $('#inner-timeline').prepend($('#' + id)); - } else { - $('#' + id).insertAfter($('#' + previd)); - } - orderLayers(); - } - }); + initLayerSortable(); // Initialize dropdown for keyframe easing $('#easing select').niceSelect(); @@ -936,8 +955,8 @@ $(document).ready(function () { onmove: function (x) { if (canvas.getActiveObject()) { var obj = canvas.getActiveObject(); - if (obj.filters.find((x) => x.type == 'RemoveColor')) { - obj.filters.find((x) => x.type == 'RemoveColor').distance = + if (obj.filters.find((i) => i.type == 'RemoveColor')) { + obj.filters.find((i) => i.type == 'RemoveColor').distance = x / 100; } obj.applyFilters(); @@ -964,8 +983,8 @@ $(document).ready(function () { onmove: function (x) { if (canvas.getActiveObject()) { var obj = canvas.getActiveObject(); - if (obj.filters.find((x) => x.type == 'Noise')) { - obj.filters.find((x) => x.type == 'Noise').noise = x; + if (obj.filters.find((i) => i.type == 'Noise')) { + obj.filters.find((i) => i.type == 'Noise').noise = x; } else { obj.filters.push( new f.Noise({ @@ -997,8 +1016,8 @@ $(document).ready(function () { onmove: function (x) { if (canvas.getActiveObject()) { var obj = canvas.getActiveObject(); - if (obj.filters.find((x) => x.type == 'Blur')) { - obj.filters.find((x) => x.type == 'Blur').blur = x / 100; + if (obj.filters.find((i) => i.type == 'Blur')) { + obj.filters.find((i) => i.type == 'Blur').blur = x / 100; } else { obj.filters.push( new f.Blur({ @@ -1006,9 +1025,9 @@ $(document).ready(function () { }) ); } + obj.applyFilters(); + canvas.renderAll(); } - obj.applyFilters(); - canvas.renderAll(); }, onfinish: function (x) { save(); diff --git a/src/js/functions.js b/src/js/functions.js index b221d2d..b0596fb 100644 --- a/src/js/functions.js +++ b/src/js/functions.js @@ -26,7 +26,7 @@ function updateSelection(e) { $(".layer[data-object='" + object.get('id') + "']").addClass( 'layer-selected' ); - if (e.e != undefined) { + if (e.e != undefined && $('.layer-selected').length > 0) { document .getElementsByClassName('layer-selected')[0] .scrollIntoView(); @@ -42,7 +42,7 @@ function updateSelection(e) { $(".layer[data-object='" + e.target.get('id') + "']").addClass( 'layer-selected' ); - if (e.e != undefined) { + if (e.e != undefined && $('.layer-selected').length > 0) { document .getElementsByClassName('layer-selected')[0] .scrollIntoView(); @@ -244,7 +244,6 @@ function group() { absolutePositioned: true, inGroup: false, strokeDashArray: false, - objectCaching: true, shadow: { color: 'black', offsetX: 0, @@ -257,18 +256,7 @@ function group() { canvas.renderAll(); newLayer(newgroup); canvas.setActiveObject(newgroup); - keyframes.sort(function (a, b) { - if (a.id.indexOf('Group') >= 0 && b.id.indexOf('Group') == -1) { - return 1; - } else if ( - b.id.indexOf('Group') >= 0 && - a.id.indexOf('Group') == -1 - ) { - return -1; - } else { - return 0; - } - }); + sortKeyframes(); save(); } $(document).on('click', '#group-objects', group); @@ -348,11 +336,19 @@ $(document).on('click', '#ungroup-objects', function () { function reGroup(id) { var group = []; var objects = []; - groups - .find((x) => x.id == id) - .objects.forEach(function (object) { - objects.push(canvas.getItemById(object)); - }); + const entry = groups.find((x) => x.id == id); + if (!entry) { + return; + } + entry.objects.forEach(function (object) { + const item = canvas.getItemById(object); + if (item) { + objects.push(item); + } + }); + if (objects.length == 0) { + return; + } var activeselection = new fabric.ActiveSelection(objects); var newgroup = activeselection.toGroup(); newgroup.set({ @@ -364,14 +360,20 @@ function reGroup(id) { } // Keep record canvas up to date -function updateRecordCanvas() { +async function updateRecordCanvas() { canvasrecord.setWidth(artboard.width); canvasrecord.setHeight(artboard.height); canvasrecord.width = artboard.width; canvasrecord.height = artboard.height; canvas.clipPath = null; - objects.forEach(async function (object) { + // Sequential, not forEach(async): the snapshot below must not be taken + // while filters are still being stripped off the objects. + for (const object of objects) { var obj = canvas.getItemById(object.id); + if (!obj) { + object.filters = []; + continue; + } if (obj.filters) { if (obj.filters.length > 0) { object.filters = []; @@ -449,7 +451,7 @@ function updateRecordCanvas() { } else { object.filters = []; } - }); + } const canvassave = canvas.toJSON([ 'volume', 'audioSrc', @@ -536,10 +538,75 @@ function updateRecordCanvas() { }); } +// Frame rate the user picked in the download modal, used by every export path +const EXPORT_FPS_DEFAULT = 30; + +function getExportFramerate() { + const fps = parseInt($('#framerate').val(), 10); + if (!(fps > 0)) { + return EXPORT_FPS_DEFAULT; + } + return Math.min(120, fps); +} + +// A still image has no frame rate +$('input[name=radio]').on('change', function () { + $('#framerate-row').toggle( + $('input[name=radio]:checked').val() != 'image' + ); +}); + +// Put the editor back into a usable state once a render has finished (or failed) +function resetRecordingUI() { + 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(); +} + +// Hand a finished WebM recording to the user, converting it first if the +// chosen format is not webm. +function deliverRecording(blob) { + $('#download-real').html('Downloading...'); + const format = $('input[name=radio]:checked').val(); + if (format == 'mp4' || format == 'gif') { + convertStreams(blob, format); + return; + } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = 'video.webm'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Give the browser a tick to start the download before releasing the blob + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + resetRecordingUI(); +} + // Download recording function downloadRecording(chunks) { $('#download-real').html('Downloading...'); - if ($('input[name=radio]:checked').val() == 'webm') { + const format = $('input[name=radio]:checked').val(); + if (format == 'webm') { var url = URL.createObjectURL( new Blob(chunks, { type: 'video/webm', @@ -551,24 +618,21 @@ function downloadRecording(chunks) { a.download = 'video.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(); - $('#download-real').html('Download'); - $('#download-real').removeClass('downloading'); - updateRecordCanvas(); - } else if ($('input[name=radio]:checked').val() == 'mp4') { - type = 'video/mp4'; + document.body.removeChild(a); + // Give the browser a tick to start the download before releasing the blob + window.setTimeout(function () { + URL.revokeObjectURL(url); + }, 60000); + resetRecordingUI(); + } else if (format == 'mp4' || format == 'gif') { + // Both are transcoded from the captured webm by the ffmpeg worker + convertStreams( + new Blob(chunks, { type: 'video/webm' }), + format + ); } else { - convertStreams(new Blob(chunks, { type: 'video/webm' }), 'gif'); + console.warn('Unknown download format: ' + format); + resetRecordingUI(); } } @@ -653,8 +717,19 @@ function save() { $('#redo').removeClass('history-active'); } - updateRecordCanvas(); - autoSave(); + schedulePersist(); +} + +// Rebuilding the record canvas and writing to IndexedDB are both expensive +// and used to run on every single edit (including every keystroke). Coalesce +// them; record() awaits updateRecordCanvas() directly when it needs it fresh. +var persistTimer; +function schedulePersist() { + window.clearTimeout(persistTimer); + persistTimer = window.setTimeout(function () { + updateRecordCanvas(); + autoSave(); + }, 400); } // Duplicate object @@ -664,6 +739,8 @@ function copyObject() { if (clipboard.type == 'activeSelection') { clipboard._objects.forEach(function (clone) { clone.clone(function (cloned) { + // layer_count only advances inside newLayer, so read it after + // the clone resolves or every copy shares one id. cloned.set({ id: 'Shape' + layer_count, }); @@ -899,12 +976,14 @@ function undoRedo(newState, saveState, newArrState, saveArrState) { canvas.clipPath = null; canvas.loadFromJSON(state, function () { canvas.clipPath = artboard; - canvas.getItemById('line_h').set({ opacity: 0 }); - canvas.getItemById('line_v').set({ opacity: 0 }); + hideGuides(canvas); canvas.renderAll(); $('.object-props').remove(); $('.layer').remove(); objects.forEach(function (object) { + if (!canvas.getItemById(object.id)) { + return; + } replaceSource(canvas.getItemById(object.id), canvas); renderLayer(canvas.getItemById(object.id)); props.forEach(function (prop) { @@ -924,6 +1003,9 @@ function undoRedo(newState, saveState, newArrState, saveArrState) { }); }); keyframes.forEach(function (keyframe) { + if (!canvas.getItemById(keyframe.id)) { + return; + } if ( keyframe.name != 'top' && keyframe.name != 'scaleY' && @@ -963,7 +1045,7 @@ $(document).on('click', '#undo', function () { } }); $(document).on('click', '#redo', function () { - if (undo.length >= 1) { + if (redo.length >= 1) { undoRedo(redo, undo, redoarr, undoarr); } }); @@ -1048,7 +1130,7 @@ function keyframeChanges(object, type, id, selection) { 'height', object, currenttime, - object.get('width'), + object.get('height'), true ); if (selection) { @@ -1193,6 +1275,43 @@ function pause() { $('#play-button').attr('src', 'assets/play-button.svg'); } +// Write an object's stored default for a property, creating the entry if the +// object was serialized before that property existed. +function setDefaultValue(object, prop, value) { + const entry = objects.find((x) => x.id == object.get('id')); + if (!entry) { + return; + } + if (!entry.defaults) { + entry.defaults = []; + } + const def = entry.defaults.find((x) => x.name == prop); + if (def) { + def.value = value; + } else { + entry.defaults.push({ name: prop, value: value }); + } +} + +// Read an object's stored default for a property (undefined when absent) +function getDefaultValue(id, prop) { + const entry = objects.find((x) => x.id == id); + if (!entry || !entry.defaults) { + return undefined; + } + const def = entry.defaults.find((x) => x.name == prop); + return def ? def.value : undefined; +} + +// Read a property that may be a "shadow.*" path. +// fabric's get() is flat, so object.get("shadow.blur") is always undefined. +function getPropValue(object, prop) { + if (typeof prop === 'string' && prop.indexOf('shadow.') === 0) { + return object.shadow ? object.shadow[prop.slice(7)] : undefined; + } + return object.get(prop); +} + // Set object value (while animating) function setObjectValue(prop, object, value, inst) { if (object.get('type') != 'group') { @@ -1221,8 +1340,6 @@ function setObjectValue(prop, object, value, inst) { object.shadow.offsetX = value; } else if (prop == 'shadow.offsetY') { object.shadow.offsetY = value; - } else if (prop == 'shadow.blur') { - object.shadow.blur = value; } else if (object.get('type') != 'group') { object.set(prop, value); } else if (prop != 'width') { @@ -1232,34 +1349,68 @@ function setObjectValue(prop, object, value, inst) { inst.renderAll(); } -// Find last keyframe in time from same object & property -function lastKeyframe(keyframe, index) { - var temparr = keyframes.slice(); - temparr.sort(function (a, b) { - return a.t - b.t; - }); - temparr.length = temparr.findIndex((x) => x === keyframe); - temparr.reverse(); - if (temparr.length == 0) { - return false; - } else { - for (var i = 0; i < temparr.length; i++) { - if ( - temparr[i].id == keyframe.id && - temparr[i].name == keyframe.name - ) { - return temparr[i]; - break; - } else if (i == temparr.length - 1) { - return false; - } +// Group keyframes by "id|name", each group sorted by time. +// Built once per rendered frame: without it every keyframe lookup sorted a +// copy of the whole keyframe list, making playback O(n^2 log n). +function buildKeyframeIndex() { + const idx = new Map(); + keyframes.forEach(function (keyframe) { + const key = keyframe.id + '|' + keyframe.name; + let arr = idx.get(key); + if (!arr) { + arr = []; + idx.set(key, arr); } + arr.push(keyframe); + }); + idx.forEach(function (arr) { + arr.sort(function (a, b) { + return a.t - b.t; + }); + }); + return idx; +} + +// All keyframes of one object/property, sorted by time +function keyframeSiblings(keyframe, idx) { + if (idx) { + return idx.get(keyframe.id + '|' + keyframe.name) || []; } + return keyframes + .filter(function (e) { + return e.id == keyframe.id && e.name == keyframe.name; + }) + .sort(function (a, b) { + return a.t - b.t; + }); +} + +// Find last keyframe in time from same object & property +function lastKeyframe(keyframe, idx) { + const arr = keyframeSiblings(keyframe, idx); + const pos = arr.indexOf(keyframe); + if (pos <= 0) { + return false; + } + return arr[pos - 1]; +} + +// Find next keyframe in time from same object & property +function nextKeyframe(keyframe, idx) { + const arr = keyframeSiblings(keyframe, idx); + const pos = arr.indexOf(keyframe); + if (pos == -1 || pos == arr.length - 1) { + return false; + } + return arr[pos + 1]; } // Check whether any keyframe exists for a certain property -function checkAnyKeyframe(id, prop, inst) { +function checkAnyKeyframe(id, prop, inst, idx) { const object = inst.getItemById(id); + if (!object) { + return false; + } if (object.get('assetType') == 'audio') { return false; } @@ -1279,13 +1430,14 @@ function checkAnyKeyframe(id, prop, inst) { ) { return false; } - const keyarr2 = $.grep(keyframes, function (e) { - return e.id == id && e.name == prop; - }); - if (keyarr2.length == 0) { - const value = objects - .find((x) => x.id == id) - .defaults.find((x) => x.name == prop).value; + const hasKeyframe = idx + ? idx.has(id + '|' + prop) + : keyframes.some((e) => e.id == id && e.name == prop); + if (!hasKeyframe) { + const value = getDefaultValue(id, prop); + if (value === undefined) { + return false; + } setObjectValue(prop, object, value, inst); } } @@ -1299,23 +1451,18 @@ function isDomElem(el) { // Play videos when seeking/playing async function playVideos(time) { - objects.forEach(async function (object) { - var object = canvas.getItemById(object.id); - if (object == null) { + objects.forEach(async function (entry) { + var inst = recording ? canvasrecord : canvas; + var object = inst.getItemById(entry.id); + var p_keyframe = p_keyframes.find((x) => x.id == entry.id); + if (object == null || !p_keyframe) { return false; } - var inst = canvas; var start = false; - if (recording) { - object = canvasrecord.getItemById(object.id); - inst = canvasrecord; - } if ( object.get('id').indexOf('Video') >= 0 && - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start <= - time && - p_keyframes.find((x) => x.id == object.id).end >= time + p_keyframe.trimstart + p_keyframe.start <= time && + p_keyframe.end >= time ) { var tempfilters = object.filters; if (object.filters.length > 0) { @@ -1334,9 +1481,7 @@ async function playVideos(time) { if ($(object.getElement())[0].paused == true) { $(object.getElement())[0].currentTime = parseFloat( ( - (time - - p_keyframes.find((x) => x.id == object.id).start + - p_keyframes.find((x) => x.id == object.id).trimstart) / + (time - p_keyframe.start + p_keyframe.trimstart) / 1000 ).toFixed(2) ); @@ -1394,10 +1539,7 @@ async function playVideos(time) { if (paused) { $(object.getElement())[0].currentTime = parseFloat( ( - (time - - p_keyframes.find((x) => x.id == object.id).start + - p_keyframes.find((x) => x.id == object.id) - .trimstart) / + (time - p_keyframe.start + p_keyframe.trimstart) / 1000 ).toFixed(2) ); @@ -1424,10 +1566,11 @@ async function playVideos(time) { // Play background audio function playAudio(time) { - objects.forEach(async function (object) { + objects.forEach(function (object) { var start = false; var obj = canvas.getItemById(object.id); - if (obj.get('assetType') == 'audio') { + var p_keyframe = p_keyframes.find((x) => x.id == object.id); + if (obj && p_keyframe && obj.get('assetType') == 'audio') { var flag = false; var animation = { value: 0, @@ -1439,20 +1582,20 @@ function playAudio(time) { duration: duration, easing: 'linear', autoplay: true, - update: async function () { - if (start && play && !paused) { + update: function () { + // `paused` is the only playback flag here; `play` used to be read + // as a variable but resolves to the global play() function, so the + // condition was always true. + if (start && !paused) { if ( !flag && - p_keyframes.find((x) => x.id == object.id).start <= - currenttime && - p_keyframes.find((x) => x.id == object.id).end >= - currenttime + p_keyframe.start <= currenttime && + p_keyframe.end >= currenttime ) { if (obj.get('src')) { obj.get('src').currentTime = - (p_keyframes.find((x) => x.id == object.id) - .trimstart - - p_keyframes.find((x) => x.id == object.id).start + + (p_keyframe.trimstart - + p_keyframe.start + currenttime) / 1000; obj.get('src').volume = obj.get('volume'); @@ -1464,19 +1607,16 @@ function playAudio(time) { audio.volume = obj.get('volume'); audio.crossOrigin = 'anonymous'; audio.currentTime = - (p_keyframes.find((x) => x.id == object.id) - .trimstart - - p_keyframes.find((x) => x.id == object.id).start + + (p_keyframe.trimstart - + p_keyframe.start + currenttime) / 1000; audio.play(); flag = true; } } else if ( - p_keyframes.find((x) => x.id == object.id).start >= - currenttime || - p_keyframes.find((x) => x.id == object.id).end <= - currenttime + p_keyframe.start >= currenttime || + p_keyframe.end <= currenttime ) { if (obj.get('src')) { obj.get('src').pause(); @@ -1502,6 +1642,7 @@ async function recordAnimate(time) { anime.speed = 1; //return new Promise(function(resolve){ var inst = canvasrecord; + const idx = buildKeyframeIndex(); if (animatedtext.length > 0) { animatedtext.forEach(function (text) { text.seek(time, inst); @@ -1515,33 +1656,23 @@ async function recordAnimate(time) { reGroup(keyframe.id); } const object = inst.getItemById(keyframe.id); - if (!object) { + const p_keyframe = p_keyframes.find((x) => x.id == keyframe.id); + if (!object || !p_keyframe) { return; } - if ( - time < - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + if (time < p_keyframe.trimstart + p_keyframe.start) { object.set('visible', false); inst.renderAll(); - } else if ( - time > p_keyframes.find((x) => x.id == keyframe.id).end || - time > duration - ) { + } else if (time > p_keyframe.end || time > duration) { object.set('visible', false); inst.renderAll(); } else { object.set('visible', true); inst.renderAll(); } - if ( - time >= - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + if (time >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(keyframe.id, prop, inst); + checkAnyKeyframe(keyframe.id, prop, inst, idx); }); } } @@ -1574,8 +1705,6 @@ async function recordAnimate(time) { object.shadow.offsetX = value; } else if (prop == 'shadow.offsetY') { object.shadow.offsetY = value; - } else if (prop == 'shadow.blur') { - object.shadow.blur = value; } else if (object.get('type') != 'group') { object.set(prop, value); } else if (prop != 'width') { @@ -1585,30 +1714,6 @@ async function recordAnimate(time) { inst.renderAll(); } - // Find next keyframe in time from same object & property - function nextKeyframe(keyframe, index) { - var temparr = keyframes.slice(); - temparr.sort(function (a, b) { - return a.t - b.t; - }); - temparr.splice(0, temparr.findIndex((x) => x === keyframe) + 1); - if (temparr.length == 0) { - return false; - } else { - for (var i = 0; i < temparr.length; i++) { - if ( - temparr[i].id == keyframe.id && - temparr[i].name == keyframe.name - ) { - return temparr[i]; - break; - } else if (i == temparr.length - 1) { - return false; - } - } - } - } - var object = canvasrecord.getItemById(keyframe.id); if (!object) { return; @@ -1623,12 +1728,13 @@ async function recordAnimate(time) { var start = false; var lasttime, lastprop; // Find last keyframe in time from same object & property - var lastkey = lastKeyframe(keyframe, index); + var lastkey = lastKeyframe(keyframe, idx); if (!lastkey) { lasttime = 0; - lastprop = objects - .find((x) => x.id == keyframe.id) - .defaults.find((x) => x.name == keyframe.name).value; + lastprop = getDefaultValue(keyframe.id, keyframe.name); + if (lastprop === undefined) { + return; + } } else { lasttime = lastkey.t; lastprop = lastkey.value; @@ -1674,7 +1780,7 @@ async function recordAnimate(time) { ) { setValue(keyframe.name, object, animation.value, inst); } - } else if (keyframe.t < time && !nextKeyframe(keyframe, index)) { + } else if (keyframe.t < time && !nextKeyframe(keyframe, idx)) { var prop = keyframe.name; if (prop == 'shadow.blur') { if (object.shadow.blur != keyframe.value) { @@ -1705,37 +1811,31 @@ async function recordAnimate(time) { // Visibility has to be applied to the object on the recording canvas, // at the time of the frame being rendered (not the editor playhead). const object2 = inst.getItemById(object.id); - if (!object2) { + const p_keyframe = p_keyframes.find((x) => x.id == object.id); + if (!object2 || !p_keyframe) { return; } - if ( - time < - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + if (time < p_keyframe.trimstart + p_keyframe.start) { object2.set('visible', false); - } else if ( - time > p_keyframes.find((x) => x.id == object.id).end || - time > duration - ) { + } else if (time > p_keyframe.end || time > duration) { object2.set('visible', false); } else { object2.set('visible', true); } - if ( - time >= - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + if (time >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(object.id, prop, inst); + checkAnyKeyframe(object.id, prop, inst, idx); }); } } }); inst.renderAll(); - playVideos(time); + // The offline renderer seeks each video layer to the exact frame time + // itself; playVideos() would fight it by starting real-time playback. + if (!offlinerender) { + playVideos(time); + } //}); } @@ -1743,52 +1843,27 @@ async function recordAnimate(time) { async function animate(play, time) { anime.speed = speed; if (!draggingPanel) { - var starttime = new Date(); var offset = time; var inst = canvas; + const idx = buildKeyframeIndex(); keyframes.forEach(function (keyframe, index) { - // Find next keyframe in time from same object & property - function nextKeyframe(keyframe, index) { - var temparr = keyframes.slice(); - temparr.sort(function (a, b) { - return a.t - b.t; - }); - temparr.splice( - 0, - temparr.findIndex((x) => x === keyframe) + 1 - ); - if (temparr.length == 0) { - return false; - } else { - for (var i = 0; i < temparr.length; i++) { - if ( - temparr[i].id == keyframe.id && - temparr[i].name == keyframe.name - ) { - return temparr[i]; - break; - } else if (i == temparr.length - 1) { - return false; - } - } - } - } // Regroup if needed (groups break to animate their children, then regroup after children have animated) if (groups.find((x) => x.id == keyframe.id)) { if (!canvas.getItemById(keyframe.id)) { reGroup(keyframe.id); } const object = canvas.getItemById(keyframe.id); - if ( - currenttime < - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + const p_keyframe = p_keyframes.find( + (x) => x.id == keyframe.id + ); + if (!object || !p_keyframe) { + return; + } + if (currenttime < p_keyframe.trimstart + p_keyframe.start) { object.set('visible', false); inst.renderAll(); } else if ( - currenttime > - p_keyframes.find((x) => x.id == keyframe.id).end || + currenttime > p_keyframe.end || currenttime > duration ) { object.set('visible', false); @@ -1797,13 +1872,9 @@ async function animate(play, time) { object.set('visible', true); inst.renderAll(); } - if ( - currenttime >= - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start - ) { + if (currenttime >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(keyframe.id, prop, inst); + checkAnyKeyframe(keyframe.id, prop, inst, idx); }); } } @@ -1845,8 +1916,6 @@ async function animate(play, time) { object.shadow.offsetX = value; } else if (prop == 'shadow.offsetY') { object.shadow.offsetY = value; - } else if (prop == 'shadow.blur') { - object.shadow.blur = value; } else if (object.get('type') != 'group') { object.set(prop, value); } else if (prop != 'width') { @@ -1856,22 +1925,28 @@ async function animate(play, time) { } var object = canvas.getItemById(keyframe.id); + var kf_p_keyframe = p_keyframes.find( + (x) => x.id == keyframe.id + ); + if (!object || !kf_p_keyframe) { + return; + } if ( keyframe.t >= time && currenttime >= - p_keyframes.find((x) => x.id == keyframe.id).trimstart + - p_keyframes.find((x) => x.id == keyframe.id).start + kf_p_keyframe.trimstart + kf_p_keyframe.start ) { var delay = 0; var start = false; var lasttime, lastprop; // Find last keyframe in time from same object & property - var lastkey = lastKeyframe(keyframe, index); + var lastkey = lastKeyframe(keyframe, idx); if (!lastkey) { lasttime = 0; - lastprop = objects - .find((x) => x.id == keyframe.id) - .defaults.find((x) => x.name == keyframe.name).value; + lastprop = getDefaultValue(keyframe.id, keyframe.name); + if (lastprop === undefined) { + return; + } } else { lasttime = lastkey.t; lastprop = lastkey.value; @@ -1900,12 +1975,8 @@ async function animate(play, time) { if (start && !paused) { if ( currenttime < - p_keyframes.find((x) => x.id == keyframe.id) - .trimstart + - p_keyframes.find((x) => x.id == keyframe.id) - .start || - currenttime > - p_keyframes.find((x) => x.id == keyframe.id).end || + kf_p_keyframe.trimstart + kf_p_keyframe.start || + currenttime > kf_p_keyframe.end || currenttime > duration ) { object.set('visible', false); @@ -1945,7 +2016,7 @@ async function animate(play, time) { } } else if ( keyframe.t < time && - !nextKeyframe(keyframe, index) + !nextKeyframe(keyframe, idx) ) { var prop = keyframe.name; if (prop == 'left' && !recording) { @@ -2007,33 +2078,28 @@ async function animate(play, time) { objects.forEach(function (object) { if (object.id.indexOf('Group') == -1) { const object2 = canvas.getItemById(object.id); - if ( - currenttime < - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + const p_keyframe = p_keyframes.find((x) => x.id == object.id); + if (!object2 || !p_keyframe) { + return; + } + if (currenttime < p_keyframe.trimstart + p_keyframe.start) { object2.set('visible', false); } else if ( - currenttime > - p_keyframes.find((x) => x.id == object.id).end || + currenttime > p_keyframe.end || currenttime > duration ) { object2.set('visible', false); } else { object2.set('visible', true); } - if ( - currenttime >= - p_keyframes.find((x) => x.id == object.id).trimstart + - p_keyframes.find((x) => x.id == object.id).start - ) { + if (currenttime >= p_keyframe.trimstart + p_keyframe.start) { props.forEach(function (prop) { - checkAnyKeyframe(object.id, prop, inst); + checkAnyKeyframe(object.id, prop, inst, idx); }); } } var obj = canvas.getItemById(object.id); - if (obj.type == 'lottie') { + if (obj && obj.type == 'lottie') { obj.goToSeconds(currenttime); inst.renderAll(); } @@ -2073,16 +2139,19 @@ async function animate(play, time) { objects.forEach(function (object) { if (object.id.indexOf('Group') == -1) { const object2 = inst.getItemById(object.id); + const p_keyframe = p_keyframes.find( + (x) => x.id == object.id + ); + if (!object2 || !p_keyframe) { + return; + } if ( currenttime < - p_keyframes.find((x) => x.id == object.id) - .trimstart + - p_keyframes.find((x) => x.id == object.id).start + p_keyframe.trimstart + p_keyframe.start ) { object2.set('visible', false); } else if ( - currenttime > - p_keyframes.find((x) => x.id == object.id).end || + currenttime > p_keyframe.end || currenttime > duration ) { object2.set('visible', false); @@ -2091,17 +2160,15 @@ async function animate(play, time) { } if ( currenttime >= - p_keyframes.find((x) => x.id == object.id) - .trimstart + - p_keyframes.find((x) => x.id == object.id).start + p_keyframe.trimstart + p_keyframe.start ) { props.forEach(function (prop) { - checkAnyKeyframe(object.id, prop, inst); + checkAnyKeyframe(object.id, prop, inst, idx); }); } } var obj = canvas.getItemById(object.id); - if (obj.type == 'lottie') { + if (obj && obj.type == 'lottie') { obj.goToSeconds(currenttime); inst.renderAll(); } @@ -2129,64 +2196,53 @@ async function animate(play, time) { } } -// Render a keyframe +// Keep group keyframes last so children animate before their group regroups +function sortKeyframes() { + keyframes.sort(function (a, b) { + if (a.id.indexOf('Group') >= 0 && b.id.indexOf('Group') == -1) { + return 1; + } else if ( + b.id.indexOf('Group') >= 0 && + a.id.indexOf('Group') == -1 + ) { + return -1; + } else { + return 0; + } + }); +} + +// Render a keyframe. +// data-time is ALWAYS the absolute timeline time, because every lookup keys +// off it. The CSS offset is relative, since the row it lives in is already +// shifted by the layer start. function renderKeyframe(object, prop, time) { - const color = objects.find((x) => x.id == object.id).color; - if (prop == 'shadow.color') { - if ( - $('#' + object.get('id')) - .find('.shadowcolor') - .is(':visible') - ) { - time = - time - - parseFloat( - p_keyframes.find((x) => x.id == object.get('id')).start - ); - } - $('#' + object.get('id')) - .find('.shadowcolor') - .prepend( - "
" - ); - $('#' + object.get('id')) - .find('.shadowcolor') - .find("[data-time='" + time + "']") - .css({ left: time / timelinetime, background: color }); - } else { - if ( - $('#' + object.get('id')) - .find('.' + prop) - .is(':visible') - ) { - time = - time - - parseFloat( - p_keyframes.find((x) => x.id == object.get('id')).start - ); - } - $('#' + object.get('id')) - .find('.' + prop) - .prepend( - "
" - ); - $('#' + object.get('id')) - .find('.' + prop) - .find("[data-time='" + time + "']") - .css({ left: time / timelinetime, background: color }); + const entry = objects.find((x) => x.id == object.get('id')); + const p_keyframe = p_keyframes.find( + (x) => x.id == object.get('id') + ); + if (!entry || !p_keyframe) { + return; } + const color = entry.color; + const rowclass = prop == 'shadow.color' ? 'shadowcolor' : prop; + const row = $('#' + object.get('id')).find('.' + rowclass); + if (row.length == 0) { + return; + } + const displaytime = time - parseFloat(p_keyframe.start); + row.prepend( + "
" + ); + row + .find("[data-time='" + time + "'][data-property='" + prop + "']") + .css({ left: displaytime / timelinetime, background: color }); } // Create a keyframe @@ -2210,19 +2266,19 @@ function newKeyframe(property, object, time, value, render) { }); if (keyarr2.length == 0) { if (property == 'left') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('left'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('left') + ); } else if (property == 'top') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('top'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('top') + ); } else { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = value; + setDefaultValue(object, property, value); } } if (keyarr.length == 0) { @@ -2266,21 +2322,7 @@ function newKeyframe(property, object, time, value, render) { ) { renderKeyframe(object, property, time); } - keyframes.sort(function (a, b) { - if ( - a.id.indexOf('Group') >= 0 && - b.id.indexOf('Group') == -1 - ) { - return 1; - } else if ( - b.id.indexOf('Group') >= 0 && - a.id.indexOf('Group') == -1 - ) { - return -1; - } else { - return 0; - } - }); + sortKeyframes(); } else if (render) { if ( property != 'top' && @@ -2308,19 +2350,19 @@ function newKeyframe(property, object, time, value, render) { } } else { if (property == 'left') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('left'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('left') + ); } else if (property == 'top') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = - object.get(property) - artboard.get('top'); + setDefaultValue( + object, + property, + object.get(property) - artboard.get('top') + ); } else { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == property).value = value; + setDefaultValue(object, property, value); } } } @@ -2368,32 +2410,33 @@ function manualKeyframe() { ); } else if (prop == 'shadow') { prop = 'shadow.color'; + // fabric's get() is not a path getter - read the shadow object directly newKeyframe( 'shadow.opacity', object, currenttime, - object.get('shadow.opacity'), + object.shadow.opacity, true ); newKeyframe( 'shadow.offsetX', object, currenttime, - object.get('shadow.offsetX'), + object.shadow.offsetX, true ); newKeyframe( 'shadow.offsetY', object, currenttime, - object.get('shadow.offsetY'), + object.shadow.offsetY, true ); newKeyframe( 'shadow.blur', object, currenttime, - object.get('shadow.blur'), + object.shadow.blur, true ); } else if (prop == 'text') { @@ -2406,7 +2449,7 @@ function manualKeyframe() { true ); } - newKeyframe(prop, object, currenttime, object.get(prop), true); + newKeyframe(prop, object, currenttime, getPropValue(object, prop), true); save(); } $(document).on('click', '.property-keyframe', manualKeyframe); @@ -2526,14 +2569,8 @@ function animateProp(prop, object) { true ); newKeyframe('top', object, currenttime, object.get('top'), true); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'left').value = - object.get('left') - artboard.get('left'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'top').value = - object.get('top') - artboard.get('top'); + setDefaultValue(object, 'left', object.get('left') - artboard.get('left')); + setDefaultValue(object, 'top', object.get('top') - artboard.get('top')); } else if (prop == 'scaleX') { newKeyframe( 'scaleY', @@ -2565,18 +2602,9 @@ function animateProp(prop, object) { objects .find((x) => x.id == object.get('id')) .animate.push('height'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'height').value = - object.get('height'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'width').value = - object.get('width'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'scaleY').value = - object.get('scaleY'); + setDefaultValue(object, 'height', object.get('height')); + setDefaultValue(object, 'width', object.get('width')); + setDefaultValue(object, 'scaleY', object.get('scaleY')); } else if (prop == 'strokeWidth') { newKeyframe( 'stroke', @@ -2585,10 +2613,7 @@ function animateProp(prop, object) { object.get('stroke'), true ); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'stroke').value = - object.get('stroke'); + setDefaultValue(object, 'stroke', object.get('stroke')); objects .find((x) => x.id == object.get('id')) .animate.push('stroke'); @@ -2628,26 +2653,11 @@ function animateProp(prop, object) { object.shadow.blur, true ); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.color').value = - object.get('shadow.color'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.opacity').value = - object.get('shadow.opacity'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetX').value = - object.get('shadow.offsetX'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetY').value = - object.get('shadow.offsetY'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.blur').value = - object.get('shadow.blur'); + setDefaultValue(object, 'shadow.color', object.shadow.color); + setDefaultValue(object, 'shadow.opacity', object.shadow.opacity); + setDefaultValue(object, 'shadow.offsetX', object.shadow.offsetX); + setDefaultValue(object, 'shadow.offsetY', object.shadow.offsetY); + setDefaultValue(object, 'shadow.blur', object.shadow.blur); objects .find((x) => x.id == object.get('id')) .animate.push('shadow.opacity'); @@ -2668,10 +2678,7 @@ function animateProp(prop, object) { object.get('lineHeight'), true ); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'lineHeight').value = - object.get('lineHeight'); + setDefaultValue(object, 'lineHeight', object.get('lineHeight')); objects .find((x) => x.id == object.get('id')) .animate.push('lineHeight'); @@ -2680,9 +2687,7 @@ function animateProp(prop, object) { // Exception if (prop != 'left' && prop != 'shadow.color') { newKeyframe(prop, object, currenttime, object.get(prop), true); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == prop).value = object.get(prop); + setDefaultValue(object, prop, object.get(prop)); } } @@ -2704,27 +2709,12 @@ function freezeProp(prop, object) { keyframes = $.grep(keyframes, function (e) { return e.id != object.get('id') || e.name != 'top'; }); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'left').value = - object.get('left') - artboard.get('left'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'top').value = - object.get('top') - artboard.get('top'); + setDefaultValue(object, 'left', object.get('left') - artboard.get('left')); + setDefaultValue(object, 'top', object.get('top') - artboard.get('top')); } else if (prop == 'scaleX') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'height').value = - object.get('height'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'width').value = - object.get('width'); - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'scaleY').value = - object.get('scaleY'); + setDefaultValue(object, 'height', object.get('height')); + setDefaultValue(object, 'width', object.get('width')); + setDefaultValue(object, 'scaleY', object.get('scaleY')); objects.find((x) => x.id == object.get('id')).animate = $.grep( objects.find((x) => x.id == object.get('id')).animate, function (e) { @@ -2753,10 +2743,7 @@ function freezeProp(prop, object) { return e.id != object.get('id') || e.name != 'height'; }); } else if (prop == 'strokeWidth') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'stroke').value = - object.get('stroke'); + setDefaultValue(object, 'stroke', object.get('stroke')); objects.find((x) => x.id == object.get('id')).animate = $.grep( objects.find((x) => x.id == object.get('id')).animate, function (e) { @@ -2767,22 +2754,10 @@ function freezeProp(prop, object) { return e.id != object.get('id') || e.name != 'stroke'; }); } else if (prop == 'shadow.color') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.opacity').value = - object.shadow.opacity; - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetX').value = - object.shadow.offsetX; - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.offsetY').value = - object.shadow.offsetY; - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'shadow.blur').value = - object.shadow.blur; + setDefaultValue(object, 'shadow.opacity', object.shadow.opacity); + setDefaultValue(object, 'shadow.offsetX', object.shadow.offsetX); + setDefaultValue(object, 'shadow.offsetY', object.shadow.offsetY); + setDefaultValue(object, 'shadow.blur', object.shadow.blur); keyframes = $.grep(keyframes, function (e) { return e.id != object.get('id') || e.name != 'shadow.opacity'; }); @@ -2820,10 +2795,7 @@ function freezeProp(prop, object) { } ); } else if (prop == 'charSpacing') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == 'lineHeight').value = - object.get('lineHeight'); + setDefaultValue(object, 'lineHeight', object.get('lineHeight')); objects.find((x) => x.id == object.get('id')).animate = $.grep( objects.find((x) => x.id == object.get('id')).animate, function (e) { @@ -2841,9 +2813,7 @@ function freezeProp(prop, object) { // Exception if (prop != 'left' && prop != 'shadow.color') { - objects - .find((x) => x.id == object.get('id')) - .defaults.find((x) => x.name == prop).value = object.get(prop); + setDefaultValue(object, prop, object.get(prop)); } $( @@ -2944,7 +2914,11 @@ function lockLayer(e) { $(this).removeClass('locked'); $(this).attr('src', 'assets/lock.svg'); object.selectable = true; - $(this).parent().parent().parent().attr('draggable', true); + $(this) + .parent() + .parent() + .find('.layer-handle') + .attr('draggable', true); } else { $(this).addClass('locked'); $(this).attr('src', 'assets/locked.svg'); @@ -2953,7 +2927,11 @@ function lockLayer(e) { canvas.discardActiveObject(); canvas.renderAll(); } - $(this).parent().parent().parent().attr('draggable', false); + $(this) + .parent() + .parent() + .find('.layer-handle') + .attr('draggable', false); } save(); } @@ -2972,8 +2950,15 @@ function centerObject(object) { // Render a layer function renderLayer(object, animate = false) { + const entry = objects.find((x) => x.id == object.get('id')); + const p_keyframe = p_keyframes.find( + (x) => x.id == object.get('id') + ); + if (!entry || !p_keyframe) { + return; + } $('#nolayers').addClass('yaylayers'); - const color = objects.find((x) => x.id == object.get('id')).color; + const color = entry.color; var src = ''; var classlock = ''; var srclock = 'lock'; @@ -3014,20 +2999,14 @@ function renderLayer(object, animate = false) { if (animate != false) { freeze = 'frozen'; } - const leftoffset = - p_keyframes.find((x) => x.id == object.get('id')).trimstart / - timelinetime; + const leftoffset = p_keyframe.trimstart / timelinetime; const width = - (p_keyframes.find((x) => x.id == object.get('id')).end - - p_keyframes.find((x) => x.id == object.get('id')).trimstart) / - timelinetime; + (p_keyframe.end - p_keyframe.trimstart) / timelinetime; $('#inner-timeline').prepend( "
x.id == object.get('id')).start) / - timelinetime + + (p_keyframe.end - p_keyframe.start) / timelinetime + "px'>
{ - return { - element: document.getElementById('nothing'), - posX: event.pageX - elementOffset.left, - posY: event.pageY - elementOffset.top, - }; - }, - }); + // Make the new layer draggable (html5sortable only wires up the + // children that exist when it is initialized) + if (typeof initLayerSortable == 'function') { + initLayerSortable(); + } if (object.selectable == false) { - $(".layer[data-object='" + object.get('id') + "']").attr( - 'draggable', - false - ); + $(".layer[data-object='" + object.get('id') + "']") + .find('.layer-handle') + .attr('draggable', false); } } // Render a property function renderProp(prop, object) { var classfreeze = ''; - srcfreeze = 'freeze'; - if ( - $.inArray( - prop, - objects.find((x) => x.id == object.get('id')).animate - ) != -1 - ) { + var srcfreeze = 'freeze'; + const entry = objects.find((x) => x.id == object.get('id')); + if (!entry) { + return; + } + if ($.inArray(prop, entry.animate) != -1) { classfreeze = 'frozen'; srcfreeze = 'frozen'; } @@ -3218,6 +3189,10 @@ function newLayer(object) { } else if (object.get('assetType') == 'audio') { color = '#11C0F7'; } + } else if (object.get('type') == 'lottie') { + color = '#F1890E'; + } else { + color = '#9211F7'; } if ( (object.get('assetType') && object.get('assetType') == 'video') || @@ -3235,25 +3210,20 @@ function newLayer(object) { start: 0, end: object.get('duration'), }); - if (object.get('duration') < duration) { - p_keyframes.push({ - start: currenttime, - end: object.get('duration') + currenttime, - trimstart: 0, - trimend: object.get('duration') + currenttime, - object: object, - id: object.get('id'), - }); - } else { - p_keyframes.push({ - start: currenttime, - end: duration - currenttime, - trimstart: 0, - trimend: duration - currenttime, - object: object, - id: object.get('id'), - }); - } + // Clamp to the project end. This used to be `duration - currenttime`, + // which shortened every layer added after t=0. + const mediaend = Math.min( + object.get('duration') + currenttime, + duration + ); + p_keyframes.push({ + start: currenttime, + end: mediaend, + trimstart: 0, + trimend: mediaend, + object: object, + id: object.get('id'), + }); } else { objects.push({ object: object, @@ -3264,25 +3234,16 @@ function newLayer(object) { locked: [], mask: 'none', }); - if (object.get('notnew')) { - p_keyframes.push({ - start: object.get('starttime'), - end: duration - object.get('starttime'), - trimstart: 0, - trimend: duration - currenttime, - object: object, - id: object.get('id'), - }); - } else { - p_keyframes.push({ - start: currenttime, - end: duration - currenttime, - trimstart: 0, - trimend: duration - currenttime, - object: object, - id: object.get('id'), - }); - } + p_keyframes.push({ + start: object.get('notnew') + ? object.get('starttime') + : currenttime, + end: duration, + trimstart: 0, + trimend: duration, + object: object, + id: object.get('id'), + }); } renderLayer(object); if ( @@ -3370,9 +3331,11 @@ function newLayer(object) { $(".layer[data-object='" + object.get('id') + "']").addClass( 'layer-selected' ); - document - .getElementsByClassName('layer-selected')[0] - .scrollIntoView(); + const selectedLayer = + document.getElementsByClassName('layer-selected')[0]; + if (selectedLayer) { + selectedLayer.scrollIntoView(); + } objects.find((x) => x.id == object.id).animate = []; animate(false, currenttime); save(); @@ -3502,6 +3465,10 @@ function loadVideo(src, x, y, center) { vidSrc.src = src; vidObj.crossOrigin = 'anonymous'; vidObj.appendChild(vidSrc); + vidObj.addEventListener('error', function () { + console.error('Could not load video', src); + $('#load-video').removeClass('loading-active'); + }); vidObj.addEventListener('loadeddata', function () { vidObj.width = this.videoWidth; vidObj.height = this.videoHeight; @@ -3547,17 +3514,20 @@ function checkCrop(obj) { // Perform a crop function crop(obj) { - var crop = canvas.getItemById('crop'); + var cropUI = canvas.getItemById('crop'); + if (!obj || !cropUI || !cropobj) { + return; + } cropobj.setCoords(); - crop.setCoords(); + cropUI.setCoords(); var cleft = - crop.get('left') - (crop.get('width') * crop.get('scaleX')) / 2; + cropUI.get('left') - (cropUI.get('width') * cropUI.get('scaleX')) / 2; var ctop = - crop.get('top') - (crop.get('height') * crop.get('scaleY')) / 2; + cropUI.get('top') - (cropUI.get('height') * cropUI.get('scaleY')) / 2; var height = - (crop.get('height') / cropobj.get('scaleY')) * crop.get('scaleY'); + (cropUI.get('height') / cropobj.get('scaleY')) * cropUI.get('scaleY'); var width = - (crop.get('width') / cropobj.get('scaleX')) * crop.get('scaleX'); + (cropUI.get('width') / cropobj.get('scaleX')) * cropUI.get('scaleX'); var img_height = cropobj.get('height') * cropobj.get('scaleY'); var img_width = cropobj.get('width') * cropobj.get('scaleX'); var left = @@ -3603,8 +3573,8 @@ function crop(obj) { canvas.renderAll(); } if (obj.get('id') != 'cropped') { - canvas.remove(crop); - canvas.remove(canvas.getItemById('overlay')); + canvas.remove(cropUI); + canvas.remove(canvas.getItemById('crop-overlay')); canvas.remove(canvas.getItemById('cropped')); cropping = false; resetControls(); @@ -3613,7 +3583,7 @@ function crop(obj) { newKeyframe('scaleX', obj, currenttime, obj.get('scaleX'), true); newKeyframe('scaleY', obj, currenttime, obj.get('scaleY'), true); newKeyframe('width', obj, currenttime, obj.get('width'), true); - newKeyframe('height', obj, currenttime, obj.get('width'), true); + newKeyframe('height', obj, currenttime, obj.get('height'), true); newKeyframe('left', obj, currenttime, obj.get('left'), true); newKeyframe('top', obj, currenttime, obj.get('top'), true); $('#properties-overlay').removeClass('properties-disabled'); @@ -3642,7 +3612,8 @@ function overlay() { height: artboard.height, fill: 'rgba(0,0,0,0.5)', selectable: false, - id: 'overlay', + // Not "overlay": that is the artboard's id + id: 'crop-overlay', }) ); } @@ -3863,6 +3834,10 @@ function loadImage(src, x, y, width, center) { image.onload = function (img) { newImage(image, x, y, width, center); }; + image.onerror = function () { + console.error('Could not load image', src); + $('#load-image').removeClass('loading-active'); + }; image.src = src; } @@ -3890,19 +3865,15 @@ function createVideoThumbnail(file, max, seekTo = 0.0, isURL) { videoPlayer.addEventListener('seeked', () => { var oc = document.createElement('canvas'); var octx = oc.getContext('2d'); - oc.width = videoPlayer.videoWidth; - oc.height = videoPlayer.videoheight; - octx.drawImage(videoPlayer, 0, 0); if (videoPlayer.videoWidth > videoPlayer.videoHeight) { + oc.width = max; oc.height = (videoPlayer.videoHeight / videoPlayer.videoWidth) * max; - oc.width = max; } else { + oc.height = max; oc.width = (videoPlayer.videoWidth / videoPlayer.videoHeight) * max; - oc.height = max; } - octx.drawImage(oc, 0, 0, oc.width, oc.height); octx.drawImage(videoPlayer, 0, 0, oc.width, oc.height); resolve(oc.toDataURL()); }); @@ -3919,17 +3890,13 @@ function createThumbnail(file, max) { if (img.width > max) { var oc = document.createElement('canvas'); var octx = oc.getContext('2d'); - oc.width = img.width; - oc.height = img.height; - octx.drawImage(img, 0, 0); if (img.width > img.height) { - oc.height = (img.height / img.width) * max; oc.width = max; + oc.height = (img.height / img.width) * max; } else { - oc.width = (img.width / img.height) * max; oc.height = max; + oc.width = (img.width / img.height) * max; } - octx.drawImage(oc, 0, 0, oc.width, oc.height); octx.drawImage(img, 0, 0, oc.width, oc.height); resolve(oc.toDataURL()); } else { @@ -4291,34 +4258,35 @@ $(document).on('click', '.align-text', alignText); // Change font function changeFont() { var font = $('#font-picker').val(); - if (canvas.getActiveObject().get('assetType')) { + const active = canvas.getActiveObject(); + if (!active || !font) { + return; + } + if (active.get('assetType')) { WebFont.load({ google: { families: [font], }, active: () => { - var object = canvas.getActiveObject(); - animatedtext - .find((x) => x.id == object.id) - .reset( - animatedtext.find((x) => x.id == object.id).text, - $.extend( - animatedtext.find((x) => x.id == object.id).props, - { fontFamily: font } - ), - canvas - ); + const text = animatedtext.find((x) => x.id == active.id); + if (!text) { + return; + } + text.reset( + text.text, + $.extend(text.props, { fontFamily: font }), + canvas + ); save(); }, }); - save(); } else { WebFont.load({ google: { families: [font], }, active: () => { - canvas.getActiveObject().set('fontFamily', font); + active.set('fontFamily', font); canvas.renderAll(); save(); }, @@ -4396,7 +4364,7 @@ function newTextbox( strokeDashArray: false, width: calculateTextWidth( text, - fontweight + ' ' + fontsize + 'px Inter' + fontweight + ' ' + fontsize + 'px ' + (font || 'Inter') ), id: 'Text' + layer_count, shadow: { @@ -4440,11 +4408,22 @@ function deleteObject(object, def = true) { }); } if (object.type == 'image') { - var temp = files.find((x) => x.name == object.get('id')); files = $.grep(files, function (a) { - return a != temp.name; + return a.name != object.get('id'); }); } + // Release any media element the object was holding on to + if (typeof object.getElement === 'function') { + const el = object.getElement(); + if (el && el.tagName == 'VIDEO') { + el.pause(); + el.removeAttribute('src'); + el.load(); + } + } + if (object.get('src') && typeof object.get('src').pause === 'function') { + object.get('src').pause(); + } $(".layer[data-object='" + object.get('id') + "']").remove(); $('#' + object.get('id')).remove(); keyframes = $.grep(keyframes, function (e) { @@ -4519,29 +4498,25 @@ function setDuration(length) { ('0' + Math.floor((seconds % 1) * 100)).slice(-2) ); $('.object-props').each(function () { + const p_keyframe = p_keyframes.find( + (x) => x.id == $(this).attr('id') + ); + if (!p_keyframe) { + return; + } $(this).css( 'width', - duration / timelinetime - - p_keyframes.find((x) => x.id == $(this).attr('id')).start / - timelinetime + - 'px' + duration / timelinetime - p_keyframe.start / timelinetime + 'px' ); - p_keyframes.find((x) => x.id == $(this).attr('id')).end = - duration; - if ( - p_keyframes.find((x) => x.id == $(this).attr('id')).trimend > - p_keyframes.find((x) => x.id == $(this).attr('id')).end - ) { - p_keyframes.find((x) => x.id == $(this).attr('id')).trimend = - duration; + p_keyframe.end = duration; + if (p_keyframe.trimend > p_keyframe.end) { + p_keyframe.trimend = duration; $(this) .find('.trim-row') .css( 'width', duration / timelinetime - - p_keyframes.find((x) => x.id == $(this).attr('id')) - .trimstart / - timelinetime + + p_keyframe.trimstart / timelinetime + 'px' ); } @@ -4578,7 +4553,7 @@ function renderTimeMarkers() { 's
' ); if (timenumber % modulo != 0) { - $('.time-number:last-child()').css('opacity', '0'); + $('#time-numbers .time-number:last-child').css('opacity', '0'); } timenumber++; } @@ -4587,21 +4562,22 @@ function renderTimeMarkers() { // Change timeline zoom level function setTimelineZoom(time) { $('.object-props').each(function () { + const p_keyframe = p_keyframes.find( + (x) => x.id == $(this).attr('id') + ); + if (!p_keyframe) { + return; + } $(this).offset({ left: - p_keyframes.find((x) => x.id == $(this).attr('id')).start / - time + + p_keyframe.start / time + $('#inner-timeline').offset().left + offset_left, }); $(this).css({ width: ($(this).width() * timelinetime) / time }); $(this) .find('.trim-row') - .css({ - left: - p_keyframes.find((x) => x.id == $(this).attr('id')) - .trimstart / time, - }); + .css({ left: p_keyframe.trimstart / time }); $(this) .find('.trim-row') .css({ @@ -4635,95 +4611,18 @@ $(document).on('input', '#timeline-zoom', function () { }); function removeKeyframe() { + if (!selectedkeyframe) { + return; + } + const time = selectedkeyframe.attr('data-time'); + const id = selectedkeyframe.attr('data-object'); + const prop = selectedkeyframe.attr('data-property'); + const names = [prop].concat(KEYFRAME_COUNTERPARTS[prop] || []); keyframes = $.grep(keyframes, function (e) { return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != selectedkeyframe.attr('data-property') + e.t != time || e.id != id || names.indexOf(e.name) == -1 ); }); - if (selectedkeyframe.attr('data-property') == 'left') { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'top' - ); - }); - } else if (selectedkeyframe.attr('data-property') == 'scaleX') { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'scaleY' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'width' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'height' - ); - }); - } else if ( - selectedkeyframe.attr('data-property') == 'strokeWidth' - ) { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'stroke' - ); - }); - } else if ( - selectedkeyframe.attr('data-property') == 'shadow.color' - ) { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.blur' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.offsetX' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.offsetY' - ); - }); - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'shadow.opacity' - ); - }); - } else if ( - selectedkeyframe.attr('data-property') == 'charSpacing' - ) { - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != selectedkeyframe.attr('data-time') || - e.id != selectedkeyframe.attr('data-object') || - e.name != 'lineHeight' - ); - }); - } selectedkeyframe.remove(); $('#keyframe-properties').removeClass('show-properties'); } @@ -4744,437 +4643,126 @@ function deleteKeyframe() { } $(document).on('click', '#delete-keyframe', deleteKeyframe); +// Properties that are always keyframed together with a leading property +const KEYFRAME_COUNTERPARTS = { + left: ['top'], + scaleX: ['scaleY', 'width', 'height'], + strokeWidth: ['stroke'], + 'shadow.color': [ + 'shadow.opacity', + 'shadow.offsetX', + 'shadow.offsetY', + 'shadow.blur', + ], + charSpacing: ['lineHeight'], +}; + // Copy keyframes function copyKeyframes() { + if (!Array.isArray(clipboard) || clipboard.length == 0) { + return; + } clipboard.sort(function (a, b) { return a.t - b.t; }); var inittime = clipboard[0].t; clipboard.forEach(function (keyframe) { + const object = canvas.getItemById(keyframe.id); + if (!object) { + return; + } var newtime = keyframe.t - inittime + currenttime; newKeyframe( keyframe.name, - canvas.getItemById(keyframe.id), + object, newtime, keyframe.value, true ); - var keyprop = keyframe.name; - if (keyprop == 'left') { + // Counterpart properties are stored as separate keyframes at the same + // time; copy whichever of them actually exist. + const counterparts = KEYFRAME_COUNTERPARTS[keyframe.name] || []; + counterparts.forEach(function (name) { const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && e.id == keyframe.id && e.name == 'top' - ); - }); - newKeyframe( - 'top', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } else if (keyprop == 'scaleX') { - var keyarr2 = $.grep(keyframes, function (e) { return ( e.t == keyframe.t && e.id == keyframe.id && - e.name == 'scaleY' - ); - }); - newKeyframe( - 'scaleY', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'width' + e.name == name ); }); if (keyarr2.length > 0) { - newKeyframe( - 'width', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); + newKeyframe(name, object, newtime, keyarr2[0].value, true); } - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'height' - ); - }); - if (keyarr2.length > 0) { - newKeyframe( - 'height', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } - } else if (keyprop == 'strokeWidth') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'stroke' - ); - }); - newKeyframe( - 'stroke', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } else if (keyprop == 'charSpacing') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'lineHeight' - ); - }); - newKeyframe( - 'lineHeight', - canvas.getItemByid(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } else if (keyprop == 'shadow.color') { - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.opacity' - ); - }); - newKeyframe( - 'shadow.opacity', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.offsetX' - ); - }); - newKeyframe( - 'shadow.offsetX', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.offsetY' - ); - }); - - newKeyframe( - 'shadow.offsetY', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == keyframe.t && - e.id == keyframe.id && - e.name == 'shadow.blur' - ); - }); - newKeyframe( - 'shadow.blur', - canvas.getItemById(keyframe.id), - newtime, - keyarr2[0].value, - true - ); - } - save(); + }); }); + save(); } -// Update keyframe (after dragging) -function updateKeyframe(drag, newval, offset) { - var time = parseFloat( - (drag.position().left * timelinetime).toFixed(1) - ); +// Update keyframe (after dragging, or when re-stamping it at the playhead) +function updateKeyframe(drag, newval) { const keyprop = drag.attr('data-property'); const keytime = drag.attr('data-time'); + const objectid = drag.attr('data-object'); const keyarr = $.grep(keyframes, function (e) { return ( e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && + e.id == objectid && e.name == keyprop ); }); + if (keyarr.length == 0) { + return; + } const keyobj = canvas.getItemById(keyarr[0].id); - time = - parseFloat( - p_keyframes.find((x) => x.id == keyobj.get('id')).start - ) + time; - if (newval) { - time = currenttime; + const p_keyframe = p_keyframes.find((x) => x.id == objectid); + if (!keyobj || !p_keyframe) { + return; } - var keyval = keyarr[0].value; - if (newval) { - if (keyprop == 'shadow.color') { - keyval = keyobj.shadow.color; - } else if (keyprop == 'volume') { - keyval = parseFloat($('#object-volume input').val() / 200); - } else { - keyval = keyobj.get(keyprop); - } - } else if (keyprop == 'left') { - keyval = keyval + artboard.get('left'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != keyprop - ); - }); - newKeyframe(keyprop, keyobj, time, keyval, false); - if (keyprop == 'left') { - const keyarr2 = $.grep(keyframes, function (e) { + // data-time is always absolute; drag.position() is relative to the layer + // row, which is itself offset by the layer start. + const time = newval + ? currenttime + : parseFloat(p_keyframe.start) + + parseFloat((drag.position().left * timelinetime).toFixed(1)); + + const names = [keyprop].concat( + KEYFRAME_COUNTERPARTS[keyprop] || [] + ); + names.forEach(function (name) { + const arr = $.grep(keyframes, function (e) { return ( e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'top' + e.id == objectid && + e.name == name ); }); - var keyval2 = keyarr2[0].value + artboard.get('top'); + if (arr.length == 0) { + return; + } + let value = arr[0].value; if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('top'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'top' - ); - }); - newKeyframe('top', keyobj, time, keyval2, false); - } else if (keyprop == 'scaleX') { - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'scaleY' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('scaleY'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'scaleY' - ); - }); - newKeyframe('scaleY', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'width' - ); - }); - if (keyarr2.length > 0) { - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('width'); + if (name == 'volume') { + value = parseFloat($('#object-volume input').val()) / 200; + } else { + value = getPropValue(keyobj, name); } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'width' - ); - }); - newKeyframe('width', keyobj, time, keyval2, false); - } - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'height' - ); - }); - if (keyarr2.length > 0) { - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('height'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'height' - ); - }); - newKeyframe('height', keyobj, time, keyval2, false); - } - } else if (keyprop == 'strokeWidth') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'stroke' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('stroke'); + } else if (name == 'left') { + // newKeyframe stores left/top relative to the artboard + value = value + artboard.get('left'); + } else if (name == 'top') { + value = value + artboard.get('top'); } keyframes = $.grep(keyframes, function (e) { return ( e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'stroke' + e.id != objectid || + e.name != name ); }); - newKeyframe('stroke', keyobj, time, keyval2, false); - } else if (keyprop == 'charSpacing') { - const keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'lineHeight' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).get('lineHeight'); - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'lineHeight' - ); - }); - newKeyframe('lineHeight', keyobj, time, keyval2, false); - } else if (keyprop == 'shadow.color') { - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.opacity' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.opacity; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.opacity' - ); - }); - newKeyframe('shadow.opacity', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.offsetX' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.offsetX; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.offsetX' - ); - }); - newKeyframe('shadow.offsetX', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.offsetY' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.offsetY; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.offsetY' - ); - }); - newKeyframe('shadow.offsetY', keyobj, time, keyval2, false); - var keyarr2 = $.grep(keyframes, function (e) { - return ( - e.t == parseFloat(keytime) && - e.id == drag.attr('data-object') && - e.name == 'shadow.blur' - ); - }); - var keyval2 = keyarr2[0].value; - if (newval) { - keyval2 = canvas.getItemById(keyarr2[0].id).shadow.blur; - } - keyframes = $.grep(keyframes, function (e) { - return ( - e.t != parseFloat(keytime) || - e.id != drag.attr('data-object') || - e.name != 'shadow.blur' - ); - }); - newKeyframe('shadow.blur', keyobj, time, keyval2, false); - } - if (offset) { - drag.attr('data-time', time); - } else { - drag.attr( - 'data-time', - time + p_keyframes.find((x) => x.id == keyarr[0].id).start - ); - } - keyframes.sort(function (a, b) { - if (a.id.indexOf('Group') >= 0 && b.id.indexOf('Group') == -1) { - return 1; - } else if ( - b.id.indexOf('Group') >= 0 && - a.id.indexOf('Group') == -1 - ) { - return -1; - } else { - return 0; - } + newKeyframe(name, keyobj, time, value, false); }); + drag.attr('data-time', time); + sortKeyframes(); } function keyframeSnap(drag) { @@ -5193,11 +4781,12 @@ function keyframeSnap(drag) { }); $('#line-snap').addClass('line-active'); } else { + let snapped = false; drag .parent() .parent() .find('.keyframe') - .each(function (index) { + .each(function () { if (!drag.is($(this))) { if ( drag.offset().left > $(this).offset().left - 5 && @@ -5212,17 +4801,67 @@ function keyframeSnap(drag) { height: drag.parent().parent().height(), }); $('#line-snap').addClass('line-active'); + snapped = true; return false; } } - if (index == $('.keyframe').length - 1) { - $('#line-snap').removeClass('line-active'); - } }); + if (!snapped) { + $('#line-snap').removeClass('line-active'); + } } } } +// Shared plumbing for the timeline drags (seekbar, keyframes, layer bars, +// timeline resize handle). Captures the pointer so the matching pointerup +// always comes back to us, and tears the drag down on pointercancel, on a +// stray native dragstart, or when the window loses focus. Without this a +// swallowed mouseup leaves the dragged element glued to the cursor. +function bindPointerDrag(e, el, onMove, onEnd) { + var pointerId = + e.originalEvent && e.originalEvent.pointerId != undefined + ? e.originalEvent.pointerId + : null; + if (pointerId != null && el && el.setPointerCapture) { + try { + el.setPointerCapture(pointerId); + } catch (err) {} + } + function end(ev) { + // Ignore another pointer going up while this one is still dragging + if ( + ev && + ev.originalEvent && + pointerId != null && + ev.originalEvent.pointerId != undefined && + ev.originalEvent.pointerId != pointerId + ) { + return; + } + $(document) + .off('pointermove', onMove) + .off('pointerup', end) + .off('pointercancel', end) + .off('dragstart', end); + window.removeEventListener('blur', end); + if (pointerId != null && el && el.releasePointerCapture) { + try { + el.releasePointerCapture(pointerId); + } catch (err) {} + } + onEnd(ev); + } + $(document) + .on('pointermove', onMove) + .on('pointerup', end) + .on('pointercancel', end) + .on('dragstart', end); + // Native listener on purpose: jQuery routes blur through its focus special + // event, which defers the handler and can drop it entirely + window.addEventListener('blur', end); +} + // Dragging a keyframe function dragKeyframe(e) { if (e.which == 3) { @@ -5243,8 +4882,9 @@ function dragKeyframe(e) { }); $(this).addClass('keyframe-selected'); } else { + const el = this; shiftkeys = $.grep(shiftkeys, function (e) { - return e.keyframe != this; + return e.keyframe !== el; }); $(this).removeClass('keyframe-selected'); } @@ -5281,9 +4921,6 @@ function dragKeyframe(e) { } } function releasedKeyframe(e) { - $('body') - .off('mousemove', draggingKeyframe) - .off('mouseup', releasedKeyframe); $('#line-snap').removeClass('line-active'); if (move) { if (shiftkeys.length == 0) { @@ -5321,18 +4958,16 @@ function dragKeyframe(e) { } }); } - } else if (!e.shiftDown) { + } else if (e && e.type == 'pointerup' && !e.shiftKey) { keyframeProperties(inst); } move = false; $('.line-active').removeClass('line-active'); save(); } - $('body') - .on('mouseup', releasedKeyframe) - .on('mousemove', draggingKeyframe); + bindPointerDrag(e, this, draggingKeyframe, releasedKeyframe); } -$(document).on('mousedown', '.keyframe', dragKeyframe); +$(document).on('pointerdown', '.keyframe', dragKeyframe); // Render current time in the playback area function renderTime() { @@ -5382,6 +5017,10 @@ function dragSeekBar(e) { if (e.which == 3) { return false; } + // Stop the browser from turning the press into a native drag-and-drop. + // When it does, the mouseup is swallowed, released() never runs and the + // seekbar stays glued to the pointer. + e.preventDefault(); var drag = $(this); var pageX = e.pageX; var offset = $(this).offset(); @@ -5425,7 +5064,6 @@ function dragSeekBar(e) { renderTime(); } function released(e) { - $('body').off('mousemove', dragging).off('mouseup', released); updateTime(drag, false); seeking = false; if (tempselection && tempselection.type != 'activeSelection') { @@ -5433,9 +5071,17 @@ function dragSeekBar(e) { } updatePanelValues(); } - $('body').on('mouseup', released).on('mousemove', dragging); + bindPointerDrag(e, this, dragging, released); } -$(document).on('mousedown', '#seekbar', dragSeekBar); +$(document).on('pointerdown', '#seekbar', dragSeekBar); +// The timeline drag targets must never become native drag sources +$(document).on( + 'dragstart', + '#seekbar, .keyframe, .main-row, .row-el, .trim-row, #timeline-handle', + function (e) { + e.preventDefault(); + } +); // Dragging layer horizontally function dragObjectProps(e) { @@ -5562,7 +5208,7 @@ function dragObjectProps(e) { setTimelineZoom(timelinetime); } drag.find('.keyframe').each(function () { - updateKeyframe($(this), false, true); + updateKeyframe($(this), false); }); animate(false, currenttime); } else if (trim == 'left') { @@ -5605,7 +5251,6 @@ function dragObjectProps(e) { } } function released(e) { - $('body').off('mousemove', dragging).off('mouseup', released); if (opened) { $(".layer[data-object='" + drag.attr('id') + "']") .find('.properties') @@ -5621,9 +5266,9 @@ function dragObjectProps(e) { animate(false, currenttime); save(); } - $('body').on('mouseup', released).on('mousemove', dragging); + bindPointerDrag(e, this, dragging, released); } -$(document).on('mousedown', '.main-row', dragObjectProps); +$(document).on('pointerdown', '.main-row', dragObjectProps); function resetHeight() { var top = $(window).height() - oldtimelinepos - 92; @@ -5654,31 +5299,29 @@ function resetHeight() { // Dragging timeline vertically function dragTimeline(e) { - const disableselect = (e) => { - return false - } - document.onselectstart = disableselect - document.onmousedown = disableselect - - oldtimelinepos = e.pageY; if (e.which == 3) { return false; } + // Suppress text selection for the duration of the drag only - leaving these + // handlers installed kills mousedown for the whole document. + const disableselect = function () { + return false; + }; + const previousSelectStart = document.onselectstart; + document.onselectstart = disableselect; + + oldtimelinepos = e.pageY; function draggingKeyframe(e) { oldtimelinepos = e.pageY; resetHeight(e); } function releasedKeyframe(e) { - $('body') - .off('mousemove', draggingKeyframe) - .off('mouseup', releasedKeyframe); + document.onselectstart = previousSelectStart || null; } - $('body') - .on('mouseup', releasedKeyframe) - .on('mousemove', draggingKeyframe); + bindPointerDrag(e, this, draggingKeyframe, releasedKeyframe); } -$(document).on('mousedown', '#timeline-handle', dragTimeline); +$(document).on('pointerdown', '#timeline-handle', dragTimeline); oldtimelinepos = $(window).height() - 92 - $('#timearea').height(); @@ -5749,6 +5392,9 @@ function keyframeProperties(inst) { e.name == selectedkeyframe.attr('data-property') ); }); + if (keyarr.length == 0) { + return; + } $('#easing select').val(keyarr[0].easing); $('#easing select').niceSelect('update'); popup.css({ @@ -5762,108 +5408,28 @@ function keyframeProperties(inst) { // Apply easing to keyframe function applyEasing() { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == selectedkeyframe.attr('data-property') - ); - }); - keyarr[0].easing = $(this).attr('data-value'); - if (selectedkeyframe.attr('data-property') == 'left') { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'top' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if (selectedkeyframe.attr('data-property') == 'scaleX') { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'scaleY' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'width' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'height' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if ( - selectedkeyframe.attr('data-property') == 'strokeWidth' - ) { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'stroke' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if ( - selectedkeyframe.attr('data-property') == 'shadow.color' - ) { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.opacity' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.offsetX' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.offsetY' - ); - }); - keyarr[0].easing = $('#easing select').val(); - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'shadow.blur' - ); - }); - keyarr[0].easing = $('#easing select').val(); - } else if ( - selectedkeyframe.attr('data-property') == 'charSpacing' - ) { - var keyarr = keyframes.filter(function (e) { - return ( - e.t == selectedkeyframe.attr('data-time') && - e.id == selectedkeyframe.attr('data-object') && - e.name == 'lineHeight' - ); - }); - keyarr[0].easing = $('#easing select').val(); + if (!selectedkeyframe) { + return; } + // Read the clicked item, not the underlying