Packaging/release scripts #1

Merged
kawa merged 9 commits from packaging/release-scripts into main 2026-08-12 17:38:12 +02:00
16 changed files with 2934 additions and 1910 deletions
Showing only changes of commit a6ec6c980a - Show all commits
+273
View File
@@ -0,0 +1,273 @@
# Bugs & Issues — audit + fixes
Audit of `src/js`, then all findings fixed. Every file below passes `node --check`.
No live browser smoke test was run (a Chrome instance held the Playwright profile
lock), so the changes are verified statically only — see **Not verified** at the end.
Legend: `[x]` fixed · P0 = feature broken · P1 = wrong data written · P2 = crash on edge path · P3 = silently wrong UI · P4 = perf/leak · P5 = cleanup
---
## P0 — Broken features
- [x] **MP4 export dead-ended and locked the UI**`src/js/functions.js`
The mp4 branch of `downloadRecording` was `type = 'video/mp4'`, an implicit global
assignment that did nothing: no download, `recording` stuck at `true`, the button
stuck on "Downloading...", and `downloadModal()` refusing to reopen until reload.
mp4 now goes through `convertStreams(blob, 'mp4')` like gif, and a new shared
`resetRecordingUI()` unlocks the editor on every exit path (success, unknown
format, worker error, FileReader error, `MediaRecorder` error).
- [x] **Copying keyframes threw**`src/js/events.js`
`canvas.getActiveObject().isEditing` ran on `null`, because selecting keyframes
clears the canvas selection — the normal path. The active object is now resolved
once and guarded, and empty `$.grep` results are no longer pushed to the clipboard.
- [x] **`canvas.getItemByid` typo** — `src/js/functions.js`
Lowercase `i`; TypeError when pasting text/`charSpacing` keyframes.
- [x] **Ctrl+Z crashed on an empty stack; Ctrl+Shift+Z was a no-op**`src/js/events.js`
Merged into one guarded handler: shift picks redo, and each branch checks its own
stack length. Previously Ctrl+Shift+Z matched both `if` blocks (redo then undo).
- [x] **Blur slider threw with nothing selected**`src/js/events.js`
`obj.applyFilters()` was outside the `if (canvas.getActiveObject())` guard. Also
renamed the shadowed `x` in the blur/noise/chroma `find()` callbacks.
## P1 — Wrong values written
- [x] **`height` keyframes stored the width** — `src/js/functions.js` (3 sites: twice in `keyframeChanges`, once in `crop`)
- [x] **`var scaleX = obj, scaleX;`** — `src/js/text.js`; the fabric object was being assigned as a scale factor.
- [x] **NaN letter delay + shadowed global `duration`**`src/js/text.js`
`delay = i * duration` read the hoisted local before its own initialiser. Renamed
to `step`, computed before use, and `!(delay > 0)` now catches NaN.
- [x] **Every letter animation wrote to the last letter**`src/js/text.js`
`index`, `animation`, `start` and `instance` are `let` per iteration instead of `var`.
- [x] **Shadow defaults/keyframes stored `undefined`**`src/js/functions.js`
fabric's `get()` is not a path getter. Added `getPropValue()` (handles `shadow.*`)
and `setDefaultValue()` / `getDefaultValue()`, and routed ~26 direct
`.defaults.find(...).value = …` writes through them — which also creates the entry
when a project predates a property instead of throwing.
- [x] **WebGL filter backend clobbered with `undefined`**`src/js/init.js`, `src/js/database.js`
Both assignments are now conditional on the backend having constructed.
## P2 — Crashes on edge paths
- [x] **`RangeError: Invalid array length`** — `src/js/functions.js`
`temparr.length = findIndex(...)` went negative for a stale keyframe reference.
`lastKeyframe` / `nextKeyframe` are now index lookups that return `false`.
- [x] **`animate()` had no null guards** — `src/js/functions.js`
Object and `p_keyframes` lookups are resolved once and guarded in every per-frame
loop (`animate`, `recordAnimate`, the playback update loop, `playVideos`,
`playAudio`), which also removed dozens of repeated `.find()` calls.
- [x] **`getAssets()` recursed synchronously forever** — `src/js/database.js`
Retries on a 250 ms timer, capped at 20 attempts, and rebuilds the asset arrays
instead of appending duplicates on re-entry after an import.
- [x] **`deleteObject` threw and leaked `files`** — `src/js/functions.js`
The entry was compared to a string so it never matched; now filtered by `name`.
Video elements are also paused and unloaded on delete.
- [x] **Unguarded `keyarr[0]` / `.defaults.find(...)`**`copyKeyframes`, `updateKeyframe`,
`applyEasing`, `keyframeProperties`, `removeKeyframe`, `checkAnyKeyframe`.
The four repetitive counterpart blocks were replaced by one
`KEYFRAME_COUNTERPARTS` map, so a missing counterpart is skipped, not fatal.
- [x] **Other unguarded lookups**`deleteAsset`, `reGroup`, `renderLayer`,
`renderProp`, `setDuration`, `setTimelineZoom`, `saveLayerName`, `updateInputs`,
`updatePanel`, `updateStrokeValues`, `animateText`, `scrollIntoView` (3 sites),
`object:modified` / `object:rotating` / `mouse:out` / `mouse:up` handlers.
`importProject` validates the payload before touching `data.project[0]`, and
`line_h`/`line_v` go through a new `hideGuides()` helper.
## P3 — Silently wrong behaviour
- [x] **`document.onmousedown` permanently disabled** — `src/js/functions.js`
`dragTimeline` installed a `return false` handler and never removed it. It now
only overrides `onselectstart`, and restores it on mouseup.
- [x] **Keyframe time drifted on every drag**`src/js/functions.js`
`data-time` is now always the absolute timeline time (the value every lookup keys
off), with the visual offset applied as CSS only. This also fixes keyframes on an
*expanded* row, whose `data-time` used to be layer-relative so no lookup matched.
`updateKeyframe`'s unused third argument is gone.
- [x] **Shift-deselect never removed a keyframe**`this` inside a `$.grep` callback is not the element.
- [x] **`e.shiftDown`** → `e.shiftKey`.
- [x] **Snap guide never hid** — the row-local index was compared against the global `.keyframe` count.
- [x] **Comparison / typo bugs**
`canvas.getActiveObjects.length``getActiveObjects().length` ·
`strokeDashArray == [10, 5]` → element comparison ·
chroma `setValue(distance)``distance * 100` · `'#FFFFF'``'#FFFFFF'` ·
`videoPlayer.videoheight``videoHeight` (and both thumbnail helpers no longer
draw the canvas onto itself) · `if (start && play && !paused)` in `playAudio`
(`play` is the global function, always truthy) · `#redo` gated on `redo.length` ·
`:last-child()``:last-child`.
- [x] **Paste loop closure**`var imgObj``let`, so each thumbnail saves its own file.
- [x] **Layers added mid-timeline were shortened**`end: duration - currenttime``duration`
(media layers clamp to `min(start + assetDuration, duration)`).
- [x] **Malformed HTML** — 5 unterminated `<img …'` strings and a stray `</div>` in `src/js/ui.js`.
- [x] **Duplicate DOM ids**`id="easing"` on both wrapper and `<select>` (the select
is now `easing-select`; `#easing select` still matches), `id="filters-title"` ×4 and
`id='item-text'` ×6 became classes, with `src/styles.css` updated to match.
Added the missing `<meta charset>`.
## P4 — Performance, leaks, export gaps
- [x] **`save()` rebuilt the record canvas on every edit** — `src/js/functions.js`
`updateRecordCanvas()` + `autoSave()` now run through a 400 ms debounce
(`schedulePersist`). `record()` still awaits `updateRecordCanvas()` directly, so an
export always captures current state. `updateObjectValues` no longer calls
`autoSave()` on every keystroke.
- [x] **`async forEach` raced the snapshot** — `src/js/functions.js`, `src/js/database.js`
Both filter-stripping loops are sequential `for…of` in `async` functions, so
`toJSON` / `toDatalessJSON` can no longer run mid-strip.
- [x] **O(n²·log n) playback**`src/js/functions.js`
`buildKeyframeIndex()` groups keyframes by `id|name` once per rendered frame;
`lastKeyframe`, `nextKeyframe` and `checkAnyKeyframe` use it instead of sorting a
copy of the entire keyframe list per keyframe per frame. The two duplicated inner
`nextKeyframe` copies are gone.
- [x] **Exports lost audio-layer sound**`src/js/recorder.js`
Audio layers were never routed into the capture stream. All sources (video
elements, audio layers, background audio) now mix through **one** AudioContext
into **one** destination track — necessary because `MediaRecorder` only records
the first audio track. Audio layers start/stop on their `p_keyframes` boundaries.
- [x] **Export timing**`src/js/recorder.js`
The render clock starts after `recorder.start()` (it used to run before, losing the
first frames), stops when the animation clock covers `duration` rather than on a
wall-clock `setTimeout`, and has a `duration + 5s` safety stop.
- [x] **Leaks** — AudioContexts are closed and stream tracks stopped on export end;
object URLs are revoked (`downloadRecording`, `PostBlob`, `exportProject`,
`importProject`); the browser scroll handler is unbound before rebinding; `sortable`
is initialised once instead of per rendered layer; deleted videos are paused and
unloaded; category/audio grids are cleared before repopulating.
## P5 — Dead code, config, cleanup
- [x] **Unreachable code calling an undefined `waitForEvent`**`src/js/recorder.js`
`initRecorder` / `recordFrame` / `exportRecording` and ~180 lines of commented-out
abandoned experiments were removed; the file is now just the live export path.
- [x] **Placeholder API keys**`src/js/init.js`
`HAS_FONTS_KEY` / `HAS_PIXABAY_KEY` gate the requests. Without a fonts key the
pickers fall back to the bundled families instead of staying empty; without a
Pixabay key search shows an explanatory message instead of 401-ing.
- [x] **`var timeout` collided with `recorder.js`'s `timeout()`** — removed (it was unused).
- [x] **Implicit globals**`srcfreeze`, `osc`, `type`, `newcolorkeyframe` (deleted, never read).
- [x] **Duplicate / unreachable branches** — the second `shadow.blur` arm in all three
`setValue` copies, the duplicated `objectCaching` key, the three copies of the
keyframe sort comparator (now `sortKeyframes()`), and the two identical crop blocks
in `events.js` (now `updateCropBounds()`).
- [x] **Converter was unfinished**`src/js/converter.js`
The `workerReady` handshake never fired (`buffersReady` was never set); worker
readiness is now tracked across calls, since the worker is created once and reused.
Added `onerror` handling, an empty-result guard, and a user-visible failure path.
- [x] **Misc**`overlay()` no longer reuses the artboard's `overlay` id ·
`getItemById` recurses so nested groups are found and stops at the first hit ·
`fabric.Lottie` guards `this.canvas` before the object is added ·
`newLottieAnimation` no longer multiplies an ms duration by 1000 ·
lottie layers get a colour · `calculateTextWidth` honours the requested font ·
`changeFont` / `loadImage` / `loadVideo` / `handleLottieUpload` have failure paths ·
`checkFilter` no longer tests for a non-existent `video` fabric type ·
`AnimatedText.assignTo` applies `text`/`props` · `animate(currenttime, false)`
`animate(false, currenttime)` (3 sites — text animation changes never refreshed) ·
`exportProject` uses a Blob instead of a `data:` URL · `clearProject` reloads after
the deletes resolve · `readTextFile` handles blob-URL status 0 and errors ·
`.catch` added to the Localbase promises · opacity inputs clamp the input, not the
wrapper · arrow-key nudge calls `setCoords()` and is suppressed while typing ·
layer reorder shortcuts accept Ctrl as well as Cmd · alignment guides ignore hidden
objects · `alignObject` saves and dropped its `console.log`.
---
---
# Frame-accurate export (new)
The real-time capture path was the root cause of "heavy scenes drop frames" and
"video layers are sampled at the wrong moment". It is now the *fallback*, not the
default.
## `src/js/render.js` — offline renderer
No clock is attached to the render. For each of `duration × 30` frames:
1. every video layer is seeked to the exact frame time and the code **awaits
`seeked`** before drawing (a bare `currentTime =` is async — drawing straight
after captures the *previous* frame, which is why real-time exports smeared
video layers);
2. lottie layers are advanced to the same time;
3. `recordAnimate(time)` lays out the frame, `canvasrecord.renderAll()` draws it;
4. a `VideoFrame` is built from the canvas with an explicit timestamp and pushed
through a `VideoEncoder` (VP9, falling back to VP8), with the encoder queue
held at ≤ 8 frames so memory stays bounded.
Audio is mixed in one `OfflineAudioContext` pass — every audio layer, every video
layer's soundtrack and the background track, each placed at its own
`start`/`trimstart`/`end` with its own gain — then encoded to Opus with
`AudioEncoder`. Sample-accurate, and unlike the live path it does not depend on
playback keeping up.
Both tracks are sorted by timestamp and muxed into one WebM.
`recordAnimate()` skips `playVideos()` while `offlinerender` is set, so real-time
playback cannot fight the explicit seeking.
## `src/js/webm-writer2.js` — extended
Was single-track video only, and unreferenced by `index.html`. Now loaded, and:
- optional second **Opus** track (`A_OPUS` + 19-byte `OpusHead` CodecPrivate,
`CodecDelay`, `SeekPreRoll`, `Audio` element with sample rate/channels);
- `addFrame(chunk, trackNumber)` accepts `EncodedAudioChunk` as well as
`EncodedVideoChunk`; `addFrameToCluster` no longer hardcodes track 1.
Three bugs fixed in the vendored library along the way:
- **`MAX_CLUSTER_DURATION_MSEC` was `5000000`** (~58 days), so every frame went
into a single cluster and block timecodes — a signed 16-bit offset — overflowed
past ~32 s. Now 5000 ms, and clusters close on a video keyframe.
- **The header buffer was a fixed 256 bytes**, which a second `TrackEntry`
overflows (`ArrayBufferDataStream's pos lies beyond end of buffer`). Now 1024.
- **`instanceof Uint8Array`** for byte payloads fails across a realm boundary;
`ArrayBuffer.isView` is now accepted too.
## Safety net
The muxer is hand-rolled, so `renderFrameAccurate()` loads the finished blob into
a `<video>` element and checks it decodes before handing it over. Any failure —
no WebCodecs, unsupported codec, encoder error, failed verification — returns
`null` and `record()` transparently falls back to the real-time encoder.
## Tests — `test/webm-muxer.test.js`
Runs in plain Node (no deps): `node test/webm-muxer.test.js`
Loads the muxer in a VM context, feeds fake encoded chunks, then parses the
resulting bytes with a small EBML reader and asserts the EBML header, Segment,
one or two `TrackEntry` with the right `CodecID`/`TrackType`, a 19-byte
`OpusHead`, multiple clusters, every block present on the right track, block
timecodes inside signed 16-bit range, ascending cluster timecodes, and that
`cluster timecode + block timecode` reconstructs the input timestamps to within
1 ms. Covers video-only and video+audio at 75 s (13 clusters, 3000 blocks).
Both cases pass. The 256-byte header bug above was caught by this test, not by
inspection.
---
## Left in place deliberately
- **`src/js/encode-worker.js`** is still unreferenced. It was the old
File-System-Access-API sketch; `render.js` supersedes it and buffers to memory
instead of requiring a save-file picker. Deleting it is a product decision.
- **MP4 and GIF** still go through the ancient asm.js ffmpeg worker
(`converter.js`, loaded from archive.org). The frame-accurate renderer feeds it
a better WebM, but that dependency is unchanged and may be offline.
## Not verified
`node --check` passes on every script; the muxer is covered by the Node test
above. The app itself was **not** loaded in a browser (a running Chrome instance
held the Playwright profile lock), so the following still needs a manual pass:
1. `cd src && python -m http.server 8765`, open `http://localhost:8765`
2. Add a shape, keyframe it, drag the keyframe, scrub, undo/redo
3. Export as WEBM with a video layer and an audio layer present — confirm the
console shows `Rendering n%` (frame-accurate path) and not
`Falling back to the real-time encoder`
4. Play the result: check audio is in sync and the video layer is not smeared
+31 -15
View File
@@ -1,15 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link rel="preconnect" href="https://fonts.gstatic.com"> <meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <!-- Third-party assets live in vendor/ so the app works offline. -->
<!-- Run `npm run vendor` to populate that directory. -->
<link rel="stylesheet" href="vendor/inter.css">
<link rel="stylesheet" href="nice-select.css"> <link rel="stylesheet" href="nice-select.css">
<link ref="stylesheet" href="range-slider.min.css"> <link ref="stylesheet" href="range-slider.min.css">
<link rel="stylesheet" href="magic-check.min.css"> <link rel="stylesheet" href="magic-check.min.css">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/nano.min.css"/> <link rel="stylesheet" href="vendor/pickr-nano.min.css"/>
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<title>Motionity - The web-based motion graphics editor for everyone</title> <title>Motionity - The web-based motion graphics editor for everyone</title>
<meta name="title" content="Motionity - The web-based motion graphics editor for everyone"> <meta name="title" content="Motionity - The web-based motion graphics editor for everyone">
@@ -79,6 +81,18 @@
<input class="magic-radio" type="radio" name="radio" value="image" id="image-format"> <input class="magic-radio" type="radio" name="radio" value="image" id="image-format">
<label for="image-format">Image</label> <label for="image-format">Image</label>
</div> </div>
<div id="framerate-row">
<p class="subheader">Frame rate</p>
<select id="framerate">
<option value="12">12 fps</option>
<option value="15">15 fps</option>
<option value="24">24 fps</option>
<option value="25">25 fps</option>
<option value="30" selected>30 fps</option>
<option value="50">50 fps</option>
<option value="60">60 fps</option>
</select>
</div>
<div id="download-real">Download</div> <div id="download-real">Download</div>
</div> </div>
<div id="import-export-modal"> <div id="import-export-modal">
@@ -168,7 +182,7 @@
<div id="filters"> <div id="filters">
<div id="filters-container"> <div id="filters-container">
<div id="filters-header"> <div id="filters-header">
<div id="filters-title">Filters</div> <div class="filters-title">Filters</div>
<img src="assets/close.svg" id="filters-close"> <img src="assets/close.svg" id="filters-close">
</div> </div>
<select id="filters-list"> <select id="filters-list">
@@ -183,7 +197,7 @@
<option value="Polaroid">Polaroid</option> <option value="Polaroid">Polaroid</option>
</select> </select>
<hr> <hr>
<div id="filters-title">Adjustments</div> <div class="filters-title">Adjustments</div>
<div id="reset-filters"><img src="assets/repeat.svg"> Reset</div> <div id="reset-filters"><img src="assets/repeat.svg"> Reset</div>
<div class="filter-row"> <div class="filter-row">
<th class="name-col">Brightness</th> <th class="name-col">Brightness</th>
@@ -216,7 +230,7 @@
</th> </th>
</div> </div>
<hr> <hr>
<div id="filters-title">Chroma key</div> <div class="filters-title">Chroma key</div>
<div class="filter-row"> <div class="filter-row">
<th class="name-col">Status</th> <th class="name-col">Status</th>
<th class="value-col"> <th class="value-col">
@@ -242,7 +256,7 @@
</th> </th>
</div> </div>
<hr> <hr>
<div id="filters-title">Stylize</div> <div class="filters-title">Stylize</div>
<div class="filter-row"> <div class="filter-row">
<th class="name-col">Noise</th> <th class="name-col">Noise</th>
<th class="value-col"> <th class="value-col">
@@ -289,7 +303,7 @@
<div id="keyframe-properties"> <div id="keyframe-properties">
<div id="easing"> <div id="easing">
<p class="property-title">Keyframe easing</p> <p class="property-title">Keyframe easing</p>
<select id="easing"> <select id="easing-select">
<option value="linear">Linear</option> <option value="linear">Linear</option>
<option value="easeInQuad">Ease in</option> <option value="easeInQuad">Ease in</option>
<option value="easeOutQuad">Ease out</option> <option value="easeOutQuad">Ease out</option>
@@ -366,16 +380,16 @@
<video id="test-video"></video> <video id="test-video"></video>
<input id="emptyInput" value=" " style="opacity:0"> <input id="emptyInput" value=" " style="opacity:0">
<script src="js/libraries/localbase.js"></script> <script src="js/libraries/localbase.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.9.6/lottie.min.js"></script> <script src="vendor/lottie.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@simonwep/selection-js/lib/selection.min.js"></script> <script src="vendor/selection.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script> <script src="vendor/jquery.min.js"></script>
<script src="js/libraries/sortable.min.js"></script> <script src="js/libraries/sortable.min.js"></script>
<script src="js/libraries/range-slider.min.js"></script> <script src="js/libraries/range-slider.min.js"></script>
<script src="js/libraries/jquery.nice-select.min.js"></script> <script src="js/libraries/jquery.nice-select.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js"></script> <script src="vendor/pickr.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/460/fabric.min.js"></script> <script src="vendor/fabric.min.js"></script>
<script src="js/libraries/anime.min.js"></script> <script src="js/libraries/anime.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script> <script src="vendor/webfont.js"></script>
<script src="js/init.js"></script> <script src="js/init.js"></script>
<script src="js/ui.js"></script> <script src="js/ui.js"></script>
<script src="js/align.js"></script> <script src="js/align.js"></script>
@@ -383,6 +397,8 @@
<script src="js/database.js"></script> <script src="js/database.js"></script>
<script src="js/lottie.js"></script> <script src="js/lottie.js"></script>
<script src="js/text.js"></script> <script src="js/text.js"></script>
<script src="js/webm-writer2.js"></script>
<script src="js/render.js"></script>
<script src="js/recorder.js"></script> <script src="js/recorder.js"></script>
<script src="js/functions.js"></script> <script src="js/functions.js"></script>
<script src="js/events.js"></script> <script src="js/events.js"></script>
+26 -3
View File
@@ -78,6 +78,20 @@ function initLines() {
canvas.add(line_v); canvas.add(line_v);
} }
// Hide the alignment guides after a canvas reload.
// They can be missing entirely in projects saved before they existed, so
// never dereference them directly.
function hideGuides(inst) {
const h = inst.getItemById('line_h');
const v = inst.getItemById('line_v');
if (h) {
h.set({ opacity: 0 });
}
if (v) {
v.set({ opacity: 0 });
}
}
function alignControls(object, type) { function alignControls(object, type) {
if (type == 'align-top') { if (type == 'align-top') {
object.set( object.set(
@@ -122,8 +136,10 @@ function alignControls(object, type) {
function alignObject() { function alignObject() {
const type = $(this).attr('id'); const type = $(this).attr('id');
const object = canvas.getActiveObject(); const object = canvas.getActiveObject();
console.log(canvas.getActiveObject().type); if (!object) {
if (canvas.getActiveObject().type == 'activeSelection') { return;
}
if (object.type == 'activeSelection') {
const tempselection = canvas.getActiveObject(); const tempselection = canvas.getActiveObject();
canvas.discardActiveObject(); canvas.discardActiveObject();
tempselection._objects.forEach(function (object) { tempselection._objects.forEach(function (object) {
@@ -157,6 +173,8 @@ function alignObject() {
); );
newKeyframe('top', object, currenttime, object.get('top'), true); newKeyframe('top', object, currenttime, object.get('top'), true);
} }
canvas.renderAll();
save();
} }
$(document).on('click', '.align', alignObject); $(document).on('click', '.align', alignObject);
@@ -240,7 +258,12 @@ function centerLines(e) {
canvas.renderAll(); canvas.renderAll();
} }
} }
if (obj != e.target && obj != line_h && obj != line_v) { if (
obj != e.target &&
obj != line_h &&
obj != line_v &&
obj.visible !== false
) {
if ( if (
obj.get('id') == 'center_h' || obj.get('id') == 'center_h' ||
obj.get('id') == 'center_v' obj.get('id') == 'center_v'
+78 -36
View File
@@ -1,7 +1,30 @@
var workerPath = // MP4/GIF export transcodes the captured WebM with an asm.js build of ffmpeg.
'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js'; // Packaged builds ship it locally (npm run vendor); a plain checkout falls back
// to the public mirror, which needs network access.
var FFMPEG_ASM_LOCAL = 'vendor/ffmpeg_asm.js';
var FFMPEG_ASM_REMOTE = 'https://archive.org/download/ffmpeg_asm/ffmpeg_asm.js';
var ffmpegAsmUrlPromise = null;
function processInWebWorker() { // The worker is built from a blob, so importScripts() needs an absolute URL.
function resolveFfmpegAsmUrl() {
if (!ffmpegAsmUrlPromise) {
ffmpegAsmUrlPromise = fetch(FFMPEG_ASM_LOCAL, { method: 'HEAD' })
.then(function (res) {
// A catch-all/SPA route answers 200 with HTML; that is not the script.
var type = res.headers.get('content-type') || '';
var local = res.ok && type.indexOf('text/html') === -1;
return local
? new URL(FFMPEG_ASM_LOCAL, location.href).href
: FFMPEG_ASM_REMOTE;
})
.catch(function () {
return FFMPEG_ASM_REMOTE;
});
}
return ffmpegAsmUrlPromise;
}
function processInWebWorker(workerPath) {
var blob = URL.createObjectURL( var blob = URL.createObjectURL(
new Blob( new Blob(
[ [
@@ -21,37 +44,62 @@ function processInWebWorker() {
} }
var worker; var worker;
// The worker is created once and reused, so its "ready" handshake only ever
// arrives for the first conversion. Remember it across calls.
var workerIsReady = false;
function convertStreams(videoBlob, setting) { async function convertStreams(videoBlob, setting) {
var aab; var aab;
var buffersReady; var buffersReady = false;
var workerReady; var posted = false;
var posted;
function convertFailed(reason) {
console.error('Conversion failed: ' + reason);
alert(
'Sorry, the ' +
setting.toUpperCase() +
' conversion failed. The WEBM format is always available.'
);
resetRecordingUI();
}
var fileReader = new FileReader(); var fileReader = new FileReader();
fileReader.onload = function () { fileReader.onload = function () {
aab = this.result; aab = this.result;
postMessage(); buffersReady = true;
if (workerIsReady) postMessage();
};
fileReader.onerror = function () {
convertFailed('could not read the recorded video');
}; };
fileReader.readAsArrayBuffer(videoBlob); fileReader.readAsArrayBuffer(videoBlob);
if (!worker) { if (!worker) {
worker = processInWebWorker(); // Safe to await here: workerIsReady is still false, so the FileReader
// callback cannot post a command before the worker exists.
worker = processInWebWorker(await resolveFfmpegAsmUrl());
} }
worker.onerror = function (e) {
convertFailed(e.message || 'worker error');
};
worker.onmessage = function (event) { worker.onmessage = function (event) {
var message = event.data; var message = event.data;
if (message.type == 'ready') { if (message.type == 'ready') {
workerReady = true; workerIsReady = true;
if (buffersReady) postMessage(); if (buffersReady) postMessage();
} else if (message.type == 'done') { } else if (message.type == 'done') {
var result = message.data[0]; var result = message.data && message.data[0];
if (!result || !result.data) {
convertFailed('the encoder returned no data');
return;
}
if (setting == 'gif') { if (setting == 'gif') {
var blob = new File([result.data], 'test.gif', { var blob = new File([result.data], 'video.gif', {
type: 'image/gif', type: 'image/gif',
}); });
PostBlob(blob); PostBlob(blob);
} else if (setting == 'mp4') { } else if (setting == 'mp4') {
var blob = new File([result.data], 'test.mp4', { var blob = new File([result.data], 'video.mp4', {
type: 'video/mp4', type: 'video/mp4',
}); });
PostBlob(blob); PostBlob(blob);
@@ -59,11 +107,17 @@ function convertStreams(videoBlob, setting) {
} }
}; };
var postMessage = function () { var postMessage = function () {
if (posted) return;
posted = true; posted = true;
// The recording was made at this rate, so the transcode has to keep it:
// a fixed -r would duplicate or drop frames and drift the timing.
const fps = getExportFramerate();
if (setting == 'gif') { if (setting == 'gif') {
worker.postMessage({ worker.postMessage({
type: 'command', type: 'command',
arguments: '-i video.webm -r 24 output-10.gif'.split(' '), arguments: ('-i video.webm -r ' + fps + ' output-10.gif').split(
' '
),
files: [ files: [
{ {
data: new Uint8Array(aab), data: new Uint8Array(aab),
@@ -74,10 +128,11 @@ function convertStreams(videoBlob, setting) {
} else if (setting == 'mp4') { } else if (setting == 'mp4') {
worker.postMessage({ worker.postMessage({
type: 'command', type: 'command',
arguments: arguments: (
'-i video.webm -c:v mpeg4 -b:v 6400k -strict experimental output.mp4'.split( '-i video.webm -c:v mpeg4 -b:v 6400k -r ' +
' ' fps +
), ' -strict experimental output.mp4'
).split(' '),
files: [ files: [
{ {
data: new Uint8Array(aab), data: new Uint8Array(aab),
@@ -97,22 +152,9 @@ function PostBlob(blob) {
a.download = blob.name || 'video'; a.download = blob.name || 'video';
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
recording = false; document.body.removeChild(a);
currenttime = 0; window.setTimeout(function () {
animate(false, 0); URL.revokeObjectURL(url);
$('#seekbar').offset({ }, 60000);
left: resetRecordingUI();
offset_left +
$('#inner-timeline').offset().left +
currenttime / timelinetime,
});
canvas.renderAll();
resizeCanvas();
if (background_audio != false) {
background_audio.pause();
background_audio = new Audio(background_audio.src);
}
$('#download-real').html('Download');
$('#download-real').removeClass('downloading');
updateRecordCanvas();
} }
+113 -35
View File
@@ -88,15 +88,24 @@ function checkDB() {
} else { } else {
loadProject(); loadProject();
} }
})
.catch(function (e) {
console.error('Could not open the local project database', e);
}); });
} }
// Automatically save project (locally) // Automatically save project (locally)
function autoSave() { async function autoSave() {
if (checkstatus) { if (checkstatus) {
canvas.clipPath = null; canvas.clipPath = null;
objects.forEach(async function (object) { // Sequential, not forEach(async): toDatalessJSON below must not run
// while filters are still being stripped off the objects.
for (const object of objects) {
var obj = canvas.getItemById(object.id); var obj = canvas.getItemById(object.id);
if (!obj) {
object.filters = [];
continue;
}
if (obj.filters) { if (obj.filters) {
if (obj.filters.length > 0) { if (obj.filters.length > 0) {
object.filters = []; object.filters = [];
@@ -174,7 +183,7 @@ function autoSave() {
} else { } else {
object.filters = []; object.filters = [];
} }
}); }
const inst = canvas.toDatalessJSON([ const inst = canvas.toDatalessJSON([
'volume', 'volume',
'audioSrc', 'audioSrc',
@@ -250,6 +259,9 @@ function autoSave() {
activepreset: activepreset, activepreset: activepreset,
width: artboard.width, width: artboard.width,
height: artboard.height, height: artboard.height,
})
.catch(function (e) {
console.error('Autosave failed', e);
}); });
objects.forEach(function (object) { objects.forEach(function (object) {
replaceSource(canvas.getItemById(object.id), canvas); replaceSource(canvas.getItemById(object.id), canvas);
@@ -289,17 +301,24 @@ function loadProject() {
currenttime = 0; currenttime = 0;
canvas.clipPath = null; canvas.clipPath = null;
canvas.clear(); canvas.clear();
fabric.filterBackend = webglBackend; if (webglBackend) {
fabric.filterBackend = webglBackend;
}
f = fabric.Image.filters; f = fabric.Image.filters;
canvas.loadFromJSON(JSON.parse(project.canvas), function () { canvas.loadFromJSON(JSON.parse(project.canvas), function () {
canvas.clipPath = artboard; canvas.clipPath = artboard;
canvas.getItemById('line_h').set({ opacity: 0 }); hideGuides(canvas);
canvas.getItemById('line_v').set({ opacity: 0 });
canvas.renderAll(); canvas.renderAll();
$('.object-props').remove(); $('.object-props').remove();
$('.layer').remove(); $('.layer').remove();
objects.forEach(function (object) { objects.forEach(function (object) {
var animatethis = false; var animatethis = false;
if (!object.animate) {
object.animate = [];
}
if (!canvas.getItemById(object.id)) {
return;
}
if (object.animate.length > 5) { if (object.animate.length > 5) {
if (isSameSet(object.animate, props)) { if (isSameSet(object.animate, props)) {
animatethis = true; animatethis = true;
@@ -331,6 +350,9 @@ function loadProject() {
} }
}); });
keyframes.forEach(function (keyframe) { keyframes.forEach(function (keyframe) {
if (!canvas.getItemById(keyframe.id)) {
return;
}
if ( if (
keyframe.name != 'top' && keyframe.name != 'top' &&
keyframe.name != 'scaleY' && keyframe.name != 'scaleY' &&
@@ -510,12 +532,18 @@ function deleteAsset(key) {
.doc({ key: key }) .doc({ key: key })
.get() .get()
.then((asset) => { .then((asset) => {
if (!asset) {
return;
}
var temp = files.filter((x) => x.file == asset.src); var temp = files.filter((x) => x.file == asset.src);
if (temp.length > 0) { if (temp.length > 0) {
temp.forEach(function (file) { temp.forEach(function (file) {
deleteObject(canvas.getItemById(file.name)); const object = canvas.getItemById(file.name);
if (object) {
deleteObject(object);
}
files = $.grep(files, function (a) { files = $.grep(files, function (a) {
return a != file; return a !== file;
}); });
}); });
} }
@@ -534,14 +562,26 @@ function deleteAsset(key) {
}); });
} }
function getAssets() { function getAssets(attempt) {
attempt = attempt || 0;
db.collection('assets') db.collection('assets')
.get() .get()
.then((assets) => { .then((assets) => {
// Sometimes the assets aren't ready when importing, really annoying // Sometimes the assets aren't ready when importing, really annoying.
// Retry on a timer with a hard cap - a bare synchronous re-call spins
// the CPU forever when the collection never resolves.
if (assets === undefined) { if (assets === undefined) {
getAssets(); if (attempt < 20) {
window.setTimeout(function () {
getAssets(attempt + 1);
}, 250);
} else {
console.error('Could not read assets from the local database');
}
} else if (assets.length > 0) { } else if (assets.length > 0) {
// Rebuild rather than append: getAssets() runs again after an import
uploaded_images = [];
uploaded_videos = [];
assets.forEach(function (asset) { assets.forEach(function (asset) {
if (asset.type == 'image') { if (asset.type == 'image') {
uploaded_images.push({ uploaded_images.push({
@@ -562,6 +602,9 @@ function getAssets() {
} }
}); });
} }
})
.catch(function (e) {
console.error('Could not read assets', e);
}); });
} }
@@ -570,10 +613,20 @@ function readTextFile(file, callback) {
rawFile.overrideMimeType('application/json'); rawFile.overrideMimeType('application/json');
rawFile.open('GET', file, true); rawFile.open('GET', file, true);
rawFile.onreadystatechange = function () { rawFile.onreadystatechange = function () {
if (rawFile.readyState === 4 && rawFile.status == '200') { if (rawFile.readyState === 4) {
callback(rawFile.responseText); // 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); rawFile.send(null);
} }
@@ -582,10 +635,20 @@ async function importProject(e) {
var file = e.target.files[0]; var file = e.target.files[0];
var path = (window.URL || window.webkitURL).createObjectURL(file); var path = (window.URL || window.webkitURL).createObjectURL(file);
readTextFile(path, function (text) { readTextFile(path, function (text) {
var data = JSON.parse(text); var data;
try {
data = JSON.parse(text);
} catch (e) {
data = null;
}
if (!data || !Array.isArray(data.project) || data.project.length == 0) {
alert('Wrong file type');
$('#import-project span').html('Import');
return;
}
delete data.project[0].id; delete data.project[0].id;
if (data.project.length > 0) { {
if (data.assets.length > 0) { if (Array.isArray(data.assets) && data.assets.length > 0) {
data.assets.forEach(function (asset) { data.assets.forEach(function (asset) {
delete asset.id; delete asset.id;
db.collection('assets').add(asset); db.collection('assets').add(asset);
@@ -598,10 +661,14 @@ async function importProject(e) {
$('#import-project span').html('Import'); $('#import-project span').html('Import');
hideModals(); hideModals();
loadProject(); loadProject();
})
.catch(function (e) {
console.error('Import failed', e);
alert('Import failed');
$('#import-project span').html('Import');
}); });
} else {
alert('Wrong file type');
} }
window.URL.revokeObjectURL(path);
}); });
} }
@@ -619,18 +686,24 @@ function exportProject() {
.get() .get()
.then((assets) => { .then((assets) => {
var exportarr = { project: project, assets: assets }; var exportarr = { project: project, assets: assets };
$('<a />', { // Blob, not a data: URL - projects with media blow past the
download: 'data.json', // maximum URL length.
href: const url = URL.createObjectURL(
'data:application/json,' + new Blob([JSON.stringify(exportarr)], {
encodeURIComponent(JSON.stringify(exportarr)), type: 'application/json',
}) })
.appendTo('body') );
.click(function () { const a = document.createElement('a');
$(this).remove(); a.style.display = 'none';
$('#export-project span').html('Export'); a.href = url;
})[0] a.download = 'data.json';
.click(); document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.setTimeout(function () {
URL.revokeObjectURL(url);
}, 60000);
$('#export-project span').html('Export');
}); });
} else { } else {
alert('Empty project'); alert('Empty project');
@@ -649,11 +722,16 @@ function clearProject() {
'Are you sure you want to clear this project? This action cannot be undone.' 'Are you sure you want to clear this project? This action cannot be undone.'
) )
) { ) {
db.collection('projects').delete(); Promise.all([
db.collection('assets').delete(); db.collection('projects').delete(),
window.setTimeout(function () { db.collection('assets').delete(),
location.reload(); ])
}, 1000); .catch(function (e) {
console.error('Could not clear the project', e);
})
.then(function () {
location.reload();
});
} }
hideMore(); hideMore();
} }
+144 -125
View File
@@ -1,17 +1,79 @@
// Remember the last valid crop rectangle so it can be restored if the user
// drags it outside the image.
function updateCropBounds() {
const cropUI = canvas.getItemById('crop');
if (!cropUI || !cropobj) {
return;
}
if (cropUI.isContainedWithinObject(cropobj)) {
cropleft = cropUI.get('left');
croptop = cropUI.get('top');
cropscalex = cropUI.get('scaleX');
cropscaley = cropUI.get('scaleY');
}
}
// Drag-to-reorder for the layer list. html5sortable only wires up the
// children present when it runs, so this is re-run every time a layer is
// added. The sortstop handler is bound only once.
let layerSortBound = false;
function initLayerSortable() {
const list = document.getElementById('layer-inner-list');
if (!list) {
return;
}
const sortableList = sortable(list, {
handle: '.layer-handle',
customDragImage: (draggedElement, elementOffset, event) => {
return {
element: document.getElementById('nothing'),
posX: event.pageX - elementOffset.left,
posY: event.pageY - elementOffset.top,
};
},
})[0];
// Re-initializing marks every handle draggable again, so locked layers
// have to be opted back out.
$('#layer-inner-list .layer').each(function () {
if ($(this).find('.lock').hasClass('locked')) {
$(this).find('.layer-handle').attr('draggable', false);
}
});
if (layerSortBound) {
return;
}
layerSortBound = true;
sortableList.addEventListener('sortupdate', function () {
syncTimelineOrder();
orderLayers();
});
}
// Re-order the timeline rows to match the layer list
function syncTimelineOrder() {
const timeline = document.getElementById('inner-timeline');
if (!timeline) {
return;
}
$('#layer-inner-list .layer').each(function () {
const row = document.getElementById(
$(this).attr('data-object')
);
if (row) {
timeline.appendChild(row);
}
});
}
$(document).ready(function () { $(document).ready(function () {
// An object is being moved in the canvas // An object is being moved in the canvas
canvas.on('object:moving', function (e) { canvas.on('object:moving', function (e) {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
if (cropping) { if (cropping) {
if ( updateCropBounds();
canvas.getItemById('crop').isContainedWithinObject(cropobj)
) {
cropleft = canvas.getItemById('crop').get('left');
croptop = canvas.getItemById('crop').get('top');
cropscalex = canvas.getItemById('crop').get('scaleX');
cropscaley = canvas.getItemById('crop').get('scaleY');
}
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} else if ( } else if (
lockmovement && lockmovement &&
@@ -39,14 +101,7 @@ $(document).ready(function () {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
if (cropping) { if (cropping) {
if ( updateCropBounds();
canvas.getItemById('crop').isContainedWithinObject(cropobj)
) {
cropleft = canvas.getItemById('crop').get('left');
croptop = canvas.getItemById('crop').get('top');
cropscalex = canvas.getItemById('crop').get('scaleX');
cropscaley = canvas.getItemById('crop').get('scaleY');
}
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} }
}); });
@@ -56,24 +111,15 @@ $(document).ready(function () {
e.target.hasControls = false; e.target.hasControls = false;
centerLines(e); centerLines(e);
if (cropping) { if (cropping) {
if ( updateCropBounds();
canvas.getItemById('crop').isContainedWithinObject(cropobj)
) {
cropleft = canvas.getItemById('crop').get('left');
croptop = canvas.getItemById('crop').get('top');
cropscalex = canvas.getItemById('crop').get('scaleX');
cropscaley = canvas.getItemById('crop').get('scaleY');
}
crop(canvas.getItemById('cropped')); crop(canvas.getItemById('cropped'));
} }
}); });
// An object is being rotated in the canvas // An object is being rotated in the canvas
canvas.on('object:rotating', function (e) { canvas.on('object:rotating', function (e) {
if (e.e.shiftKey) { if (canvas.getActiveObject()) {
canvas.getActiveObject().snapAngle = 15; canvas.getActiveObject().snapAngle = e.e.shiftKey ? 15 : 0;
} else {
canvas.getActiveObject().snapAngle = 0;
} }
e.target.hasControls = false; e.target.hasControls = false;
}); });
@@ -82,8 +128,10 @@ $(document).ready(function () {
canvas.on('object:modified', function (e) { canvas.on('object:modified', function (e) {
e.target.hasControls = true; e.target.hasControls = true;
if (!editinggroup && !cropping) { if (!editinggroup && !cropping) {
canvas.getActiveObject().lockMovementX = false; if (canvas.getActiveObject()) {
canvas.getActiveObject().lockMovementY = false; canvas.getActiveObject().lockMovementX = false;
canvas.getActiveObject().lockMovementY = false;
}
canvas.renderAll(); canvas.renderAll();
if (e.target.type == 'activeSelection') { if (e.target.type == 'activeSelection') {
const tempselection = canvas.getActiveObject(); const tempselection = canvas.getActiveObject();
@@ -193,8 +241,12 @@ $(document).ready(function () {
this.setViewportTransform(this.viewportTransform); this.setViewportTransform(this.viewportTransform);
this.isDragging = false; this.isDragging = false;
this.selection = true; this.selection = true;
line_h.opacity = 0; if (line_h) {
line_v.opacity = 0; line_h.opacity = 0;
}
if (line_v) {
line_v.opacity = 0;
}
}); });
// Detect mouse over canvas (for dragging objects from the library) // Detect mouse over canvas (for dragging objects from the library)
@@ -214,7 +266,9 @@ $(document).ready(function () {
canvas.on('mouse:out', function (e) { canvas.on('mouse:out', function (e) {
overCanvas = false; overCanvas = false;
if (wip) { if (wip) {
e.target.hasControls = true; if (e.target) {
e.target.hasControls = true;
}
canvas.discardActiveObject(); canvas.discardActiveObject();
wip = false; wip = false;
canvas.renderAll(); canvas.renderAll();
@@ -320,13 +374,16 @@ $(document).ready(function () {
} }
}, 1000); }, 1000);
} }
// Redo // Redo / undo (shift decides which; never both in one keypress)
if (e.which === 90 && (e.ctrlKey || e.metaKey) && e.shiftKey) {
undoRedo(redo, undo, redoarr, undoarr);
}
// Undo
if (e.which === 90 && (e.ctrlKey || e.metaKey)) { 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 // Duplicate object
if (e.which === 68 && (e.ctrlKey || e.metaKey)) { if (e.which === 68 && (e.ctrlKey || e.metaKey)) {
@@ -353,51 +410,30 @@ $(document).ready(function () {
if (e.keyCode === 13 && editingproject) { if (e.keyCode === 13 && editingproject) {
saveProjectName(); saveProjectName();
} }
// Left arrow key (move object to the left) // Arrow keys nudge the selection
if (e.keyCode === 37 && canvas.getActiveObject()) { if (
var obj = canvas.getActiveObject(); e.keyCode >= 37 &&
var step = 2; e.keyCode <= 40 &&
canvas.getActiveObject() &&
!canvas.getActiveObject().isEditing &&
!focus &&
!editinglayer &&
!editingproject
) {
const obj = canvas.getActiveObject();
// Bigger step if shift is down // Bigger step if shift is down
if (e.shiftKey) { const step = e.shiftKey ? 7 : 2;
step = 7; if (e.keyCode === 37) {
obj.left = obj.left - step;
} 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; // Without this the selection box stays where the object used to be
canvas.renderAll(); obj.setCoords();
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;
canvas.renderAll(); canvas.renderAll();
autoKeyframe(obj, { action: 'drag' }, false); autoKeyframe(obj, { action: 'drag' }, false);
} }
@@ -406,7 +442,7 @@ $(document).ready(function () {
if ( if (
e.keyCode === 221 && e.keyCode === 221 &&
canvas.getActiveObjects() && canvas.getActiveObjects() &&
e.metaKey (e.metaKey || e.ctrlKey)
) { ) {
if (canvas.getActiveObjects().length == 1) { if (canvas.getActiveObjects().length == 1) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
@@ -434,7 +470,7 @@ $(document).ready(function () {
if ( if (
e.keyCode === 219 && e.keyCode === 219 &&
canvas.getActiveObjects() && canvas.getActiveObjects() &&
e.metaKey (e.metaKey || e.ctrlKey)
) { ) {
if (canvas.getActiveObjects().length == 1) { if (canvas.getActiveObjects().length == 1) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
@@ -545,25 +581,24 @@ $(document).ready(function () {
// Copy event // Copy event
window.addEventListener('copy', function (e) { window.addEventListener('copy', function (e) {
// Selecting keyframes clears the canvas selection, so there is often no
// active object at all here - never dereference it unguarded.
const activeObject = canvas.getActiveObject();
if (activeObject && activeObject.isEditing) {
return;
}
// Copy selected object // Copy selected object
if ( if (activeObject && shiftkeys.length == 0) {
canvas.getActiveObject() &&
shiftkeys.length == 0 &&
!canvas.getActiveObject().isEditing
) {
var emptyInp = document.getElementById('emptyInput'); var emptyInp = document.getElementById('emptyInput');
emptyInp.select(); emptyInp.select();
emptyInp.focus(); emptyInp.focus();
setTimeout(function () { setTimeout(function () {
document.execCommand('copy'); document.execCommand('copy');
}, 0); }, 0);
clipboard = canvas.getActiveObject(); clipboard = activeObject;
cliptype = 'object'; cliptype = 'object';
// Copy selected keyframe(s) // Copy selected keyframe(s)
} else if ( } else if (shiftkeys.length > 0) {
shiftkeys.length > 0 &&
!canvas.getActiveObject().isEditing
) {
var emptyInp = document.getElementById('emptyInput'); var emptyInp = document.getElementById('emptyInput');
emptyInp.select(); emptyInp.select();
emptyInp.focus(); emptyInp.focus();
@@ -580,7 +615,9 @@ $(document).ready(function () {
e.name == drag.attr('data-property') e.name == drag.attr('data-property')
); );
}); });
clipboard.push(keyarr[0]); if (keyarr.length > 0) {
clipboard.push(keyarr[0]);
}
}); });
cliptype = 'keyframe'; cliptype = 'keyframe';
} }
@@ -598,7 +635,9 @@ $(document).ready(function () {
} else { } else {
for (var i = 0; i < imgs.length; i++) { for (var i = 0; i < imgs.length; i++) {
if (imgs[i].type.indexOf('image') == -1) continue; if (imgs[i].type.indexOf('image') == -1) continue;
var imgObj = imgs[i].getAsFile(); // `let` so each async thumbnail callback keeps its own file
let imgObj = imgs[i].getAsFile();
if (!imgObj) continue;
if (imgObj.size / 1024 / 1024 <= 10) { if (imgObj.size / 1024 / 1024 <= 10) {
createThumbnail(imgObj, 250).then(function (data) { createThumbnail(imgObj, 250).then(function (data) {
saveFile( saveFile(
@@ -803,27 +842,7 @@ $(document).ready(function () {
syncScrollHoz($('#timeline'), $('#seekarea')); syncScrollHoz($('#timeline'), $('#seekarea'));
// Initialize layer sorting // Initialize layer sorting
sortable('#layer-inner-list', { initLayerSortable();
customDragImage: (draggedElement, elementOffset, event) => {
return {
element: document.getElementById('nothing'),
posX: event.pageX - elementOffset.left,
posY: event.pageY - elementOffset.top,
};
},
})[0].addEventListener('sortstop', function (e) {
const id = $(e.detail.item).attr('data-object');
const previd = $(e.detail.item).prev().attr('data-object');
if ($('.sortable-dragging').length == 1) {
$('.sortable-dragging').remove();
if (previd == undefined) {
$('#inner-timeline').prepend($('#' + id));
} else {
$('#' + id).insertAfter($('#' + previd));
}
orderLayers();
}
});
// Initialize dropdown for keyframe easing // Initialize dropdown for keyframe easing
$('#easing select').niceSelect(); $('#easing select').niceSelect();
@@ -936,8 +955,8 @@ $(document).ready(function () {
onmove: function (x) { onmove: function (x) {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'RemoveColor')) { if (obj.filters.find((i) => i.type == 'RemoveColor')) {
obj.filters.find((x) => x.type == 'RemoveColor').distance = obj.filters.find((i) => i.type == 'RemoveColor').distance =
x / 100; x / 100;
} }
obj.applyFilters(); obj.applyFilters();
@@ -964,8 +983,8 @@ $(document).ready(function () {
onmove: function (x) { onmove: function (x) {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'Noise')) { if (obj.filters.find((i) => i.type == 'Noise')) {
obj.filters.find((x) => x.type == 'Noise').noise = x; obj.filters.find((i) => i.type == 'Noise').noise = x;
} else { } else {
obj.filters.push( obj.filters.push(
new f.Noise({ new f.Noise({
@@ -997,8 +1016,8 @@ $(document).ready(function () {
onmove: function (x) { onmove: function (x) {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (obj.filters.find((x) => x.type == 'Blur')) { if (obj.filters.find((i) => i.type == 'Blur')) {
obj.filters.find((x) => x.type == 'Blur').blur = x / 100; obj.filters.find((i) => i.type == 'Blur').blur = x / 100;
} else { } else {
obj.filters.push( obj.filters.push(
new f.Blur({ new f.Blur({
@@ -1006,9 +1025,9 @@ $(document).ready(function () {
}) })
); );
} }
obj.applyFilters();
canvas.renderAll();
} }
obj.applyFilters();
canvas.renderAll();
}, },
onfinish: function (x) { onfinish: function (x) {
save(); save();
+762 -1196
View File
File diff suppressed because it is too large Load Diff
+70 -40
View File
@@ -3,7 +3,6 @@ var GOOGLE_FONTS_API_KEY = 'GOOGLE_FONTS_API_KEY';
// for legacy browsers // for legacy browsers
const AudioContext = window.AudioContext || window.webkitAudioContext; const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
var oldsrc, oldobj; var oldsrc, oldobj;
var oldtimelinepos; var oldtimelinepos;
var speed = 1; var speed = 1;
@@ -58,7 +57,6 @@ var editingpanel = false;
var files = []; var files = [];
var re = /(?:\.([^.]+))?$/; var re = /(?:\.([^.]+))?$/;
var filelist = []; var filelist = [];
var timeout;
var spacehold = false; var spacehold = false;
var spacerelease = false; var spacerelease = false;
var tempselection; var tempselection;
@@ -94,7 +92,7 @@ var chromaslider, noiseslider, blurslider;
var isChrome = var isChrome =
window.chrome && Object.values(window.chrome).length !== 0; window.chrome && Object.values(window.chrome).length !== 0;
var eyeDropper; var eyeDropper;
if (isChrome) { if (isChrome && typeof EyeDropper !== 'undefined') {
eyeDropper = new EyeDropper(); eyeDropper = new EyeDropper();
} }
var presets = [ var presets = [
@@ -156,20 +154,30 @@ var sliders = [];
var hovertime = 0; var hovertime = 0;
var animatedtext = []; var animatedtext = [];
// Get list of fonts // Get list of fonts.
$.ajax({ // Both API keys are placeholders in the repository - replace them to enable
url: // the Google Fonts list and the Pixabay browser.
'https://www.googleapis.com/webfonts/v1/webfonts?key=' + const HAS_FONTS_KEY =
GOOGLE_FONTS_API_KEY + GOOGLE_FONTS_API_KEY && GOOGLE_FONTS_API_KEY != 'GOOGLE_FONTS_API_KEY';
'&sort=alpha', const HAS_PIXABAY_KEY = API_KEY && API_KEY != 'PIXABAY_API';
type: 'GET', if (HAS_FONTS_KEY) {
dataType: 'json', // added data type $.ajax({
success: function (response) { url:
response.items.forEach(function (item) { 'https://www.googleapis.com/webfonts/v1/webfonts?key=' +
fonts.push(item.family); GOOGLE_FONTS_API_KEY +
}); '&sort=alpha',
}, type: 'GET',
}); dataType: 'json', // added data type
success: function (response) {
response.items.forEach(function (item) {
fonts.push(item.family);
});
},
error: function () {
console.warn('Could not load the Google Fonts list');
},
});
}
// Panel variants // Panel variants
const canvas_panel = const canvas_panel =
@@ -444,6 +452,25 @@ var text_items = {
], ],
}; };
// Without a Google Fonts key the font pickers would be empty, so fall back
// to the families that are already bundled in the text browser.
// (Declared here because it reads text_items, defined just above.)
if (!HAS_FONTS_KEY) {
Object.keys(text_items).forEach(function (group) {
text_items[group].forEach(function (item) {
if (fonts.indexOf(item.fontname) == -1) {
fonts.push(item.fontname);
}
});
});
['Inter', 'Syne'].forEach(function (name) {
if (fonts.indexOf(name) == -1) {
fonts.push(name);
}
});
fonts.sort();
}
WebFont.load({ WebFont.load({
google: { google: {
families: ['Syne'], families: ['Syne'],
@@ -460,7 +487,11 @@ try {
var canvas2dBackend = new fabric.Canvas2dFilterBackend(); var canvas2dBackend = new fabric.Canvas2dFilterBackend();
fabric.filterBackend = fabric.initFilterBackend(); fabric.filterBackend = fabric.initFilterBackend();
fabric.filterBackend = webglBackend; // Only take over the backend if WebGL actually initialized, otherwise filters
// would silently break on machines without a usable WebGL context.
if (webglBackend) {
fabric.filterBackend = webglBackend;
}
// Lottie support // Lottie support
fabric.Lottie = fabric.util.createClass(fabric.Image, { fabric.Lottie = fabric.util.createClass(fabric.Image, {
@@ -491,14 +522,18 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
}); });
this.lottieItem.addEventListener('enterFrame', (e) => { this.lottieItem.addEventListener('enterFrame', (e) => {
this.canvas.requestRenderAll(); if (this.canvas) {
this.canvas.requestRenderAll();
}
}); });
this.lottieItem.addEventListener('DOMLoaded', () => { this.lottieItem.addEventListener('DOMLoaded', () => {
this.lottieItem.goToAndStop(currenttime, false); this.lottieItem.goToAndStop(currenttime, false);
this.lottieItem.duration = this.lottieItem.duration =
this.lottieItem.getDuration(false) * 1000; this.lottieItem.getDuration(false) * 1000;
this.canvas.requestRenderAll(); if (this.canvas) {
this.canvas.requestRenderAll();
}
canvas.renderAll(); canvas.renderAll();
canvas.fire('lottie:loaded', { any: 'payload' }); canvas.fire('lottie:loaded', { any: 'payload' });
}); });
@@ -508,7 +543,9 @@ fabric.Lottie = fabric.util.createClass(fabric.Image, {
goToSeconds: function (seconds) { goToSeconds: function (seconds) {
this.lottieItem.goToAndStop(seconds, false); this.lottieItem.goToAndStop(seconds, false);
this.canvas.requestRenderAll(); if (this.canvas) {
this.canvas.requestRenderAll();
}
}, },
goToFrame: function (frame) { goToFrame: function (frame) {
this.lottieItem.goToAndStop(frame, true); this.lottieItem.goToAndStop(frame, true);
@@ -758,30 +795,23 @@ textBoxControls.mr = new fabric.Control({
// Get any object by ID // Get any object by ID
fabric.Canvas.prototype.getItemById = function (name) { fabric.Canvas.prototype.getItemById = function (name) {
var object = null, function search(list) {
objects = this.getObjects(); for (var i = 0; i < list.length; i++) {
for (var i = 0, len = this.size(); i < len; i++) { const item = list[i];
if (objects[i].get('type') == 'group') { if (item.id && item.id === name) {
if (objects[i].get('id') && objects[i].get('id') === name) { return item;
object = objects[i];
break;
} }
var wip = i; // Recurse so groups nested more than one level deep are found too
for (var o = 0; o < objects[i]._objects.length; o++) { if (item._objects && item._objects.length > 0) {
if ( const found = search(item._objects);
objects[wip]._objects[o].id && if (found) {
objects[wip]._objects[o].id === name return found;
) {
object = objects[wip]._objects[o];
break;
} }
} }
} else if (objects[i].id && objects[i].id === name) {
object = objects[i];
break;
} }
return null;
} }
return object; return search(this.getObjects());
}; };
// Create the artboard // Create the artboard
+1 -1
View File
@@ -14,7 +14,7 @@ async function newLottieAnimation(x, y, json) {
strokeWidth: 0, strokeWidth: 0,
cursorDuration: 1, cursorDuration: 1,
cursorDelay: 250, cursorDelay: 250,
duration: duration * 1000, duration: duration,
assetType: 'sprite', assetType: 'sprite',
id: 'Sprite' + layer_count, id: 'Sprite' + layer_count,
objectCaching: false, objectCaching: false,
+211 -329
View File
@@ -1,68 +1,108 @@
const FPS = 30; // Everything the current export needs to tear down when it finishes
let frame = 0; var exportAudio = null;
var chunks = [];
var stream;
var rec;
var track;
function timeout(ms) { // Route every sound source of the project into a single destination node.
return new Promise((resolve) => setTimeout(resolve, ms)); //
} // This has to be one shared AudioContext with one destination:
// - MediaRecorder only records the first audio track of a stream, so
// adding one track per source silently dropped all but one of them,
// - a context per source was never closed, and browsers cap how many a
// page may hold.
function buildExportAudio(stream) {
const ctx = new AudioContext();
const destination = ctx.createMediaStreamDestination();
const elements = [];
var connected = false;
function initRecorder() { function connect(element) {
stream = document.getElementById('canvasrecord').captureStream(0); try {
track = stream.getVideoTracks()[0]; ctx.createMediaElementSource(element).connect(destination);
connected = true;
if (!track.requestFrame) { return true;
track.requestFrame = () => stream.requestFrame(); } catch (e) {
// Already bound to another context, or tainted by CORS
console.warn('Could not route audio for export', e);
return false;
}
} }
rec = new MediaRecorder(stream, { objects.forEach(function (object) {
bitsPerSecond: 3200000, const obj = canvasrecord.getItemById(object.id);
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
if (!obj || !p_keyframe) {
return;
}
if (obj.get('assetType') == 'video') {
const element = $(obj.getElement())[0];
if (element) {
connect(element);
}
} else if (
obj.get('assetType') == 'audio' &&
obj.get('audioSrc')
) {
// Audio layers used to be left out of the export entirely
const element = new Audio(obj.get('audioSrc'));
element.crossOrigin = 'anonymous';
element.volume = obj.get('volume');
if (connect(element)) {
elements.push({
element: element,
start: p_keyframe.start,
end: p_keyframe.end,
trimstart: p_keyframe.trimstart,
});
}
}
}); });
rec.ondataavailable = function (evt) { if (background_audio != false && connect(background_audio)) {
console.log('chunky'); elements.push({
chunks.push(evt.data); element: background_audio,
}; start: 0,
end: duration,
trimstart: 0,
});
}
rec.start(); if (connected) {
stream.addTrack(destination.stream.getAudioTracks()[0]);
console.log('Recorder has been started'); }
return { context: ctx, elements: elements, timers: [] };
rec.onstart = function () {
rec.pause();
console.log('start!');
};
} }
async function recordFrame() { // Start the scheduled audio layers relative to the start of the recording
console.log(frame); function startExportAudio(audio) {
if (!audio) {
waitForEvent(rec, 'pause'); return;
}
//rec.onpause = async function(e) { audio.elements.forEach(function (item) {
item.element.currentTime = item.trimstart / 1000;
// wake up the recorder audio.timers.push(
rec.resume(); window.setTimeout(function () {
recordAnimate(false, (frame / FPS) * 1000); item.element.play();
//animate(false, (frame/FPS)*1000) }, Math.max(0, item.start))
// force write the frame );
track.requestFrame(); audio.timers.push(
window.setTimeout(function () {
// wait until our frame-time elapsed item.element.pause();
await timeout(1000 / FPS); }, Math.max(0, item.end))
);
// sleep recorder });
rec.pause();
//}
} }
async function exportRecording() { function stopExportAudio(audio) {
rec.stop(); if (!audio) {
stream.getTracks().forEach((track) => track.stop()); return;
await waitForEvent(rec, 'stop'); }
return new Blob(chunks); audio.timers.forEach(window.clearTimeout);
audio.timers = [];
audio.elements.forEach(function (item) {
item.element.pause();
});
if (audio.context && audio.context.state != 'closed') {
audio.context.close();
}
} }
// Record canvas // Record canvas
@@ -85,280 +125,122 @@ async function record() {
document.body.removeChild(link); document.body.removeChild(link);
recording = false; recording = false;
updateRecordCanvas(); updateRecordCanvas();
} else { return;
if (!recording) {
recording = true;
paused = true;
await recordAnimate(0);
$('#download-real').html('Rendering...');
$('#download-real').addClass('downloading');
var fps = 60;
var aCtx = new AudioContext();
function audioTimerLoop(callback, frequency) {
var freq = frequency / 1000;
var silence = aCtx.createGain();
silence.gain.value = 0;
silence.connect(aCtx.destination);
onOSCend();
var stopped = false;
function onOSCend() {
osc = aCtx.createOscillator();
osc.onended = onOSCend;
osc.connect(silence);
osc.start(0);
osc.stop(aCtx.currentTime + freq);
callback(aCtx.currentTime);
if (stopped) {
osc.onended = function () {
return;
};
}
}
return function () {
stopped = true;
};
}
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
var stream = document
.getElementById('canvasrecord')
.captureStream(fps);
objects.forEach(function (object) {
if (
canvasrecord.getItemById(object.id).get('assetType') &&
canvasrecord.getItemById(object.id).get('assetType') ==
'video'
) {
var audio = $(
canvasrecord.getItemById(object.id).getElement()
)[0];
var audioContext = new AudioContext();
var audioSource =
audioContext.createMediaElementSource(audio);
var audioDestination =
audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
stream.addTrack(
audioDestination.stream.getAudioTracks()[0]
);
}
});
if (background_audio != false) {
var audioContext = new AudioContext();
var audioSource =
audioContext.createMediaElementSource(background_audio);
var audioDestination =
audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
background_audio.currentTime = 0;
background_audio.play();
}
let chunks = [];
var recorder = new MediaRecorder(stream, {
bitsPerSecond: 3200000,
});
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onstop = (e) => {
stopAnim();
downloadRecording(chunks);
animate(false, 0);
$('#seekbar').offset({
left:
offset_left +
$('#inner-timeline').offset().left +
currenttime / timelinetime,
});
canvas.renderAll();
console.log('Finished rendering');
};
recorder.start();
setTimeout(function () {
recorder.stop();
}, duration);
async function renderAnim(time) {
await recordAnimate(time * 1000);
}
}
} }
if (recording) {
return;
}
recording = true;
paused = true;
await recordAnimate(0);
$('#download-real').html('Rendering...');
$('#download-real').addClass('downloading');
// Preferred path: render every frame offline, with no clock attached, so
// nothing is dropped and video layers land on the exact frame.
if (frameAccurateSupported()) {
const blob = await renderFrameAccurate();
if (blob) {
deliverRecording(blob);
return;
}
console.warn('Falling back to the real-time encoder');
$('#download-real').html('Rendering...');
await updateRecordCanvas();
await recordAnimate(0);
}
const fps = getExportFramerate();
const aCtx = new AudioContext();
// requestAnimationFrame is throttled in background tabs, an oscillator is
// not, so the render keeps running while the tab is hidden.
function audioTimerLoop(callback, frequency) {
const freq = frequency / 1000;
const silence = aCtx.createGain();
silence.gain.value = 0;
silence.connect(aCtx.destination);
var stopped = false;
var osc;
function onOSCend() {
if (stopped) {
return;
}
osc = aCtx.createOscillator();
osc.onended = onOSCend;
osc.connect(silence);
osc.start(0);
osc.stop(aCtx.currentTime + freq);
callback(aCtx.currentTime);
}
onOSCend();
return function () {
stopped = true;
if (osc) {
osc.onended = null;
}
};
}
const stream = document
.getElementById('canvasrecord')
.captureStream(fps);
exportAudio = buildExportAudio(stream);
const chunks = [];
const recorder = new MediaRecorder(stream, {
bitsPerSecond: 3200000,
});
recorder.ondataavailable = (e) => chunks.push(e.data);
recorder.onerror = (e) => {
console.error('Recording failed', e);
stopAnim();
stopExportAudio(exportAudio);
exportAudio = null;
aCtx.close();
resetRecordingUI();
};
recorder.onstop = () => {
stopAnim();
stopExportAudio(exportAudio);
exportAudio = null;
stream.getTracks().forEach((track) => track.stop());
aCtx.close();
downloadRecording(chunks);
console.log('Finished rendering');
};
// The capture is real time, so the animation clock is driven off the audio
// clock and the recording is stopped once it has covered the timeline.
var origin = null;
var stopping = false;
async function renderAnim(time) {
if (origin === null) {
origin = time;
}
const elapsed = (time - origin) * 1000;
if (elapsed >= duration) {
if (!stopping) {
stopping = true;
await recordAnimate(duration);
if (recorder.state != 'inactive') {
recorder.stop();
}
}
return;
}
await recordAnimate(elapsed);
}
recorder.start();
startExportAudio(exportAudio);
var stopAnim = audioTimerLoop(renderAnim, 1000 / fps);
// Safety net: never leave the UI stuck if the oscillator clock stalls
window.setTimeout(function () {
if (recorder.state != 'inactive') {
recorder.stop();
}
}, duration + 5000);
} }
/*
initRecorder();
//await timeout(2000)
// draw one frame at a time
while (frame++ < FPS * (duration/1000)) {
await longDraw(); // do the long drawing
await recordFrame(); // record at constant FPS
}
// now all the frames have been drawn
const recorded = await exportRecording(); // we can get our final video file
const a = document.createElement('a');
a.style.display = 'none';
a.href = URL.createObjectURL(recorded);
a.download = "test.webm";
document.body.appendChild(a);
a.click();
recording = false;
currenttime = 0;
animate(false, 0);
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
canvas.renderAll();
resizeCanvas();
if (background_audio != false) {
background_audio.pause();
background_audio = new Audio(background_audio.src)
}
$("#download-real").html("Download");
$("#download-real").removeClass("downloading");
updateRecordCanvas();
// Fake long drawing operations that make real-time recording impossible
function longDraw() {
recordAnimate((frame/FPS)*1000)
return wait(Math.random() * 300)
.then(recordAnimate((frame/FPS)*1000));
}*/
/*
paused = true;
recording = true;
$("#download-real").html("Rendering...");
$("#download-real").addClass("downloading");
var fps = 60;
var aCtx = new AudioContext();
function audioTimerLoop(callback, frequency) {
var freq = frequency / 1000;
var silence = aCtx.createGain();
silence.gain.value = 0;
silence.connect(aCtx.destination);
onOSCend();
var stopped = false;
function onOSCend() {
osc = aCtx.createOscillator();
osc.onended = onOSCend;
osc.connect(silence);
osc.start(0);
osc.stop(aCtx.currentTime + freq);
callback(aCtx.currentTime);
if (stopped) {
osc.onended = function() {
return;
};
}
};
return function() {
stopped = true;
};
}
var stopAnim = audioTimerLoop(renderAnim, 1000/(fps));
var stream = document.getElementById("canvasrecord").captureStream(fps);
objects.forEach(function(object){
if (canvasrecord.getItemById(object.id).get("assetType") && canvasrecord.getItemById(object.id).get("assetType") == "video") {
var audio = $(canvasrecord.getItemById(object.id).getElement())[0];
var audioContext = new AudioContext();
var audioSource = audioContext.createMediaElementSource(audio);
var audioDestination = audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
}
})
if (background_audio != false) {
var audioContext = new AudioContext();
var audioSource = audioContext.createMediaElementSource(background_audio);
var audioDestination = audioContext.createMediaStreamDestination();
audioSource.connect(audioDestination);
stream.addTrack(audioDestination.stream.getAudioTracks()[0]);
background_audio.currentTime = 0;
background_audio.play();
}
let chunks = [];
var recorder = new MediaRecorder(stream, {
bitsPerSecond : 3200000,
});
recorder.ondataavailable = e => chunks.push(e.data);
recorder.onstop = e => {
stopAnim();
downloadRecording(chunks);
animate(false, 0);
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
canvas.renderAll();
console.log("Finished rendering")
}
recorder.start();
setTimeout(function() {
recorder.stop();
}, duration)
async function renderAnim(time) {
await animate(false, time*1000);
}
*/
/*
$("#download-real").html("Rendering...");
$("#download-real").addClass("downloading");
// browser check
if (typeof MediaStreamTrackGenerator === undefined || typeof MediaStream === undefined || typeof VideoFrame === undefined) {
console.log('Your browser does not support the web APIs used in this demo');
return;
}
// recording setup
const fps = 60;
const generator = new MediaStreamTrackGenerator({ kind: "video" });
const writer = generator.writable.getWriter();
const stream = new MediaStream();
stream.addTrack(generator);
const recorder = new MediaRecorder(stream, { mimeType: "video/webm" });
recorder.start();
function timeout(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// animate stuff
console.log('rendering...')
console.log(duration);
for (let i = 0; i < (duration/1000)*fps; i++) {
animate(false, (i/fps)*1000);
const frame = new VideoFrame(document.getElementById("canvasrecord"), {
timestamp: (i / fps)*1000
});
await writer.write(frame);
await timeout(100)
console.log("frame "+(i/fps)*1000);
}
console.log('rendering done');
// stop recording and
recorder.addEventListener("dataavailable", (evt) => {
const a = document.createElement('a');
a.style.display = 'none';
a.href = URL.createObjectURL(evt.data);
a.download = "test.webm";
document.body.appendChild(a);
a.click();
recording = false;
currenttime = 0;
animate(false, 0);
$("#seekbar").offset({left:offset_left+$("#inner-timeline").offset().left+(currenttime/timelinetime)});
canvas.renderAll();
resizeCanvas();
if (background_audio != false) {
background_audio.pause();
background_audio = new Audio(background_audio.src)
}
$("#download-real").html("Download");
$("#download-real").removeClass("downloading");
updateRecordCanvas();
});
recorder.stop();
*/
+515
View File
@@ -0,0 +1,515 @@
// Frame-accurate offline renderer.
//
// The original export path captured the canvas with MediaRecorder in real
// time, so anything the browser could not draw at 60fps was simply dropped and
// video layers were sampled wherever they happened to be. This renders one
// frame at a time with no clock attached: every frame is drawn at the frame
// rate picked in the download modal, every video layer is seeked to the exact
// frame time, and the audio is mixed offline.
//
// Needs WebCodecs (Chrome/Edge). record() falls back to the real-time path
// when this is unavailable or fails.
// The muxer can only close a cluster on a video keyframe, and a cluster may
// not span more than ~32s (block timecodes are a signed 16 bit offset), so
// keyframes have to stay frequent.
const RENDER_KEYFRAME_INTERVAL = 2; // seconds
// A seek that never completes must not hang the whole export
const RENDER_MAX_SEEK_WAIT = 2000;
// recordAnimate() drives video playback in real time; while rendering offline
// the frames are seeked explicitly instead.
var offlinerender = false;
function frameAccurateSupported() {
return (
typeof VideoEncoder !== 'undefined' &&
typeof VideoFrame !== 'undefined' &&
typeof WebMWriter !== 'undefined' &&
typeof OfflineAudioContext !== 'undefined'
);
}
function renderProgress(text) {
$('#download-real').html(text);
}
// ---------------------------------------------------------------------------
// Media positioning
// ---------------------------------------------------------------------------
// Put every time-based layer at exactly `time` and wait until it is actually
// showing that frame. A plain currentTime assignment is asynchronous - drawing
// before 'seeked' captures the previous frame.
async function seekMediaForFrame(time) {
const waits = [];
objects.forEach(function (object) {
const obj = canvasrecord.getItemById(object.id);
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
if (!obj || !p_keyframe) {
return;
}
if (obj.type == 'lottie') {
obj.goToSeconds(time);
return;
}
if (obj.get('assetType') != 'video') {
return;
}
const element = $(obj.getElement())[0];
if (!element) {
return;
}
element.pause();
const visible =
time >= p_keyframe.trimstart + p_keyframe.start &&
time <= p_keyframe.end;
obj.set('visible', visible);
if (!visible) {
return;
}
var target =
(time - p_keyframe.start + p_keyframe.trimstart) / 1000;
if (element.duration) {
target = Math.min(target, Math.max(0, element.duration - 0.001));
}
target = Math.max(0, target);
if (Math.abs(element.currentTime - target) < 0.0005) {
return;
}
waits.push(
new Promise(function (resolve) {
var settled = false;
function finish() {
if (settled) {
return;
}
settled = true;
element.removeEventListener('seeked', finish);
element.removeEventListener('error', finish);
resolve();
}
element.addEventListener('seeked', finish);
element.addEventListener('error', finish);
// A stuck seek must not hang the whole export
window.setTimeout(finish, RENDER_MAX_SEEK_WAIT);
try {
element.currentTime = target;
} catch (e) {
finish();
}
})
);
});
await Promise.all(waits);
}
// ---------------------------------------------------------------------------
// Audio
// ---------------------------------------------------------------------------
// Every sound in the project, with where it sits on the timeline
function collectAudioSources() {
const sources = [];
objects.forEach(function (object) {
const obj = canvasrecord.getItemById(object.id);
const p_keyframe = p_keyframes.find((x) => x.id == object.id);
if (!obj || !p_keyframe) {
return;
}
if (obj.get('assetType') == 'audio' && obj.get('audioSrc')) {
sources.push({
src: obj.get('audioSrc'),
start: p_keyframe.start,
end: p_keyframe.end,
trimstart: p_keyframe.trimstart,
volume: obj.get('volume'),
});
} else if (obj.get('assetType') == 'video' && obj.get('source')) {
sources.push({
src: obj.get('source'),
start: p_keyframe.start,
end: p_keyframe.end,
trimstart: p_keyframe.trimstart,
volume: 1,
});
}
});
if (background_audio != false && background_audio.src) {
sources.push({
src: background_audio.src,
start: 0,
end: duration,
trimstart: 0,
volume: 1,
});
}
return sources;
}
// Mix the whole timeline in one pass. OfflineAudioContext renders faster than
// real time and is sample-accurate, unlike routing live elements into a
// MediaStream.
async function renderAudioBuffer() {
const sources = collectAudioSources();
if (sources.length == 0) {
return null;
}
const sampleRate = 48000;
const frames = Math.ceil((duration / 1000) * sampleRate);
if (!(frames > 0)) {
return null;
}
const offline = new OfflineAudioContext(2, frames, sampleRate);
const decoded = [];
for (const source of sources) {
try {
const response = await fetch(source.src);
const bytes = await response.arrayBuffer();
const buffer = await offline.decodeAudioData(bytes);
decoded.push({ source: source, buffer: buffer });
} catch (e) {
// Silent video, unsupported container, or an unreachable asset
console.warn('Skipping audio source', e);
}
}
if (decoded.length == 0) {
return null;
}
decoded.forEach(function (item) {
const node = offline.createBufferSource();
node.buffer = item.buffer;
const gain = offline.createGain();
gain.gain.value =
typeof item.source.volume == 'number' ? item.source.volume : 1;
node.connect(gain);
gain.connect(offline.destination);
const when = Math.max(0, item.source.start / 1000);
const offset = Math.max(0, item.source.trimstart / 1000);
const length = Math.max(
0,
(item.source.end - item.source.start) / 1000
);
if (length > 0 && offset < item.buffer.duration) {
node.start(when, offset, length);
}
});
return await offline.startRendering();
}
// Matroska needs the Opus identification header as CodecPrivate
function buildOpusHead(channels, sampleRate) {
const head = new Uint8Array(19);
const view = new DataView(head.buffer);
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0); // "OpusHead"
head[8] = 1; // version
head[9] = channels;
view.setUint16(10, 3840, true); // pre-skip
view.setUint32(12, sampleRate, true);
view.setUint16(16, 0, true); // output gain
head[18] = 0; // channel mapping family
return head;
}
async function encodeAudioBuffer(audioBuffer) {
const channels = Math.min(2, audioBuffer.numberOfChannels);
const sampleRate = audioBuffer.sampleRate;
const config = {
codec: 'opus',
sampleRate: sampleRate,
numberOfChannels: channels,
bitrate: 128000,
};
if (typeof AudioEncoder === 'undefined') {
return null;
}
const support = await AudioEncoder.isConfigSupported(config);
if (!support || !support.supported) {
return null;
}
const chunks = [];
var failed = false;
const encoder = new AudioEncoder({
output: function (chunk) {
chunks.push(chunk);
},
error: function (e) {
failed = true;
console.error('Audio encoding failed', e);
},
});
encoder.configure(config);
const sliceFrames = Math.round(sampleRate / 10); // 100ms
const planes = [];
for (var c = 0; c < channels; c++) {
planes.push(audioBuffer.getChannelData(c));
}
for (
var offset = 0;
offset < audioBuffer.length && !failed;
offset += sliceFrames
) {
const count = Math.min(sliceFrames, audioBuffer.length - offset);
const planar = new Float32Array(count * channels);
for (var ch = 0; ch < channels; ch++) {
planar.set(
planes[ch].subarray(offset, offset + count),
ch * count
);
}
const data = new AudioData({
format: 'f32-planar',
sampleRate: sampleRate,
numberOfFrames: count,
numberOfChannels: channels,
timestamp: Math.round((offset / sampleRate) * 1e6),
data: planar,
});
encoder.encode(data);
data.close();
}
await encoder.flush();
encoder.close();
if (failed || chunks.length == 0) {
return null;
}
return {
chunks: chunks,
sampleRate: sampleRate,
channels: channels,
codecPrivate: buildOpusHead(channels, sampleRate),
};
}
// ---------------------------------------------------------------------------
// Video
// ---------------------------------------------------------------------------
async function pickVideoCodec(width, height, fps) {
const candidates = [
{ codec: 'vp09.00.10.08', name: 'VP9' },
{ codec: 'vp8', name: 'VP8' },
];
for (const candidate of candidates) {
const config = {
codec: candidate.codec,
width: width,
height: height,
bitrate: 8000000,
framerate: fps,
};
try {
const support = await VideoEncoder.isConfigSupported(config);
if (support && support.supported) {
return { config: config, name: candidate.name };
}
} catch (e) {
// try the next one
}
}
return null;
}
// ---------------------------------------------------------------------------
// Orchestration
// ---------------------------------------------------------------------------
// Confirm the muxed file is actually decodable before handing it to the user.
// The muxer is hand-rolled, so a silent failure here would otherwise reach
// the user as a file that will not play.
function verifyRenderedBlob(blob) {
return new Promise(function (resolve) {
const element = document.createElement('video');
const url = URL.createObjectURL(blob);
var settled = false;
function finish(ok) {
if (settled) {
return;
}
settled = true;
URL.revokeObjectURL(url);
element.removeAttribute('src');
resolve(ok);
}
element.onloadedmetadata = function () {
finish(element.videoWidth > 0 && element.videoHeight > 0);
};
element.onerror = function () {
finish(false);
};
window.setTimeout(function () {
finish(false);
}, 5000);
element.src = url;
});
}
// Returns a WebM Blob, or null to tell the caller to use the real-time path
async function renderFrameAccurate() {
const canvasElement = document.getElementById('canvasrecord');
const width = canvasElement.width;
const height = canvasElement.height;
if (!(width > 0 && height > 0)) {
return null;
}
const fps = getExportFramerate();
const selected = await pickVideoCodec(width, height, fps);
if (!selected) {
console.warn('No supported WebCodecs video configuration');
return null;
}
offlinerender = true;
var encoder = null;
try {
renderProgress('Mixing audio...');
var audio = null;
try {
const audioBuffer = await renderAudioBuffer();
if (audioBuffer) {
audio = await encodeAudioBuffer(audioBuffer);
}
} catch (e) {
console.warn('Rendering without audio', e);
audio = null;
}
const writer = new WebMWriter({
codec: selected.name,
width: width,
height: height,
audio: audio
? {
sampleRate: audio.sampleRate,
channels: audio.channels,
codecPrivate: audio.codecPrivate,
}
: null,
});
const videoChunks = [];
var encoderFailed = false;
encoder = new VideoEncoder({
output: function (chunk) {
videoChunks.push(chunk);
},
error: function (e) {
encoderFailed = true;
console.error('Video encoding failed', e);
},
});
encoder.configure(selected.config);
const totalFrames = Math.max(
1,
Math.round((duration / 1000) * fps)
);
const frameDuration = 1e6 / fps;
const keyframeEvery = Math.max(
1,
Math.round(fps * RENDER_KEYFRAME_INTERVAL)
);
for (var i = 0; i < totalFrames; i++) {
if (encoderFailed) {
break;
}
const time = (i / fps) * 1000;
await seekMediaForFrame(time);
await recordAnimate(time);
canvasrecord.renderAll();
const frame = new VideoFrame(canvasElement, {
timestamp: Math.round(i * frameDuration),
duration: Math.round(frameDuration),
});
encoder.encode(frame, { keyFrame: i % keyframeEvery == 0 });
frame.close();
// Keep the encoder queue short so memory stays bounded
while (encoder.encodeQueueSize > 8 && !encoderFailed) {
await new Promise(function (resolve) {
window.setTimeout(resolve, 4);
});
}
if (i % 5 == 0) {
renderProgress(
'Rendering ' +
Math.round(((i + 1) / totalFrames) * 100) +
'%'
);
}
}
await encoder.flush();
encoder.close();
encoder = null;
if (encoderFailed || videoChunks.length == 0) {
return null;
}
renderProgress('Muxing...');
// Interleave both tracks in timestamp order: a cluster's blocks have to
// be ordered, and this avoids threading the two producers together.
const all = [];
videoChunks.forEach(function (chunk) {
all.push({ chunk: chunk, track: 1 });
});
if (audio) {
audio.chunks.forEach(function (chunk) {
all.push({ chunk: chunk, track: 2 });
});
}
all.sort(function (a, b) {
if (a.chunk.timestamp == b.chunk.timestamp) {
return a.track - b.track;
}
return a.chunk.timestamp - b.chunk.timestamp;
});
all.forEach(function (item) {
writer.addFrame(item.chunk, item.track);
});
const blob = await writer.complete();
if (!blob || blob.size == 0) {
return null;
}
if (!(await verifyRenderedBlob(blob))) {
console.warn(
'Rendered file failed verification, using the real-time encoder'
);
return null;
}
return blob;
} catch (e) {
console.error('Frame-accurate render failed', e);
return null;
} finally {
offlinerender = false;
if (encoder && encoder.state != 'closed') {
try {
encoder.close();
} catch (e) {
// already torn down
}
}
}
}
+30 -18
View File
@@ -1,10 +1,16 @@
function animateText(group, ms, play, props, cv, id) { function animateText(group, ms, play, props, cv, id) {
var starttime = p_keyframes.find((x) => x.id == id).start; const p_keyframe = p_keyframes.find((x) => x.id == id);
ms -= starttime; if (!group || !p_keyframe) {
return;
}
ms -= p_keyframe.start;
var length = group._objects.length; var length = group._objects.length;
var globaldelay = 0; var globaldelay = 0;
// Every letter needs its own binding: the anime callbacks below run long
// after the loop has finished, so `var` would make them all share the last
// letter's state.
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
var index = i; let index = i;
if (props.order == 'backward') { if (props.order == 'backward') {
index = length - i - 1; index = length - i - 1;
} }
@@ -12,9 +18,10 @@ function animateText(group, ms, play, props, cv, id) {
let top = group.item(index).defaultTop; let top = group.item(index).defaultTop;
let scaleX = group.item(index).defaultScaleX; let scaleX = group.item(index).defaultScaleX;
let scaleY = group.item(index).defaultScaleY; let scaleY = group.item(index).defaultScaleY;
var delay = i * duration; // Named `step` so it does not shadow the global project duration
var duration = props.duration / length; let step = props.duration / length;
var animation = { let delay = i * step;
let animation = {
opacity: 0, opacity: 0,
top: top, top: top,
left: left, left: left,
@@ -22,7 +29,7 @@ function animateText(group, ms, play, props, cv, id) {
scaleY: scaleY, scaleY: scaleY,
}; };
if (props.typeAnim == 'letter') { if (props.typeAnim == 'letter') {
delay = i * duration - 100; delay = i * step - 100;
} else if (props.typeAnim == 'word') { } else if (props.typeAnim == 'word') {
if (group.item(index).text == ' ') { if (group.item(index).text == ' ') {
globaldelay += 500; globaldelay += 500;
@@ -30,8 +37,8 @@ function animateText(group, ms, play, props, cv, id) {
delay = globaldelay; delay = globaldelay;
} }
if (props.preset == 'typewriter') { if (props.preset == 'typewriter') {
delay = i * duration; delay = i * step;
duration = 20; step = 20;
} else if (props.preset == 'fade in') { } else if (props.preset == 'fade in') {
} else if (props.preset == 'slide top') { } else if (props.preset == 'slide top') {
animation.top += 20; animation.top += 20;
@@ -48,14 +55,14 @@ function animateText(group, ms, play, props, cv, id) {
animation.scaleX = 1.5; animation.scaleX = 1.5;
animation.scaleY = 1.5; animation.scaleY = 1.5;
} }
if (delay < 0) { if (!(delay > 0)) {
delay = 0; delay = 0;
} }
if (duration < 20) { if (!(step > 20)) {
duration = 20; step = 20;
} }
var start = false; let start = false;
var instance = anime({ let instance = anime({
targets: animation, targets: animation,
delay: delay, delay: delay,
opacity: 1, opacity: 1,
@@ -63,7 +70,7 @@ function animateText(group, ms, play, props, cv, id) {
top: top, top: top,
scaleX: scaleX, scaleX: scaleX,
scaleY: scaleY, scaleY: scaleY,
duration: duration, duration: step,
easing: props.easing, easing: props.easing,
autoplay: play, autoplay: play,
update: function () { update: function () {
@@ -228,8 +235,7 @@ class AnimatedText {
var obj = cv.getItemById(this.id); var obj = cv.getItemById(this.id);
var left = obj.left; var left = obj.left;
var top = obj.top; var top = obj.top;
var scaleX = obj, var scaleX = obj.scaleX;
scaleX;
var scaleY = obj.scaleY; var scaleY = obj.scaleY;
var angle = obj.angle; var angle = obj.angle;
var start = p_keyframes.find((x) => x.id == this.id).start; var start = p_keyframes.find((x) => x.id == this.id).start;
@@ -260,10 +266,16 @@ class AnimatedText {
cv, cv,
this.id this.id
); );
animate(currenttime, false); animate(false, currenttime);
save(); save();
} }
assignTo(id, text, props) { assignTo(id, text, props) {
this.id = id; this.id = id;
if (text !== undefined) {
this.text = text;
}
if (props !== undefined) {
this.props = props;
}
} }
} }
+174 -91
View File
@@ -1,5 +1,8 @@
// Update panel (when selecting / de-selecting objects) // Update panel (when selecting / de-selecting objects)
function updatePanel(selection) { function updatePanel(selection) {
if (selection && !canvas.getActiveObject()) {
selection = false;
}
if (!selection) { if (!selection) {
$('#align').addClass('align-off'); $('#align').addClass('align-off');
$('#object-specific').html(canvas_panel); $('#object-specific').html(canvas_panel);
@@ -330,17 +333,19 @@ function convertToHex(nonHexColorString) {
function updateStrokeValues() { function updateStrokeValues() {
const object = canvas.getActiveObject(); const object = canvas.getActiveObject();
if (!object) {
return;
}
$('.line-join-active').removeClass('line-join-active'); $('.line-join-active').removeClass('line-join-active');
if ( const dash = object.get('strokeDashArray');
object.get('strokeDashArray') == false && const hasDash = Array.isArray(dash) && dash.length > 0;
object.get('strokeWidth') == 0 if (!hasDash && object.get('strokeWidth') == 0) {
) {
$('#miter').addClass('line-join-active'); $('#miter').addClass('line-join-active');
$('#miter img').attr('src', 'assets/miter-active.svg'); $('#miter img').attr('src', 'assets/miter-active.svg');
} else if (object.get('strokeDashArray') == false) { } else if (!hasDash) {
$('#bevel').addClass('line-join-active'); $('#bevel').addClass('line-join-active');
$('#bevel img').attr('src', 'assets/bevel-active.svg'); $('#bevel img').attr('src', 'assets/bevel-active.svg');
} else if (object.get('strokeDashArray') == [10, 5]) { } else if (dash[0] == 10 && dash[1] == 5) {
$('#round').addClass('line-join-active'); $('#round').addClass('line-join-active');
$('#round img').attr('src', 'assets/round-active.svg'); $('#round img').attr('src', 'assets/round-active.svg');
} else { } else {
@@ -350,39 +355,39 @@ function updateStrokeValues() {
} }
function toggleAnimationOrder() { function toggleAnimationOrder() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active').removeClass( $('.order-toggle-item-active').removeClass(
'order-toggle-item-active' 'order-toggle-item-active'
); );
if ($(this).attr('id') == 'order-backward') { if ($(this).attr('id') == 'order-backward') {
animatedtext text.setProp({ order: 'backward' });
.find((x) => x.id == object.id)
.setProp({ order: 'backward' }, canvas);
} else if ($(this).attr('id') == 'order-forward') { } else if ($(this).attr('id') == 'order-forward') {
animatedtext text.setProp({ order: 'forward' });
.find((x) => x.id == object.id)
.setProp({ order: 'forward' }, canvas);
} }
$(this).addClass('order-toggle-item-active'); $(this).addClass('order-toggle-item-active');
animate(currenttime, false); // animate(play, time) - the arguments used to be the other way round, so
// the canvas never refreshed after changing a text animation.
animate(false, currenttime);
save(); save();
} }
function toggleAnimationType() { function toggleAnimationType() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
if (!text) {
return;
}
$('.order-toggle-item-active-2').removeClass( $('.order-toggle-item-active-2').removeClass(
'order-toggle-item-active-2' 'order-toggle-item-active-2'
); );
if ($(this).attr('id') == 'type-words') { if ($(this).attr('id') == 'type-words') {
animatedtext text.setProp({ typeAnim: 'word' });
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'word' }, canvas);
} else if ($(this).attr('id') == 'type-letters') { } else if ($(this).attr('id') == 'type-letters') {
animatedtext text.setProp({ typeAnim: 'letter' });
.find((x) => x.id == object.id)
.setProp({ typeAnim: 'letter' }, canvas);
} }
$(this).addClass('order-toggle-item-active-2'); $(this).addClass('order-toggle-item-active-2');
animate(currenttime, false); animate(false, currenttime);
save(); save();
} }
$(document).on( $(document).on(
@@ -473,7 +478,7 @@ function updatePanelValues() {
var tempstore = false; var tempstore = false;
var object = canvas.getActiveObject(); var object = canvas.getActiveObject();
if ( if (
canvas.getActiveObjects.length > 1 || canvas.getActiveObjects().length > 1 ||
object.get('type') == 'activeSelection' object.get('type') == 'activeSelection'
) { ) {
object = object.toGroup(); object = object.toGroup();
@@ -590,7 +595,8 @@ function updatePanelValues() {
// Update opacity input // Update opacity input
function updateInputs(id) { function updateInputs(id) {
if (canvas.getActiveObject().get('assetType') == 'audio') { const active = canvas.getActiveObject();
if (!active || active.get('assetType') == 'audio') {
return false; return false;
} }
if ($('#object-o input').val() > 100) { if ($('#object-o input').val() > 100) {
@@ -604,7 +610,7 @@ function updateInputs(id) {
id == 'object-color-fill-opacity' id == 'object-color-fill-opacity'
) { ) {
if ($('#object-color-fill-opacity input').val() > 100) { if ($('#object-color-fill-opacity input').val() > 100) {
$('#object-color-fill-opacity').val(100); $('#object-color-fill-opacity input').val(100);
} else if ($('#object-color-fill-opacity input').val() < 0) { } else if ($('#object-color-fill-opacity input').val() < 0) {
$('#object-color-fill-opacity input').val(0); $('#object-color-fill-opacity input').val(0);
} }
@@ -628,7 +634,7 @@ function updateInputs(id) {
id == 'object-color-stroke-opacity' id == 'object-color-stroke-opacity'
) { ) {
if ($('#object-color-stroke-opacity input').val() > 100) { if ($('#object-color-stroke-opacity input').val() > 100) {
$('#object-color-stroke-opacity').val(100); $('#object-color-stroke-opacity input').val(100);
} else if ($('#object-color-stroke-opacity input').val() < 0) { } else if ($('#object-color-stroke-opacity input').val() < 0) {
$('#object-color-stroke-opacity input').val(0); $('#object-color-stroke-opacity input').val(0);
} }
@@ -652,7 +658,7 @@ function updateInputs(id) {
id == 'object-color-shadow-opacity' id == 'object-color-shadow-opacity'
) { ) {
if ($('#object-color-shadow-opacity input').val() > 100) { if ($('#object-color-shadow-opacity input').val() > 100) {
$('#object-color-shadow-opacity').val(100); $('#object-color-shadow-opacity input').val(100);
} else if ($('#object-color-shadow-opacity input').val() < 0) { } else if ($('#object-color-shadow-opacity input').val() < 0) {
$('#object-color-shadow-opacity input').val(0); $('#object-color-shadow-opacity input').val(0);
} }
@@ -673,7 +679,6 @@ function updateInputs(id) {
// Update object position based on panel input values // Update object position based on panel input values
function updateObjectValues(type) { function updateObjectValues(type) {
autoSave();
if (canvas.getActiveObjects().length > 0) { if (canvas.getActiveObjects().length > 0) {
if ($(this).find('input').val() || type) { if ($(this).find('input').val() || type) {
var object = canvas.getActiveObject(); var object = canvas.getActiveObject();
@@ -832,14 +837,11 @@ function updateObjectValues(type) {
} }
} }
function setTextAnimation() { function setTextAnimation() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
animatedtext if (!text) {
.find((x) => x.id == object.id) return;
.reset( }
$(this).parent().find('input').val(), text.reset($(this).parent().find('input').val(), text.props, canvas);
animatedtext.find((x) => x.id == object.id).props,
canvas
);
} }
$(document).on('input', '.property-input', updateObjectValues); $(document).on('input', '.property-input', updateObjectValues);
@@ -850,8 +852,10 @@ $(document).on('click', '#animatedset', setTextAnimation);
// Toggle picker (maybe it could be condensed?) // Toggle picker (maybe it could be condensed?)
function togglePicker() { function togglePicker() {
const object = canvas.getActiveObject(); const object = canvas.getActiveObject();
if (!object && $(this).attr('id') != 'canvas-color') {
return;
}
if (!o_fill.isOpen()) { if (!o_fill.isOpen()) {
newcolorkeyframe = true;
if ($(this).attr('id') == 'object-color-fill') { if ($(this).attr('id') == 'object-color-fill') {
colormode = 'fill'; colormode = 'fill';
o_fill.setColor(object.get('fill')); o_fill.setColor(object.get('fill'));
@@ -879,7 +883,6 @@ function togglePicker() {
colormode = 'shadow'; colormode = 'shadow';
o_fill.setColor(object.shadow.color); o_fill.setColor(object.shadow.color);
} }
newcolorkeyframe = false;
o_fill.show(); o_fill.show();
} else { } else {
o_fill.hide(); o_fill.hide();
@@ -917,6 +920,7 @@ function populateGrid(type) {
}); });
} else if (type == 'image-tool') { } else if (type == 'image-tool') {
$('#images-grid').html(''); $('#images-grid').html('');
$('#categories').html('');
image_categories.forEach(function (category) { image_categories.forEach(function (category) {
$('#categories').append( $('#categories').append(
"<div class='category' data-name='" + "<div class='category' data-name='" +
@@ -930,6 +934,7 @@ function populateGrid(type) {
}); });
} else if (type == 'video-tool') { } else if (type == 'video-tool') {
$('#images-grid').html(''); $('#images-grid').html('');
$('#categories').html('');
video_categories.forEach(function (category) { video_categories.forEach(function (category) {
$('#categories').append( $('#categories').append(
"<div class='category' data-name='" + "<div class='category' data-name='" +
@@ -959,7 +964,7 @@ function populateGrid(type) {
item.key + item.key +
"'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" + "'><img class='delete-media' draggable=false src='assets/more-options.svg'><img draggable=false onload='onLoadImage(this)' class='image-thing' src='" +
item.thumb + item.thumb +
"'</div>" "'></div>"
); );
} }
}); });
@@ -999,6 +1004,7 @@ function populateGrid(type) {
} }
} else if (type == 'audio-tool') { } else if (type == 'audio-tool') {
var flag = false; var flag = false;
$('#audio-list').html('');
audio_items.forEach(function (item) { audio_items.forEach(function (item) {
if (item.src == background_key) { if (item.src == background_key) {
flag = true; flag = true;
@@ -1015,7 +1021,7 @@ function populateGrid(type) {
item.desc + item.desc +
"</a><div class='audio-info-duration'>" + "</a><div class='audio-info-duration'>" +
item.duration + item.duration +
'</div></div></div></div>' '</div></div></div>'
); );
} else { } else {
$('#audio-list').append( $('#audio-list').append(
@@ -1031,7 +1037,7 @@ function populateGrid(type) {
item.desc + item.desc +
"</a><div class='audio-info-duration'>" + "</a><div class='audio-info-duration'>" +
item.duration + item.duration +
'</div></div></div></div>' '</div></div></div>'
); );
} }
}); });
@@ -1057,7 +1063,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1074,7 +1080,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1091,7 +1097,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1108,7 +1114,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1125,7 +1131,7 @@ function populateGrid(type) {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
text.fontname + text.fontname +
"' style='font-family: " + "' style='font-family: " +
text.fontname + text.fontname +
@@ -1175,27 +1181,27 @@ function updateBrowser(type) {
if (type == 'image-tool') { if (type == 'image-tool') {
$('#browser-container').html(image_browser); $('#browser-container').html(image_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'shape-tool') { } else if (type == 'shape-tool') {
$('#browser-container').html(shape_browser); $('#browser-container').html(shape_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'video-tool') { } else if (type == 'video-tool') {
$('#browser-container').html(video_browser); $('#browser-container').html(video_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'text-tool') { } else if (type == 'text-tool') {
$('#browser-container').html(text_browser); $('#browser-container').html(text_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'upload-tool') { } else if (type == 'upload-tool') {
$('#browser-container').html(upload_browser); $('#browser-container').html(upload_browser);
populateGrid('images-tab'); populateGrid('images-tab');
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} else if (type == 'audio-tool') { } else if (type == 'audio-tool') {
$('#browser-container').html(audio_browser); $('#browser-container').html(audio_browser);
populateGrid(type); populateGrid(type);
$('#browser').on('scroll', scrollBottom); $('#browser').off('scroll', scrollBottom).on('scroll', scrollBottom);
} }
} }
@@ -1260,10 +1266,14 @@ $(document).on('click', '.tool:not(.tool-active)', switchTool);
// Replace image or video by dragging on top and holding a key // Replace image or video by dragging on top and holding a key
function replaceObject(src, object) { function replaceObject(src, object) {
if (!src || !object) {
return;
}
var img = new Image(); var img = new Image();
var width = object.width; var width = object.width;
var height = object.height; var height = object.height;
oldsrc = object._originalElement.currentSrc; const element = object.getElement();
oldsrc = element ? element.currentSrc || element.src : null;
oldobj = object; oldobj = object;
img.onload = function () { img.onload = function () {
object.setElement(img); object.setElement(img);
@@ -1271,6 +1281,9 @@ function replaceObject(src, object) {
object.set('height', height); object.set('height', height);
canvas.renderAll(); canvas.renderAll();
}; };
img.onerror = function () {
console.warn('Could not load replacement image');
};
img.src = src; img.src = src;
} }
@@ -1339,7 +1352,9 @@ function dragObject(e) {
(replacing && !e.ctrlKey) (replacing && !e.ctrlKey)
) { ) {
drag.css('visibility', 'visible'); drag.css('visibility', 'visible');
replaceObject(oldsrc, oldobj); if (oldsrc && oldobj) {
replaceObject(oldsrc, oldobj);
}
replacing = false; replacing = false;
canvas.discardActiveObject(); canvas.discardActiveObject();
$('#replace-image').removeClass('replace-active'); $('#replace-image').removeClass('replace-active');
@@ -1361,7 +1376,21 @@ function dragObject(e) {
$('#properties').removeClass('noselect'); $('#properties').removeClass('noselect');
$('#controls').removeClass('noselect'); $('#controls').removeClass('noselect');
draggingPanel = false; draggingPanel = false;
$('body').off('mousemove', dragging).off('mouseup', released); // An aborted drag (pointercancel, a stray native dragstart, or the window
// losing focus) carries no pointer position, so there is nowhere to drop.
// Undo any in-progress replacement and bin the ghost.
if (!e || e.type != 'pointerup') {
if (replacing) {
if (oldsrc && oldobj) {
replaceObject(oldsrc, oldobj);
}
replacing = false;
canvas.discardActiveObject();
canvas.renderAll();
}
drag.remove();
return false;
}
canvasx = canvas.getPointer(e).x; canvasx = canvas.getPointer(e).x;
canvasy = canvas.getPointer(e).y; canvasy = canvas.getPointer(e).y;
var xpos = canvasx + offsetx - artboard.get('left'); var xpos = canvasx + offsetx - artboard.get('left');
@@ -1561,16 +1590,32 @@ function dragObject(e) {
} }
drag.remove(); drag.remove();
} }
$('body').on('mouseup', released).on('mousemove', dragging); // No pointer capture here (unlike the timeline drags): the drop target is
// worked out from the canvas hover state, and capturing would retarget those
// events to the panel item and leave overCanvas permanently false.
bindPointerDrag(e, null, dragging, released);
} }
$(document).on('mousedown', '.image-grid-item', dragObject); $(document).on('pointerdown', '.image-grid-item', dragObject);
$(document).on('mousedown', '.video-grid-item', dragObject); $(document).on('pointerdown', '.video-grid-item', dragObject);
$(document).on('mousedown', '.grid-item', dragObject); $(document).on('pointerdown', '.grid-item', dragObject);
$(document).on('mousedown', '.grid-emoji-item', dragObject); $(document).on('pointerdown', '.grid-emoji-item', dragObject);
$(document).on('mousedown', '.add-text', dragObject); $(document).on('pointerdown', '.add-text', dragObject);
$(document).on('mousedown click mouseup', '.credit', function (e) { $(document).on(
e.stopPropagation(); 'pointerdown mousedown click mouseup',
}); '.credit',
function (e) {
e.stopPropagation();
}
);
// Panel items must never become native drag sources - a native drag swallows
// the pointerup and leaves the dragged ghost stuck to the cursor
$(document).on(
'dragstart',
'.image-grid-item, .video-grid-item, .grid-item, .grid-emoji-item, .add-text, .credit',
function (e) {
e.preventDefault();
}
);
// Collapse library // Collapse library
function collapsePanel() { function collapsePanel() {
@@ -1613,18 +1658,29 @@ function setPreset() {
} }
$(document).on('change', '#preset', setPreset); $(document).on('change', '#preset', setPreset);
function activeAnimatedText() {
const object = canvas.getActiveObject();
if (!object) {
return null;
}
return animatedtext.find((x) => x.id == object.id) || null;
}
function setTextPreset() { function setTextPreset() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
animatedtext if (!text) {
.find((x) => x.id == object.id) return;
.setProp({ preset: $(this).val() }, canvas); }
text.setProp({ preset: $(this).val() });
animate(false, currenttime);
save(); save();
} }
function setTextEasing() { function setTextEasing() {
var object = canvas.getActiveObject(); const text = activeAnimatedText();
animatedtext if (!text) {
.find((x) => x.id == object.id) return;
.setProp({ easing: $(this).val() }, canvas); }
text.setProp({ easing: $(this).val() });
animate(false, currenttime);
save(); save();
} }
$(document).on('change', '#preset-picker', setTextPreset); $(document).on('change', '#preset-picker', setTextPreset);
@@ -1651,11 +1707,14 @@ function saveLayerName() {
if ($('.name-active').val() == '') { if ($('.name-active').val() == '') {
$('.name-active').val('Untitled layer'); $('.name-active').val('Untitled layer');
} }
objects.find( const entry = objects.find(
(x) => (x) =>
x.id == $('.name-active').parent().parent().attr('data-object') x.id == $('.name-active').parent().parent().attr('data-object')
).label = $('.name-active').val(); );
save(); if (entry) {
entry.label = $('.name-active').val();
save();
}
$('.name-active').removeClass('name-active'); $('.name-active').removeClass('name-active');
if (window.getSelection) { if (window.getSelection) {
if (window.getSelection().empty) { if (window.getSelection().empty) {
@@ -1750,6 +1809,9 @@ function fancyTimeFormat(duration) {
function loadMoreMedia() { function loadMoreMedia() {
var value = $('#browser-search input').val(); var value = $('#browser-search input').val();
if (!HAS_PIXABAY_KEY) {
return;
}
if (value != '' && page != false) { if (value != '' && page != false) {
page += 1; page += 1;
if ($('#image-tool').hasClass('tool-active')) { if ($('#image-tool').hasClass('tool-active')) {
@@ -1772,7 +1834,7 @@ function loadMoreMedia() {
hit.user + hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" + "</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL + hit.webformatURL +
"'</div>" "'></div>"
); );
}); });
} else { } else {
@@ -1800,7 +1862,7 @@ function loadMoreMedia() {
hit.user + hit.user +
"</a><div id='time-video'>" + "</a><div id='time-video'>" +
fancyTimeFormat(hit.duration) + fancyTimeFormat(hit.duration) +
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>" "</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
); );
createVideoThumbnail(video, 250, 0, true).then(function ( createVideoThumbnail(video, 250, 0, true).then(function (
data data
@@ -1821,6 +1883,15 @@ function loadMoreMedia() {
function search() { function search() {
page = 1; page = 1;
var value = $('#browser-search input').val(); var value = $('#browser-search input').val();
const pixabaySearch =
$('#image-tool').hasClass('tool-active') ||
$('#video-tool').hasClass('tool-active');
if (pixabaySearch && !HAS_PIXABAY_KEY) {
$('#shapes-cont').html(
"<div id='no-results'>Image and video search needs a Pixabay API key. Set API_KEY in js/init.js.</div>"
);
return;
}
if ($('#image-tool').hasClass('tool-active')) { if ($('#image-tool').hasClass('tool-active')) {
var URL = var URL =
'https://pixabay.com/api/?key=' + 'https://pixabay.com/api/?key=' +
@@ -1845,7 +1916,7 @@ function search() {
hit.user + hit.user +
"</a><img draggable=false onload='onLoadImage(this)' src='" + "</a><img draggable=false onload='onLoadImage(this)' src='" +
hit.webformatURL + hit.webformatURL +
"'</div>" "'></div>"
); );
}); });
} else { } else {
@@ -1883,7 +1954,7 @@ function search() {
hit.user + hit.user +
"</a><div id='time-video'>" + "</a><div id='time-video'>" +
fancyTimeFormat(hit.duration) + fancyTimeFormat(hit.duration) +
"</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'</div>" "</div><img draggable=false onload='onLoadImage(this)' src='assets/transparent.png'></div>"
); );
//createVideoThumbnail(video, 250, 0, true).then(function(data){ //createVideoThumbnail(video, 250, 0, true).then(function(data){
$(".image-grid-item[data-src='" + video + "']") $(".image-grid-item[data-src='" + video + "']")
@@ -1963,7 +2034,7 @@ function search() {
}, },
}); });
$('#shapes-cont').append( $('#shapes-cont').append(
"<div id='item-text' class='add-text noselect' data-font='" + "<div class='item-text add-text noselect' data-font='" +
font + font +
"' style='font-family: " + "' style='font-family: " +
font + font +
@@ -2069,10 +2140,7 @@ function checkFilter() {
resetFilters(); resetFilters();
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if ( if (canvas.getActiveObjects().length == 1 && obj.filters) {
canvas.getActiveObjects().length == 1 &&
(obj.type == 'image' || obj.type == 'video')
) {
var value = 'none'; var value = 'none';
if (obj.filters.length > 0) { if (obj.filters.length > 0) {
obj.filters.forEach(function (filter) { obj.filters.forEach(function (filter) {
@@ -2224,6 +2292,9 @@ $(document).on('click', '#reset-filters', removeFilters);
function updateChromaValues() { function updateChromaValues() {
if (canvas.getActiveObject()) { if (canvas.getActiveObject()) {
var obj = canvas.getActiveObject(); var obj = canvas.getActiveObject();
if (!obj.filters) {
return;
}
if ($('.status-active').attr('id') == 'status-on') { if ($('.status-active').attr('id') == 'status-on') {
if (obj.filters.find((x) => x.type == 'RemoveColor')) { if (obj.filters.find((x) => x.type == 'RemoveColor')) {
obj.filters.find((x) => x.type == 'RemoveColor').distance = obj.filters.find((x) => x.type == 'RemoveColor').distance =
@@ -2264,7 +2335,8 @@ function updateChromaUI() {
$('.status-active').removeClass('status-active'); $('.status-active').removeClass('status-active');
$('#status-on').addClass('status-active'); $('#status-on').addClass('status-active');
chromaslider.setValue( chromaslider.setValue(
obj.filters.find((x) => x.type == 'RemoveColor').distance obj.filters.find((x) => x.type == 'RemoveColor').distance *
100
); );
$('#chroma-color input').val( $('#chroma-color input').val(
obj.filters.find((x) => x.type == 'RemoveColor').color obj.filters.find((x) => x.type == 'RemoveColor').color
@@ -2278,7 +2350,7 @@ function updateChromaUI() {
$('#status-off').addClass('status-active'); $('#status-off').addClass('status-active');
chromaslider.setValue(1); chromaslider.setValue(1);
$('#chroma-color input').val('#FFFFFF'); $('#chroma-color input').val('#FFFFFF');
$('#color-chroma-side').css('background-color', '#FFFFF'); $('#color-chroma-side').css('background-color', '#FFFFFF');
} }
} }
} }
@@ -2350,13 +2422,24 @@ function hideMore() {
function handleLottieUpload() { function handleLottieUpload() {
var filething = $('#filepick3').get(0).files; var filething = $('#filepick3').get(0).files;
if (!filething || filething.length == 0) {
return;
}
var reader = new FileReader(); var reader = new FileReader();
reader.onload = function (event) { reader.onload = function (event) {
newLottieAnimation( try {
artboard.get('left') + artboard.get('width') / 2, newLottieAnimation(
artboard.get('top') + artboard.get('height') / 2, artboard.get('left') + artboard.get('width') / 2,
event.target.result artboard.get('top') + artboard.get('height') / 2,
); event.target.result
);
} catch (e) {
console.error(e);
alert('That does not look like a valid Lottie file');
}
};
reader.onerror = function () {
alert('Could not read the file');
}; };
reader.readAsDataURL(filething.item(0)); reader.readAsDataURL(filething.item(0));
} }
+126 -17
View File
@@ -596,7 +596,12 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
buffer.writeEBMLVarInt(4); // Size field buffer.writeEBMLVarInt(4); // Size field
ebml.dataOffset = buffer.pos + bufferFileOffset; ebml.dataOffset = buffer.pos + bufferFileOffset;
buffer.writeFloatBE(ebml.data.value); buffer.writeFloatBE(ebml.data.value);
} else if (ebml.data instanceof Uint8Array) { } else if (
ebml.data instanceof Uint8Array ||
(ArrayBuffer.isView(ebml.data) &&
ebml.data.BYTES_PER_ELEMENT === 1)) {
// isView as well as instanceof: a byte array that crossed a realm
// boundary (worker, vm context) fails the instanceof check
buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field buffer.writeEBMLVarInt(ebml.data.byteLength); // Size field
ebml.dataOffset = buffer.pos + bufferFileOffset; ebml.dataOffset = buffer.pos + bufferFileOffset;
buffer.writeBytes(ebml.data); buffer.writeBytes(ebml.data);
@@ -630,7 +635,8 @@ function writeEBML(buffer, bufferFileOffset, ebml) {
*/ */
let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) { let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
return function(options) { return function(options) {
let MAX_CLUSTER_DURATION_MSEC = 5000000, DEFAULT_TRACK_NUMBER = 1, let MAX_CLUSTER_DURATION_MSEC = 5000, DEFAULT_TRACK_NUMBER = 1,
AUDIO_TRACK_NUMBER = 2,
writtenHeader = false, videoWidth = 0, videoHeight = 0, writtenHeader = false, videoWidth = 0, videoHeight = 0,
firstTimestampEver = true, earliestTimestamp = 0, firstTimestampEver = true, earliestTimestamp = 0,
@@ -649,6 +655,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
// (optional) // (optional)
codec: 'VP8', // Codec to write to webm file codec: 'VP8', // Codec to write to webm file
// Optional second track holding Opus audio. Supply:
// {sampleRate, channels, codecPrivate: Uint8Array (OpusHead)}
audio: null,
}, },
seekPoints = { seekPoints = {
@@ -787,9 +796,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
} }
]; ];
let tracks = { let trackEntries = [{
'id': 0x1654ae6b, // Tracks
'data': [{
'id': 0xae, // TrackEntry 'id': 0xae, // TrackEntry
'data': [ 'data': [
{ {
@@ -868,7 +875,87 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
'data': options.codec 'data': options.codec
},*/ },*/
] ]
}] }];
// Optional Opus audio track.
// CodecPrivate must be an OpusHead block, and Opus needs the pre-roll
// hints or players will start it with audible artefacts.
if (options.audio) {
trackEntries.push({
'id': 0xae, // TrackEntry
'data': [
{
'id': 0xd7, // TrackNumber
'data': AUDIO_TRACK_NUMBER
},
{
'id': 0x73c5, // TrackUID
'data': AUDIO_TRACK_NUMBER
},
{
'id': 0x83, // TrackType (2 = audio)
'data': 2
},
{
'id': 0x9c, // FlagLacing
'data': 0
},
{
'id': 0x22b59c, // Language
'data': 'und'
},
{
'id': 0xb9, // FlagEnabled
'data': 1
},
{
'id': 0x88, // FlagDefault
'data': 1
},
{
'id': 0x55aa, // FlagForced
'data': 0
},
{
'id': 0x86, // CodecID
'data': 'A_OPUS'
},
{
'id': 0x63A2, // CodecPrivate
'data': options.audio.codecPrivate
},
{
'id': 0x56AA, // CodecDelay (ns)
'data': 6500000
},
{
'id': 0x56BB, // SeekPreRoll (ns)
'data': 80000000
},
{
'id': 0xe1, // Audio
'data': [
{
'id': 0xb5, // SamplingFrequency
'data': new EBMLFloat64(options.audio.sampleRate)
},
{
'id': 0x9f, // Channels
'data': options.audio.channels
},
{
'id': 0x6264, // BitDepth
'data': 32
}
]
}
]
});
}
let tracks = {
'id': 0x1654ae6b, // Tracks
'data': trackEntries
}; };
ebmlSegment = { ebmlSegment = {
@@ -881,7 +968,9 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
] ]
}; };
let bufferStream = new ArrayBufferDataStream(256); // Has to fit the header, SeekHead, SegmentInfo and every TrackEntry.
// 256 was only ever enough for a single video track.
let bufferStream = new ArrayBufferDataStream(1024);
writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]); writeEBML(bufferStream, blobBuffer.pos, [ebmlHeader, ebmlSegment]);
blobBuffer.write(bufferStream.getAsDataArray()); blobBuffer.write(bufferStream.getAsDataArray());
@@ -1036,7 +1125,7 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
* @param {Frame} frame * @param {Frame} frame
*/ */
function addFrameToCluster(frame) { function addFrameToCluster(frame) {
frame.trackNumber = DEFAULT_TRACK_NUMBER; frame.trackNumber = frame.trackNumber || DEFAULT_TRACK_NUMBER;
var time = frame.intime / 1000; var time = frame.intime / 1000;
if (firstTimestampEver) { if (firstTimestampEver) {
earliestTimestamp = time; earliestTimestamp = time;
@@ -1045,19 +1134,30 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
} else { } else {
time = time - earliestTimestamp; time = time - earliestTimestamp;
} }
lastTimeCode = time; if (time > lastTimeCode) {
if (clusterDuration == 0) clusterStartTime = time; lastTimeCode = time;
}
// Start a new cluster on a video keyframe once the current one has
// grown past the limit. SimpleBlock timecodes are a signed 16 bit
// offset from the cluster, so clusters cannot span more than ~32s.
const isVideoKeyframe =
frame.trackNumber == DEFAULT_TRACK_NUMBER && frame.type == 'key';
if (
clusterFrameBuffer.length > 0 && isVideoKeyframe &&
time - clusterStartTime >= MAX_CLUSTER_DURATION_MSEC) {
flushClusterFrameBuffer();
}
if (clusterFrameBuffer.length === 0) {
clusterStartTime = time;
}
// Frame timecodes are relative to the start of their cluster: // Frame timecodes are relative to the start of their cluster:
// frame.timecode = Math.round(clusterDuration);
frame.timecode = Math.round(time - clusterStartTime); frame.timecode = Math.round(time - clusterStartTime);
clusterFrameBuffer.push(frame); clusterFrameBuffer.push(frame);
clusterDuration = frame.timecode + 1; clusterDuration = frame.timecode + 1;
if (clusterDuration >= MAX_CLUSTER_DURATION_MSEC) {
flushClusterFrameBuffer();
}
} }
/** /**
@@ -1106,24 +1206,33 @@ let WebMWriter = function(ArrayBufferDataStream, BlobBuffer) {
* toDataUrl() on an image yourself. * toDataUrl() on an image yourself.
* *
*/ */
this.addFrame = function(frame) { this.addFrame = function(frame, trackNumber) {
if (!writtenHeader) { if (!writtenHeader) {
videoWidth = options.width; videoWidth = options.width;
videoHeight = options.height; videoHeight = options.height;
writeHeader(); writeHeader();
} }
if (frame.constructor.name == 'EncodedVideoChunk') { const name = frame.constructor.name;
if (name == 'EncodedVideoChunk' || name == 'EncodedAudioChunk') {
let frameData = new Uint8Array(frame.byteLength); let frameData = new Uint8Array(frame.byteLength);
frame.copyTo(frameData); frame.copyTo(frameData);
addFrameToCluster({ addFrameToCluster({
frame: frameData, frame: frameData,
intime: frame.timestamp, intime: frame.timestamp,
type: frame.type, type: frame.type,
trackNumber: trackNumber ||
(name == 'EncodedAudioChunk' ? AUDIO_TRACK_NUMBER :
DEFAULT_TRACK_NUMBER),
}); });
return; return;
} }
}; };
// Encoded audio for the optional second track
this.addAudioChunk = function(chunk) {
this.addFrame(chunk, AUDIO_TRACK_NUMBER);
};
/** /**
* Finish writing the video and return a Promise to signal completion. * Finish writing the video and return a Promise to signal completion.
* *
+94 -4
View File
@@ -734,7 +734,7 @@ body {
height: 28px; height: 28px;
line-height: 28px; line-height: 28px;
} }
#item-text { .item-text {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
height: 34px; height: 34px;
@@ -1418,7 +1418,8 @@ input[type="number"] {
/* Download modal */ /* Download modal */
#download-modal { #download-modal {
width: 300px; width: 300px;
height: 255px; /* Grows with its content: the frame rate row is hidden for image exports */
padding-bottom: 20px;
background-color: var(--panel-back); background-color: var(--panel-back);
border: 1px solid var(--panel-stroke); border: 1px solid var(--panel-stroke);
border-radius: 5px; border-radius: 5px;
@@ -1463,6 +1464,28 @@ input[type="number"] {
margin-left: 10px; margin-left: 10px;
margin-bottom: 20px; margin-bottom: 20px;
} }
#framerate-row {
margin-bottom: 20px;
}
#framerate-row .subheader {
margin-bottom: 8px;
}
#framerate {
background-color: var(--input-color);
border: none;
border-radius: 5px;
color: var(--main-text-color);
font-family: Inter;
font-size: 14px;
height: 35px;
outline: none;
padding-left: 11px;
width: calc(100% - 40px);
margin-left: 20px;
}
#framerate:hover {
cursor: pointer;
}
.magic-radio:checked + label:before { .magic-radio:checked + label:before {
background-color: var(--accent-color) !important; background-color: var(--accent-color) !important;
border: 0px !important; border: 0px !important;
@@ -1626,6 +1649,46 @@ layer:nth-child(even) .properties {
font-size: 14px; font-size: 14px;
text-indent: 25px; text-indent: 25px;
display: inline-block; display: inline-block;
position: relative;
}
.layer-handle {
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
width: 9px;
height: 16px;
text-indent: 0;
opacity: 0.3;
cursor: grab;
background-image: radial-gradient(
currentColor 1px,
transparent 1.5px
);
background-size: 4px 5px;
background-position: 1px 2px;
transition: opacity 0.15s ease;
}
.layer:hover .layer-handle {
opacity: 0.7;
}
.layer .layer-handle:hover {
opacity: 1;
}
.layer-handle:active {
cursor: grabbing;
}
.layer-handle[draggable="false"] {
cursor: not-allowed;
opacity: 0.15 !important;
}
.sortable-placeholder {
height: 35px;
margin-bottom: 2px;
box-sizing: border-box;
border: 1px dashed var(--accent-color);
border-radius: 3px;
background: rgba(0, 122, 255, 0.12);
} }
.layer-custom-name { .layer-custom-name {
background: transparent; background: transparent;
@@ -2020,6 +2083,33 @@ layer:nth-child(even) .properties {
z-index: 99999999; z-index: 99999999;
pointer-events: all; pointer-events: all;
top: 0px; top: 0px;
/* Never let the browser start a native drag or a selection on the seekbar */
-webkit-user-drag: none;
-webkit-user-select: none;
user-select: none;
touch-action: none;
}
#seekbar:after,
#seek-hover {
-webkit-user-drag: none;
}
/* Same for the other timeline drag targets */
.keyframe,
.main-row,
.row-el,
.trim-row,
#timeline-handle {
-webkit-user-drag: none;
touch-action: none;
}
/* And for the library panel items dragged onto the canvas */
.image-grid-item,
.video-grid-item,
.grid-item,
.grid-emoji-item,
.add-text,
.credit {
-webkit-user-drag: none;
} }
#seekbar:hover { #seekbar:hover {
outline: 3px solid rgba(255, 255, 255, 0.1); outline: 3px solid rgba(255, 255, 255, 0.1);
@@ -2438,10 +2528,10 @@ layer:nth-child(even) .properties {
margin-top: -45px; margin-top: -45px;
box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%); box-shadow: 0px 8px 4px -4px rgb(12 13 26 / 50%);
} }
#filters-header #filters-title { #filters-header .filters-title {
margin-top: 0px!important; margin-top: 0px!important;
} }
#filters-title { .filters-title {
color: var(--main-text-color); color: var(--main-text-color);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
+286
View File
@@ -0,0 +1,286 @@
// Exercise the extended WebM muxer without a browser and validate the EBML it
// produces. Feeds fake encoded chunks, then parses the resulting file.
const fs = require('fs');
const vm = require('vm');
const code = fs.readFileSync(
require('path').join(__dirname, '..', 'src', 'js', 'webm-writer2.js'),
'utf8'
);
const sandbox = { Blob, console };
sandbox.self = sandbox;
vm.createContext(sandbox);
// `module` must stay undefined so the browser branch runs
vm.runInContext(code, sandbox);
const WebMWriter = sandbox.WebMWriter;
if (!WebMWriter) throw new Error('WebMWriter not exported');
class EncodedVideoChunk {
constructor(o) {
this.timestamp = o.timestamp;
this.type = o.type;
this._data = o.data;
this.byteLength = o.data.length;
}
copyTo(dst) {
dst.set(this._data);
}
}
class EncodedAudioChunk {
constructor(o) {
this.timestamp = o.timestamp;
this.type = o.type;
this._data = o.data;
this.byteLength = o.data.length;
}
copyTo(dst) {
dst.set(this._data);
}
}
function opusHead(channels, sampleRate) {
const head = new Uint8Array(19);
const view = new DataView(head.buffer);
head.set([0x4f, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64], 0);
head[8] = 1;
head[9] = channels;
view.setUint16(10, 3840, true);
view.setUint32(12, sampleRate, true);
view.setUint16(16, 0, true);
head[18] = 0;
return head;
}
// ---- EBML parser -----------------------------------------------------------
function readVarInt(buf, pos, stripMarker) {
const first = buf[pos];
if (first === 0) throw new Error('invalid varint at ' + pos);
let width = 1;
let mask = 0x80;
while (!(first & mask)) {
mask >>= 1;
width++;
}
let value = stripMarker ? first & (mask - 1) : first;
let unknown = stripMarker && (first & (mask - 1)) === mask - 1;
for (let i = 1; i < width; i++) {
value = value * 256 + buf[pos + i];
if (buf[pos + i] !== 0xff) unknown = false;
}
return { value, width, unknown };
}
function parse(buf, start, end, depth, out) {
let pos = start;
while (pos < end) {
const id = readVarInt(buf, pos, false);
const idBytes = buf.slice(pos, pos + id.width);
let idHex = 0;
for (const b of idBytes) idHex = idHex * 256 + b;
pos += id.width;
const size = readVarInt(buf, pos, true);
pos += size.width;
const dataStart = pos;
const dataEnd = size.unknown ? end : Math.min(dataStart + size.value, end);
out.push({ id: idHex, depth, dataStart, dataEnd });
const MASTER = [
0x1a45dfa3, 0x18538067, 0x1654ae6b, 0xae, 0x1f43b675, 0x1549a966,
0x114d9b74, 0x4dbb, 0xe0, 0xe1, 0x1c53bb6b, 0xbb, 0xb7,
];
if (MASTER.includes(idHex)) parse(buf, dataStart, dataEnd, depth + 1, out);
pos = dataEnd;
if (dataEnd <= dataStart && size.value === 0) continue;
}
}
function uintAt(buf, s, e) {
let v = 0;
for (let i = s; i < e; i++) v = v * 256 + buf[i];
return v;
}
function strAt(buf, s, e) {
return Buffer.from(buf.slice(s, e)).toString('ascii').replace(/\0+$/, '');
}
async function run(withAudio) {
const writer = new WebMWriter({
codec: 'VP9',
width: 640,
height: 480,
audio: withAudio
? { sampleRate: 48000, channels: 2, codecPrivate: opusHead(2, 48000) }
: null,
});
const FPS = 30;
const SECONDS = 75;
const items = [];
for (let i = 0; i < FPS * SECONDS; i++) {
items.push({
track: 1,
chunk: new EncodedVideoChunk({
timestamp: Math.round((i / FPS) * 1e6),
type: i % (FPS * 2) === 0 ? 'key' : 'delta',
data: new Uint8Array(120).fill(i & 0xff),
}),
});
}
if (withAudio) {
for (let i = 0; i < SECONDS * 10; i++) {
items.push({
track: 2,
chunk: new EncodedAudioChunk({
timestamp: Math.round(i * 0.1 * 1e6),
type: 'key',
data: new Uint8Array(40).fill(0x55),
}),
});
}
}
items.sort((a, b) =>
a.chunk.timestamp === b.chunk.timestamp
? a.track - b.track
: a.chunk.timestamp - b.chunk.timestamp
);
items.forEach((it) => writer.addFrame(it.chunk, it.track));
const blob = await writer.complete();
const buf = new Uint8Array(await blob.arrayBuffer());
const els = [];
parse(buf, 0, buf.length, 0, els);
const label = withAudio ? 'video+audio' : 'video only';
const problems = [];
if (!els.some((e) => e.id === 0x1a45dfa3)) problems.push('no EBML header');
if (!els.some((e) => e.id === 0x18538067)) problems.push('no Segment');
const trackEntries = els.filter((e) => e.id === 0xae);
const expectedTracks = withAudio ? 2 : 1;
if (trackEntries.length !== expectedTracks)
problems.push(`expected ${expectedTracks} TrackEntry, got ${trackEntries.length}`);
const codecIds = els
.filter((e) => e.id === 0x86)
.map((e) => strAt(buf, e.dataStart, e.dataEnd));
if (!codecIds.includes('V_VP9')) problems.push('missing V_VP9, got ' + codecIds);
if (withAudio && !codecIds.includes('A_OPUS'))
problems.push('missing A_OPUS, got ' + codecIds);
const trackTypes = els
.filter((e) => e.id === 0x83)
.map((e) => uintAt(buf, e.dataStart, e.dataEnd));
if (!trackTypes.includes(1)) problems.push('no video TrackType');
if (withAudio && !trackTypes.includes(2)) problems.push('no audio TrackType');
if (withAudio) {
const priv = els.find((e) => e.id === 0x63a2);
if (!priv) problems.push('no CodecPrivate');
else {
if (priv.dataEnd - priv.dataStart !== 19)
problems.push('CodecPrivate is not 19 bytes');
if (strAt(buf, priv.dataStart, priv.dataStart + 8) !== 'OpusHead')
problems.push('CodecPrivate is not OpusHead');
}
}
const clusters = els.filter((e) => e.id === 0x1f43b675);
if (clusters.length < 2)
problems.push(`expected multiple clusters over ${SECONDS}s, got ${clusters.length}`);
// Every SimpleBlock: track number valid, timecode inside signed 16 bit
const blocks = els.filter((e) => e.id === 0xa3);
if (blocks.length !== items.length)
problems.push(`expected ${items.length} blocks, got ${blocks.length}`);
const seenTracks = new Set();
let badTimecode = 0;
for (const b of blocks) {
const tn = readVarInt(buf, b.dataStart, true);
seenTracks.add(tn.value);
const tc = (buf[b.dataStart + tn.width] << 8) | buf[b.dataStart + tn.width + 1];
const signed = tc > 32767 ? tc - 65536 : tc;
if (signed < 0 || signed > 32767) badTimecode++;
}
if (badTimecode) problems.push(badTimecode + ' block timecodes out of range');
const expectTrackSet = withAudio ? [1, 2] : [1];
for (const t of expectTrackSet)
if (!seenTracks.has(t)) problems.push('no blocks for track ' + t);
for (const t of seenTracks)
if (!expectTrackSet.includes(t)) problems.push('unexpected track ' + t);
// Reconstruct absolute times: cluster timecode + block timecode must give
// back exactly the timestamps that went in, in order.
const rebuilt = [];
let lastClusterTime = -1;
for (const c of clusters) {
const tcEl = els.find(
(e) => e.id === 0xe7 && e.dataStart > c.dataStart && e.dataEnd <= c.dataEnd
);
if (!tcEl) {
problems.push('cluster without Timecode');
continue;
}
const clusterTime = uintAt(buf, tcEl.dataStart, tcEl.dataEnd);
if (clusterTime < lastClusterTime) problems.push('cluster timecodes go backwards');
lastClusterTime = clusterTime;
const inner = blocks.filter(
(b) => b.dataStart > c.dataStart && b.dataEnd <= c.dataEnd
);
if (inner.length === 0) problems.push('empty cluster');
for (const b of inner) {
const tn = readVarInt(buf, b.dataStart, true);
const raw = (buf[b.dataStart + tn.width] << 8) | buf[b.dataStart + tn.width + 1];
const rel = raw > 32767 ? raw - 65536 : raw;
if (rel < 0) problems.push('negative block timecode in cluster');
rebuilt.push({ track: tn.value, ms: clusterTime + rel });
}
}
const expected = items.map((it) => ({
track: it.track,
ms: Math.round(it.chunk.timestamp / 1000),
}));
if (rebuilt.length !== expected.length) {
problems.push('block count mismatch on rebuild');
} else {
let drift = 0;
for (let i = 0; i < expected.length; i++) {
if (rebuilt[i].track !== expected[i].track) {
problems.push('track order changed at block ' + i);
break;
}
drift = Math.max(drift, Math.abs(rebuilt[i].ms - expected[i].ms));
}
if (drift > 1) problems.push('timestamp drift of ' + drift + 'ms');
}
const durationEl = els.find((e) => e.id === 0x4489);
if (!durationEl) problems.push('no Duration');
else {
const dv = new DataView(buf.buffer, buf.byteOffset + durationEl.dataStart, 8);
const d = dv.getFloat64(0);
if (!(d > 0)) problems.push('Duration is ' + d);
else if (Math.abs(d - (SECONDS - 1 / FPS) * 1000) > 200)
problems.push('Duration ' + d + 'ms is not ~' + SECONDS * 1000);
}
console.log(
`[${label}] ${buf.length} bytes, ${clusters.length} clusters, ` +
`${blocks.length} blocks, tracks=${[...seenTracks].join(',')}`
);
if (problems.length) {
console.log(' FAIL:');
problems.forEach((p) => console.log(' - ' + p));
return false;
}
console.log(' OK');
return true;
}
(async () => {
const a = await run(false);
const b = await run(true);
process.exit(a && b ? 0 : 1);
})();