mirror of
https://github.com/mvschwarz/openrig-studio-apps.git
synced 2026-11-07 05:56:21 -10:00
Initial release — studio apps and providers
Six applications built on the @openrig/studio SDK, and the two providers they depend on. Apps: files, media-manager, canvas, mini-nle, cutdown, agents. Each carries an app.json declaring its surface, its provider, the KINDS of roots it needs, the verbs and byte routes it calls, its vendored dependencies, and the binaries it requires — enough for a box that has none of them to install it or refuse with a named reason. Providers: @openrig/studio-video and @openrig/studio-cutdown. Both ship ffmpeg/ffprobe as npm dependencies rather than assuming a system install, and prefer the packaged binary while keeping PATH as a fallback. Licensed Apache-2.0.
This commit is contained in:
commit
f08cf04e6d
65 files changed
+22230
No files matched your search
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,4 @@
|
||||
OpenRig Studio Apps
|
||||
Copyright 2026 mvschwarz
|
||||
|
||||
This product includes software developed by mvschwarz.
|
||||
@@ -0,0 +1,52 @@
|
||||
# openrig-studio-apps
|
||||
|
||||
The apps and providers for OpenRig Studio. Every app here is a **surface** that
|
||||
runs in the SDK's shell (`@openrig/studio`) — the shell is a rail of tabs, a
|
||||
stage, and an agent sidebar, plus the contract a surface speaks to reach them.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
apps/<id>/
|
||||
app.json the manifest: surface, provider, roots, verbs
|
||||
app/<id>.html the surface
|
||||
vendor/ assets that travel with THIS app
|
||||
providers/<name>/
|
||||
package.json declares roots (as KINDS), binaries, first-run, peers
|
||||
*.mjs the server(s)
|
||||
```
|
||||
|
||||
## Ultralight vs heavy — the only distinction that matters
|
||||
|
||||
**Who serves the bytes.**
|
||||
|
||||
| | ultralight | heavy |
|
||||
|---|---|---|
|
||||
| ships | HTML + a script or two | its own service |
|
||||
| served by | the shell (`path:` row) | itself (`url:` row) |
|
||||
| install | drop the file, register a row | npm / docker / git — normal distribution |
|
||||
| needs the SDK | **yes — the shell is its runtime** | only to appear as a tab |
|
||||
|
||||
That inverts the intuitive reading: the *ultralight* app is the one that needs
|
||||
the SDK. `files` is ultralight. `media-manager`, `canvas` and `mini-nle` are
|
||||
clients of `@openrig/studio-video`. `cutdown` is a client of
|
||||
`@openrig/studio-cutdown`.
|
||||
|
||||
## Manifests declare; they do not imply
|
||||
|
||||
Every field in `app.json` and in a provider's `openrig` block exists because
|
||||
leaving it out is how bespokeness happens. Roots are declared as **kinds**
|
||||
(`project`, `media`, `footage`) and bound to real directories at install. Every
|
||||
hardcoded path removed from this code — `content-piece-1/assembly`,
|
||||
`workspace/<project>`, `$HOME/studio/app` — existed because there was nowhere to
|
||||
say this. **If there is a field for it, nobody inlines it.**
|
||||
|
||||
A manifest authored on a machine that already has everything installed will
|
||||
always under-declare. Test one by installing it somewhere that has nothing.
|
||||
|
||||
## Provenance
|
||||
|
||||
Ported from the frozen `studio-box` spike, then de-bespoked: the original
|
||||
project each tool was built for was still wired in as a default. The code is
|
||||
ported, not extracted — hand-extracting it silently broke three contracts that
|
||||
answered HTTP 200 with real data while the pane rendered empty.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "agents",
|
||||
"name": "AGENTS",
|
||||
"summary": "Talk to the seats on this box. The rig roster, live, with a way to actually say something.",
|
||||
"category": "system",
|
||||
"maker": { "name": "OpenRig", "url": "https://github.com/mvschwarz" },
|
||||
"surface": {
|
||||
"entry": "app/agents.html",
|
||||
"path": "/surfaces/agents.html",
|
||||
"glyph": "◇",
|
||||
"hint": "the seats on this box, and a way to talk to them"
|
||||
},
|
||||
"requires": {
|
||||
"host_capabilities": ["seats.list", "seats.send", "seats.capture"],
|
||||
"_note": "ULTRALIGHT. No provider, no vendor, no roots. It rides verbs the STUDIO serves, which is the point of this app existing — it is the smallest thing that proves an app can drop onto the rail and work."
|
||||
},
|
||||
"verbs": ["/api/seats", "/api/seats/send", "/api/seats/capture"],
|
||||
"vendor": []
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<title>AGENTS</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ground: #17171d; --well: #1d1d24; --edge: #2e2e38;
|
||||
--ink: #d7d3cc; --faint: #85818a;
|
||||
--brass: #c9a35a; --brass-hi: #e0bd77; --live: #6ac47a; --down: #a33b2a;
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--ground); color: var(--ink); font: 13px var(--mono); }
|
||||
header { padding: 14px 18px; border-bottom: 1px solid var(--edge); display: flex; gap: 14px; align-items: baseline; }
|
||||
header h1 { margin: 0; font: 700 13px var(--mono); letter-spacing: .1em; color: var(--brass-hi); text-transform: uppercase; }
|
||||
header .sub { color: var(--faint); font-size: 11px; }
|
||||
.wrap { display: grid; grid-template-columns: 300px 1fr; height: calc(100vh - 48px); }
|
||||
.roster { border-right: 1px solid var(--edge); overflow: auto; padding: 10px; }
|
||||
.seat { padding: 9px 10px; border: 1px solid var(--edge); border-radius: 3px; margin-bottom: 6px; cursor: pointer; background: var(--well); }
|
||||
.seat:hover { border-color: var(--brass); }
|
||||
.seat[aria-selected="true"] { border-color: var(--brass-hi); background: #23202a; }
|
||||
.seat .n { color: var(--brass-hi); font-weight: 700; letter-spacing: .04em; }
|
||||
.seat .s { color: var(--faint); font-size: 10.5px; margin-top: 2px; word-break: break-all; }
|
||||
.seat .m { color: var(--faint); font-size: 10px; margin-top: 4px; }
|
||||
.dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
|
||||
.dot.on { background: var(--live); } .dot.off { background: var(--down); } .dot.unk { background: #6b6b76; }
|
||||
.pane { display: flex; flex-direction: column; min-width: 0; }
|
||||
.panehead { padding: 10px 16px; border-bottom: 1px solid var(--edge); color: var(--faint); font-size: 11px; display: flex; justify-content: space-between; }
|
||||
pre.capture { flex: 1; margin: 0; padding: 14px 16px; overflow: auto; white-space: pre-wrap; word-break: break-word; font-size: 12px; line-height: 1.45; }
|
||||
.composer { border-top: 1px solid var(--edge); padding: 12px 16px; display: flex; gap: 10px; }
|
||||
textarea { flex: 1; resize: none; height: 62px; background: var(--well); color: var(--ink); border: 1px solid var(--edge); border-radius: 3px; padding: 9px 11px; font: 12px var(--mono); }
|
||||
textarea:focus { outline: none; border-color: var(--brass); }
|
||||
button { background: transparent; color: var(--brass-hi); border: 1px solid var(--brass-hi); border-radius: 3px; padding: 0 18px; font: 700 11px var(--mono); letter-spacing: .07em; text-transform: uppercase; cursor: pointer; }
|
||||
button:hover:not(:disabled) { background: var(--brass-hi); color: var(--ground); }
|
||||
button:disabled { opacity: .4; cursor: default; }
|
||||
.status { padding: 8px 16px; font-size: 11px; border-top: 1px solid var(--edge); color: var(--faint); }
|
||||
.status.err { color: var(--down); } .status.ok { color: var(--live); }
|
||||
.empty { padding: 30px; color: var(--faint); text-align: center; }
|
||||
</style>
|
||||
|
||||
<header>
|
||||
<h1>Agents</h1>
|
||||
<span class="sub" id="sub">loading the roster...</span>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="roster" id="roster"></div>
|
||||
<div class="pane">
|
||||
<div class="panehead">
|
||||
<span id="paneTitle">no seat selected</span>
|
||||
<span id="paneNote"></span>
|
||||
</div>
|
||||
<pre class="capture" id="capture">Pick a seat to see what it is doing.</pre>
|
||||
<div class="composer">
|
||||
<textarea id="msg" placeholder="Say something to this seat. Shift+Enter for a newline, Enter to send." disabled></textarea>
|
||||
<button id="send" disabled>Send</button>
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ULTRALIGHT. No provider, no bundle, no build. Every verb here is served by
|
||||
// the STUDIO, which is what makes this the smallest honest proof that an app
|
||||
// can drop onto the rail and work.
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let seats = [], selected = null, pollTimer = null;
|
||||
|
||||
const setStatus = (t, kind) => { $("status").textContent = t; $("status").className = "status " + (kind || ""); };
|
||||
|
||||
// Seat state is REPORTED, not interpreted. "unknown" is the honest answer on
|
||||
// this host because the daemon-ingest link is down for claude-code seats;
|
||||
// rendering "idle" would invent a fact about a live seat.
|
||||
const dotClass = (s) => s.lifecycle === "running" ? (s.activity && s.activity !== "unknown" ? "on" : "unk") : "off";
|
||||
|
||||
async function loadRoster() {
|
||||
try {
|
||||
const r = await fetch("/api/seats", { cache: "no-store" }).then((x) => x.json());
|
||||
if (!r.ok) throw new Error(r.error || "roster unavailable");
|
||||
seats = r.seats || [];
|
||||
$("sub").textContent = seats.length + " seat(s) on this box";
|
||||
$("roster").innerHTML = seats.length
|
||||
? seats.map((s) => '<div class="seat" data-seat="' + s.seat + '" aria-selected="' + (s.seat === selected) + '">'
|
||||
+ '<div class="n"><span class="dot ' + dotClass(s) + '"></span>' + (s.name || s.seat) + '</div>'
|
||||
+ '<div class="s">' + s.seat + '</div>'
|
||||
+ '<div class="m">' + (s.activity || "unknown") + (s.pendingWork ? " - " + s.pendingWork + " pending" : "") + '</div>'
|
||||
+ '</div>').join("")
|
||||
: '<div class="empty">No rig is running on this box.<br/>That is a real state, not an error.</div>';
|
||||
for (const el of document.querySelectorAll(".seat")) {
|
||||
el.addEventListener("click", () => select(el.dataset.seat));
|
||||
}
|
||||
} catch (e) {
|
||||
// A failed roster must not look like an empty one.
|
||||
$("sub").textContent = "roster failed";
|
||||
$("roster").innerHTML = '<div class="empty" style="color:var(--down)">Could not read the roster.<br/><br/>'
|
||||
+ e.message + '<br/><br/>This is a failure, not an empty box.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCapture() {
|
||||
if (!selected) return;
|
||||
try {
|
||||
const r = await fetch("/api/seats/capture?seat=" + encodeURIComponent(selected) + "&lines=120", { cache: "no-store" }).then((x) => x.json());
|
||||
$("capture").textContent = r.ok ? (r.text || "(pane is empty)") : "capture failed: " + r.error;
|
||||
$("paneNote").textContent = r.ok ? "last " + r.lines + " lines - scrollback-bounded" : "";
|
||||
} catch (e) { $("capture").textContent = "capture failed: " + e.message; }
|
||||
}
|
||||
|
||||
function select(seat) {
|
||||
selected = seat;
|
||||
for (const el of document.querySelectorAll(".seat")) el.setAttribute("aria-selected", el.dataset.seat === seat);
|
||||
$("paneTitle").textContent = seat;
|
||||
$("msg").disabled = false; $("send").disabled = false; $("msg").focus();
|
||||
setStatus("");
|
||||
refreshCapture();
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = setInterval(refreshCapture, 4000);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = $("msg").value.trim();
|
||||
if (!text || !selected) return;
|
||||
$("send").disabled = true; setStatus("sending...");
|
||||
try {
|
||||
const r = await fetch("/api/seats/send", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ seat: selected, text })
|
||||
}).then((x) => x.json());
|
||||
if (!r.ok) { setStatus(r.error, "err"); return; }
|
||||
$("msg").value = "";
|
||||
// `verified` is reported, never translated. It is dead for claude-code
|
||||
// seats on this host and carries real signal only for codex ones.
|
||||
setStatus(r.delivered
|
||||
? ("delivered" + (r.verified ? " - verified" : " - not verified (weak signal on some runtimes, not a failure)"))
|
||||
: "sent, delivery unconfirmed", r.delivered ? "ok" : "");
|
||||
setTimeout(refreshCapture, 1200);
|
||||
} catch (e) { setStatus(e.message, "err"); }
|
||||
finally { $("send").disabled = false; }
|
||||
}
|
||||
|
||||
$("send").addEventListener("click", send);
|
||||
$("msg").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
|
||||
loadRoster();
|
||||
setInterval(loadRoster, 15000);
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "canvas",
|
||||
"name": "CANVAS",
|
||||
"summary": "A freeform canvas a human and an agent drive together \u2014 pin media, sketch, arrange.",
|
||||
"category": "create",
|
||||
"maker": {
|
||||
"name": "OpenRig",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"surface": {
|
||||
"entry": "app/canvas.html",
|
||||
"path": "/surfaces/canvas.html",
|
||||
"glyph": "\u25c8",
|
||||
"hint": "freeform canvas \u2014 pin media, sketch, arrange"
|
||||
},
|
||||
"provider": {
|
||||
"package": "@openrig/studio-video",
|
||||
"required": true,
|
||||
"_note": "This app is a CLIENT of that provider. Installing the surface alone gets you an HTML file that 502s \u2014 the field rigs-to-apps v1 had no slot for, which is why media-manager was listed there and was not installable.",
|
||||
"run": {
|
||||
"entry": "export-server.mjs",
|
||||
"args": [
|
||||
"--port",
|
||||
"{{port}}",
|
||||
"--slice-root",
|
||||
"{{root:project}}",
|
||||
"--canvas-root",
|
||||
"{{root:canvas}}",
|
||||
"--media-root",
|
||||
"{{root:media}}"
|
||||
],
|
||||
"env": {
|
||||
"CUTDOWN_API": "http://127.0.0.1:{{port:@openrig/studio-cutdown}}"
|
||||
},
|
||||
"_note": "Placeholders the box resolves: {{port}} = the port assigned to THIS provider, {{root:<kind>}} = the binding for that root kind (an array repeats its preceding flag), {{port:<pkg>}} = another provider's assigned port, which is how inter-provider wiring stops being a hardcoded default."
|
||||
},
|
||||
"supplies": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"_supplies_note": "Binaries this provider SHIPS (ffmpeg-static/ffprobe-static), so the host does not need them. requires.binaries still declares the NEED \u2014 that stays legible and true; this says who satisfies it. Without the distinction the installer refuses a working install on any box without a system ffmpeg, which is most of them."
|
||||
},
|
||||
"roots": {
|
||||
"canvas": {
|
||||
"required": true
|
||||
},
|
||||
"media": {
|
||||
"required": true
|
||||
},
|
||||
"project": {
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"verbs": [
|
||||
"/api/canvas",
|
||||
"/api/canvas/apply",
|
||||
"/api/canvases",
|
||||
"/api/library",
|
||||
"/api/media",
|
||||
"/api/thumb",
|
||||
"/api/focus"
|
||||
],
|
||||
"vendor": [
|
||||
"tldraw-v5.2.5"
|
||||
],
|
||||
"_vendor_note": "3.4MB vendored deliberately \u2014 the box never calls cdn.tldraw.com.",
|
||||
"requires": {
|
||||
"binaries": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"ffmpeg_filters": [
|
||||
"scale",
|
||||
"concat"
|
||||
],
|
||||
"_filters_note": "REQUIRED vs PREFERRED, and the split is the point. scale/concat are load-bearing: no cut renders without them. drawtext only writes the LABEL on a placeholder slate, and the assembler now renders an unlabelled slate and reports the degradation instead of failing the export. So drawtext must NOT block an install \u2014 the Linux ffmpeg-static build (7.0.2) lacks it while the macOS one (6.0) has it, and losing a caption on an unshot beat is not worth refusing a working studio.",
|
||||
"ffmpeg_filters_preferred": [
|
||||
"drawtext"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenRig Canvas</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/vendor/tldraw-v5.2.5/tldraw.css">
|
||||
<style>
|
||||
/* dark chrome wrapped around
|
||||
the tldraw editor. The canvas is a THINKING surface: media cards, ink,
|
||||
notes, arrows, frames — storyboarding, not graphic design. The value
|
||||
ladder: ground #121214 · panel/card white-translucent · active = INVERT
|
||||
(solid white bg, black text). Coral = needs-human/error, indigo = routing.
|
||||
BOUNDARY: --well stays the dark obsidian well so the vendored tldraw
|
||||
editor (#editor .tl-container) keeps its own dark theme, untouched. */
|
||||
:root {
|
||||
--bg: #121214; --panel: rgba(255,255,255,.05); --well: #12100c;
|
||||
--line: rgba(255,255,255,.10); --hairline: rgba(255,255,255,.16);
|
||||
--ink: rgba(255,255,255,.95); --dim: rgba(255,255,255,.52);
|
||||
--coral: #ff6b5a; --indigo: #8b93ff;
|
||||
--sans: Helvetica, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font: 13px/1.45 var(--sans); overflow: hidden; }
|
||||
header { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); }
|
||||
header h1 { font: 600 13px var(--sans); letter-spacing: 0.08em; color: var(--ink); margin: 0; text-transform: uppercase; }
|
||||
header .sub { color: var(--dim); font-size: 11px; }
|
||||
header .spacer { flex: 1; }
|
||||
header button { background: var(--panel); color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 2px; font: 11.5px var(--sans); padding: 4px 10px; cursor: pointer; }
|
||||
header button:hover { border-color: rgba(255,255,255,.35); color: var(--ink); }
|
||||
/* PIN -> AGENT is the primary chrome action: ACTIVE = INVERT (solid, black text) */
|
||||
#pinBtn { background: var(--ink); color: #111; border-color: var(--ink); font-weight: 600; }
|
||||
#pinBtn:hover { border-color: var(--ink); color: #111; opacity: .9; }
|
||||
#sync { font-size: 10px; color: var(--dim); letter-spacing: .05em; }
|
||||
#sync.dirty { color: var(--coral); }
|
||||
|
||||
/* ---- canvas tabs: boards are files, pages live inside a board ---- */
|
||||
#tabs { display: flex; align-items: center; gap: 2px; min-width: 0; overflow-x: auto; }
|
||||
#tabs .tab { border: 1px solid var(--line); border-bottom-color: transparent; border-radius: 2px 2px 0 0;
|
||||
background: var(--bg); color: var(--dim); font: 11px var(--sans); padding: 4px 12px 5px;
|
||||
cursor: pointer; white-space: nowrap; letter-spacing: .04em; }
|
||||
#tabs .tab:hover { color: var(--ink); }
|
||||
#tabs .tab.on { background: var(--ink); color: #111; border-color: var(--ink); font-weight: 600; }
|
||||
#tabs .tab.add { padding: 4px 9px 5px; font-weight: 600; }
|
||||
|
||||
.body { display: grid; grid-template-columns: 230px minmax(0, 1fr); height: calc(100vh - 62px); }
|
||||
|
||||
/* ---- assets rail: the unified index; tags optional, never required ---- */
|
||||
#rail { border-right: 1px solid var(--line); overflow-y: auto; padding: 8px 0; background: var(--panel); }
|
||||
#rail h3 { margin: 8px 12px 4px; font: 600 9.5px var(--sans); letter-spacing: 0.1em;
|
||||
color: var(--dim); text-transform: uppercase; }
|
||||
#rail .filters { display: flex; flex-wrap: wrap; gap: 4px; padding: 2px 12px 6px; }
|
||||
#rail .chip { font-size: 9.5px; border: 1px solid var(--line); border-radius: 2px; padding: 1px 6px;
|
||||
color: var(--dim); cursor: pointer; }
|
||||
#rail .chip.on { background: var(--ink); border-color: var(--ink); color: #111; font-weight: 600; }
|
||||
#rail input[type="search"] { width: calc(100% - 24px); margin: 0 12px 6px; background: rgba(255,255,255,.05);
|
||||
color: var(--ink); border: 1px solid transparent; border-radius: 2px; font: 11px var(--sans); padding: 4px 8px; }
|
||||
.atile { display: flex; gap: 8px; align-items: center; padding: 4px 12px; cursor: grab; }
|
||||
.atile:hover { background: rgba(255,255,255,.06); }
|
||||
.atile img { width: 52px; height: 34px; object-fit: cover; background: #000;
|
||||
border: 1px solid var(--line); border-radius: 2px; flex: none; }
|
||||
.atile .ph { width: 52px; height: 34px; display: grid; place-items: center; background: #000;
|
||||
border: 1px solid var(--line); border-radius: 2px; color: var(--dim); flex: none; font-size: 13px; }
|
||||
.atile .nm { font-size: 10.5px; color: var(--ink); overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; min-width: 0; }
|
||||
.atile .k { font-size: 9px; color: var(--dim); }
|
||||
.empty { color: var(--dim); padding: 16px 12px; font-size: 11px; }
|
||||
|
||||
/* ---- editor mount ---- */
|
||||
#editor { position: relative; min-width: 0; }
|
||||
#editor .tl-container { background: var(--well); }
|
||||
|
||||
/* ---- the ask modal (house prompt) ---- */
|
||||
#askwrap { display: none; position: fixed; inset: 0; z-index: 600;
|
||||
background: rgba(0,0,0,.65); align-items: center; justify-content: center; }
|
||||
#askwrap.open { display: flex; }
|
||||
#ask { width: min(420px, 90vw); background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 2px; padding: 16px; }
|
||||
#ask h2 { margin: 0 0 12px; font: 600 11.5px var(--sans); color: var(--ink); letter-spacing: .05em; line-height: 1.5; }
|
||||
#ask input { width: 100%; padding: 8px 10px; font-size: 12px; background: rgba(255,255,255,.05);
|
||||
color: var(--ink); border: 1px solid transparent; border-radius: 2px; font-family: var(--sans); }
|
||||
#rail input[type="search"]:focus, #ask input:focus { outline: none;
|
||||
border-color: rgba(255,255,255,.20); box-shadow: 0 0 0 2px rgba(255,255,255,.20); }
|
||||
#ask .row { display: flex; gap: 8px; margin-top: 12px; }
|
||||
#ask .spacer { flex: 1; }
|
||||
#ask button { background: var(--panel); color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 2px; font: 11.5px var(--sans); padding: 4px 10px; cursor: pointer; }
|
||||
#ask button.primary { background: var(--ink); border-color: var(--ink); color: #111; font-weight: 600; }
|
||||
</style>
|
||||
<style>
|
||||
/* dark-scheme scrollbars without color-scheme the
|
||||
browser paints LIGHT scrollbars on our dark surfaces */
|
||||
html { color-scheme: dark; }
|
||||
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.22) transparent; }
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 4px; border: 2px solid transparent; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.32); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Canvas</h1>
|
||||
<div id="tabs"></div>
|
||||
<span class="spacer"></span>
|
||||
<span id="sync">—</span>
|
||||
<button id="pinBtn" title="hand the selected media cards to the agent as a named durable selection">PIN → AGENT</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<nav id="rail">
|
||||
<h3>Assets</h3>
|
||||
<input id="search" type="search" placeholder="filter…">
|
||||
<div class="filters" id="groupChips"></div>
|
||||
<div class="filters" id="tagChips"></div>
|
||||
<div id="atiles"><div class="empty">Loading…</div></div>
|
||||
</nav>
|
||||
<div id="editor"></div>
|
||||
</div>
|
||||
|
||||
<div id="askwrap"><div id="ask">
|
||||
<h2 id="askTitle"></h2>
|
||||
<input type="text" id="askInput">
|
||||
<div class="row"><span class="spacer"></span>
|
||||
<button id="askCancel">cancel</button><button id="askOk" class="primary">OK</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<script src="/vendor/tldraw-v5.2.5/tldraw-bundle.js"></script>
|
||||
<script>
|
||||
(() => {
|
||||
const { tldraw, React, ReactDOMClient } = window.TldrawBundle;
|
||||
const { Tldraw, loadSnapshot, getSnapshot, createShapeId, AssetRecordType } = tldraw;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (t) => String(t ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
|
||||
// self-hosted fonts: the vendored bundle's ONLY runtime asset need —
|
||||
// the box never calls cdn.tldraw.com
|
||||
const FONT_BASE = "/vendor/tldraw-v5.2.5/fonts";
|
||||
const FONTS = {
|
||||
tldraw_mono: "IBMPlexMono-Medium", tldraw_mono_italic: "IBMPlexMono-MediumItalic",
|
||||
tldraw_mono_bold: "IBMPlexMono-Bold", tldraw_mono_italic_bold: "IBMPlexMono-BoldItalic",
|
||||
tldraw_serif: "IBMPlexSerif-Medium", tldraw_serif_italic: "IBMPlexSerif-MediumItalic",
|
||||
tldraw_serif_bold: "IBMPlexSerif-Bold", tldraw_serif_italic_bold: "IBMPlexSerif-BoldItalic",
|
||||
tldraw_sans: "IBMPlexSans-Medium", tldraw_sans_italic: "IBMPlexSans-MediumItalic",
|
||||
tldraw_sans_bold: "IBMPlexSans-Bold", tldraw_sans_italic_bold: "IBMPlexSans-BoldItalic",
|
||||
tldraw_draw: "Shantell_Sans-Informal_Regular", tldraw_draw_italic: "Shantell_Sans-Informal_Regular_Italic",
|
||||
tldraw_draw_bold: "Shantell_Sans-Informal_Bold", tldraw_draw_italic_bold: "Shantell_Sans-Informal_Bold_Italic",
|
||||
};
|
||||
const assetUrls = { fonts: Object.fromEntries(Object.entries(FONTS).map(([k, f]) => [k, `${FONT_BASE}/${f}.woff2`])) };
|
||||
|
||||
const state = {
|
||||
editor: null,
|
||||
canvas: null, // active board slug (tabs); pages organize WITHIN a board
|
||||
canvases: [],
|
||||
rev: 0,
|
||||
lastSynced: null, // the document at the last successful sync — 3-way merge base
|
||||
dirty: false,
|
||||
saving: false,
|
||||
loading: true, // suppresses autosave while a doc is being loaded/switched
|
||||
emptyDoc: null, // pristine store snapshot, for opening a blank board
|
||||
assets: [], libAssets: [], tags: {}, byAsset: {},
|
||||
group: "all", tag: null,
|
||||
};
|
||||
const canvasQS = () => `canvas=${encodeURIComponent(state.canvas || "")}`;
|
||||
|
||||
function ask(title, placeholder = "", value = "") {
|
||||
return new Promise((resolve) => {
|
||||
$("askTitle").textContent = title;
|
||||
const inp = $("askInput");
|
||||
inp.placeholder = placeholder; inp.value = value;
|
||||
$("askwrap").classList.add("open"); inp.focus();
|
||||
const done = (v) => { $("askwrap").classList.remove("open");
|
||||
inp.onkeydown = $("askOk").onclick = $("askCancel").onclick = null; resolve(v); };
|
||||
$("askOk").onclick = () => done(inp.value.trim());
|
||||
$("askCancel").onclick = () => done(null);
|
||||
inp.onkeydown = (e) => {
|
||||
if (e.key === "Enter") done(inp.value.trim());
|
||||
if (e.key === "Escape") { e.stopPropagation(); done(null); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function api(pathname, opts) {
|
||||
const res = await fetch(pathname, opts);
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) { const err = new Error(payload.error || `HTTP ${res.status}`); err.payload = payload; err.status = res.status; throw err; }
|
||||
return payload;
|
||||
}
|
||||
const post = (pathname, body) => api(pathname, { method: "POST",
|
||||
headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
||||
|
||||
const setSync = (text, dirty) => { $("sync").textContent = text; $("sync").classList.toggle("dirty", !!dirty); };
|
||||
|
||||
/* ================= sync engine: autosave + rev discipline ================ */
|
||||
|
||||
const docOf = () => getSnapshot(state.editor.store).document;
|
||||
|
||||
let saveTimer = null;
|
||||
function scheduleSave() {
|
||||
// loading guard: loadSnapshot (boot/switch) flushes into the user-source
|
||||
// listener a tick later — without this, every reload cost a phantom rev
|
||||
if (state.loading) return;
|
||||
state.dirty = true;
|
||||
setSync(`rev ${state.rev} · unsaved`, true);
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(save, 1500);
|
||||
}
|
||||
|
||||
async function save(reason = "ui autosave") {
|
||||
if (!state.editor || state.saving || state.loading) { if (state.dirty) { clearTimeout(saveTimer); saveTimer = setTimeout(save, 800); } return; }
|
||||
state.saving = true;
|
||||
const savingCanvas = state.canvas;
|
||||
const document = docOf();
|
||||
try {
|
||||
const r = await post(`/api/canvas/apply?${canvasQS()}`, { baseRev: state.rev, document, reason });
|
||||
if (savingCanvas !== state.canvas) return; // switched mid-save; the new board owns state now
|
||||
state.rev = r.rev; state.lastSynced = document; state.dirty = false;
|
||||
setSync(`rev ${state.rev} · saved`, false);
|
||||
} catch (e) {
|
||||
if (savingCanvas !== state.canvas) return;
|
||||
if (e.status === 409 && e.payload && e.payload.document) mergeAndResave(e.payload);
|
||||
else setSync(`save failed: ${e.message}`.slice(0, 60), true);
|
||||
} finally { state.saving = false; }
|
||||
}
|
||||
|
||||
// record-level 3-way merge, OURS (the human at the surface) wins conflicts:
|
||||
// start from the server doc, overlay every record we changed vs base, drop
|
||||
// every record we deleted vs base that the server left untouched.
|
||||
function mergeAndResave(server) {
|
||||
const base = state.lastSynced?.store || {};
|
||||
const ours = docOf();
|
||||
const merged = { ...server.document, store: { ...server.document.store } };
|
||||
for (const [id, rec] of Object.entries(ours.store)) {
|
||||
if (JSON.stringify(base[id]) !== JSON.stringify(rec)) merged.store[id] = rec;
|
||||
}
|
||||
for (const id of Object.keys(base)) {
|
||||
if (!(id in ours.store) && JSON.stringify(base[id]) === JSON.stringify(server.document.store[id])) {
|
||||
delete merged.store[id];
|
||||
}
|
||||
}
|
||||
state.rev = server.rev;
|
||||
applyRemote(merged);
|
||||
state.dirty = true;
|
||||
clearTimeout(saveTimer); saveTimer = setTimeout(() => save("merge after concurrent edit"), 200);
|
||||
}
|
||||
|
||||
// apply a full target document to the live store as a REMOTE change
|
||||
// (listener has source:"user", so this never echoes into autosave)
|
||||
function applyRemote(target) {
|
||||
const store = state.editor.store;
|
||||
store.mergeRemoteChanges(() => {
|
||||
const current = getSnapshot(store).document.store;
|
||||
const puts = [], removes = [];
|
||||
for (const [id, rec] of Object.entries(target.store)) {
|
||||
if (JSON.stringify(current[id]) !== JSON.stringify(rec)) puts.push(rec);
|
||||
}
|
||||
for (const id of Object.keys(current)) if (!(id in target.store)) removes.push(id);
|
||||
if (puts.length) store.put(puts);
|
||||
if (removes.length) store.remove(removes);
|
||||
});
|
||||
}
|
||||
|
||||
// quiet poll: agent edits surface live under the human (7s, skip while
|
||||
// dirty — the 409 merge path owns that case)
|
||||
setInterval(async () => {
|
||||
if (!state.editor || state.dirty || state.saving || state.loading) return;
|
||||
try {
|
||||
const ping = await api(`/api/canvas?${canvasQS()}&since=${state.rev}`);
|
||||
if (!ping.changed || ping.canvas !== state.canvas) return;
|
||||
const full = await api(`/api/canvas?${canvasQS()}`);
|
||||
if (!full.document || full.canvas !== state.canvas) return;
|
||||
applyRemote(full.document);
|
||||
state.rev = full.rev; state.lastSynced = full.document;
|
||||
setSync(`rev ${state.rev} · updated by ${full.updated_by || "agent"}`, false);
|
||||
} catch {}
|
||||
}, 7000);
|
||||
|
||||
/* ================= boards: tabs + switching ================= */
|
||||
|
||||
function renderTabs() {
|
||||
const tabs = $("tabs"); tabs.innerHTML = "";
|
||||
for (const c of state.canvases) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "tab" + (c.slug === state.canvas ? " on" : "");
|
||||
b.textContent = c.slug;
|
||||
b.title = c.updated_by ? `rev ${c.rev} · last by ${c.updated_by}` : "empty board";
|
||||
b.onclick = () => switchCanvas(c.slug);
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
const add = document.createElement("button");
|
||||
add.className = "tab add"; add.textContent = "+";
|
||||
add.title = "new board (canvases are boards; use Pages inside a board for scenes/acts)";
|
||||
add.onclick = async () => {
|
||||
const name = await ask("Name the new board", "brand v3");
|
||||
if (!name) return;
|
||||
const r = await post("/api/canvases", { name });
|
||||
state.canvases = r.canvases;
|
||||
switchCanvas(r.slug);
|
||||
};
|
||||
tabs.appendChild(add);
|
||||
}
|
||||
|
||||
async function loadCanvasDoc(slug) {
|
||||
state.loading = true;
|
||||
const full = await api(`/api/canvas?canvas=${encodeURIComponent(slug)}`);
|
||||
state.canvas = full.canvas;
|
||||
loadSnapshot(state.editor.store, { document: full.document || state.emptyDoc });
|
||||
// settle: let the loadSnapshot flush pass before autosave re-arms
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
state.rev = full.rev;
|
||||
state.lastSynced = docOf();
|
||||
state.dirty = false;
|
||||
state.loading = false;
|
||||
try { localStorage.setItem("canvasLast", state.canvas); } catch {}
|
||||
setSync(`rev ${state.rev} · saved`, false);
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
async function switchCanvas(slug) {
|
||||
if (!state.editor || slug === state.canvas) return;
|
||||
if (state.dirty) await save("switching board");
|
||||
try { await loadCanvasDoc(slug); } catch (e) { setSync(`switch failed: ${e.message}`.slice(0, 60), true); state.loading = false; }
|
||||
}
|
||||
|
||||
/* ================= focus contract (byte-identical shape) ================= */
|
||||
|
||||
let focusTimer = null, lastFocusKey = "";
|
||||
function reportFocus() {
|
||||
clearTimeout(focusTimer);
|
||||
focusTimer = setTimeout(() => {
|
||||
if (!state.editor) return;
|
||||
const shapes = state.editor.getSelectedShapes();
|
||||
const selection = shapes.map((s) => s.meta?.assetName || s.id).slice(0, 200);
|
||||
const key = selection.join(",");
|
||||
if (key === lastFocusKey) return;
|
||||
lastFocusKey = key;
|
||||
fetch("/api/focus", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ surface: "canvas", selection,
|
||||
view: `${state.canvas || ""}/${state.editor.getCurrentPage()?.name || ""}`.slice(0, 40) }) }).catch(() => {});
|
||||
}, 600);
|
||||
}
|
||||
|
||||
$("pinBtn").onclick = async () => {
|
||||
const shapes = state.editor ? state.editor.getSelectedShapes() : [];
|
||||
const assets = shapes.map((s) => s.meta?.assetName).filter(Boolean);
|
||||
if (!assets.length) { setSync("select media cards first", true); return; }
|
||||
const name = await ask("Name this selection (optional — e.g. 'act 2 boards')", "act 2 boards");
|
||||
if (name === null) return;
|
||||
await post("/api/selections", { name, assets, surface: "canvas" });
|
||||
try { parent.postMessage({ t: "open-agent" }, "*"); } catch {}
|
||||
setSync(`pinned ${assets.length} → agent`, false);
|
||||
};
|
||||
|
||||
/* ================= media placement from the unified index ================ */
|
||||
|
||||
async function loadRail() {
|
||||
try {
|
||||
const [proj, lib, tags] = await Promise.all([
|
||||
api("/api/project-assets").catch(() => ({ assets: [] })),
|
||||
api("/api/library").catch(() => ({ assets: [] })),
|
||||
api("/api/tags").catch(() => ({ tags: {}, byAsset: {} })),
|
||||
]);
|
||||
state.assets = (proj.assets || [])
|
||||
.filter((a) => a.exists !== false && ["video", "image"].includes(a.media_type))
|
||||
.map((a) => ({ name: a.ref.split("/").pop(), kind: a.media_type, title: a.title || a.ref,
|
||||
src: `/api/media?path=${encodeURIComponent(a.path)}`,
|
||||
thumb: `/api/thumb?src=${encodeURIComponent(a.ref)}&t=1`, path: a.path }));
|
||||
state.libAssets = (lib.assets || [])
|
||||
.filter((a) => ["video", "image"].includes(a.kind))
|
||||
.map((a) => ({ name: a.name, kind: a.kind, title: a.name, lib: true,
|
||||
src: `/api/library/media?path=${encodeURIComponent(a.path)}`,
|
||||
thumb: `/api/thumb?abs=${encodeURIComponent(a.path)}&t=1`, path: a.path }));
|
||||
state.tags = tags.tags || {}; state.byAsset = tags.byAsset || {};
|
||||
renderRail();
|
||||
} catch (e) { $("atiles").innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
function railPool() {
|
||||
let pool = state.group === "library" ? state.libAssets
|
||||
: state.group === "all" ? state.assets.concat(state.libAssets) : state.assets;
|
||||
if (state.group === "video" || state.group === "image") pool = pool.filter((a) => a.kind === state.group && !a.lib);
|
||||
if (state.tag) pool = pool.filter((a) => (state.byAsset[a.name] || []).includes(state.tag));
|
||||
const q = $("search").value.toLowerCase();
|
||||
if (q) pool = pool.filter((a) => [a.name, a.title, ...(state.byAsset[a.name] || [])].join(" ").toLowerCase().includes(q));
|
||||
return pool;
|
||||
}
|
||||
|
||||
function renderRail() {
|
||||
const groups = [["all", "All"], ["image", "Image"], ["video", "Video"], ["library", "Library"]];
|
||||
$("groupChips").innerHTML = groups.map(([id, name]) =>
|
||||
`<span class="chip${state.group === id ? " on" : ""}" data-g="${id}">${name}</span>`).join("");
|
||||
$("groupChips").querySelectorAll(".chip").forEach((c) => c.onclick = () => { state.group = c.dataset.g; renderRail(); });
|
||||
const tagNames = Object.keys(state.tags).sort();
|
||||
$("tagChips").innerHTML = tagNames.map((t) =>
|
||||
`<span class="chip${state.tag === t ? " on" : ""}" data-t="${esc(t)}">${esc(t)}</span>`).join("");
|
||||
$("tagChips").querySelectorAll(".chip").forEach((c) => c.onclick = () => { state.tag = state.tag === c.dataset.t ? null : c.dataset.t; renderRail(); });
|
||||
const pool = railPool();
|
||||
const tiles = $("atiles"); tiles.innerHTML = pool.length ? "" : '<div class="empty">Nothing matches. Tags are optional — clear filters or ask the agent.</div>';
|
||||
for (const a of pool.slice(0, 400)) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "atile"; d.draggable = true;
|
||||
d.innerHTML = `<img loading="lazy" src="${esc(a.thumb)}" onerror="this.outerHTML='<span class=ph>${a.kind === "video" ? "▤" : "▣"}</span>'">
|
||||
<span class="nm" title="${esc(a.title)}">${esc(a.name)}<br><span class="k">${esc(a.kind)}${a.lib ? " · library" : ""}</span></span>`;
|
||||
d.onclick = () => placeAsset(a, null);
|
||||
d.ondragstart = (e) => { e.dataTransfer.setData("text/x-canvas-asset", JSON.stringify(a)); e.dataTransfer.effectAllowed = "copy"; };
|
||||
tiles.appendChild(d);
|
||||
}
|
||||
}
|
||||
$("search").addEventListener("input", renderRail);
|
||||
|
||||
// measure natural aspect from the poster; cards land at 480 wide
|
||||
function measure(a) {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ w: img.naturalWidth || 480, h: img.naturalHeight || 270 });
|
||||
img.onerror = () => resolve({ w: 480, h: 270 });
|
||||
img.src = a.thumb;
|
||||
});
|
||||
}
|
||||
|
||||
async function placeAsset(a, pagePoint) {
|
||||
if (!state.editor) return;
|
||||
while (state.loading) await new Promise((r) => setTimeout(r, 50)); // never place into a mid-load store
|
||||
const editor = state.editor;
|
||||
const dims = await measure(a);
|
||||
const W = 480, H = Math.max(40, Math.round(W * dims.h / dims.w));
|
||||
let at = pagePoint;
|
||||
if (!at) {
|
||||
const b = editor.getViewportPageBounds();
|
||||
at = { x: b.x + b.w / 2, y: b.y + b.h / 2 };
|
||||
}
|
||||
const assetId = AssetRecordType.createId();
|
||||
editor.createAssets([{
|
||||
id: assetId, typeName: "asset", type: a.kind,
|
||||
props: { name: a.name, src: a.src, w: dims.w, h: dims.h, mimeType: a.kind === "video" ? "video/mp4" : "image/jpeg", isAnimated: false },
|
||||
meta: { assetPath: a.path, assetName: a.name },
|
||||
}]);
|
||||
editor.createShape({
|
||||
id: createShapeId(), type: a.kind, x: at.x - W / 2, y: at.y - H / 2,
|
||||
props: { assetId, w: W, h: H },
|
||||
meta: { assetPath: a.path, assetName: a.name },
|
||||
});
|
||||
setSync(`placed ${a.name}`, true);
|
||||
}
|
||||
|
||||
$("editor").addEventListener("dragover", (e) => {
|
||||
if ([...e.dataTransfer.types].includes("text/x-canvas-asset")) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }
|
||||
});
|
||||
$("editor").addEventListener("drop", (e) => {
|
||||
const raw = e.dataTransfer.getData("text/x-canvas-asset");
|
||||
if (!raw) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
let at = null;
|
||||
try { at = state.editor.screenToPage({ x: e.clientX, y: e.clientY }); } catch {}
|
||||
placeAsset(JSON.parse(raw), at);
|
||||
}, true);
|
||||
|
||||
/* ================= mount ================= */
|
||||
|
||||
async function boot() {
|
||||
// boards first: pick the remembered tab, else the first board
|
||||
try {
|
||||
state.canvases = (await api("/api/canvases")).canvases;
|
||||
} catch { state.canvases = [{ slug: "main", rev: 0 }]; }
|
||||
let last = null;
|
||||
try { last = localStorage.getItem("canvasLast"); } catch {}
|
||||
const startSlug = state.canvases.some((c) => c.slug === last) ? last : state.canvases[0].slug;
|
||||
renderTabs();
|
||||
// unkeyed tldraw UNMOUNTS the editor after 5s on a production origin
|
||||
// (https non-loopback). The key is a runtime prop; the box carries it
|
||||
// as a file so keying needs no redeploy: echo '{"licenseKey":"…"}' >
|
||||
// ~/studio/app/canvas-license.json
|
||||
let licenseKey;
|
||||
try { licenseKey = (await (await fetch("/canvas-license.json")).json()).licenseKey; } catch {}
|
||||
|
||||
const root = ReactDOMClient.createRoot($("editor"));
|
||||
root.render(React.createElement(Tldraw, {
|
||||
assetUrls,
|
||||
...(licenseKey ? { licenseKey } : {}),
|
||||
inferDarkMode: false,
|
||||
onMount: (editor) => {
|
||||
state.editor = editor;
|
||||
state.emptyDoc = docOf(); // pristine snapshot: what a blank board loads
|
||||
editor.user.updateUserPreferences({ colorScheme: "dark" });
|
||||
editor.store.listen(() => scheduleSave(), { scope: "document", source: "user" });
|
||||
editor.store.listen(() => reportFocus(), { scope: "session", source: "user" });
|
||||
loadCanvasDoc(startSlug).catch((e) => { setSync(`load failed: ${e.message}`.slice(0, 60), true); state.loading = false; });
|
||||
},
|
||||
}));
|
||||
loadRail();
|
||||
// tab freshness: other sessions/agents create boards too
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const canvases = (await api("/api/canvases")).canvases;
|
||||
if (JSON.stringify(canvases) !== JSON.stringify(state.canvases)) { state.canvases = canvases; renderTabs(); }
|
||||
} catch {}
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
// testability handles (probe law): automated probes
|
||||
window.__canvas = {
|
||||
state,
|
||||
save: () => save("probe"),
|
||||
place: (a, at) => placeAsset(a, at),
|
||||
doc: () => (state.editor ? docOf() : null),
|
||||
switchCanvas,
|
||||
createCanvas: async (name) => { const r = await post("/api/canvases", { name }); state.canvases = r.canvases; await switchCanvas(r.slug); return r.slug; },
|
||||
};
|
||||
|
||||
boot();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+1
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# tldraw-v5.2.5 — pinned prebuilt bundle (CANVAS surface)
|
||||
|
||||
Committed artifact, built ONCE offline on 2026-07-25 by visual-art-director@openrig-studio.
|
||||
NO build step in this repo — that is the point. To rebuild (version bump only):
|
||||
|
||||
```
|
||||
npm init -y && npm install [email protected] [email protected] [email protected] esbuild
|
||||
cat > entry.js <<'EOF'
|
||||
import * as tldraw from "tldraw";
|
||||
import * as React from "react";
|
||||
import * as ReactDOMClient from "react-dom/client";
|
||||
window.TldrawBundle = { tldraw, React, ReactDOMClient, version: "5.2.5" };
|
||||
EOF
|
||||
npx esbuild entry.js --bundle --minify --format=iife \
|
||||
--define:process.env.NODE_ENV='"production"' \
|
||||
--loader:.svg=dataurl --loader:.png=dataurl --loader:.woff2=dataurl \
|
||||
--outfile=tldraw-bundle.js
|
||||
cp node_modules/tldraw/tldraw.css .
|
||||
# fonts: the ONLY runtime CDN dependency in tldraw 5.2.5 — self-hosted here
|
||||
for f in <the 16 names in fonts/>; do curl -O https://cdn.tldraw.com/5.2.5/fonts/$f.woff2; done
|
||||
```
|
||||
|
||||
Contents: `tldraw-bundle.js` (1.9MB IIFE — tldraw + react + react-dom under
|
||||
`window.TldrawBundle`; React exists ONLY inside this blob, surface glue stays vanilla),
|
||||
`tldraw.css` (self-contained, data-URI cursors only), `fonts/` (16 woff2 — the surface
|
||||
passes `assetUrls.fonts` overrides pointing here so the box never calls cdn.tldraw.com),
|
||||
`LICENSE.md` (tldraw's own).
|
||||
|
||||
LICENSE STATUS (founder research 2026-07-25): dev/localhost use is unkeyed and fine;
|
||||
PRODUCTION on an HTTPS non-localhost domain (bray.rigs.to) requires a license key —
|
||||
noncommercial "hobby license" (approval + watermark) or the 100-day production trial.
|
||||
Founder action before box deploy; flagged via dev-driver3. A version bump changes the
|
||||
store schema — canvas.json docs migrate on next UI load; bump the dir name and the
|
||||
`version` field together.
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
const _POINT_B64_LENGTH = 8;
|
||||
const FIRST_POINT_B64_LENGTH = 16;
|
||||
const FIRST_POINT_2D_B64_LENGTH = 12;
|
||||
const DEFAULT_PRESSURE = 0.5;
|
||||
const DIM_2D = 2;
|
||||
const DIM_3D = 3;
|
||||
const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const B64_LOOKUP = new Uint8Array(128);
|
||||
for (let i = 0; i < 64; i++) {
|
||||
B64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
|
||||
}
|
||||
const SIX_BIT_MASK = 63;
|
||||
const PADDING_CHAR_CODE = "=".charCodeAt(0);
|
||||
const POW2 = new Float64Array(31);
|
||||
for (let i = 0; i < 31; i++) {
|
||||
POW2[i] = Math.pow(2, i - 15);
|
||||
}
|
||||
const POW2_SUBNORMAL = Math.pow(2, -14) / 1024;
|
||||
const MANTISSA = new Float64Array(1024);
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
MANTISSA[i] = 1 + i / 1024;
|
||||
}
|
||||
function nativeGetFloat16(dataView, offset) {
|
||||
return dataView.getFloat16(offset, true);
|
||||
}
|
||||
function fallbackGetFloat16(dataView, offset) {
|
||||
return float16BitsToNumber(dataView.getUint16(offset, true));
|
||||
}
|
||||
const getFloat16 = typeof DataView.prototype.getFloat16 === "function" ? nativeGetFloat16 : fallbackGetFloat16;
|
||||
function nativeSetFloat16(dataView, offset, value) {
|
||||
;
|
||||
dataView.setFloat16(offset, value, true);
|
||||
}
|
||||
function fallbackSetFloat16(dataView, offset, value) {
|
||||
dataView.setUint16(offset, numberToFloat16Bits(value), true);
|
||||
}
|
||||
const setFloat16 = typeof DataView.prototype.setFloat16 === "function" ? nativeSetFloat16 : fallbackSetFloat16;
|
||||
function nativeBase64ToUint8Array(base64) {
|
||||
return Uint8Array.fromBase64(base64);
|
||||
}
|
||||
function fallbackBase64ToUint8Array(base64) {
|
||||
const paddedLength = base64.length;
|
||||
let padding = 0;
|
||||
if (paddedLength > 0 && base64.charCodeAt(paddedLength - 1) === PADDING_CHAR_CODE) {
|
||||
padding++;
|
||||
if (paddedLength > 1 && base64.charCodeAt(paddedLength - 2) === PADDING_CHAR_CODE) {
|
||||
padding++;
|
||||
}
|
||||
}
|
||||
const numBytes = Math.floor(paddedLength * 3 / 4) - padding;
|
||||
const bytes = new Uint8Array(numBytes);
|
||||
let byteIndex = 0;
|
||||
const fullGroups = Math.floor((paddedLength - padding) / 4) * 4;
|
||||
for (let i = 0; i < fullGroups; i += 4) {
|
||||
const c0 = B64_LOOKUP[base64.charCodeAt(i)];
|
||||
const c1 = B64_LOOKUP[base64.charCodeAt(i + 1)];
|
||||
const c2 = B64_LOOKUP[base64.charCodeAt(i + 2)];
|
||||
const c3 = B64_LOOKUP[base64.charCodeAt(i + 3)];
|
||||
const bitmap = c0 << 18 | c1 << 12 | c2 << 6 | c3;
|
||||
bytes[byteIndex++] = bitmap >> 16 & 255;
|
||||
bytes[byteIndex++] = bitmap >> 8 & 255;
|
||||
bytes[byteIndex++] = bitmap & 255;
|
||||
}
|
||||
if (padding === 1) {
|
||||
const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)];
|
||||
const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)];
|
||||
const c2 = B64_LOOKUP[base64.charCodeAt(fullGroups + 2)];
|
||||
const bitmap = c0 << 18 | c1 << 12 | c2 << 6;
|
||||
bytes[byteIndex++] = bitmap >> 16 & 255;
|
||||
bytes[byteIndex++] = bitmap >> 8 & 255;
|
||||
} else if (padding === 2) {
|
||||
const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)];
|
||||
const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)];
|
||||
const bitmap = c0 << 18 | c1 << 12;
|
||||
bytes[byteIndex++] = bitmap >> 16 & 255;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
function nativeUint8ArrayToBase64(uint8Array) {
|
||||
return uint8Array.toBase64();
|
||||
}
|
||||
function fallbackUint8ArrayToBase64(uint8Array) {
|
||||
const len = uint8Array.length;
|
||||
const fullGroups = Math.floor(len / 3) * 3;
|
||||
let result = "";
|
||||
for (let i = 0; i < fullGroups; i += 3) {
|
||||
const byte1 = uint8Array[i];
|
||||
const byte2 = uint8Array[i + 1];
|
||||
const byte3 = uint8Array[i + 2];
|
||||
const bitmap = byte1 << 16 | byte2 << 8 | byte3;
|
||||
result += BASE64_CHARS[bitmap >> 18 & SIX_BIT_MASK] + // bits 23–18 (top sextet)
|
||||
BASE64_CHARS[bitmap >> 12 & SIX_BIT_MASK] + // bits 17–12
|
||||
BASE64_CHARS[bitmap >> 6 & SIX_BIT_MASK] + // bits 11–6
|
||||
BASE64_CHARS[bitmap & SIX_BIT_MASK];
|
||||
}
|
||||
const remaining = len - fullGroups;
|
||||
if (remaining === 1) {
|
||||
const bitmap = uint8Array[fullGroups] << 16;
|
||||
result += BASE64_CHARS[bitmap >> 18 & SIX_BIT_MASK] + BASE64_CHARS[bitmap >> 12 & SIX_BIT_MASK] + "==";
|
||||
} else if (remaining === 2) {
|
||||
const bitmap = uint8Array[fullGroups] << 16 | uint8Array[fullGroups + 1] << 8;
|
||||
result += BASE64_CHARS[bitmap >> 18 & SIX_BIT_MASK] + BASE64_CHARS[bitmap >> 12 & SIX_BIT_MASK] + BASE64_CHARS[bitmap >> 6 & SIX_BIT_MASK] + "=";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const uint8ArrayToBase64 = typeof Uint8Array.prototype.toBase64 === "function" ? nativeUint8ArrayToBase64 : fallbackUint8ArrayToBase64;
|
||||
const base64ToUint8Array = typeof Uint8Array.fromBase64 === "function" ? nativeBase64ToUint8Array : fallbackBase64ToUint8Array;
|
||||
function float16BitsToNumber(bits) {
|
||||
const sign = bits >> 15;
|
||||
const exp = bits >> 10 & 31;
|
||||
const frac = bits & 1023;
|
||||
if (exp === 0) {
|
||||
return sign ? -frac * POW2_SUBNORMAL : frac * POW2_SUBNORMAL;
|
||||
}
|
||||
if (exp === 31) {
|
||||
return frac ? NaN : sign ? -Infinity : Infinity;
|
||||
}
|
||||
const magnitude = POW2[exp] * MANTISSA[frac];
|
||||
return sign ? -magnitude : magnitude;
|
||||
}
|
||||
function numberToFloat16Bits(value) {
|
||||
if (value === 0) return Object.is(value, -0) ? 32768 : 0;
|
||||
if (!Number.isFinite(value)) {
|
||||
if (Number.isNaN(value)) return 32256;
|
||||
return value > 0 ? 31744 : 64512;
|
||||
}
|
||||
const sign = value < 0 ? 1 : 0;
|
||||
value = Math.abs(value);
|
||||
const exp = Math.floor(Math.log2(value));
|
||||
let expBiased = exp + 15;
|
||||
if (expBiased >= 31) {
|
||||
return sign << 15 | 31744;
|
||||
}
|
||||
if (expBiased <= 0) {
|
||||
const frac2 = Math.round(value * Math.pow(2, 14) * 1024);
|
||||
return sign << 15 | frac2 & 1023;
|
||||
}
|
||||
const mantissa = value / Math.pow(2, exp) - 1;
|
||||
let frac = Math.round(mantissa * 1024);
|
||||
if (frac >= 1024) {
|
||||
frac = 0;
|
||||
expBiased++;
|
||||
if (expBiased >= 31) {
|
||||
return sign << 15 | 31744;
|
||||
}
|
||||
}
|
||||
return sign << 15 | expBiased << 10 | frac;
|
||||
}
|
||||
class b64Vecs {
|
||||
/**
|
||||
* Encode a single point (x, y, z) to 8 base64 characters using legacy Float16 encoding.
|
||||
* Each coordinate is encoded as a Float16 value, resulting in 6 bytes total.
|
||||
*
|
||||
* @param x - The x coordinate
|
||||
* @param y - The y coordinate
|
||||
* @param z - The z coordinate
|
||||
* @returns An 8-character base64 string representing the point
|
||||
* @internal
|
||||
*/
|
||||
static _legacyEncodePoint(x, y, z) {
|
||||
const buffer = new Uint8Array(6);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
setFloat16(dataView, 0, x);
|
||||
setFloat16(dataView, 2, y);
|
||||
setFloat16(dataView, 4, z);
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Convert an array of VecModels to a base64 string using legacy Float16 encoding.
|
||||
* Uses Float16 encoding for each coordinate (x, y, z). If a point's z value is
|
||||
* undefined, it defaults to 0.5.
|
||||
*
|
||||
* @param points - An array of VecModel objects to encode
|
||||
* @returns A base64-encoded string containing all points
|
||||
* @internal Used only for migrations from legacy format
|
||||
*/
|
||||
static _legacyEncodePoints(points) {
|
||||
if (points.length === 0) return "";
|
||||
const buffer = new Uint8Array(points.length * 6);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const offset = i * 6;
|
||||
setFloat16(dataView, offset, p.x);
|
||||
setFloat16(dataView, offset + 2, p.y);
|
||||
setFloat16(dataView, offset + 4, p.z ?? 0.5);
|
||||
}
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Convert a legacy base64 string back to an array of VecModels.
|
||||
* Decodes Float16-encoded coordinates (x, y, z) from the base64 string.
|
||||
*
|
||||
* @param base64 - The base64-encoded string containing point data
|
||||
* @returns An array of VecModel objects decoded from the string
|
||||
* @internal Used only for migrations from legacy format
|
||||
*/
|
||||
static _legacyDecodePoints(base64) {
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const result = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += 6) {
|
||||
result.push({
|
||||
x: getFloat16(dataView, offset),
|
||||
y: getFloat16(dataView, offset + 2),
|
||||
z: getFloat16(dataView, offset + 4)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Encode an array of VecModels using delta encoding for improved precision.
|
||||
* The first point is stored as Float32 (high precision for absolute position),
|
||||
* subsequent points are stored as Float16 deltas from the previous point.
|
||||
* This provides full precision for the starting position and excellent precision
|
||||
* for deltas between consecutive points (which are typically small values).
|
||||
*
|
||||
* Format:
|
||||
* - First point: 3 Float32 values = 12 bytes = 16 base64 chars
|
||||
* - Delta points: 3 Float16 values each = 6 bytes = 8 base64 chars each
|
||||
*
|
||||
* @param points - An array of VecModel objects to encode
|
||||
* @param dim - Encoding dimension; `2` routes through the 2D variant (drops z), `3` (default) keeps x, y, z
|
||||
* @returns A base64-encoded string containing delta-encoded points
|
||||
* @public
|
||||
*/
|
||||
static encodePoints(points, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.encodePoints2D(points);
|
||||
if (points.length === 0) return "";
|
||||
const firstPointBytes = 12;
|
||||
const deltaBytes = (points.length - 1) * 6;
|
||||
const totalBytes = firstPointBytes + deltaBytes;
|
||||
const buffer = new Uint8Array(totalBytes);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
const first = points[0];
|
||||
dataView.setFloat32(0, first.x, true);
|
||||
dataView.setFloat32(4, first.y, true);
|
||||
dataView.setFloat32(8, first.z ?? 0.5, true);
|
||||
let prevX = first.x;
|
||||
let prevY = first.y;
|
||||
let prevZ = first.z ?? 0.5;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const z = p.z ?? 0.5;
|
||||
const offset = firstPointBytes + (i - 1) * 6;
|
||||
setFloat16(dataView, offset, p.x - prevX);
|
||||
setFloat16(dataView, offset + 2, p.y - prevY);
|
||||
setFloat16(dataView, offset + 4, z - prevZ);
|
||||
prevX = p.x;
|
||||
prevY = p.y;
|
||||
prevZ = z;
|
||||
}
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Decode a delta-encoded base64 string back to an array of absolute VecModels.
|
||||
* The first point is stored as Float32 (high precision), subsequent points are
|
||||
* Float16 deltas that are accumulated to reconstruct absolute positions.
|
||||
*
|
||||
* @param base64 - The base64-encoded string containing delta-encoded point data
|
||||
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
|
||||
* @returns An array of VecModel objects with absolute coordinates
|
||||
* @public
|
||||
*/
|
||||
static decodePoints(base64, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.decodePoints2D(base64);
|
||||
if (base64.length === 0) return [];
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const result = [];
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
let z = dataView.getFloat32(8, true);
|
||||
result.push({ x, y, z });
|
||||
const firstPointBytes = 12;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 6) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
z += getFloat16(dataView, offset + 4);
|
||||
result.push({ x, y, z });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Get the first point from a delta-encoded base64 string.
|
||||
* The first point is stored as Float32 for full precision.
|
||||
*
|
||||
* @param b64Points - The delta-encoded base64 string
|
||||
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
|
||||
* @returns The first point as a VecModel, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeFirstPoint(b64Points, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.decodeFirstPoint2D(b64Points);
|
||||
if (b64Points.length < FIRST_POINT_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_B64_LENGTH));
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return {
|
||||
x: dataView.getFloat32(0, true),
|
||||
y: dataView.getFloat32(4, true),
|
||||
z: dataView.getFloat32(8, true)
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get the last point from a delta-encoded base64 string.
|
||||
* Requires decoding all points to accumulate deltas.
|
||||
*
|
||||
* @param b64Points - The delta-encoded base64 string
|
||||
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
|
||||
* @returns The last point as a VecModel, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeLastPoint(b64Points, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.decodeLastPoint2D(b64Points);
|
||||
if (b64Points.length < FIRST_POINT_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
let z = dataView.getFloat32(8, true);
|
||||
const firstPointBytes = 12;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 6) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
z += getFloat16(dataView, offset + 4);
|
||||
}
|
||||
return { x, y, z };
|
||||
}
|
||||
/**
|
||||
* Encode an array of VecModels as 2D delta-encoded points, dropping z entirely.
|
||||
* Use for draw shapes from devices that don't report pressure, where z is a
|
||||
* constant 0.5 and storing it wastes ~33% of per-point bytes.
|
||||
*
|
||||
* Format:
|
||||
* - First point: 2 Float32 values (x, y) = 8 bytes
|
||||
* - Delta points: 2 Float16 values (dx, dy) = 4 bytes each
|
||||
*
|
||||
* @param points - An array of VecModel objects to encode (z is discarded)
|
||||
* @returns A base64-encoded string containing 2D delta-encoded points
|
||||
* @public
|
||||
*/
|
||||
static encodePoints2D(points) {
|
||||
if (points.length === 0) return "";
|
||||
const firstPointBytes = 8;
|
||||
const deltaBytes = (points.length - 1) * 4;
|
||||
const buffer = new Uint8Array(firstPointBytes + deltaBytes);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
const first = points[0];
|
||||
dataView.setFloat32(0, first.x, true);
|
||||
dataView.setFloat32(4, first.y, true);
|
||||
let prevX = first.x;
|
||||
let prevY = first.y;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const offset = firstPointBytes + (i - 1) * 4;
|
||||
setFloat16(dataView, offset, p.x - prevX);
|
||||
setFloat16(dataView, offset + 2, p.y - prevY);
|
||||
prevX = p.x;
|
||||
prevY = p.y;
|
||||
}
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Decode a 2D delta-encoded base64 string back to an array of absolute VecModels.
|
||||
* The z coordinate is always set to 0.5 (the default pressure value) so downstream
|
||||
* consumers don't need a separate code path.
|
||||
*
|
||||
* @param base64 - The base64-encoded string containing 2D delta-encoded point data
|
||||
* @returns An array of VecModel objects with absolute (x, y) and z = 0.5
|
||||
* @public
|
||||
*/
|
||||
static decodePoints2D(base64) {
|
||||
if (base64.length === 0) return [];
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const result = [];
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
result.push({ x, y, z: DEFAULT_PRESSURE });
|
||||
const firstPointBytes = 8;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
result.push({ x, y, z: DEFAULT_PRESSURE });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Get the first point from a 2D delta-encoded base64 string.
|
||||
*
|
||||
* @param b64Points - The 2D delta-encoded base64 string
|
||||
* @returns The first point with z = 0.5, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeFirstPoint2D(b64Points) {
|
||||
if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_2D_B64_LENGTH));
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return {
|
||||
x: dataView.getFloat32(0, true),
|
||||
y: dataView.getFloat32(4, true),
|
||||
z: DEFAULT_PRESSURE
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get the last point from a 2D delta-encoded base64 string.
|
||||
* Requires decoding all points to accumulate deltas.
|
||||
*
|
||||
* @param b64Points - The 2D delta-encoded base64 string
|
||||
* @returns The last point with z = 0.5, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeLastPoint2D(b64Points) {
|
||||
if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
const firstPointBytes = 8;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
}
|
||||
return { x, y, z: DEFAULT_PRESSURE };
|
||||
}
|
||||
/**
|
||||
* Whether an encoded path contains only a single point (a "dot"), inferred from
|
||||
* the encoded length without decoding — cheap enough for the render path.
|
||||
*
|
||||
* The single-point length depends on the encoding dimension, so this takes the
|
||||
* segment's `dim`: a one-point path is `FIRST_POINT_B64_LENGTH` chars (3D) or
|
||||
* `FIRST_POINT_2D_B64_LENGTH` chars (2D). Keeping this beside the layout constants
|
||||
* is deliberate — it is the single source of truth for "how long is one point", so
|
||||
* callers never hard-code a length threshold (which silently breaks when a new
|
||||
* encoding is added).
|
||||
*
|
||||
* @param b64Points - The encoded path string
|
||||
* @param dim - Encoding dimension; `2` for (x, y), `3` (default) for (x, y, z)
|
||||
* @returns true if the path encodes exactly one point
|
||||
* @public
|
||||
*/
|
||||
static isSinglePoint(b64Points, dim) {
|
||||
return b64Points.length <= (dim === DIM_2D ? FIRST_POINT_2D_B64_LENGTH : FIRST_POINT_B64_LENGTH);
|
||||
}
|
||||
}
|
||||
export {
|
||||
DIM_2D,
|
||||
DIM_3D,
|
||||
b64Vecs,
|
||||
fallbackBase64ToUint8Array,
|
||||
fallbackUint8ArrayToBase64,
|
||||
float16BitsToNumber,
|
||||
numberToFloat16Bits
|
||||
};
|
||||
//# sourceMappingURL=b64Vecs.mjs.map
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+657
@@ -0,0 +1,657 @@
|
||||
(()=>{var Tye=Object.create;var jB=Object.defineProperty;var Cye=Object.getOwnPropertyDescriptor;var Pye=Object.getOwnPropertyNames;var Eye=Object.getPrototypeOf,Iye=Object.prototype.hasOwnProperty;var _ye=(t,e,n)=>()=>{if(n)throw n[0];try{return t&&(e=t(t=0)),e}catch(o){throw n=[o],o}};var ai=(t,e)=>()=>{try{return e||t((e={exports:{}}).exports,e),e.exports}catch(n){throw e=0,n}},Va=(t,e)=>{for(var n in e)jB(t,n,{get:e[n],enumerable:!0})},Mye=(t,e,n,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let r of Pye(e))!Iye.call(t,r)&&r!==n&&jB(t,r,{get:()=>e[r],enumerable:!(o=Cye(e,r))||o.enumerable});return t};var _=(t,e,n)=>(n=t!=null?Tye(Eye(t)):{},Mye(e||!t||!t.__esModule?jB(n,"default",{value:t,enumerable:!0}):n,t));var bK=ai((dC,ub)=>{var Dye=200,nz="__lodash_hash_undefined__",ZI=1,X$=2,Z$=9007199254740991,$I="[object Arguments]",qB="[object Array]",Oye="[object AsyncFunction]",J$="[object Boolean]",Q$="[object Date]",eK="[object Error]",tK="[object Function]",Lye="[object GeneratorFunction]",KI="[object Map]",nK="[object Number]",Nye="[object Null]",cb="[object Object]",z$="[object Promise]",Fye="[object Proxy]",oK="[object RegExp]",GI="[object Set]",rK="[object String]",Bye="[object Symbol]",zye="[object Undefined]",XB="[object WeakMap]",iK="[object ArrayBuffer]",WI="[object DataView]",Uye="[object Float32Array]",Hye="[object Float64Array]",Vye="[object Int8Array]",jye="[object Int16Array]",$ye="[object Int32Array]",Kye="[object Uint8Array]",Gye="[object Uint8ClampedArray]",Wye="[object Uint16Array]",Yye="[object Uint32Array]",qye=/[\\^$.*+?()[\]{}|]/g,Xye=/^\[object .+?Constructor\]$/,Zye=/^(?:0|[1-9]\d*)$/,fo={};fo[Uye]=fo[Hye]=fo[Vye]=fo[jye]=fo[$ye]=fo[Kye]=fo[Gye]=fo[Wye]=fo[Yye]=!0;fo[$I]=fo[qB]=fo[iK]=fo[J$]=fo[WI]=fo[Q$]=fo[eK]=fo[tK]=fo[KI]=fo[nK]=fo[cb]=fo[oK]=fo[GI]=fo[rK]=fo[XB]=!1;var sK=typeof global=="object"&&global&&global.Object===Object&&global,Jye=typeof self=="object"&&self&&self.Object===Object&&self,Yd=sK||Jye||Function("return this")(),aK=typeof dC=="object"&&dC&&!dC.nodeType&&dC,U$=aK&&typeof ub=="object"&&ub&&!ub.nodeType&&ub,lK=U$&&U$.exports===aK,GB=lK&&sK.process,H$=(function(){try{return GB&&GB.binding&&GB.binding("util")}catch{}})(),V$=H$&&H$.isTypedArray;function Qye(t,e){for(var n=-1,o=t==null?0:t.length,r=0,i=[];++n<o;){var s=t[n];e(s,n,t)&&(i[r++]=s)}return i}function eSe(t,e){for(var n=-1,o=e.length,r=t.length;++n<o;)t[r+n]=e[n];return t}function tSe(t,e){for(var n=-1,o=t==null?0:t.length;++n<o;)if(e(t[n],n,t))return!0;return!1}function nSe(t,e){for(var n=-1,o=Array(t);++n<t;)o[n]=e(n);return o}function oSe(t){return function(e){return t(e)}}function rSe(t,e){return t.has(e)}function iSe(t,e){return t?.[e]}function sSe(t){var e=-1,n=Array(t.size);return t.forEach(function(o,r){n[++e]=[r,o]}),n}function aSe(t,e){return function(n){return t(e(n))}}function lSe(t){var e=-1,n=Array(t.size);return t.forEach(function(o){n[++e]=o}),n}var cSe=Array.prototype,uSe=Function.prototype,JI=Object.prototype,WB=Yd["__core-js_shared__"],cK=uSe.toString,Su=JI.hasOwnProperty,j$=(function(){var t=/[^.]+$/.exec(WB&&WB.keys&&WB.keys.IE_PROTO||"");return t?"Symbol(src)_1."+t:""})(),uK=JI.toString,dSe=RegExp("^"+cK.call(Su).replace(qye,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),$$=lK?Yd.Buffer:void 0,YI=Yd.Symbol,K$=Yd.Uint8Array,dK=JI.propertyIsEnumerable,pSe=cSe.splice,vg=YI?YI.toStringTag:void 0,G$=Object.getOwnPropertySymbols,fSe=$$?$$.isBuffer:void 0,hSe=aSe(Object.keys,Object),ZB=db(Yd,"DataView"),pC=db(Yd,"Map"),JB=db(Yd,"Promise"),QB=db(Yd,"Set"),ez=db(Yd,"WeakMap"),fC=db(Object,"create"),mSe=Tg(ZB),gSe=Tg(pC),ySe=Tg(JB),SSe=Tg(QB),bSe=Tg(ez),W$=YI?YI.prototype:void 0,YB=W$?W$.valueOf:void 0;function wg(t){var e=-1,n=t==null?0:t.length;for(this.clear();++e<n;){var o=t[e];this.set(o[0],o[1])}}function vSe(){this.__data__=fC?fC(null):{},this.size=0}function wSe(t){var e=this.has(t)&&delete this.__data__[t];return this.size-=e?1:0,e}function xSe(t){var e=this.__data__;if(fC){var n=e[t];return n===nz?void 0:n}return Su.call(e,t)?e[t]:void 0}function TSe(t){var e=this.__data__;return fC?e[t]!==void 0:Su.call(e,t)}function CSe(t,e){var n=this.__data__;return this.size+=this.has(t)?0:1,n[t]=fC&&e===void 0?nz:e,this}wg.prototype.clear=vSe;wg.prototype.delete=wSe;wg.prototype.get=xSe;wg.prototype.has=TSe;wg.prototype.set=CSe;function qd(t){var e=-1,n=t==null?0:t.length;for(this.clear();++e<n;){var o=t[e];this.set(o[0],o[1])}}function PSe(){this.__data__=[],this.size=0}function ESe(t){var e=this.__data__,n=QI(e,t);if(n<0)return!1;var o=e.length-1;return n==o?e.pop():pSe.call(e,n,1),--this.size,!0}function ISe(t){var e=this.__data__,n=QI(e,t);return n<0?void 0:e[n][1]}function _Se(t){return QI(this.__data__,t)>-1}function MSe(t,e){var n=this.__data__,o=QI(n,t);return o<0?(++this.size,n.push([t,e])):n[o][1]=e,this}qd.prototype.clear=PSe;qd.prototype.delete=ESe;qd.prototype.get=ISe;qd.prototype.has=_Se;qd.prototype.set=MSe;function xg(t){var e=-Line truncated
|
||||
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+hU+t+LX}var mU=!1;function gU(t,e){if(!t||mU)return"";mU=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var o={DetermineComponentFrameRoot:function(){try{if(e){var u=function(){throw Error()};if(Object.defineProperty(u.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(u,[])}catch(f){var p=f}Reflect.construct(t,[],u)}else{try{u.call()}catch(f){p=f}t.call(u.prototype)}}else{try{throw Error()}catch(f){p=f}(u=t())&&typeof u.catch=="function"&&u.catch(function(){})}}catch(f){if(f&&p&&typeof f.stack=="string")return[f.stack,p.stack]}return[null,null]}};o.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var r=Object.getOwnPropertyDescriptor(o.DetermineComponentFrameRoot,"name");r&&r.configurable&&Object.defineProperty(o.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var i=o.DetermineComponentFrameRoot(),s=i[0],a=i[1];if(s&&a){var l=s.split(`
|
||||
`),c=a.split(`
|
||||
`);for(r=o=0;o<l.length&&!l[o].includes("DetermineComponentFrameRoot");)o++;for(;r<c.length&&!c[r].includes("DetermineComponentFrameRoot");)r++;if(o===l.length||r===c.length)for(o=l.length-1,r=c.length-1;1<=o&&0<=r&&l[o]!==c[r];)r--;for(;1<=o&&0<=r;o--,r--)if(l[o]!==c[r]){if(o!==1||r!==1)do if(o--,r--,0>r||l[o]!==c[r]){var d=`
|
||||
`+l[o].replace(" at new "," at ");return t.displayName&&d.includes("<anonymous>")&&(d=d.replace("<anonymous>",t.displayName)),d}while(1<=o&&0<=r);break}}}finally{mU=!1,Error.prepareStackTrace=n}return(n=t?t.displayName||t.name:"")?my(n):""}function eCe(t,e){switch(t.tag){case 26:case 27:case 5:return my(t.type);case 16:return my("Lazy");case 13:return t.child!==e&&e!==null?my("Suspense Fallback"):my("Suspense");case 19:return my("SuspenseList");case 0:case 15:return gU(t.type,!1);case 11:return gU(t.type.render,!1);case 1:return gU(t.type,!0);case 31:return my("Activity");default:return""}}function NX(t){try{var e="",n=null;do e+=eCe(t,n),n=t,t=t.return;while(t);return e}catch(o){return`
|
||||
Error generating stack: `+o.message+`
|
||||
`+o.stack}}var e5=Object.prototype.hasOwnProperty,$5=Gr.unstable_scheduleCallback,yU=Gr.unstable_cancelCallback,tCe=Gr.unstable_shouldYield,nCe=Gr.unstable_requestPaint,tl=Gr.unstable_now,oCe=Gr.unstable_getCurrentPriorityLevel,dJ=Gr.unstable_ImmediatePriority,pJ=Gr.unstable_UserBlockingPriority,$A=Gr.unstable_NormalPriority,rCe=Gr.unstable_LowPriority,fJ=Gr.unstable_IdlePriority,iCe=Gr.log,sCe=Gr.unstable_setDisableYieldValue,G1=null,nl=null;function Mh(t){if(typeof iCe=="function"&&sCe(t),nl&&typeof nl.setStrictMode=="function")try{nl.setStrictMode(G1,t)}catch{}}var ol=Math.clz32?Math.clz32:cCe,aCe=Math.log,lCe=Math.LN2;function cCe(t){return t>>>=0,t===0?32:31-(aCe(t)/lCe|0)|0}var cA=256,uA=262144,dA=4194304;function gy(t){var e=t&42;if(e!==0)return e;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function gk(t,e,n){var o=t.pendingLanes;if(o===0)return 0;var r=0,i=t.suspendedLanes,s=t.pingedLanes;t=t.warmLanes;var a=o&134217727;return a!==0?(o=a&~i,o!==0?r=gy(o):(s&=a,s!==0?r=gy(s):n||(n=a&~t,n!==0&&(r=gy(n))))):(a=o&~i,a!==0?r=gy(a):s!==0?r=gy(s):n||(n=o&~t,n!==0&&(r=gy(n)))),r===0?0:e!==0&&e!==r&&(e&i)===0&&(i=r&-r,n=e&-e,i>=n||i===32&&(n&4194048)!==0)?e:r}function W1(t,e){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&e)===0}function uCe(t,e){switch(t){case 1:case 2:case 4:case 8:case 64:return e+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function hJ(){var t=dA;return dA<<=1,(dA&62914560)===0&&(dA=4194304),t}function SU(t){for(var e=[],n=0;31>n;n++)e.push(t);return e}function Y1(t,e){t.pendingLanes|=e,e!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function dCe(t,e,n,o,r,i){var s=t.pendingLanes;t.pendingLanes=n,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=n,t.entangledLanes&=n,t.errorRecoveryDisabledLanes&=n,t.shellSuspendCounter=0;var a=t.entanglements,l=t.expirationTimes,c=t.hiddenUpdates;for(n=s&~n;0<n;){var d=31-ol(n),u=1<<d;a[d]=0,l[d]=-1;var p=c[d];if(p!==null)for(c[d]=null,d=0;d<p.length;d++){var f=p[d];f!==null&&(f.lane&=-536870913)}n&=~u}o!==0&&mJ(t,o,0),i!==0&&r===0&&t.tag!==0&&(t.suspendedLanes|=i&~(s&~e))}function mJ(t,e,n){t.pendingLanes|=e,t.suspendedLanes&=~e;var o=31-ol(e);t.entangledLanes|=e,t.entanglements[o]=t.entanglements[o]|1073741824|n&261930}function gJ(t,e){var n=t.entangledLanes|=e;for(t=t.entanglements;n;){var o=31-ol(n),r=1<<o;r&e|t[o]&e&&(t[o]|=e),n&=~r}}function yJ(t,e){var n=e&-e;return n=(n&42)!==0?1:K5(n),(n&(t.suspendedLanes|e))!==0?0:n}function K5(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function G5(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function SJ(){var t=hn.p;return t!==0?t:(t=window.event,t===void 0?32:Hee(t.type))}function FX(t,e){var n=hn.p;try{return hn.p=t,e()}finally{hn.p=n}}var Wh=Math.random().toString(36).slice(2),Li="__reactFiber$"+Wh,Sa="__reactProps$"+Wh,zw="__reactContainer$"+Wh,t5="__reactEvents$"+Wh,pCe="__reactListeners$"+Wh,fCe="__reactHandles$"+Wh,BX="__reactResources$"+Wh,q1="__reactMarker$"+Wh;function W5(t){delete t[Li],delete t[Sa],delete t[t5],delete t[pCe],delete t[fCe]}function dw(t){var e=t[Li];if(e)return e;for(var n=t.parentNode;n;){if(e=n[zw]||n[Li]){if(n=e.alternate,e.child!==null||n!==null&&n.child!==null)for(t=GZ(t);t!==null;){if(n=t[Li])return n;t=GZ(t)}return e}t=n,n=t.parentNode}return null}function Uw(t){if(t=t[Li]||t[zw]){var e=t.tag;if(e===5||e===6||e===13||e===31||e===26||e===27||e===3)return t}return null}function m1(t){var e=t.tag;if(e===5||e===26||e===27||e===6)return t.stateNode;throw Error(ee(33))}function ww(t){var e=t[BX];return e||(e=t[BX]={hoistableStyles:new Map,hoistableScripts:new Map}),e}function hi(t){t[q1]=!0}var bJ=new Set,vJ={};function My(t,e){Mw(t,e),Mw(t+"Capture",e)}function Mw(t,e){for(vJ[t]=e,t=0;t<e.length;t++)bJ.add(e[t])}var hCe=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u
Line truncated
|
||||
`).replace(j1e,"")}function Mee(t,e){return e=zZ(e),zZ(t)===e}function zn(t,e,n,o,r,i){switch(n){case"children":typeof o=="string"?e==="body"||e==="textarea"&&o===""||Aw(t,o):(typeof o=="number"||typeof o=="bigint")&&e!=="body"&&Aw(t,""+o);break;case"className":pA(t,"class",o);break;case"tabIndex":pA(t,"tabindex",o);break;case"dir":case"role":case"viewBox":case"width":case"height":pA(t,n,o);break;case"style":EJ(t,o,i);break;case"data":if(e!=="object"){pA(t,"data",o);break}case"src":case"href":if(o===""&&(e!=="a"||n!=="href")){t.removeAttribute(n);break}if(o==null||typeof o=="function"||typeof o=="symbol"||typeof o=="boolean"){t.removeAttribute(n);break}o=IA(""+o),t.setAttribute(n,o);break;case"action":case"formAction":if(typeof o=="function"){t.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof i=="function"&&(n==="formAction"?(e!=="input"&&zn(t,e,"name",r.name,r,null),zn(t,e,"formEncType",r.formEncType,r,null),zn(t,e,"formMethod",r.formMethod,r,null),zn(t,e,"formTarget",r.formTarget,r,null)):(zn(t,e,"encType",r.encType,r,null),zn(t,e,"method",r.method,r,null),zn(t,e,"target",r.target,r,null)));if(o==null||typeof o=="symbol"||typeof o=="boolean"){t.removeAttribute(n);break}o=IA(""+o),t.setAttribute(n,o);break;case"onClick":o!=null&&(t.onclick=Ap);break;case"onScroll":o!=null&&kt("scroll",t);break;case"onScrollEnd":o!=null&&kt("scrollend",t);break;case"dangerouslySetInnerHTML":if(o!=null){if(typeof o!="object"||!("__html"in o))throw Error(ee(61));if(n=o.__html,n!=null){if(r.children!=null)throw Error(ee(60));t.innerHTML=n}}break;case"multiple":t.multiple=o&&typeof o!="function"&&typeof o!="symbol";break;case"muted":t.muted=o&&typeof o!="function"&&typeof o!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(o==null||typeof o=="function"||typeof o=="boolean"||typeof o=="symbol"){t.removeAttribute("xlink:href");break}n=IA(""+o),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":o!=null&&typeof o!="function"&&typeof o!="symbol"?t.setAttribute(n,""+o):t.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":o&&typeof o!="function"&&typeof o!="symbol"?t.setAttribute(n,""):t.removeAttribute(n);break;case"capture":case"download":o===!0?t.setAttribute(n,""):o!==!1&&o!=null&&typeof o!="function"&&typeof o!="symbol"?t.setAttribute(n,o):t.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":o!=null&&typeof o!="function"&&typeof o!="symbol"&&!isNaN(o)&&1<=o?t.setAttribute(n,o):t.removeAttribute(n);break;case"rowSpan":case"start":o==null||typeof o=="function"||typeof o=="symbol"||isNaN(o)?t.removeAttribute(n):t.setAttribute(n,o);break;case"popover":kt("beforetoggle",t),kt("toggle",t),EA(t,"popover",o);break;case"xlinkActuate":xp(t,"http://www.w3.org/1999/xlink","xlink:actuate",o);break;case"xlinkArcrole":xp(t,"http://www.w3.org/1999/xlink","xlink:arcrole",o);break;case"xlinkRole":xp(t,"http://www.w3.org/1999/xlink","xlink:role",o);break;case"xlinkShow":xp(t,"http://www.w3.org/1999/xlink","xlink:show",o);break;case"xlinkTitle":xp(t,"http://www.w3.org/1999/xlink","xlink:title",o);break;case"xlinkType":xp(t,"http://www.w3.org/1999/xlink","xlink:type",o);break;case"xmlBase":xp(t,"http://www.w3.org/XML/1998/namespace","xml:base",o);break;case"xmlLang":xp(t,"http://www.w3.org/XML/1998/namespace","xml:lang",o);break;case"xmlSpace":xp(t,"http://www.w3.org/XML/1998/namespace","xml:space",o);break;case"is":EA(t,"is",o);break;case"innerText":case"textContent":break;default:(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")&&(n=bCe.get(n)||n,EA(t,n,o))}}function R5(t,e,n,o,r,i){switch(n){case"style":EJ(t,o,i);break;case"dangerouslySetInnerHTML":if(o!=null){if(typeof o!="object"||!("__html"in o))throw Error(ee(61));if(n=o.__html,n!=null){if(r.children!=null)throw Error(ee(60));t.innerHTML=n}}break;case"children":typeof o=="string"?Aw(t,o):(typeof o=="number"||typeof o=="bigint")&&Aw(t,""+o);break;case"onScroll":o!=null&&kt("scroll",t);break;case"onScrollEnd":o!=null&&kt("scrollend",t);break;case"onClick":o!=null&&(t.onclick=Ap);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":ca
Line truncated
|
||||
`)),t.didWarn=!0;return}let i=new Map;for(let a of e)KB(i,a.name,{version:a.version,modules:[]}).modules.push(a.modules);let s=new Map;for(let[a,l]of i)l.modules.length>1&&s.set(a,l);if(s.size>0){let a=[`${Wd("[tldraw]",["bold","bgRed","textWhite"])} ${Wd("You have multiple instances of some tldraw libraries active. This can lead to bugs and unexpected behavior. ",["textRed","bold"])}`,"","This usually means that your bundler is misconfigured, and is importing the same library multiple times - usually once as an ES Module, and once as a CommonJS module.","","The following libraries have been imported multiple times:",...Array.from(s,([l,c])=>{let d=c.modules.map((u,p)=>u==="esm"?` ${p+1}. ES Modules`:` ${p+1}. CommonJS`).join(`
|
||||
`);return` \u2022 \u274C ${Wd(l,["bold"])} v${c.version}:
|
||||
${d}`}),"","You should configure your bundler to only import one version of each library."];console.log(a.join(`
|
||||
`)),t.didWarn=!0;return}}function B$(t,e){let n=t.match(/^(\d+)\.(\d+)\.(\d+)(?:-(\w+))?$/),o=e.match(/^(\d+)\.(\d+)\.(\d+)(?:-(\w+))?$/);return!n||!o?t.localeCompare(e):n[1]!==o[1]?Number(n[1])-Number(o[1]):n[2]!==o[2]?Number(n[2])-Number(o[2]):n[3]!==o[3]?Number(n[3])-Number(o[3]):n[4]&&o[4]?n[4].localeCompare(o[4]):n[4]?1:o[4]?-1:0}var kye={bold:"1",textBlue:"94",textRed:"31",textGreen:"32",bgRed:"41",textWhite:"97"};function Wd(t,e=[]){return`\x1B[${e.map(n=>kye[n]).join(";")}m${t}\x1B[m`}function Rye(){try{return!1}catch{return!1}}function KB(t,e,n){return t.has(e)?t.get(e):(t.set(e,n),n)}var Ho=_(bK(),1),JG=_(gz(),1),_b=_(QK(),1),kz=_(sG(),1);function aG(t,e){if(t.length===0)return[];let n=(Math.abs(e)%t.length+t.length)%t.length;return[...t.slice(n),...t.slice(0,n)]}function yb(t,e){let n=[];e:for(let o of t){for(let r of n)if(e?e(o,r):o===r)continue e;n.push(o)}return n}function we(t){return t.filter(e=>e!=null)}function ko(t){return t[t.length-1]}function Sb(t,e){let n,o=1/0;for(let r of t){let i=e(r);i<o&&(n=r,o=i)}return n}function h_(t,e){let n,o=-1/0;for(let r of t){let i=e(r);i>o&&(n=r,o=i)}return n}function lG(t,e){let n=[],o=[];for(let r of t)e(r)?n.push(r):o.push(r);return[n,o]}function Jd(t,e){if(t===e)return!0;if(t.length!==e.length)return!1;for(let n=0;n<t.length;n++)if(!Object.is(t[n],e[n]))return!1;return!0}function mc(t,e,n){let o=new Set(e.map(i=>i[t])),r=[];for(let i of n)o.has(i[t])||r.push(i);for(let i of e)r.push(i);return r}function wC(t){let e=(...n)=>{try{return t(...n)}catch(o){throw o instanceof Error&&Error.captureStackTrace&&Error.captureStackTrace(o,e),o}};return e}function gl(){}var Vn={ok(t){return{ok:!0,value:t}},err(t){return{ok:!1,error:t}},all(t){return t.every(e=>e.ok)?Vn.ok(t.map(e=>e.value)):Vn.err(t.find(e=>!e.ok)?.error)}};function ze(t,e){let n=e&&t&&typeof t=="object"&&e in t?t[e]:t;throw new Error(`Unknown switch case ${n}`)}var ne=wC((t,e)=>{if(!t)throw new Error(e||"Assertion Error")}),wn=wC((t,e)=>{if(t==null)throw new Error(e??"value must be defined");return t});function Jf(){let t,e,n=new Promise((o,r)=>{t=o,e=r});return Object.assign(n,{resolve:t,reject:e})}function gc(t){return new Promise(e=>setTimeout(e,t))}function Kt(...t){if(t.length===2){let[e,n]=t;n.addInitializer(function(){ne(Reflect.isExtensible(this),"Cannot bind to a non-extensible class.");let r=e.bind(this),i=Reflect.defineProperty(this,n.name,{value:r,writable:!0,configurable:!0});ne(i,"Cannot bind a non-configurable class method.")})}else{let[e,n,o]=t;if(!o||typeof o.value!="function")throw new TypeError(`Only methods can be decorated with @bind. <${n}> is not a method!`);return{configurable:!0,get(){let r=o.value.bind(this);return Object.defineProperty(this,n,{value:r,configurable:!0,writable:!0}),r}}}}var Mt=class{items=new WeakMap;get(e,n){return this.items.has(e)||this.items.set(e,n(e)),this.items.get(e)}};function bu(t,e){let n,o=(...r)=>(n||(n={},n.promise=new Promise((i,s)=>{n.resolve=i,n.reject=s})),clearTimeout(n.timeout),n.latestArgs=r,n.timeout=setTimeout(()=>{let i=n;n=void 0;try{i.resolve(t(...i.latestArgs))}catch(s){i.reject(s)}},e),n.promise);return o.cancel=()=>{if(!n)return;clearTimeout(n.timeout);let r=n;n=void 0,r.promise.catch(gl),r.reject(new Error("Debounced function was cancelled"))},o}var Cz=new WeakMap;function Qf(t,e){if(typeof t!="object"||t===null)return;let n=Cz.get(t);n||(n={tags:{},extras:{}},Cz.set(t,n)),e.tags&&(n.tags={...n.tags,...e.tags}),e.extras&&(n.extras={...n.extras,...e.extras})}function cG(t){return Cz.get(t)??{tags:{},extras:{}}}var m_=class{constructor(e){this.timeout=e}timeout;queue=[];running=!1;isEmpty(){return this.queue.length===0&&!this.running}async run(){if(!this.running)try{for(this.running=!0;this.queue.length;)await this.queue.shift()(),this.timeout&&await gc(this.timeout)}finally{this.running=!1}}async push(e){return new Promise((n,o)=>{this.queue.push(()=>Promise.resolve(e()).then(n).catch(o)),this.run()})}close(){this.queue=[]}};async function mo(t,e){return window.fetch(t,{referrerPolicy:"strict-origin-when-cross-origin",...e})}function yl(t,e){let n=new window.Image(t,e);return n.referrerPolicy="strict-origin-when-cross-origin",n}var Nn=class t{static async urlToArrayBuffer(e){return await(await mo(e)).arrayBuffer()}static async urlToBlob(e){return await(await mo(e)).blob()}static async urlToDataUrl(e){if(e.startsWith("data:"))return e;let n=await t.urlToBlob(e);return await t.blobToDataUrl(n)}static async blobToDataUrl(e){return await new Promise((n,o)=>{if(e){let r=new FileReader;r.onload=()=>n(r.result),r.onerror=i=>o(i),r.onabort=i=>o(i),r.readAsDataURL(e)}})}static async blobToText(e){return await new Promise((n,o)=>{if(e){let r=new FileReader;r.onload=()=>n(r.result),r.onerror=i=>o(i),r.onabort=i=>o(i),r.readAsText(e)}})}static rewriteMimeType(e,n){return e.type===n?e:e instanceof File?new File([e],e.name,{type:n}):new Blob([e],{type:n})}};function Qd(t){let e=0;for(let n=0;n<t.length;n++)e=(e<<5)-e+t.charCodeAt(n),e|=
Line truncated
|
||||
`+" ".repeat(s)+"\u21B3 ";for(let[l,c]of Object.entries(i))c?(o+=`${a}Computed(${l}) changed`,r(c,s+2)):o+=`${a}Atom(${l}) changed`}r(n,1),console.log(o)}var yo=Symbol.for("com.tldraw.state/RESET_VALUE");var kb=class{constructor(e){this.capacity=e,this.buffer=new Array(e)}capacity;index=0;buffer;pushEntry(e,n,o){if(o!==void 0){if(o===yo){this.clear();return}this.buffer[this.index]=[e,n,o],this.index=(this.index+1)%this.capacity}}clear(){this.index=0,this.buffer.fill(void 0)}getChangesSince(e){let{index:n,capacity:o,buffer:r}=this;for(let i=0;i<o;i++){let s=(n-1+o-i)%o,a=r[s];if(!a)return yo;let[l,c]=a;if(i===0&&e>=c)return[];if(l<=e&&e<c){let d=i+1,u=new Array(d);for(let p=0;p<d;p++)u[p]=r[(s+p)%o][2];return u}}return yo}};var A_=class{constructor(e,n){this.parent=e,this.isSync=n}parent;isSync;asyncProcessCount=0;initialAtomValues=new Map;get isRoot(){return this.parent===null}commit(){if(Ut.globalIsReacting)for(let e of this.initialAtomValues.keys())cW(e);else this.isRoot?aW(this.initialAtomValues.keys()):this.initialAtomValues.forEach((e,n)=>{this.parent.initialAtomValues.has(n)||this.parent.initialAtomValues.set(n,e)})}abort(){Ut.globalEpoch++,this.initialAtomValues.forEach((e,n)=>{n.set(e),n.historyBuffer?.clear()}),this.commit()}},Ut=ra("transactions",()=>({globalEpoch:0,globalIsReacting:!1,currentTransaction:null,cleanupReactors:null,reactionEpoch:0}));function oW(){return Ut.reactionEpoch}function Ei(){return Ut.globalEpoch}function rW(){return Ut.globalIsReacting}var Dz;function iW(t){t.lastTraversedEpoch!==Ut.globalEpoch&&(t.lastTraversedEpoch=Ut.globalEpoch,"__isEffectScheduler"in t?Dz.add(t):t.children.visit(iW))}function sW(t,e){Dz=t,iW(e)}function aW(t){if(Ut.globalIsReacting)throw new Error("flushChanges cannot be called during a reaction");let e=Ut.currentTransaction;try{Ut.currentTransaction=null,Ut.globalIsReacting=!0,Ut.reactionEpoch=Ut.globalEpoch;let n=new Set;for(let r of t)r.children.visit(i=>sW(n,i));for(let r of n)r.maybeScheduleEffect();let o=0;for(;Ut.cleanupReactors?.size;){if(o++>1e3)throw new Error("Reaction update depth limit exceeded");let r=Ut.cleanupReactors;Ut.cleanupReactors=null;for(let i of r)i.maybeScheduleEffect()}}finally{Ut.cleanupReactors=null,Ut.globalIsReacting=!1,Ut.currentTransaction=e,Dz=void 0}}function lW(t,e){Ut.currentTransaction?Ut.currentTransaction.initialAtomValues.has(t)||Ut.currentTransaction.initialAtomValues.set(t,e):Ut.globalIsReacting?cW(t):aW([t])}function cW(t){let e=Ut.cleanupReactors??=new Set;t.children.visit(n=>sW(e,n))}function uW(){Ut.globalEpoch++}function Oz(t){let e=new A_(Ut.currentTransaction,!0);Ut.currentTransaction=e;try{let n,o=!1;try{n=t(()=>o=!0)}catch(r){throw e.abort(),r}if(Ut.currentTransaction!==e)throw new Error("Transaction boundaries overlap");return o?e.abort():e.commit(),n}finally{Ut.currentTransaction=e.parent}}function Ro(t){return Ut.currentTransaction?t():Oz(t)}async function dW(t){if(Ut.currentTransaction?.isSync)throw new Error("deferAsyncEffects cannot be called during a sync transaction");for(;Ut.globalIsReacting;)await new Promise(r=>queueMicrotask(()=>r(null)));let e=Ut.currentTransaction??new A_(null,!1);if(e.isSync)throw new Error("deferAsyncEffects cannot be called during a sync transaction");Ut.currentTransaction=e,e.asyncProcessCount++;let n,o;try{n=await t()}catch(r){o=r??null}if(--e.asyncProcessCount>0){if(typeof o<"u")throw o;return n}if(Ut.currentTransaction=null,typeof o<"u")throw e.abort(),o;return e.commit(),n}var Lz=class{constructor(e,n,o){this.name=e,this.current=n,this.isEqual=o?.isEqual??null,o&&(o.historyLength&&(this.historyBuffer=new kb(o.historyLength)),this.computeDiff=o.computeDiff)}name;current;isEqual;computeDiff;lastChangedEpoch=Ei();children=new bc;historyBuffer;__unsafe__getWithoutCapture(e){return this.current}get(){return Ab(this),this.current}set(e,n){if(this.isEqual?.(this.current,e)??I_(this.current,e))return this.current;uW(),this.historyBuffer&&this.historyBuffer.pushEntry(this.lastChangedEpoch,Ei(),n??this.computeDiff?.(this.current,e,this.lastChangedEpoch,Ei())??yo),this.lastChangedEpoch=Ei();let o=this.current;return this.current=e,lW(this,o),e}update(e){return this.set(e(this.current))}getDiffSince(e){return Ab(this),e>=this.lastChangedEpoch?at:this.historyBuffer?.getChangesSince(e)??yo}},k_=ra("Atom",()=>Lz);function me(t,e,n){return new k_(t,e,n)}function pW(t){return t instanceof k_}var fW=!1;function hW(){fW||(fW=!0,console.warn(`Using \`@computed\` as a decorator for getters is deprecated and will be removed in the near future. Please refactor to use \`@computed\` as a decorator for methods.
|
||||
|
||||
// Before
|
||||
@computed
|
||||
get foo() {
|
||||
return 'foo'
|
||||
}
|
||||
|
||||
// After
|
||||
@computed
|
||||
getFoo() {
|
||||
return 'foo'
|
||||
}
|
||||
`))}var Vr=Symbol.for("com.tldraw.state/UNINITIALIZED");function Ii(t){return t===Vr}var Nz=ra("WithDiff",()=>class{constructor(e,n){this.value=e,this.diff=n}value;diff});function np(t,e){return new Nz(t,e)}var Fz=class{constructor(e,n,o){this.name=e,this.derive=n,o?.historyLength&&(this.historyBuffer=new kb(o.historyLength)),this.computeDiff=o?.computeDiff,this.isEqual=o?.isEqual??I_}name;derive;__isComputed=!0;lastChangedEpoch=-1;lastTraversedEpoch=-1;__debug_ancestor_epochs__=null;lastCheckedEpoch=-1;parentSet=new bc;parents=[];parentEpochs=[];children=new bc;get isActivelyListening(){return!this.children.isEmpty}historyBuffer;state=Vr;error=null;computeDiff;isEqual;__unsafe__getWithoutCapture(e){let n=this.lastChangedEpoch===-1,o=Ei();if(!n&&(this.lastCheckedEpoch===o||this.isActivelyListening&&rW()&&this.lastTraversedEpoch<oW()||!E_(this)))if(this.lastCheckedEpoch=o,this.error){if(e)return this.state;throw this.error.thrownValue}else return this.state;try{__(this);let r=this.derive(this.state,this.lastCheckedEpoch),i=r instanceof Nz?r.value:r,s=this.state===Vr;if(s||!this.isEqual(this.state,i)){if(this.historyBuffer&&!s){let a=r instanceof Nz?r.diff:void 0;this.historyBuffer.pushEntry(this.lastChangedEpoch,Ei(),a??this.computeDiff?.(this.state,i,this.lastCheckedEpoch,Ei())??yo)}this.lastChangedEpoch=Ei(),this.state=i}return this.error=null,this.lastCheckedEpoch=Ei(),this.state}catch(r){if(this.state!==Vr&&(this.state=Vr,this.lastChangedEpoch=Ei()),this.lastCheckedEpoch=Ei(),this.historyBuffer&&this.historyBuffer.clear(),this.error={thrownValue:r},!e)throw r;return this.state}finally{M_()}}get(){try{return this.__unsafe__getWithoutCapture()}finally{Ab(this)}}getDiffSince(e){return this.__unsafe__getWithoutCapture(!0),Ab(this),e>=this.lastChangedEpoch?at:this.historyBuffer?.getChangesSince(e)??yo}},Rb=ra("Computed",()=>Fz);function Jwe(t={},e,n,o){let r=o.value,i=Symbol.for("__@tldraw/state__computed__"+n);return o.value=function(){let s=this[i];return s||(s=new Rb(n,r.bind(this),t),Object.defineProperty(this,i,{enumerable:!1,configurable:!1,writable:!1,value:s})),s.get()},o.value[Bz]=!0,o}function Qwe(t={},e,n,o){let r=o.get,i=Symbol.for("__@tldraw/state__computed__"+n);return o.get=function(){let s=this[i];return s||(s=new Rb(n,r.bind(this),t),Object.defineProperty(this,i,{enumerable:!1,configurable:!1,writable:!1,value:s})),s.get()},o}function e0e(t,e,n){ne(n.kind==="method","@computed can only be used on methods");let o=Symbol.for("__@tldraw/state__computed__"+String(n.name)),r=function(){let i=this[o];return i||(i=new Rb(String(n.name),e.bind(this),t),Object.defineProperty(this,o,{enumerable:!1,configurable:!1,writable:!1,value:i})),i.get()};return r[Bz]=!0,r}function mW(t={},e){if(e.length===2){let[n,o]=e;return e0e(t,n,o)}else{let[n,o,r]=e;return r.get?(hW(),Qwe(t,n,o,r)):Jwe(t,n,o,r)}}var Bz="@@__isComputedMethod__@@";function gW(t,e){let n=Symbol.for("__@tldraw/state__computed__"+e.toString()),o=t[n];if(!o){let r=t[e];typeof r=="function"&&r[Bz]&&r.call(t),o=t[n]}return o}function $(){if(arguments.length===1){let t=arguments[0];return(...e)=>mW(t,e)}else return typeof arguments[0]=="string"?new Rb(arguments[0],arguments[1],arguments[2]):mW(void 0,arguments)}var zz=class{constructor(e,n,o){this.name=e,this.runEffect=n,this._scheduleEffect=o?.scheduleEffect}name;runEffect;__isEffectScheduler=!0;_isActivelyListening=!1;get isActivelyListening(){return this._isActivelyListening}lastTraversedEpoch=-1;lastReactedEpoch=-1;_scheduleCount=0;__debug_ancestor_epochs__=null;get scheduleCount(){return this._scheduleCount}parentSet=new bc;parentEpochs=[];parents=[];_scheduleEffect;maybeScheduleEffect(){if(this._isActivelyListening&&this.lastReactedEpoch!==Ei()){if(this.parents.length&&!E_(this)){this.lastReactedEpoch=Ei();return}this.scheduleEffect()}}scheduleEffect(){this._scheduleCount++,this._scheduleEffect?this._scheduleEffect(this.maybeExecute):this.execute()}maybeExecute=()=>{this._isActivelyListening&&this.execute()};attach(){this._isActivelyListening=!0;for(let e=0,n=this.parents.length;e<n;e++)IC(this.parents[e],this)}detach(){this._isActivelyListening=!1;for(let e=0,n=this.parents.length;e<n;e++)Mb(this.parents[e],this)}execute(){try{__(this);let e=Ei(),n=this.runEffect(this.lastReactedEpoch);return this.lastReactedEpoch=e,n}finally{M_()}}},ia=ra("EffectScheduler",()=>zz);function Qt(t,e,n){let o=new ia(t,e,n);return o.attach(),o.scheduleEffect(),()=>{o.detach()}}function R_(t,e,n){let o=new ia(t,e,n);return{scheduler:o,start:r=>{let i=r?.force??!1;o.attach(),i?o.scheduleEffect():o.maybeScheduleEffect()},stop:()=>{o.detach()}}}function D_(t){return t instanceof k_||t instanceof Rb}function yW(t,e,n){let o=e;try{let l=xu(t);l&&(o=JSON.parse(l))}catch{x_(t)}let r=me(t,o,n),i=Qt(`save ${t} to localStorage`,()=>{Tu(t,JSON.stringify(r.get()))}),s=l=>{if(l.key===t){if(l.newValue===null){r.set(e);return}try{let c=JSON.parse(l.newValue);r.set(c)}catch{}}};return window.addEventListener("storage",s),[r,()=>
Line truncated
|
||||
`).map((i,s)=>s===0?i:` ${i}`).join(`
|
||||
`);super(o?`At ${o}: ${r}`:r),this.rawMessage=e,this.path=n}rawMessage;path;name="ValidationError"};function $a(t,e){throw e instanceof Pe?new Pe(e.rawMessage,[t,...e.path]):new Pe(e.toString(),[t])}function _i(t){if(t===null)return"null";if(Array.isArray(t))return"an array";let e=typeof t;switch(e){case"bigint":case"boolean":case"function":case"number":case"string":case"symbol":return`a ${e}`;case"object":return`an ${e}`;case"undefined":return"undefined";default:ze(e)}}var dn=class t{constructor(e,n,o=!1){this.validationFn=e,this.validateUsingKnownGoodVersionFn=n,this.skipSameValueCheck=o}validationFn;validateUsingKnownGoodVersionFn;skipSameValueCheck;validate(e){let n=this.validationFn(e);if(H0e&&!this.skipSameValueCheck&&!Object.is(e,n))throw new Pe("Validator functions must return the same value they were passed");return n}validateUsingKnownGoodVersion(e,n){return Object.is(e,n)?e:this.validateUsingKnownGoodVersionFn?this.validateUsingKnownGoodVersionFn(e,n):this.validate(n)}isValid(e){try{return this.validate(e),!0}catch{return!1}}nullable(){return aY(this)}optional(){return sY(this)}refine(e){return new t(n=>e(this.validate(n)),(n,o)=>{let r=this.validateUsingKnownGoodVersion(n,o);return Object.is(n,r)?n:e(r)},!0)}check(e,n){return typeof e=="string"?this.refine(o=>{try{n(o)}catch(r){$a(`(check ${e})`,r)}return o}):this.refine(o=>(e(o),o))}},Hb=class extends dn{constructor(e){super(n=>{let o=l3.validate(n);for(let r=0;r<o.length;r++)try{e.validate(o[r])}catch(i){$a(r,i)}return o},(n,o)=>{if(Object.is(n,o))return n;if(!e.validateUsingKnownGoodVersion)return this.validate(o);let r=l3.validate(o),i=n.length!==r.length;for(let s=0;s<r.length;s++){let a=r[s];if(s>=n.length){i=!0;try{e.validate(a)}catch(l){$a(s,l)}continue}if(!Object.is(n[s],a))try{let l=e.validateUsingKnownGoodVersion(n[s],a);Object.is(l,n[s])||(i=!0)}catch(l){$a(s,l)}}return i?o:n}),this.itemValidator=e}itemValidator;nonEmpty(){return this.check(e=>{if(e.length===0)throw new Pe("Expected a non-empty array")})}lengthGreaterThan1(){return this.check(e=>{if(e.length<=1)throw new Pe("Expected an array with length greater than 1")})}},Vb=class t extends dn{constructor(e,n=!1){let o=[],r=[];for(let[i,s]of Object.entries(e))o.push(i),r.push(s);super(i=>{if(typeof i!="object"||i===null)throw new Pe(`Expected object, got ${_i(i)}`);for(let s=0;s<o.length;s++){let a=o[s];try{r[s].validate(Gt(i,a))}catch(l){$a(a,l)}}if(!n){for(let s in i)if(xn(i,s)&&!xn(e,s))throw new Pe("Unexpected property",[s])}return i},(i,s)=>{if(Object.is(i,s))return i;if(typeof s!="object"||s===null)throw new Pe(`Expected object, got ${_i(s)}`);let a=!1;for(let l=0;l<o.length;l++){let c=o[l],d=Gt(i,c),u=Gt(s,c);if(!Object.is(d,u))try{let p=r[l],f=p.validateUsingKnownGoodVersion?p.validateUsingKnownGoodVersion(d,u):p.validate(u);Object.is(f,d)||(a=!0)}catch(p){$a(c,p)}}if(n){if(!a){for(let l of Object.keys(s))if(!xn(e,l)&&!Object.is(Gt(i,l),Gt(s,l))){a=!0;break}}}else for(let l in s)if(xn(s,l)&&!xn(e,l))throw new Pe("Unexpected property",[l]);if(!a){for(let l in i)if(xn(i,l)&&!xn(s,l)){a=!0;break}}return a?s:i}),this.config=e,this.shouldAllowUnknownProperties=n}config;shouldAllowUnknownProperties;allowUnknownProperties(){return new t(this.config,!0)}extend(e){return new t({...this.config,...e})}},Kg=class t extends dn{constructor(e,n,o,r){super(i=>{this.expectObject(i);let s=this.getMatchingSchema(i);if(s===void 0)return this.unknownValueValidation(i,this.getVariant(i));try{return s.validate(i)}catch(a){$a(`(${e} = ${this.getVariant(i)})`,a)}},(i,s)=>{this.expectObject(s),this.expectObject(i);let a=this.getMatchingSchema(s);if(a===void 0)return this.unknownValueValidation(s,this.getVariant(s));try{return Gt(i,e)!==Gt(s,e)?a.validate(s):a.validateUsingKnownGoodVersion?a.validateUsingKnownGoodVersion(i,s):a.validate(s)}catch(l){$a(`(${e} = ${this.getVariant(s)})`,l)}}),this.key=e,this.config=n,this.unknownValueValidation=o,this.useNumberKeys=r}key;config;unknownValueValidation;useNumberKeys;expectObject(e){if(typeof e!="object"||e===null)throw new Pe(`Expected an object, got ${_i(e)}`,[])}getVariant(e){return Gt(e,this.key)}getMatchingSchema(e){let n=Gt(e,this.key);if(!this.useNumberKeys&&typeof n!="string")throw new Pe(`Expected a string for key "${this.key}", got ${_i(n)}`);if(this.useNumberKeys){let o=Number(n);if(o-o!==0)throw new Pe(`Expected a number for key "${this.key}", got "${n}"`)}return xn(this.config,n)?this.config[n]:void 0}validateUnknownVariants(e){return new t(this.key,this.config,e,this.useNumberKeys)}},jb=class extends dn{constructor(e,n){super(o=>{if(typeof o!="object"||o===null)throw new Pe(`Expected object, got ${_i(o)}`);for(let r in o)if(xn(o,r))try{e.validate(r),n.validate(o[r])}catch(i){$a(r,i)}return o},(o,r)=>{if(typeof r!="object"||r===null)throw new Pe(`Expected object, got ${_i(r)}`);let i=r,s=!1,a=0;for(let l in i){if(!xn(i,l))continue;a++;let c=i[l];if(!xn(o,l)){s=!0;try{e.validate(l),n.validate(c)}catch(u){$a(l,u)}continue}let d=o[l];if(!Object
Line truncated
|
||||
`).map(o=>o?{type:"paragraph",content:[{type:"text",text:o}]}:{type:"paragraph"})}}var nn=class t{constructor(e,n,o){this.id=e,this.defaultValue=n,this.type=o}id;defaultValue;type;static define(e,n){let{defaultValue:o,type:r=A.any}=n;return new t(e,o,r)}static defineEnum(e,n){let{defaultValue:o,values:r}=n;return new OC(e,o,r)}setDefaultValue(e){this.defaultValue=e}validate(e){return this.type.validate(e)}validateUsingKnownGoodVersion(e,n){return this.type.validateUsingKnownGoodVersion?this.type.validateUsingKnownGoodVersion(e,n):this.validate(n)}},OC=class extends nn{constructor(e,n,o){super(e,n,A.literalEnum(...o)),this.values=[...o]}values;addValues(...e){for(let n of e)this.values.includes(n)||this.values.push(n);this.type=A.literalEnum(...this.values)}removeValues(...e){for(let n of e)this.values.includes(n)&&this.values.splice(this.values.indexOf(n),1);this.type=A.literalEnum(...this.values)}};var G_=tn("com.tldraw.shape",{AddIsLocked:1,HoistOpacity:2,AddMeta:3,AddWhite:4}),W_=Tn({sequenceId:"com.tldraw.shape",recordType:"shape",sequence:[{id:G_.AddIsLocked,up:t=>{t.isLocked=!1},down:t=>{delete t.isLocked}},{id:G_.HoistOpacity,up:t=>{t.opacity=Number(t.props.opacity??"1"),delete t.props.opacity},down:t=>{let e=t.opacity;delete t.opacity,t.props.opacity=e<.175?"0.1":e<.375?"0.25":e<.625?"0.5":e<.875?"0.75":"1"}},{id:G_.AddMeta,up:t=>{t.meta={}}},{id:G_.AddWhite,up:t=>{},down:t=>{t.props.color==="white"&&(t.props.color="black")}}]});function ca(t){return t?t.typeName==="shape":!1}function _n(t){return t?t.startsWith("shape:"):!1}function Ue(t){return`shape:${t??De()}`}function Xb(t){let e=new Map;for(let[n,o]of Object.entries(t))if(o instanceof nn){if(e.has(o))throw new Error(`Duplicate style prop ${o.id}. Each style prop can only be used once within a shape.`);e.set(o,n)}return e}function ua(t){return t}function oo(t,e){return lr(e,(n,o)=>`com.tldraw.shape.${t}/${o}`)}function fY(t){return jn("shape",{scope:"document",validator:A.model("shape",A.union("type",lr(t,(e,{props:n,meta:o})=>j_(e,n,o))))}).withDefaultProperties(()=>({x:0,y:0,rotation:0,isLocked:!1,opacity:1,meta:{}}))}function Y_(t,e){let n=[];for(let[o,{migrations:r}]of Object.entries(e)){let i=`com.tldraw.${t}.${o}`;r?"sequenceId"in r?(ne(i===r.sequenceId,`sequenceId mismatch for ${o} ${Vg} migrations. Expected '${i}', got '${r.sequenceId}'`),n.push(r)):"sequence"in r?n.push(aa({sequenceId:i,retroactive:!0,sequence:r.sequence.map(s=>"id"in s?g3(t,o,s):s)})):n.push(aa({sequenceId:i,retroactive:!0,sequence:Object.keys(r.migrators).map(s=>Number(s)).sort((s,a)=>s-a).map(s=>({id:`${i}/${s}`,scope:"record",filter:a=>a.typeName===t&&a.type===o,up:a=>{let l=r.migrators[s].up(a);if(l)return l},down:a=>{let l=r.migrators[s].down(a);if(l)return l}}))})):n.push(aa({sequenceId:i,retroactive:!0,sequence:[]}))}return n}function g3(t,e,n){return{id:n.id,dependsOn:n.dependsOn,scope:"record",filter:o=>o.typeName===t&&o.type===e,up:o=>{let r=n.up(o.props);r&&(o.props=r)},down:typeof n.down=="function"?o=>{let r=n.down(o.props);r&&(o.props=r)}:void 0}}var hY=["black","grey","light-violet","violet","blue","light-blue","yellow","orange","green","light-green","light-red","red","white"],Lt=nn.defineEnum("tldraw:color",{defaultValue:"black",values:hY}),ch=nn.defineEnum("tldraw:labelColor",{defaultValue:"black",values:hY});function Zb(t){let e=new Set;for(let o of Object.values(t))for(let r of[o.colors.light,o.colors.dark])for(let[i,s]of Object.entries(r))typeof s=="object"&&s!==null&&e.add(i);e.size>0&&(Lt.addValues(...e),ch.addValues(...e));let n=Lt.values.filter(o=>!e.has(o));n.length>0&&(Lt.removeValues(...n),ch.removeValues(...n))}var Es=nn.defineEnum("tldraw:dash",{defaultValue:"draw",values:["draw","solid","dashed","dotted","none"]});var Ai=nn.defineEnum("tldraw:fill",{defaultValue:"none",values:["none","semi","solid","pattern","fill","lined-fill"]});var Zo=nn.defineEnum("tldraw:font",{defaultValue:"draw",values:["draw","sans","serif","mono"]}),qg={draw:"'tldraw_draw', sans-serif",sans:"'tldraw_sans', sans-serif",serif:"'tldraw_serif', serif",mono:"'tldraw_mono', monospace"};function Jb(t){return typeof t=="object"&&t!==null&&"fontFamily"in t}function Qb(t){let e=new Set;for(let r of Object.values(t))if(r.fonts)for(let[i,s]of Object.entries(r.fonts))Jb(s)&&e.add(i);let n=[...e].filter(r=>!Zo.values.includes(r));n.length>0&&Zo.addValues(...n);let o=Zo.values.filter(r=>!e.has(r));o.length>0&&Zo.removeValues(...o)}var ur=nn.defineEnum("tldraw:size",{defaultValue:"m",values:["s","m","l","xl"]});var yxe=["arc","elbow"],uh=nn.defineEnum("tldraw:arrowKind",{defaultValue:"arc",values:yxe}),mY=["arrow","triangle","square","dot","pipe","diamond","inverted","bar","none"],ev=nn.defineEnum("tldraw:arrowheadStart",{defaultValue:"none",values:mY}),tv=nn.defineEnum("tldraw:arrowheadEnd",{defaultValue:"arrow",values:mY}),nv={kind:uh,labelColor:ch,color:Lt,fill:Ai,dash:Es,size:ur,arrowheadStart:ev,arrowheadEnd:tv,font:Zo,start:vl,end:vl,bend:A.number,richText:xl,labelPositio
Line truncated
|
||||
|
||||
\`\`\`js
|
||||
${p??u}
|
||||
\`\`\`
|
||||
|
||||
My browser: ${navigator.userAgent}`),(0,At.jsxs)("div",{ref:o,className:(0,Z3.default)("tl-container tl-error-boundary",h===null?"":h?"tl-theme__dark":"tl-theme__light"),children:[(0,At.jsx)("div",{className:"tl-error-boundary__overlay"}),n&&(0,At.jsx)(yp,{onError:gl,fallback:()=>null,children:(0,At.jsx)(fh,{editor:n,children:(0,At.jsx)("div",{className:"tl-overlay tl-error-boundary__canvas",children:d?(0,At.jsx)(d,{}):null})})}),(0,At.jsx)("div",{className:(0,Z3.default)("tl-modal","tl-error-boundary__content",{"tl-error-boundary__content__expanded":r&&!l}),children:l?(0,At.jsxs)(At.Fragment,{children:[(0,At.jsx)("h2",{children:"Are you sure?"}),(0,At.jsx)("p",{children:"Resetting your data will delete your drawing and cannot be undone."}),(0,At.jsxs)("div",{className:"tl-error-boundary__content__actions",children:[(0,At.jsx)("button",{className:"tlui-button",onClick:()=>c(!1),children:"Cancel"}),(0,At.jsx)("button",{className:"tlui-button tl-error-boundary__reset",onClick:S,children:"Reset data"})]})]}):(0,At.jsxs)(At.Fragment,{children:[(0,At.jsx)("h2",{children:"Something went wrong"}),(0,At.jsx)("p",{children:"Please refresh your browser."}),(0,At.jsx)("p",{children:"If the issue continues after refreshing, you may need to reset the tldraw data stored on your device."}),(0,At.jsxs)("p",{children:[(0,At.jsx)("strong",{children:"Note:"})," Resetting will erase your current project and any unsaved work."]}),!1,r&&(0,At.jsxs)(At.Fragment,{children:["Message:",(0,At.jsx)("h4",{children:(0,At.jsx)("code",{children:u})}),"Stack trace:",(0,At.jsxs)("div",{className:"tl-error-boundary__content__error",children:[(0,At.jsx)("pre",{children:(0,At.jsx)("code",{children:p??u})}),(0,At.jsx)("button",{className:"tlui-button",onClick:y,children:s?"Copied!":"Copy"})]})]}),(0,At.jsxs)("div",{className:"tl-error-boundary__content__actions",children:[(0,At.jsx)("button",{className:"tlui-button",onClick:()=>i(!r),children:r?"Hide details":"Show details"}),(0,At.jsxs)("div",{className:"tl-error-boundary__content__actions__group",children:[(0,At.jsx)("button",{className:"tlui-button tl-error-boundary__reset",onClick:()=>c(!0),children:"Reset data"}),(0,At.jsx)("button",{className:"tlui-button tl-error-boundary__refresh",onClick:g,children:"Refresh Page"})]})]})]})})]})};var ay=_(j(),1);function HM({x:t,y:e,z:n,size:o}){let r=ki("grid"),i=O(),{gridSteps:s}=i.options;return(0,ay.jsxs)("svg",{className:"tl-grid",version:"1.1",xmlns:"http://www.w3.org/2000/svg","aria-hidden":"true",children:[(0,ay.jsx)("defs",{children:s.map(({min:a,mid:l,step:c},d)=>{let u=c*o*n,p=.5+t*n,f=.5+e*n,h=p>0?p%u:u+p%u,m=f>0?f%u:u+f%u,y=n<l?Hr(n,[a,l],[0,1]):1;return(0,ay.jsx)("pattern",{id:ph(r,`${c}`),width:u,height:u,patternUnits:"userSpaceOnUse",children:(0,ay.jsx)("circle",{className:"tl-grid-dot",cx:h,cy:m,r:1,opacity:y})},d)})}),s.map(({step:a},l)=>(0,ay.jsx)("rect",{width:"100%",height:"100%",fill:`url(#${r}_${a})`},l))]})}var Jq=_(j(),1),Qq=_(on(),1),eX=_(H(),1),VM=(0,eX.forwardRef)(function({children:e,shape:n,isBackground:o,...r},i){let s="fill"in n.props&&n.props.fill!=="none";return(0,Jq.jsx)("div",{ref:i,"data-shape-type":n.type,"data-shape-is-filled":o?void 0:s,"data-shape-id":n.id,draggable:!1,...r,className:(0,Qq.default)("tl-shape",o&&"tl-shape-background",r.className),children:e})});var Yv=_(j(),1),tX=_(on(),1);function qv(t){return(0,Yv.jsx)("svg",{width:16,height:16,viewBox:"0 0 16 16","aria-hidden":"false",...t,className:(0,tX.default)("tl-spinner",t.className),children:(0,Yv.jsxs)("g",{strokeWidth:2,fill:"none",fillRule:"evenodd",children:[(0,Yv.jsx)("circle",{strokeOpacity:.25,cx:8,cy:8,r:7,stroke:"currentColor"}),(0,Yv.jsx)("path",{strokeLinecap:"round",d:"M15 8c0-4.5-4.5-7-7-7",stroke:"currentColor"})]})})}function jM(){return null}var nX=_(j(),1),oX=_(on(),1);function ks({children:t,className:e="",...n}){return(0,nX.jsx)("div",{...n,className:(0,oX.default)("tl-html-container",e),children:t})}var rX=_(j(),1),iX=_(on(),1);function Ri({children:t,className:e="",...n}){return(0,rX.jsx)("svg",{...n,className:(0,iX.default)("tl-svg-container",e),"aria-hidden":"true",children:t})}var GM=_(H(),1);var sX=_(H(),1);function aX(t,e){let n=(0,sX.useRef)(t);return e(t,n.current)?n.current:(n.current=t,t)}var OTe=(t,e)=>(t??=null,e??=null,t===e?!0:!t||!e?!1:Jd(t,e));function Rs(t){return aX(t,OTe)}var LTe=(t,e)=>(t??=null,e??=null,t===e?!0:!t||!e?!1:Ag(t,e));function Ds(t){return aX(t,LTe)}var lX="TLDRAW_USER_DATA_v3",$M=A.object({id:A.string,name:A.string.nullable().optional(),color:A.string.nullable().optional(),locale:A.string.nullable().optional(),animationSpeed:A.number.nullable().optional(),areKeyboardShortcutsEnabled:A.boolean.nullable().optional(),edgeScrollSpeed:A.number.nullable().optional(),colorScheme:A.literalEnum("light","dark","system").optional(),isSnapMode:A.boolean.nullable().optional(),isWrapMode:A.boolean.nullable().optional(),isDynamicSizeMode:A.boolean.nullable().optional(),isPasteAtCursorMode:A.boolean.nullable().op
Line truncated
|
||||
`),o.after&&(e+=`.${i}::after {${rte(o.after)}}
|
||||
`)}let r=bq(n);for(let[i,s]of Object.entries(o.self))s&&r.setProperty(i,s);r.fontKerning==="auto"&&(r.fontKerning="normal")}return e}async getFontFaceCss(){return await this.fonts.createCss()}dispose(){ate()}};function NPe(t,{defaultStyles:e,parentStyles:n}){return t.computedStyleMap?ste(t.computedStyleMap(),{defaultStyles:e,parentStyles:n}):W4(bM(t),{defaultStyles:e,parentStyles:n})}function ote(t,e){let n=bM(t,e),o=n.getPropertyValue("content");if(!(o===""||o==="none"))return W4(n,{defaultStyles:Gw,parentStyles:Gw})}function ste(t,{defaultStyles:e,parentStyles:n}){let o={},i={currentColor:t.get("color")?.toString()||"",parentStyles:n,defaultStyles:e,getStyle:s=>t.get(s)?.toString()??""};for(let s of t.keys()){if(!V4(s))continue;let a=t.get(s).toString();if(e[s]===a)continue;let l=Gt(G4,s);l&&l(a,s,i)||(o[s]=a)}return o}function W4(t,{defaultStyles:e,parentStyles:n}){let o={},i={currentColor:t.color,parentStyles:n,defaultStyles:e,getStyle:s=>t.getPropertyValue(s)};for(let s in t){if(!V4(s))continue;let a=t.getPropertyValue(s);if(e[s]===a)continue;let l=Gt(G4,s);l&&l(a,s,i)||(o[s]=a)}return o}function rte(t){let e="";for(let[n,o]of Object.entries(t))e+=`${n}: ${o};`;return e}var Ly,Nk={};function FPe(t){if(!Ly||Ly.ownerDocument!==t){ate();let e=t.createElement("iframe");e.style.display="none",t.body.appendChild(e);let n=wn(e.contentDocument,"frame must have a document"),o=n.createElementNS("http://www.w3.org/2000/svg","svg"),r=n.createElementNS("http://www.w3.org/2000/svg","foreignObject");o.appendChild(r),n.body.appendChild(o),Ly={iframe:e,foreignObject:r,document:n,ownerDocument:t}}return Ly}function ate(){Ly&&(Ly.iframe.remove(),Ly=void 0);for(let t in Nk)delete Nk[t]}var ite={defaultStyles:Gw,parentStyles:Gw};function BPe(t,e){let n=Nk[e];if(!n){let{foreignObject:o,document:r}=FPe(t),i=r.createElement(e);o.appendChild(i),n=i.computedStyleMap?ste(i.computedStyleMap(),ite):W4(bM(i),ite),o.removeChild(i),Nk[e]=n}return n}var zPe=1;async function ute(t,e,n={}){let o=nte(t,e,n);if(!o)return;let r=t.getContainer(),i=r.ownerDocument.createElement("div");i.className=$4,i.inert=!0,i.tabIndex=-1,Object.assign(i.style,{position:"absolute",top:"0px",left:"0px",width:o.width+"px",height:o.height+"px",pointerEvents:"none",opacity:0}),r.appendChild(i);let s=(0,cte.createRoot)(i,{identifierPrefix:`export_${zPe++}_`});try{await Promise.resolve(),(0,lte.flushSync)(()=>{s.render(o.jsx)}),await o.exportDelay.resolve();let a=i.firstElementChild;return ne(a instanceof qa(r).SVGSVGElement,"Expected an SVG element"),await UPe(a),{svg:a,width:o.width,height:o.height,trimPadding:o.trimPadding}}finally{setTimeout(()=>{s.unmount(),r.removeChild(i)},0)}}async function UPe(t){let e=[...t.querySelectorAll("foreignObject.tl-export-embed-styles > *")];if(!e.length)return;let n=new Lk(t);try{n.fonts.startFindingDocumentFontFaces(t.ownerDocument),await Promise.all(e.map(i=>U4(i)));for(let i of e)n.readRootElementStyles(i);await n.fetchResources();let o=await n.getFontFaceCss();n.unwrapCustomElements();let r=n.embedStyles();if(o||r){let i=t.ownerDocument.createElementNS("http://www.w3.org/2000/svg","style");i.textContent=`${o}
|
||||
${r}`,t.prepend(i)}}finally{n.dispose()}}var Y4=null;function HPe(){return Y4||(Y4={maxWidth:q4("width"),maxHeight:q4("height"),maxArea:q4("area")}),Y4}var dte=8192,VPe=4096*4096,jPe={area:[16384,14188,11402,11180,10836,8192,4096],height:[8388607,65535,32767,16384,8192,4096],width:[4194303,65535,32767,16384,8192,4096]};function q4(t){let e=pn().createElement("canvas");e.width=1,e.height=1;let n=e.getContext("2d");for(let o of jPe[t]){let r=t==="height"?1:o,i=t==="width"?1:o,s=pn().createElement("canvas");s.width=r,s.height=i,s.getContext("2d").fillRect(r-1,i-1,1,1),n.drawImage(s,r-1,i-1,1,1,0,0,1,1);let l=n.getImageData(0,0,1,1).data[3]!==0;if(s.width=0,s.height=0,l)return e.width=0,e.height=0,t==="area"?o*o:o}throw e.width=0,e.height=0,Error("Failed to determine maximum canvas dimension")}function Ww(t,e){if(t<=dte&&e<=dte&&t*e<=VPe)return[t,e];let{maxWidth:n,maxHeight:o,maxArea:r}=HPe(),i=t/e;if(t>n&&(t=n,e=t/i),e>o&&(e=o,t=e*i),t*e>r){let s=Math.sqrt(r/(t*e));t*=s,e*=s}return[t,e]}async function pte(t,e){return(await X4(t,e))?.blob??null}async function X4(t,e){let{type:n,width:o,height:r,quality:i=1,pixelRatio:s=2,trimPadding:a=0,scale:l=1}=e;if(o<=0||r<=0)return null;let[c,d]=Ww(o*s,r*s);c=Math.floor(c),d=Math.floor(d);let u=c/o,p=await fte(t,c,d);if(!p)return null;let f=a>0?$Pe(p,a*l*u):{canvas:p,width:c,height:d},h=await new Promise(y=>f.canvas.toBlob(g=>{(!g||xt.throwToBlob.get())&&y(null),y(g)},"image/"+n,i));if(!h)return null;let m;return n==="png"?m=vu.setPhysChunk(new DataView(await h.arrayBuffer()),u,{type:"image/"+n}):m=h,{blob:m,width:f.width/u,height:f.height/u}}async function fte(t,e,n){let o=await Nn.blobToDataUrl(new Blob([t],{type:"image/svg+xml"}));return new Promise(r=>{let i=yl();i.crossOrigin="anonymous",i.onload=async()=>{He.isSafari&&await gc(250);let s=pn().createElement("canvas"),a=s.getContext("2d");s.width=e,s.height=n,a.imageSmoothingEnabled=!0,a.imageSmoothingQuality="high",a.drawImage(i,0,0,e,n),r(s)},i.onerror=()=>{r(null)},i.src=o})}function hte(t,e){let n=t.width,o=t.height,r=t.getContext("2d"),i=Math.ceil(e);if(i<=0||i*2>=n||i*2>=o)return null;let a=r.getImageData(0,0,n,o).data,l=a[0],c=a[1],d=a[2],u=a[3],p=u===0;function f(T){if(p)return a[T+3]>0;{if(a[T+3]!==u)return!0;let P=a[T],M=a[T+1],E=a[T+2];return P!==l||M!==c||E!==d}}let h=i,m=i,y=n-i,g=o-i,S=m;for(let T=0;T<m;T++){let C=!1;for(let P=0;P<n;P++)if(f((T*n+P)*4)){C=!0;break}if(C){S=T;break}}let b=g;for(let T=o-1;T>=g;T--){let C=!1;for(let P=0;P<n;P++)if(f((T*n+P)*4)){C=!0;break}if(C){b=T+1;break}}let v=h;for(let T=0;T<h;T++){let C=!1;for(let P=S;P<b;P++)if(f((P*n+T)*4)){C=!0;break}if(C){v=T;break}}let w=y;for(let T=n-1;T>=y;T--){let C=!1;for(let P=S;P<b;P++)if(f((P*n+T)*4)){C=!0;break}if(C){w=T+1;break}}return v===0&&S===0&&w===n&&b===o?null:{cropLeft:v,cropTop:S,cropRight:w,cropBottom:b}}function $Pe(t,e){let n=t.width,o=t.height,r=hte(t,e);if(!r)return{canvas:t,width:n,height:o};let{cropLeft:i,cropTop:s,cropRight:a,cropBottom:l}=r,c=a-i,d=l-s,u=pn().createElement("canvas");return u.width=c,u.height=d,u.getContext("2d").drawImage(t,i,s,c,d,0,0,c,d),{canvas:u,width:c,height:d}}async function mte(t,e){let{width:n,height:o,trimPadding:r,scale:i}=e;if(r<=0)return null;let s=Math.floor(n),a=Math.floor(o);if(s<=0||a<=0)return null;let l=await fte(t,s,a);if(!l)return null;let c=r*i,d=hte(l,c);if(!d)return null;let{cropLeft:u,cropTop:p,cropRight:f,cropBottom:h}=d,g=new DOMParser().parseFromString(t,"image/svg+xml").documentElement,S=g.getAttribute("viewBox");if(!S)return null;let[b,v,w,T]=S.split(/\s+/).map(Number),C=b+u/s*w,P=v+p/a*T,M=(f-u)/s*w,E=(h-p)/a*T,I=M*i,R=E*i;return g.setAttribute("viewBox",`${C} ${P} ${M} ${E}`),g.setAttribute("width",String(I)),g.setAttribute("height",String(R)),{svg:new XMLSerializer().serializeToString(g),width:I,height:R}}var Ko={menus:me("open menus",[]),getOpenMenus(t){return t?this.menus.get().filter(e=>e.endsWith("-"+t)):this.menus.get()},addOpenMenu(t,e=""){let n=e?`${t}-${e}`:t,o=new Set(this.menus.get());o.has(n)||(o.add(n),this.menus.set([...o]))},deleteOpenMenu(t,e=""){let n=e?`${t}-${e}`:t,o=new Set(this.menus.get());o.has(n)&&(o.delete(n),this.menus.set([...o]))},clearOpenMenus(t){this.menus.set(t?this.menus.get().filter(e=>!e.endsWith("-"+t)):[])},_hiddenMenus:[],hideOpenMenus(t){if(this._hiddenMenus=[...this.getOpenMenus(t)],this._hiddenMenus.length!==0)for(let e of this._hiddenMenus)this.deleteOpenMenu(e,t)},showOpenMenus(t){if(this._hiddenMenus.length!==0){for(let e of this._hiddenMenus)this.addOpenMenu(e,t);this._hiddenMenus=[]}},isMenuOpen(t,e){return this.getOpenMenus(e).includes(`${t}-${e}`)},hasOpenMenus(t){return this.getOpenMenus(t).length>0},hasAnyOpenMenus(){return this.getOpenMenus().length>0},forContext(t){return{getOpenMenus:()=>this.getOpenMenus(t),addOpenMenu:e=>this.addOpenMenu(e,t),deleteOpenMenu:e=>this.deleteOpenMenu(e,t),clearOpenMenus:()=>this.clearOpenMenus(t),isMenuOpen:e=>this.isMenuOpen(e,t),hasOpenMenus:()=>this.hasOpenMenus(t),hasAnyOpenMenus:()=>t
Line truncated
|
||||
`)}},eH={style:"normal",weight:"normal",stretch:"normal",unicodeRange:"U+0-10FFFF",featureSettings:"normal",ascentOverride:"normal",descentOverride:"normal",lineGapOverride:"normal"},Mte=new WeakMap;function sEe(t){return JSON.stringify([t.family,...Cr(eH).map(([e,n])=>t[e]??n)])}var Gu={Recording:"recording",RecordingPreserveRedoStack:"recordingPreserveRedoStack",Paused:"paused"},Qw=class{store;dispose;state=Gu.Recording;pendingDiff=new nH;stacks=me("HistoryManager.stacks",{undos:dP(),redos:dP()},{isEqual:(e,n)=>e.undos===n.undos&&e.redos===n.redos});annotateError;constructor(e){this.store=e.store,this.annotateError=e.annotateError??gl,this.dispose=this.store.addHistoryInterceptor((n,o)=>{if(o==="user")switch(this.state){case Gu.Recording:this.pendingDiff.apply(n.changes),this.stacks.get().redos.length>0&&this.stacks.update(({undos:r})=>({undos:r,redos:dP()}));break;case Gu.RecordingPreserveRedoStack:this.pendingDiff.apply(n.changes);break;case Gu.Paused:break;default:ze(this.state)}})}flushPendingDiff(){if(this.pendingDiff.isEmpty())return;let e=this.pendingDiff.clear();this.stacks.update(({undos:n,redos:o})=>({undos:n.push({type:"diff",diff:e}),redos:o}))}getNumUndos(){return this.stacks.get().undos.length+(this.pendingDiff.isEmpty()?0:1)}getNumRedos(){return this.stacks.get().redos.length}_isReplaying=!1;isReplaying(){return this._isReplaying}_isInBatch=!1;batch(e,n){let o=this.state;o!==Gu.Paused&&n?.history&&(this.state=aEe[n.history]);try{if(this._isInBatch)return Ro(e),this;this._isInBatch=!0;try{Ro(e)}catch(r){throw this.annotateError(r),r}finally{this._isInBatch=!1}return this}finally{this.state=o}}_undo({pushToRedoStack:e,toMark:n=void 0}){let o=this.state,r=this._isReplaying;this.state=Gu.Paused,this._isReplaying=!0;try{let{undos:i,redos:s}=this.stacks.get(),a=this.pendingDiff.clear(),l=Fb(a),c=Ug(a);e&&!l&&(s=s.push({type:"diff",diff:a}));let d=!1;if(l)for(;i.head?.type==="stop";){let u=i.head;if(i=i.tail,e&&(s=s.push(u)),u.id===n){d=!0;break}}if(!d)e:for(;i.head;){let u=i.head;switch(i=i.tail,e&&(s=s.push(u)),u.type){case"diff":lh(c,[Ug(u.diff)]);break;case"stop":if(!n)break e;if(u.id===n){d=!0;break e}break;default:ze(u)}}if(!d&&n)return this.pendingDiff.restore(a),this;this.store.applyDiff(c,{ignoreEphemeralKeys:!0}),this.store.ensureStoreIsUsable(),this.stacks.set({undos:i,redos:s})}finally{this._isReplaying=r,this.state=o}return this}undo(){return this._undo({pushToRedoStack:!0}),this}redo(){let e=this.state,n=this._isReplaying;this.state=Gu.Paused,this._isReplaying=!0;try{this.flushPendingDiff();let{undos:o,redos:r}=this.stacks.get();if(r.length===0)return this;for(;r.head?.type==="stop";)o=o.push(r.head),r=r.tail;let i=zg();for(;r.head;){let s=r.head;if(o=o.push(s),r=r.tail,s.type==="diff")lh(i,[s.diff]);else break}this.store.applyDiff(i,{ignoreEphemeralKeys:!0}),this.store.ensureStoreIsUsable(),this.stacks.set({undos:o,redos:r})}finally{this._isReplaying=n,this.state=e}return this}bail(){return this._undo({pushToRedoStack:!1}),this}bailToMark(e){return e&&this._undo({pushToRedoStack:!1,toMark:e}),this}squashToMark(e){let n=this.stacks.get().undos,o=[];for(;n.head&&!(n.head.type==="stop"&&n.head.id===e);)n.head.type==="diff"&&o.push(n.head.diff),n=n.tail;if(!n.head||n.head?.id!==e)return console.error("Could not find mark to squash to: ",e),this;if(o.length===0)return this;let r=zg();return lh(r,o.reverse()),this.stacks.update(({redos:i})=>({undos:n.push({type:"diff",diff:r}),redos:i})),this}_mark(e){Ro(()=>{this.flushPendingDiff(),this.stacks.update(({undos:n,redos:o})=>({undos:n.push({type:"stop",id:e}),redos:o}))})}clear(){this.stacks.set({undos:dP(),redos:dP()}),this.pendingDiff.clear()}getMarkIdMatching(e){let n=this.stacks.get().undos;for(;n.head;){if(n.head.type==="stop"&&n.head.id.includes(e))return n.head.id;n=n.tail}return null}debug(){let{undos:e,redos:n}=this.stacks.get();return{undos:Ate(e),redos:Ate(n),pendingDiff:this.pendingDiff.debug(),state:this.state}}},aEe={record:Gu.Recording,"record-preserveRedoStack":Gu.RecordingPreserveRedoStack,ignore:Gu.Paused},nH=class{diff=zg();isEmptyAtom=me("PendingDiff.isEmpty",!0);clear(){let e=this.diff;return this.diff=zg(),this.isEmptyAtom.set(!0),e}restore(e){this.diff=e,this.isEmptyAtom.set(Fb(e))}isEmpty(){return this.isEmptyAtom.get()}apply(e){lh(this.diff,[e]),tH(e.added)||tH(e.removed)?this.isEmptyAtom.set(Fb(this.diff)):this.isEmptyAtom.__unsafe__getWithoutCapture()&&this.isEmptyAtom.set(!tH(e.updated))}debug(){return{diff:this.diff,isEmpty:this.isEmpty()}}};function tH(t){for(let e in t)return!0;return!1}function dP(){return lEe}var oH=class{length=0;head=null;tail=this;push(e){return new rH(e,this)}},lEe=new oH,rH=class t{constructor(e,n){this.head=e,this.tail=n,this.length=n.length+1}head;tail;length;push(e){return new t(e,this)}};function Ate(t){if(!t.length)return at;let e=[];for(;t.length;)e.push(t.head),t=t.tail;return e}var cEe=Object.defineProperty,uEe=Object.getOwnPropertyDescriptor,kte=(t,e,n,o)=>{for(var r=o>1?void 0
Line truncated
|
||||
`).split(`
|
||||
`).map(e=>e||" ").join(`
|
||||
`)}var IEe={start:"left","start-legacy":"left",middle:"center","middle-legacy":"center",end:"right","end-legacy":"right"},_Ee=/\s/,Kk=Object.freeze({"overflow-wrap":"break-word","word-break":"auto",width:null,height:null,"max-width":null,"min-width":null}),n0=class{constructor(e){this.editor=e,this.elm=this.createMeasurementEl(),this.editor.getContainer().appendChild(this.elm)}editor;elm;poolElms=[];createMeasurementEl(){let e=this.editor.getContainerDocument().createElement("div");e.classList.add("tl-text"),e.classList.add("tl-text-measure"),e.setAttribute("dir","auto"),e.tabIndex=-1;for(let n of nh(Kk))e.style.setProperty(n,Kk[n]);return e}resetElementStyles(e,n){for(let o of n)o in Kk?e.style.setProperty(o,Kk[o]):e.style.removeProperty(o)}setElementStyles(e,n){let o=[];for(let[r,i]of Object.entries(n)){let s=e.style.getPropertyValue(r);if(typeof i=="string"){if(s===i)continue;o.push([r,s||null]),e.style.setProperty(r,i)}else{if(!s)continue;o.push([r,s]),e.style.removeProperty(r)}}return()=>{for(let[r,i]of o)i===null||i===""?e.style.removeProperty(r):e.style.setProperty(r,i)}}getMeasureStyles(e){return{"font-family":e.fontFamily,"font-style":e.fontStyle,"font-weight":e.fontWeight,"font-size":e.fontSize+"px","line-height":`${Ns(e.fontSize,e.lineHeight)}px`,padding:e.padding,"max-width":e.maxWidth?e.maxWidth+"px":void 0,"min-width":e.minWidth?e.minWidth+"px":void 0,"overflow-wrap":e.disableOverflowWrapBreaking?"normal":"break-word",...e.otherStyles}}dispose(){this.elm.remove();for(let{el:e}of this.poolElms)e.remove();this.poolElms.length=0}ensurePoolSize(e){if(this.poolElms.length>=e)return;let n=this.editor.getContainerDocument().createDocumentFragment();for(;this.poolElms.length<e;){let o=this.createMeasurementEl();this.poolElms.push({el:o,html:"",appliedStyleKeys:[]}),n.appendChild(o)}this.editor.getContainer().appendChild(n)}getPoolItem(e){return this.ensurePoolSize(e+1),this.poolElms[e]}measureHtmlBatch(e){if(e.length===0)return[];for(;this.poolElms.length>e.length;){let{el:o}=this.poolElms.pop();o.remove()}for(let o=0;o<e.length;o++){let{html:r,opts:i}=e[o],s=this.getPoolItem(o),{el:a}=s;this.resetElementStyles(a,s.appliedStyleKeys);let l=this.getMeasureStyles(i);this.setElementStyles(a,l),s.appliedStyleKeys=Object.keys(l),s.html!==r&&(a.innerHTML=r,s.html=r)}let n=[];for(let o=0;o<e.length;o++){let r=this.getPoolItem(o).el,i=e[o].opts.measureScrollWidth?r.scrollWidth:0,s=r.getBoundingClientRect();n.push({x:0,y:0,w:s.width,h:s.height,scrollWidth:i})}return n}measureText(e,n){let o=this.editor.getContainerDocument().createElement("div");return o.textContent=Fte(e),this.measureHtml(o.innerHTML,n)}measureHtml(e,n){let{elm:o}=this,r=this.setElementStyles(o,this.getMeasureStyles(n));try{o.innerHTML=e;let i=n.measureScrollWidth?o.scrollWidth:0,s=o.getBoundingClientRect();return{x:0,y:0,w:s.width,h:s.height,scrollWidth:i}}finally{r()}}measureElementTextNodeSpans(e,{shouldTruncateToFirstLine:n=!1}={}){let o=[],r=e.getBoundingClientRect(),i=-r.left,s=-r.top,a=new Range,l=e.childNodes[0],c=0,d=null,u=null,p=0,f=0,h=!1;for(let m of e.childNodes)if(m.nodeType===Node.TEXT_NODE)for(let y of m.textContent??""){a.setStart(l,c),a.setEnd(l,c+y.length);let g=a.getClientRects(),S=g[g.length-1],b=S.top+s,v=S.left+i,w=S.right+i,T=v<f,C=_Ee.test(y);if(C!==u||b!==p||!d){if(d){if(n&&b!==p){h=!0;break}o.push(d)}d={box:{x:v,y:b,w:S.width,h:S.height},text:y},f=v}else T&&(d.box.x=v),d.box.w=T?d.box.w+S.width:w-d.box.x,d.text+=y;y===`
|
||||
`&&(f=0),u=C,p=b,c+=y.length}return d&&o.push(d),{spans:o,didTruncate:h}}measureTextSpans(e,n){if(e==="")return[];let{elm:o}=this,r=n.overflow==="truncate-ellipsis"||n.overflow==="truncate-clip",i=Math.ceil(n.width-n.padding*2),s={"font-family":n.fontFamily,"font-style":n.fontStyle,"font-weight":n.fontWeight,"font-size":n.fontSize+"px","line-height":`${Ns(n.fontSize,n.lineHeight)}px`,width:`${i}px`,height:"min-content","text-align":IEe[n.textAlign],"overflow-wrap":r?"anywhere":"break-word","word-break":r?"break-all":"normal",...n.otherStyles},a=this.setElementStyles(o,s);try{let l=Fte(e);o.textContent=l;let{spans:c,didTruncate:d}=this.measureElementTextNodeSpans(o,{shouldTruncateToFirstLine:r});if(n.overflow==="truncate-ellipsis"&&d){o.textContent="\u2026";let u=Math.ceil(this.measureElementTextNodeSpans(o).spans[0].box.w);o.style.setProperty("width",`${i-u}px`),o.textContent=l;let p=this.measureElementTextNodeSpans(o,{shouldTruncateToFirstLine:!0}).spans,f=p[p.length-1];return p.push({text:"\u2026",box:{x:Math.min(f.box.x+f.box.w,n.width-n.padding-u),y:f.box.y,w:u,h:f.box.h}}),p}return c}finally{a()}}};var MEe=Object.defineProperty,AEe=Object.getOwnPropertyDescriptor,kEe=(t,e,n,o)=>{for(var r=o>1?void 0:o?AEe(e,n):e,i=t.length-1,s;i>=0;i--)(s=t[i])&&(r=(o?s(e,n,r):s(r))||r);return o&&r&&MEe(e,n,r),r};function Jh(t){return{default:Dk,...t}}var Fy=class{constructor(e,n){this.editor=e,this._themes=me("ThemeManager._definitions",n.themes),this._currentThemeId=me("ThemeManager._currentThemeName",n.initial)}editor;_themes;_currentThemeId;getColorMode(){return this.editor.user.getIsDarkMode()?"dark":"light"}getThemes(){return this._themes.get()}getTheme(e){return this._themes.get()[e]}getCurrentThemeId(){return this._currentThemeId.get()}getCurrentTheme(){return this._themes.get()[this.getCurrentThemeId()]}setCurrentTheme(e){this._currentThemeId.set(e)}updateThemes(e){this._themes.update(n=>{let o=typeof e=="function"?e(st(n)):e;return this._currentThemeId.get()in o||this._currentThemeId.set("default"),o})}updateTheme(e){this._themes.update(n=>({...n,[e.id]:e}))}dispose(){}};kEe([$],Fy.prototype,"getColorMode",1);var REe=Object.defineProperty,DEe=Object.getOwnPropertyDescriptor,zte=(t,e,n,o)=>{for(var r=o>1?void 0:o?DEe(e,n):e,i=t.length-1,s;i>=0;i--)(s=t[i])&&(r=(o?s(e,n,r):s(r))||r);return o&&r&&REe(e,n,r),r},Bte=(typeof process<"u",sh),Qh=class{constructor(e){this.editor=e,this.editor.disposables.add(this.dispose),this.start()}editor;cancelRaf;isPaused=!0;now=0;start(){this.isPaused=!1,this.cancelRaf?.(),this.cancelRaf=Bte(this.tick),this.now=Date.now()}tick(){if(this.isPaused)return;let e=Date.now(),n=e-this.now;this.now=e,this.editor.emit("frame",n),this.editor.emit("tick",n),this.cancelRaf=Bte(this.tick)}dispose(){this.isPaused=!0,this.cancelRaf?.()}};zte([Kt],Qh.prototype,"tick",1);zte([Kt],Qh.prototype,"dispose",1);var OEe=Object.defineProperty,LEe=Object.getOwnPropertyDescriptor,yi=(t,e,n,o)=>{for(var r=o>1?void 0:o?LEe(e,n):e,i=t.length-1,s;i>=0;i--)(s=t[i])&&(r=(o?s(e,n,r):s(r))||r);return o&&r&&OEe(e,n,r),r},No=class{constructor(e,n){if(this.user=e,this.colorScheme=n,typeof window>"u"||!dr().matchMedia)return;let o=dr().matchMedia("(prefers-color-scheme: dark)");o?.matches&&this.systemColorScheme.set("dark");let r=i=>{i.matches?this.systemColorScheme.set("dark"):this.systemColorScheme.set("light")};o?.addEventListener("change",r),this.disposables.add(()=>o?.removeEventListener("change",r))}user;colorScheme;systemColorScheme=me("systemColorScheme","light");disposables=new Set;dispose(){this.disposables.forEach(e=>e())}updateUserPreferences(e){this.user.setUserPreferences({...this.user.userPreferences.get(),...e})}getUserPreferences(){return{id:this.getExternalId(),name:this.getName(),locale:this.getLocale(),color:this.getColor(),animationSpeed:this.getAnimationSpeed(),areKeyboardShortcutsEnabled:this.getAreKeyboardShortcutsEnabled(),isSnapMode:this.getIsSnapMode(),colorScheme:this.user.userPreferences.get().colorScheme,isDarkMode:this.getIsDarkMode(),isWrapMode:this.getIsWrapMode(),isDynamicResizeMode:this.getIsDynamicResizeMode(),enhancedA11yMode:this.getEnhancedA11yMode(),inputMode:this.getInputMode(),isZoomDirectionInverted:this.getIsZoomDirectionInverted()}}getIsDarkMode(){switch(this.user.userPreferences.get().colorScheme??this.colorScheme){case"dark":return!0;case"light":return!1;case"system":return this.systemColorScheme.get()==="dark";default:return!1}}getEdgeScrollSpeed(){return this.user.userPreferences.get().edgeScrollSpeed??Jo.edgeScrollSpeed}getAnimationSpeed(){return this.user.userPreferences.get().animationSpeed??Jo.animationSpeed}getAreKeyboardShortcutsEnabled(){return this.user.userPreferences.get().areKeyboardShortcutsEnabled??Jo.areKeyboardShortcutsEnabled}getExternalId(){return this.user.userPreferences.get().id}getId(){return this.getExternalId()}getRecordId(){return xc(this.getExternalId())}getName(){return this.user.userPreferences.get().name?.trim()??Jo.name}getLocale(){return
Line truncated
|
||||
content is missing a schema.`);let{select:o=!1,preserveIds:r=!1,preservePosition:i=!1}=n,{point:s=void 0}=n,a=this.getCurrentPageId(),{rootShapeIds:l}=e,c=[],d=[],u=[],p=[],f={store:{...Object.fromEntries(e.assets.map(E=>[E.id,E])),...Object.fromEntries(e.shapes.map(E=>[E.id,E])),...Object.fromEntries(e.bindings?.map(E=>[E.id,E])??[]),...Object.fromEntries(e.users?.map(E=>[E.id,E])??[])},schema:e.schema},h=this.store.schema.migrateStoreSnapshot(f);if(h.type==="error")throw Error("Could not put content: could not migrate content");for(let E of Object.values(h.value))switch(E.typeName){case"asset":{c.push(E);break}case"shape":{d.push(E);break}case"binding":{u.push(E);break}case"user":{p.push(E);break}}if(p.length>0){let E=new Set(this.store.allRecords().filter(R=>R.typeName==="user").map(R=>R.id)),I=p.filter(R=>!E.has(R.id));I.length>0&&this.store.put(I)}let m=new Map(r?d.map(E=>[E.id,E.id]):d.map(E=>[E.id,Ue()])),y=new Map(r?u.map(E=>[E.id,E.id]):u.map(E=>[E.id,wl()])),g=a,S=new Map(d.map(E=>[E.id,E])),b=we(l.map(E=>S.get(E)));if(s)b.length>0&&(g=this.getShapeAtPoint(s,{hitInside:!0,hitFrameInside:!0,hitLocked:!0,filter:I=>{let R=this.getShapeUtil(I);return b.every(D=>R.canReceiveNewChildrenOfType?.(I,D.type))}})?.id??a);else if(!i){let E=this.getSelectedShapes(),I=null,R=D=>{let N=this.getShapeUtil(D);return b.every(z=>N.canReceiveNewChildrenOfType?.(D,z.type))};for(let D of E){let N=R(D)?D:this.findShapeAncestor(D,R)??(_n(D.parentId)?this.getShape(D.parentId):null);if(!N){I=null;break}if(!I)I=N;else if(I.id!==N.id){let z=this.getShapeAncestors(I);R(I)&&z.push(I);let F=z.filter(R),k=new Set([N.id,...this.getShapeAncestors(N).map(U=>U.id)]),L=null;for(let U=F.length-1;U>=0;U--)if(k.has(F[U].id)){L=F[U];break}if(I=L,!I)break}}I&&m.has(I.id)&&(I=null),I&&(g=I.id)}let v=this.getHighestIndexForParent(g),w=[],T=d.map(E=>{let I=m.get(E.id),R={...E,id:I};return l.includes(E.id)&&(R.parentId=a,w.push(R)),m.has(R.parentId)?R.parentId=m.get(E.parentId):(l.push(R.id),R.index=v,v=cr(v)),R});if(T.length+this.getCurrentPageShapeIds().size>this.options.maxShapesPerPage)return Yk(this),this;let C=u.map(E=>({...E,id:wn(y.get(E.id)),fromId:wn(m.get(E.fromId)),toId:wn(m.get(E.toId))})),P=[],M=[];for(let E of c)this.store.has(E.id)||((E.type==="image"&&E.props.src?.startsWith("data:image")||E.type==="video"&&E.props.src?.startsWith("data:video"))&&(M.push(st(E)),E.props.src=null),P.push(E));return Promise.allSettled(M.map(async E=>{let I=await Bk(E.props.src,E.props.name,E.props.mimeType??"image/png"),R=await this.getAssetForExternalContent({type:"file",file:I,assetId:E.id});if(!R){this.deleteAssets([E.id]);return}this.updateAssets([{...R,id:E.id}])})),this.run(()=>{P.length>0&&this.createAssets(P),this.createShapes(T),this.createBindings(C),o&&this.select(...w.map(z=>z.id)),g!==a&&this.reparentShapes(w.map(z=>z.id),g);let E=Y.Common(we(w.map(z=>this.getShapePageBounds(z.id))));if(s===void 0)if(Vo(g))if(i)s=E.center;else{let z=this.getViewportPageBounds();s=w.some(k=>{let L=this.getShapePageBounds(k.id);return L&&z.collides(L)})?E.center:z.center}else{let z=this.getShape(g);s=Q.applyToPoint(this.getShapePageTransform(z),this.getShapeGeometry(z).bounds.center)}let I=Y.Common(we(w.map(({id:z})=>this.getShapePageBounds(z)))).center,R=x.Sub(s,I);if((R.x!==0||R.y!==0)&&this.updateShapes(w.map(({id:z})=>{let F=this.getShape(z),k=this.getShapeParentTransform(z).decompose().rotation,L=x.Rot(R,-k);return{id:F.id,type:F.type,x:F.x+L.x,y:F.y+L.y}})),Vo(g)){let z=we(w.map(k=>this.getShape(k.id))),{reparenting:F}=lP(this,z,(k,L)=>{if(m.has(L.id))return!1;let U=this.getShapePageBounds(k),V=this.getShapePageBounds(L);return!U||!V?!1:V.containsPoint(U.center)});F.forEach((k,L)=>{k.length!==0&&this.reparentShapes(k.map(U=>U.id),L)})}let D=new Set(T.map(z=>z.id)),N=w.map(z=>z.id).filter(z=>{let F=this.getShape(z);return!F||Vo(F.parentId)?!1:!this.getSortedChildIdsForParent(z).some(L=>D.has(L))});N.length>0&&dt(this,N)}),this}async getSvgElement(e,n={}){let o=e.length===0?this.getCurrentPageShapeIdsSorted():typeof e[0]=="string"?e:e.map(r=>r.id);if(o.length!==0)return await this.fonts.loadRequiredFontsForCurrentPage(this.options.maxFontsToLoadBeforeRender),ute(this,o,n)}async getSvgString(e,n={}){let o=await this.getSvgElement(e,n);return o?{svg:new XMLSerializer().serializeToString(o.svg),width:o.width,height:o.height,trimPadding:o.trimPadding}:void 0}async toImage(e,n={}){let o={format:"png",scale:1,pixelRatio:n.format==="svg"?void 0:2,...n},r=await this.getSvgString(e,o);if(!r)throw new Error("Could not create SVG");switch(o.format){case"svg":{let i=r.svg,s=r.width,a=r.height;if(r.trimPadding>0){let l=await mte(i,{width:s,height:a,trimPadding:r.trimPadding,scale:o.scale});l&&(i=l.svg,s=l.width,a=l.height)}return{blob:new Blob([i],{type:"image/svg+xml"}),width:s,height:a}}case"jpeg":case"png":case"webp":{let i=await X4(r.svg,{type:o.format,quality:o.quality,pixelRatio:o.pixelRatio,width:r.width,height:r.height,trimPadding:r.trimPadding,scale:o
Line truncated
|
||||
|
||||
Keep seeing this message?
|
||||
\u2022 If you're using tldraw in a private or "incognito" window, try loading tldraw in a regular window or in a different browser.
|
||||
\u2022 If your hard disk is full, try clearing up some space and then reload the page.`)}function nne(){window.alert(`Oops! We could not access your browser's storage\u2014and the app won't work correctly without that. We now need to reload the page and try again.
|
||||
|
||||
Keep seeing this message?
|
||||
\u2022 If you're using tldraw in a private or "incognito" window, try loading tldraw in a regular window or in a different browser.`)}var GEe=350,WEe=1e4,one=Symbol("UPDATE_INSTANCE_STATE"),YEe=t=>t,gH=class{onmessage;constructor(e){}postMessage(e){}close(){}},qEe=typeof BroadcastChannel>"u"?gH:BroadcastChannel,tR=class{constructor(e,{persistenceKey:n,sessionId:o=uy,onLoad:r,onLoadError:i},s=new qEe(`tldraw-tab-sync-${n}`)){this.store=e,this.channel=s,typeof window<"u"&&(window.tlsync=this),this.persistenceKey=n,this.sessionId=o,this.db=new wh(n),this.disposables.add(()=>this.db.close()),this.serializedSchema=this.store.schema.serialize(),this.$sessionStateSnapshot=dy(this.store),this.disposables.add(e.listen(({changes:a})=>{this.diffQueue.push(a),this.channel.postMessage(YEe({type:"diff",storeId:this.store.id,changes:a,schema:this.serializedSchema})),this.schedulePersist()},{source:"user",scope:"document"})),this.disposables.add(e.listen(()=>{this.diffQueue.push(one),this.schedulePersist()},{scope:"session"})),this.connect(r,i),this.documentTypes=new Set(Object.values(this.store.schema.types).filter(a=>a.scope==="document").map(a=>a.typeName))}store;channel;disposables=new Set;diffQueue=[];didDispose=!1;shouldDoFullDBWrite=!0;isReloading=!1;persistenceKey;sessionId;serializedSchema;isDebugging=!1;documentTypes;$sessionStateSnapshot;db;initTime=Date.now();debug(...e){this.isDebugging&&console.debug(...e)}async connect(e,n){this.debug("connecting");let o;try{o=await this.db.load({sessionId:this.sessionId})}catch(r){n(r),nne();return}if(this.debug("loaded data from store",o,"didDispose",this.didDispose),!this.didDispose)try{if(o){let r=Object.fromEntries(o.records.map(l=>[l.id,l])),i=o.sessionStateSnapshot??eA(r),s=this.store.schema.migrateStoreSnapshot({store:r,schema:o.schema??this.store.schema.serializeEarliestVersion()});if(s.type==="error"){console.error("failed to migrate store",s),n(new Error(`Failed to migrate store: ${s.reason}`));return}let a=Object.values(s.value).filter(l=>this.documentTypes.has(l.typeName));a.length>0&&this.store.mergeRemoteChanges(()=>{this.store.put(a,"initialize")}),i&&py(this.store,i,{forceOverwrite:!0})}this.channel.onmessage=({data:r})=>{this.debug("got message",r);let i=r,s=this.store.schema.getMigrationsSince(i.schema);if(s.ok){if(s.value.length>0){this.debug("telling them to reload"),this.channel.postMessage({type:"announce",schema:this.serializedSchema}),this.shouldDoFullDBWrite=!0,this.persistIfNeeded();return}}else{if(Date.now()-this.initTime<5e3){n(new Error("Schema mismatch, please close other tabs and reload the page"));return}this.debug("reloading"),this.isReloading=!0,window?.location?.reload?.();return}i.type==="diff"&&(this.debug("applying diff"),Ro(()=>{this.store.mergeRemoteChanges(()=>{this.store.applyDiff(i.changes)})}))},this.channel.postMessage({type:"announce",schema:this.serializedSchema}),this.disposables.add(()=>{this.channel.close()}),e(this)}catch(r){if(this.debug("error loading data from store",r),this.didDispose)return;n(r);return}}close(){this.debug("closing"),this.didDispose=!0,this.disposables.forEach(e=>e()),typeof window<"u"&&window.tlsync===this&&delete window.tlsync}isPersisting=!1;didLastWriteError=!1;scheduledPersistTimeout=null;schedulePersist(){this.debug("schedulePersist",this.scheduledPersistTimeout),!this.scheduledPersistTimeout&&(this.scheduledPersistTimeout=setTimeout(()=>{this.scheduledPersistTimeout=null,this.persistIfNeeded()},this.didLastWriteError?WEe:GEe))}persistIfNeeded(){this.debug("persistIfNeeded",{isPersisting:this.isPersisting,isReloading:this.isReloading,shouldDoFullDBWrite:this.shouldDoFullDBWrite,diffQueueLength:this.diffQueue.length,storeIsPossiblyCorrupt:this.store.isPossiblyCorrupted()}),this.scheduledPersistTimeout&&(clearTimeout(this.scheduledPersistTimeout),this.scheduledPersistTimeout=null),!this.isPersisting&&(this.isReloading||this.store.isPossiblyCorrupted()||(this.shouldDoFullDBWrite||this.diffQueue.length>0)&&this.doPersist())}async doPersist(){if(ne(!this.isPersisting,"persist already in progress"),this.didDispose)return;this.isPersisting=!0,this.debug("doPersist start");let e=this.diffQueue;this.diffQueue=[];try{if(this.shouldDoFullDBWrite)this.shouldDoFullDBWrite=!1,await this.db.storeSnapshot({schema:this.store.schema,snapshot:this.store.serialize(),sessionId:this.sessionId,sessionStateSnapshot:this.$sessionStateSnapshot.get()});else{let n=Hg(e.filter(o=>o!==one));await this.db.storeChanges({changes:n,schema:this.store.schema,sessionId:this.sessionId,sessionStateSnapshot:this.$sessionStateSnapshot.get()})}this.didLastWriteError=!1}catch(n){this.shouldDoFullDBWrite=!0,this.didLastWriteError=!0,console.error("failed to store changes in indexed db",n),tne(),typeof window<"u"&&window.location.reload()}this.isPersisting=!1,this.debug("doPersist end"),this.schedulePersist()}};var c0=_(H(),1);function u0(t){let e=(0,c0.useRef)(t),[n,o]=(0,c0.useState)(t);n!==e.current&&o(e.current);let r=(0,c0.useCallback)
Line truncated
|
||||
/* ------------------- SEE LICENSE -------------------
|
||||
The tldraw watermark is part of tldraw's license. It is shown for unlicensed
|
||||
or "licensed-with-watermark" users. By using this library, you agree to
|
||||
preserve the watermark's behavior, keeping it visible, unobscured, and
|
||||
available to user-interaction.
|
||||
|
||||
To remove the watermark, please purchase a license at tldraw.dev.
|
||||
*/
|
||||
|
||||
.${n} {
|
||||
position: absolute;
|
||||
bottom: max(var(--tl-space-2), env(safe-area-inset-bottom));
|
||||
right: max(var(--tl-space-2), env(safe-area-inset-right));
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--tl-layer-watermark) !important;
|
||||
background-color: color-mix(in srgb, var(--tl-color-background) 62%, transparent);
|
||||
opacity: 1;
|
||||
border-radius: 5px;
|
||||
pointer-events: all;
|
||||
padding: 2px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.${n} > button {
|
||||
position: absolute;
|
||||
width: 96px;
|
||||
height: 32px;
|
||||
pointer-events: all;
|
||||
cursor: inherit;
|
||||
color: var(--tl-color-text);
|
||||
opacity: .38;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background-color: currentColor;
|
||||
}
|
||||
|
||||
.${n}[data-debug='true'] {
|
||||
bottom: max(46px, env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.${n}[data-mobile='true'] {
|
||||
border-radius: 4px 0px 0px 4px;
|
||||
right: max(-2px, calc(env(safe-area-inset-right) - 2px));
|
||||
width: 8px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.${n}[data-mobile='true'] > button {
|
||||
width: 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.tl-container[dir='rtl'] .${n} {
|
||||
right: auto;
|
||||
left: max(var(--tl-space-2), env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.tl-container[dir='rtl'] .${n}[data-mobile='true'] {
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
left: max(-2px, calc(env(safe-area-inset-left) - 2px));
|
||||
}
|
||||
|
||||
.${n}[data-unlicensed='true'] > button {
|
||||
font-size: 100px;
|
||||
position: absolute;
|
||||
pointer-events: all;
|
||||
cursor: pointer;
|
||||
color: var(--tl-color-text);
|
||||
opacity: 0.8;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.${n}[data-mobile='true'][data-unlicensed='true'] > button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.${n} > button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.${n}:hover {
|
||||
background-color: var(--tl-color-background);
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
transition-delay: 0.32s;
|
||||
}
|
||||
|
||||
.${n}:hover > button {
|
||||
animation: ${n}_delayed_link 0.2s forwards ease-in-out;
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
.${n} > button:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ${n}_delayed_link {
|
||||
0% {
|
||||
cursor: inherit;
|
||||
opacity: .38;
|
||||
pointer-events: none;
|
||||
}
|
||||
100% {
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
}`;return(0,ul.jsx)("style",{nonce:e.options.nonce,children:o})});var lIe=[],cIe=[],uIe=[],dIe=[],pIe="tl-container",hR=(0,Rn.memo)(function({store:e,components:n,className:o,user:r,options:i,textOptions:s,deepLinks:a,...l}){let c=Jh(l.themes);Zb(c),Qb(c);let[d,u]=(0,Rn.useState)(null),p=(0,Rn.useMemo)(()=>r??cy(),[r]),f=n?.ErrorFallback===void 0?Wv:n?.ErrorFallback,h=(0,Rn.useMemo)(()=>{let y=i;return s&&(y={...y,text:y?.text??s}),a!==void 0&&(y={...y,deepLinks:y?.deepLinks??a}),y},[i,s,a]),m={...l,shapeUtils:l.shapeUtils??lIe,bindingUtils:l.bindingUtils??cIe,assetUtils:l.assetUtils??uIe,tools:l.tools??dIe,components:n,options:Ds(h)};return(0,ao.jsx)("div",{ref:u,"data-tldraw":Yw,draggable:!1,className:(0,Pne.default)(`${pIe} tl-theme__light`,o),tabIndex:-1,role:"application","aria-label":i?.branding??"tldraw",children:(0,ao.jsx)(bh,{fallback:f,onError:y=>Qf(y,{tags:{origin:"react.tldraw-before-app"}}),children:d&&(0,ao.jsx)(fne,{licenseKey:l.licenseKey,children:(0,ao.jsx)(Nv,{container:d,children:(0,ao.jsx)(Qte,{overrides:n,children:e?e instanceof ip?(0,ao.jsx)(Ine,{...m,store:e,user:p}):(0,ao.jsx)(Ene,{...m,store:e,user:p}):(0,ao.jsx)(fIe,{...m,store:e,user:p})})})})})})});function fIe(t){let{defaultName:e,snapshot:n,initialData:o,shapeUtils:r,bindingUtils:i,assetUtils:s,persistenceKey:a,sessionId:l,user:c,assets:d,users:u,migrations:p,themes:f}=t,h=nR({shapeUtils:r,bindingUtils:i,assetUtils:s,initialData:o,persistenceKey:a,sessionId:l,defaultName:e,snapshot:n,assets:d,users:u,migrations:p,themes:f});return(0,ao.jsx)(Ene,{...t,store:h,user:c})}var Ene=(0,Rn.memo)(function({store:e,user:n,...o}){let r=ot();(0,Rn.useLayoutEffect)(()=>{n.userPreferences.get().colorScheme==="dark"&&(r.classList.remove("tl-theme__light"),r.classList.add("tl-theme__dark"))},[r,n]);let{LoadingScreen:i}=Kn();switch(e.status){case"error":throw e.error;case"loading":return i?(0,ao.jsx)(i,{}):null;case"not-synced":break;case"synced-local":break;case"synced-remote":break}return(0,ao.jsx)(Ine,{...o,store:e.store,user:n})}),bH=()=>pn().location.search.includes("tldraw_preserve_focus");function Ine({onMount:t,children:e,store:n,tools:o,shapeUtils:r,bindingUtils:i,assetUtils:s,overlayUtils:a,user:l,initialState:c,autoFocus:d=!0,cameraOptions:u,options:p,licenseKey:f,getShapeVisibility:h,colorScheme:m,assetUrls:y,themes:g,initialTheme:S}){let{ErrorFallback:b}=Kn(),v=ot(),[w,T]=u0(null),C=(0,Rn.useRef)(null),P=p?.deepLinks,M=Ds(P===!0?{}:P),E=(0,Rn.useRef)({autoFocus:d&&!bH(),initialState:c,colorScheme:m,cameraOptions:u,deepLinks:M,themes:g,initialTheme:S});(0,Rn.useLayoutEffect)(()=>{E.current={autoFocus:d&&!bH(),initialState:c,colorScheme:m,cameraOptions:u,deepLinks:M,themes:g,initialTheme:S}},[d,c,m,u,M,g,S]),(0,Rn.useLayoutEffect)(()=>{let{autoFocus:k,initialState:L,colorScheme:U,cameraOptions:V,deepLinks:q,themes:Z,initialTheme:le}=E.current,xe=new de({store:n,shapeUtils:r,bindingUtils:i,assetUtils:s,overlayUtils:a,tools:o,getContainer:()=>v,user:l,initialState:L,autoFocus:k,cameraOptions:V,options:p,licenseKey:f,getShapeVisibility:h,colorScheme:U,fontAssetUrls:y?.fonts,themes:Z,initialTheme:le});return xe.updateViewportScreenBounds(C.current??v),q&&(q?.getUrl?xe.navigateToDeepLink({...q,url:q.getUrl(xe)}):xe.navigateToDeepLink(q)),T(xe),()=>{xe.dispose()}},[s,i,m,v,p,a,r,n,o,l,T,f,h,y]),(0,Rn.useLayoutEffect)(()=>{if(w&&M)return w.registerDeepLinkListener(M)},[w,M]),(0,Rn.useLayoutEffect)(()=>{w&&(u||p?.camera)&&w.setCameraOptions({...u,...p?.camera})},[w,u,p?.camera]),(0,Rn.useLayoutEffect)(()=>{if(w&&g)for(let k of Object.values(g))w.updateTheme(k)},[w,g]),(0,Rn.useLayoutEffect)(()=>{w&&S&&w.setCurrentTheme(S)},[w,S]);let I=(0,Rn.useSyncExternalStore)((0,Rn.useCallback)(k=>w?(w.on("crash",k),()=>w.off("crash",k)):()=>{},[w]),()=>w?.getCrashingError()??null);(0,Rn.useEffect)(function(){if(!w)return;function L(){w&&w.focus()}function U(){w&&w.blur()}if(d&&bH()){let V=w.getContainer();return V.addEventListener("pointerdown",L),V.ownerDocument.body.addEventListener("pointerdown",U),()=>{V.removeEventListener("pointerdown",L),V.ownerDocument.body.removeEventListener("pointerdown",U)}}},[w,d]);let[R,D]=(0,Rn.useState)(null),N=R;w!==N?.editor&&(N=null),(0,Rn.useLayoutEffect)(()=>{if(!w)return;if(w.options.maxFontsToLoadBeforeRender===0){D({editor:w,isLoaded:!0});return}let k=!1;return D({editor:w,isLoaded:!1}),w.fonts.loadRequiredFontsForCurrentPage(w.options.maxFontsToLoadBeforeRender).finally(()=>{k||D({editor:w,isLoaded:!0})}),()=>{k=!0}},[w]);let{Canvas:z,LoadingScreen:F}=Kn();return!w||!N?.isLoaded?(0,ao.jsxs)(ao.Fragment,{children:[F&&(0,ao.jsx)(F,{}),(0,ao.jsx)("div",{className:"tl-canvas",ref:C})]}):(0,ao.jsx)(bh,{fallback:b,onError:k=>w.annotateError(k,{origin:"react.tldraw",willCrashApp:!0}),children:I?(0,ao.jsx)(mIe,{crashingError:I}):(0,ao.jsx)(fh,{editor:w,children:(0,ao.jsxs)(hIe,{onMount:t,children:[e??(z?(0,ao.jsx)(z,{},w.contextId):null),(0,ao.jsx)(Cne,{})]})})})}function hIe({children:t,onMount:e}){return vne(),Wte(),m
Line truncated
|
||||
`)}};jy.empty=new jy(!0);var PH=class{constructor(e,n){this.string=e,this.nodeTypes=n,this.inline=null,this.pos=0,this.tokens=e.split(/\s*(?=\b|\W|$)/),this.tokens[this.tokens.length-1]==""&&this.tokens.pop(),this.tokens[0]==""&&this.tokens.shift()}get next(){return this.tokens[this.pos]}eat(e){return this.next==e&&(this.pos++||!0)}err(e){throw new SyntaxError(e+" (in content expression '"+this.string+"')")}};function Yne(t){let e=[];do e.push(vIe(t));while(t.eat("|"));return e.length==1?e[0]:{type:"choice",exprs:e}}function vIe(t){let e=[];do e.push(wIe(t));while(t.next&&t.next!=")"&&t.next!="|");return e.length==1?e[0]:{type:"seq",exprs:e}}function wIe(t){let e=CIe(t);for(;;)if(t.eat("+"))e={type:"plus",expr:e};else if(t.eat("*"))e={type:"star",expr:e};else if(t.eat("?"))e={type:"opt",expr:e};else if(t.eat("{"))e=xIe(t,e);else break;return e}function Ane(t){/\D/.test(t.next)&&t.err("Expected number, got '"+t.next+"'");let e=Number(t.next);return t.pos++,e}function xIe(t,e){let n=Ane(t),o=n;return t.eat(",")&&(t.next!="}"?o=Ane(t):o=-1),t.eat("}")||t.err("Unclosed braced range"),{type:"range",min:n,max:o,expr:e}}function TIe(t,e){let n=t.nodeTypes,o=n[e];if(o)return[o];let r=[];for(let i in n){let s=n[i];s.isInGroup(e)&&r.push(s)}return r.length==0&&t.err("No node type or group '"+e+"' found"),r}function CIe(t){if(t.eat("(")){let e=Yne(t);return t.eat(")")||t.err("Missing closing paren"),e}else if(/\W/.test(t.next))t.err("Unexpected token '"+t.next+"'");else{let e=TIe(t,t.next).map(n=>(t.inline==null?t.inline=n.isInline:t.inline!=n.isInline&&t.err("Mixing inline and block content"),{type:"name",value:n}));return t.pos++,e.length==1?e[0]:{type:"choice",exprs:e}}}function PIe(t){let e=[[]];return r(i(t,0),n()),e;function n(){return e.push([])-1}function o(s,a,l){let c={term:l,to:a};return e[s].push(c),c}function r(s,a){s.forEach(l=>l.to=a)}function i(s,a){if(s.type=="choice")return s.exprs.reduce((l,c)=>l.concat(i(c,a)),[]);if(s.type=="seq")for(let l=0;;l++){let c=i(s.exprs[l],a);if(l==s.exprs.length-1)return c;r(c,a=n())}else if(s.type=="star"){let l=n();return o(a,l),r(i(s.expr,l),l),[o(l)]}else if(s.type=="plus"){let l=n();return r(i(s.expr,a),l),r(i(s.expr,l),l),[o(l)]}else{if(s.type=="opt")return[o(a)].concat(i(s.expr,a));if(s.type=="range"){let l=a;for(let c=0;c<s.min;c++){let d=n();r(i(s.expr,l),d),l=d}if(s.max==-1)r(i(s.expr,l),l);else for(let c=s.min;c<s.max;c++){let d=n();o(l,d),r(i(s.expr,l),d),l=d}return[o(l)]}else{if(s.type=="name")return[o(a,void 0,s.value)];throw new Error("Unknown expr type")}}}}function qne(t,e){return e-t}function kne(t,e){let n=[];return o(e),n.sort(qne);function o(r){let i=t[r];if(i.length==1&&!i[0].term)return o(i[0].to);n.push(r);for(let s=0;s<i.length;s++){let{term:a,to:l}=i[s];!a&&n.indexOf(l)==-1&&o(l)}}}function EIe(t){let e=Object.create(null);return n(kne(t,0));function n(o){let r=[];o.forEach(s=>{t[s].forEach(({term:a,to:l})=>{if(!a)return;let c;for(let d=0;d<r.length;d++)r[d][0]==a&&(c=r[d][1]);kne(t,l).forEach(d=>{c||r.push([a,c=[]]),c.indexOf(d)==-1&&c.push(d)})})});let i=e[o.join(",")]=new jy(o.indexOf(t.length-1)>-1);for(let s=0;s<r.length;s++){let a=r[s][1].sort(qne);i.next.push({type:r[s][0],next:e[a.join(",")]||n(a)})}return i}}function IIe(t,e){for(let n=0,o=[t];n<o.length;n++){let r=o[n],i=!r.validEnd,s=[];for(let a=0;a<r.next.length;a++){let{type:l,next:c}=r.next[a];s.push(l.name),i&&!(l.isText||l.hasRequiredAttrs())&&(i=!1),o.indexOf(c)==-1&&o.push(c)}i&&e.err("Only non-generatable nodes ("+s.join(", ")+") in a required position (see https://prosemirror.net/docs/guide/#generatable)")}}function Xne(t){let e=Object.create(null);for(let n in t){let o=t[n];if(!o.hasDefault)return null;e[n]=o.default}return e}function Zne(t,e){let n=Object.create(null);for(let o in t){let r=e&&e[o];if(r===void 0){let i=t[o];if(i.hasDefault)r=i.default;else throw new RangeError("No value supplied for attribute "+o)}n[o]=r}return n}function Jne(t,e,n,o){for(let r in e)if(!(r in t))throw new RangeError(`Unsupported attribute ${r} for ${n} of type ${o}`);for(let r in t)t[r].validate&&t[r].validate(e[r])}function Qne(t,e){let n=Object.create(null);if(e)for(let o in e)n[o]=new EH(t,o,e[o]);return n}var xR=class t{constructor(e,n,o){this.name=e,this.schema=n,this.spec=o,this.markSet=null,this.groups=o.group?o.group.split(" "):[],this.attrs=Qne(e,o.attrs),this.defaultAttrs=Xne(this.attrs),this.contentMatch=null,this.inlineContent=null,this.isBlock=!(o.inline||e=="text"),this.isText=e=="text"}get isInline(){return!this.isBlock}get isTextblock(){return this.isBlock&&this.inlineContent}get isLeaf(){return this.contentMatch==jy.empty}get isAtom(){return this.isLeaf||!!this.spec.atom}isInGroup(e){return this.groups.indexOf(e)>-1}get whitespace(){return this.spec.whitespace||(this.spec.code?"pre":"normal")}hasRequiredAttrs(){for(let e in this.attrs)if(this.attrs[e].isRequired)return!0;return!1}compatibleContent(e){return this==e||this.contentMatch.compatible(e.contentMatch)}computeAttr
Line truncated
|
||||
`);else if(s.linebreakReplacement&&/[\r\n]/.test(o)&&this.top.findWrapping(s.linebreakReplacement.create())){let a=o.split(/\r?\n|\r/);for(let l=0;l<a.length;l++)l&&this.insertNode(s.linebreakReplacement.create(),n,!0),a[l]&&this.insertNode(s.text(a[l]),n,!/\S/.test(a[l]));o=""}else o=o.replace(/\r?\n|\r/g," ");else if(o=o.replace(/[ \t\r\n\u000c]+/g," "),/^[ \t\r\n\u000c]/.test(o)&&this.open==this.nodes.length-1){let a=r.content[r.content.length-1],l=e.previousSibling;(!a||l&&l.nodeName=="BR"||a.isText&&/[ \t\r\n\u000c]$/.test(a.text))&&(o=o.slice(1))}o&&this.insertNode(s.text(o),n,!/\S/.test(o)),this.findInText(e)}else this.findInside(e)}addElement(e,n,o){let r=this.localPreserveWS,i=this.top;(e.tagName=="PRE"||/pre/.test(e.style&&e.style.whiteSpace))&&(this.localPreserveWS=!0);let s=e.nodeName.toLowerCase(),a;toe.hasOwnProperty(s)&&this.parser.normalizeLists&&RIe(e);let l=this.options.ruleFromNode&&this.options.ruleFromNode(e)||(a=this.parser.matchTag(e,this,o));e:if(l?l.ignore:kIe.hasOwnProperty(s))this.findInside(e),this.ignoreFallback(e,n);else if(!l||l.skip||l.closeParent){l&&l.closeParent?this.open=Math.max(0,this.open-1):l&&l.skip.nodeType&&(e=l.skip);let c,d=this.needsBlock;if(eoe.hasOwnProperty(s))i.content.length&&i.content[0].isInline&&this.open&&(this.open--,i=this.top),c=!0,i.type||(this.needsBlock=!0);else if(!e.firstChild){this.leafFallback(e,n);break e}let u=l&&l.skip?n:this.readStyles(e,n);u&&this.addAll(e,u),c&&this.sync(i),this.needsBlock=d}else{let c=this.readStyles(e,n);c&&this.addElementByRule(e,l,c,l.consuming===!1?a:void 0)}this.localPreserveWS=r}leafFallback(e,n){e.nodeName=="BR"&&this.top.type&&this.top.type.inlineContent&&this.addTextNode(e.ownerDocument.createTextNode(`
|
||||
`),n)}ignoreFallback(e,n){e.nodeName=="BR"&&(!this.top.type||!this.top.type.inlineContent)&&this.findPlace(this.parser.schema.text("-"),n,!0)}readStyles(e,n){let o=e.style;if(o&&o.length)for(let r=0;r<this.parser.matchedStyles.length;r++){let i=this.parser.matchedStyles[r],s=o.getPropertyValue(i);if(s)for(let a=void 0;;){let l=this.parser.matchStyle(i,s,this,a);if(!l)break;if(l.ignore)return null;if(l.clearMark?n=n.filter(c=>!l.clearMark(c)):n=n.concat(this.parser.schema.marks[l.mark].create(l.attrs)),l.consuming===!1)a=l;else break}}return n}addElementByRule(e,n,o,r){let i,s;if(n.node)if(s=this.parser.schema.nodes[n.node],s.isLeaf)this.insertNode(s.create(n.attrs),o,e.nodeName=="BR")||this.leafFallback(e,o);else{let l=this.enter(s,n.attrs||null,o,n.preserveWhitespace);l&&(i=!0,o=l)}else{let l=this.parser.schema.marks[n.mark];o=o.concat(l.create(n.attrs))}let a=this.top;if(s&&s.isLeaf)this.findInside(e);else if(r)this.addElement(e,o,r);else if(n.getContent)this.findInside(e),n.getContent(e,this.parser.schema).forEach(l=>this.insertNode(l,o,!1));else{let l=e;typeof n.contentElement=="string"?l=e.querySelector(n.contentElement):typeof n.contentElement=="function"?l=n.contentElement(e):n.contentElement&&(l=n.contentElement),this.findAround(e,l,!0),this.addAll(l,o),this.findAround(e,l,!1)}i&&this.sync(a)&&this.open--}addAll(e,n,o,r){let i=o||0;for(let s=o?e.childNodes[o]:e.firstChild,a=r==null?null:e.childNodes[r];s!=a;s=s.nextSibling,++i)this.findAtPoint(e,i),this.addDOM(s,n);this.findAtPoint(e,i)}findPlace(e,n,o){let r,i;for(let s=this.open,a=0;s>=0;s--){let l=this.nodes[s],c=l.findWrapping(e);if(c&&(!r||r.length>c.length+a)&&(r=c,i=l,!c.length))break;if(l.solid){if(o)break;a+=2}}if(!r)return null;this.sync(i);for(let s=0;s<r.length;s++)n=this.enterInner(r[s],null,n,!1);return n}insertNode(e,n,o){if(e.isInline&&this.needsBlock&&!this.top.type){let i=this.textblockFromContext();i&&(n=this.enterInner(i,null,n))}let r=this.findPlace(e,n,o);if(r){this.closeExtra();let i=this.top;i.match&&(i.match=i.match.matchType(e.type));let s=sn.none;for(let a of r.concat(e.marks))(i.type?i.type.allowsMarkType(a.type):Lne(a.type,e.type))&&(s=a.addToSet(s));return i.content.push(e.mark(s)),!0}return!1}enter(e,n,o,r){let i=this.findPlace(e.create(n),o,!1);return i&&(i=this.enterInner(e,n,o,!0,r)),i}enterInner(e,n,o,r=!1,i){this.closeExtra();let s=this.top;s.match=s.match&&s.match.matchType(e);let a=Dne(e,i,s.options);s.options&gP&&s.content.length==0&&(a|=gP);let l=sn.none;return o=o.filter(c=>(s.type?s.type.allowsMarkType(c.type):Lne(c.type,e))?(l=c.addToSet(l),!1):!0),this.nodes.push(new m0(e,n,l,r,null,a)),this.open++,o}closeExtra(e=!1){let n=this.nodes.length-1;if(n>this.open){for(;n>this.open;n--)this.nodes[n-1].content.push(this.nodes[n].finish(e));this.nodes.length=this.open+1}}finish(){return this.open=0,this.closeExtra(this.isOpen),this.nodes[0].finish(!!(this.isOpen||this.options.topOpen))}sync(e){for(let n=this.open;n>=0;n--){if(this.nodes[n]==e)return this.open=n,!0;this.localPreserveWS&&(this.nodes[n].options|=SP)}return!1}get currentPos(){this.closeExtra();let e=0;for(let n=this.open;n>=0;n--){let o=this.nodes[n].content;for(let r=o.length-1;r>=0;r--)e+=o[r].nodeSize;n&&e++}return e}findAtPoint(e,n){if(this.find)for(let o=0;o<this.find.length;o++)this.find[o].node==e&&this.find[o].offset==n&&(this.find[o].pos=this.currentPos)}findInside(e){if(this.find)for(let n=0;n<this.find.length;n++)this.find[n].pos==null&&e.nodeType==1&&e.contains(this.find[n].node)&&(this.find[n].pos=this.currentPos)}findAround(e,n,o){if(e!=n&&this.find)for(let r=0;r<this.find.length;r++)this.find[r].pos==null&&e.nodeType==1&&e.contains(this.find[r].node)&&n.compareDocumentPosition(this.find[r].node)&(o?2:4)&&(this.find[r].pos=this.currentPos)}findInText(e){if(this.find)for(let n=0;n<this.find.length;n++)this.find[n].node==e&&(this.find[n].pos=this.currentPos-(e.nodeValue.length-this.find[n].offset))}matchesContext(e){if(e.indexOf("|")>-1)return e.split(/\s*\|\s*/).some(this.matchesContext,this);let n=e.split("/"),o=this.options.context,r=!this.isOpen&&(!o||o.parent.type==this.nodes[0].type),i=-(o?o.depth+1:0)+(r?0:1),s=(a,l)=>{for(;a>=0;a--){let c=n[a];if(c==""){if(a==n.length-1||a==0)continue;for(;l>=i;l--)if(s(a-1,l))return!0;return!1}else{let d=l>0||l==0&&r?this.nodes[l].type:o&&l>=i?o.node(l-i).type:null;if(!d||d.name!=c&&!d.isInGroup(c))return!1;l--}}return!0};return s(n.length-1,this.open)}textblockFromContext(){let e=this.options.context;if(e)for(let n=e.depth;n>=0;n--){let o=e.node(n).contentMatchAt(e.indexAfter(n)).defaultType;if(o&&o.isTextblock&&o.defaultAttrs)return o}for(let n in this.parser.schema.nodes){let o=this.parser.schema.nodes[n];if(o.isTextblock&&o.defaultAttrs)return o}}};function RIe(t){for(let e=t.firstChild,n=null;e;e=e.nextSibling){let o=e.nodeType==1?e.nodeName.toLowerCase():null;o&&toe.hasOwnProperty(o)&&n?(n.appendChild(e),e=n):o=="li"?n=e:o&&(n=null)}}function DIe(t,e){return(t.matches||t.msMatchesSelector||t
Line truncated
|
||||
`))}})}function GIe(t,e,n){let o=t.resolve(e),r=o.index();return o.parent.canReplaceWith(r,r+1,n)}function WIe(t,e,n,o,r){let i=t.doc.nodeAt(e);if(!i)throw new RangeError("No node at given position");n||(n=i.type);let s=n.create(o,null,r||i.marks);if(i.isLeaf)return t.replaceWith(e,e+i.nodeSize,s);if(!n.validContent(i.content))throw new RangeError("Invalid content for node type "+n.name);t.step(new Rr(e,e+i.nodeSize,e+1,e+i.nodeSize-1,new ge(re.from(s),0,0),1,!0))}function dl(t,e,n=1,o){let r=t.resolve(e),i=r.depth-n,s=o&&o[o.length-1]||r.parent;if(i<0||r.parent.type.spec.isolating||!r.parent.canReplace(r.index(),r.parent.childCount)||!s.type.validContent(r.parent.content.cutByIndex(r.index(),r.parent.childCount)))return!1;for(let c=r.depth-1,d=n-2;c>i;c--,d--){let u=r.node(c),p=r.index(c);if(u.type.spec.isolating)return!1;let f=u.content.cutByIndex(p,u.childCount),h=o&&o[d+1];h&&(f=f.replaceChild(0,h.type.create(h.attrs)));let m=o&&o[d]||u;if(!u.canReplace(p+1,u.childCount)||!m.type.validContent(f))return!1}let a=r.indexAfter(i),l=o&&o[0];return r.node(i).canReplaceWith(a,a,l?l.type:r.node(i+1).type)}function YIe(t,e,n=1,o){let r=t.doc.resolve(e),i=re.empty,s=re.empty;for(let a=r.depth,l=r.depth-n,c=n-1;a>l;a--,c--){i=re.from(r.node(a).copy(i));let d=o&&o[c];s=re.from(d?d.type.create(d.attrs,s):r.node(a).copy(s))}t.step(new Hi(e,e,new ge(i.append(s),n,n),!0))}function zl(t,e){let n=t.resolve(e),o=n.index();return doe(n.nodeBefore,n.nodeAfter)&&n.parent.canReplace(o,o+1)}function qIe(t,e){e.content.size||t.type.compatibleContent(e.type);let n=t.contentMatchAt(t.childCount),{linebreakReplacement:o}=t.type.schema;for(let r=0;r<e.childCount;r++){let i=e.child(r),s=i.type==o?t.type.schema.nodes.text:i.type;if(n=n.matchType(s),!n||!t.type.allowsMarks(i.marks))return!1}return n.validEnd}function doe(t,e){return!!(t&&e&&!t.isLeaf&&qIe(t,e))}function $y(t,e,n=-1){let o=t.resolve(e);for(let r=o.depth;;r--){let i,s,a=o.index(r);if(r==o.depth?(i=o.nodeBefore,s=o.nodeAfter):n>0?(i=o.node(r+1),a++,s=o.node(r).maybeChild(a)):(i=o.node(r).maybeChild(a-1),s=o.node(r+1)),i&&!i.isTextblock&&doe(i,s)&&o.node(r).canReplace(a,a+1))return e;if(r==0)break;e=n<0?o.before(r):o.after(r)}}function XIe(t,e,n){let o=null,{linebreakReplacement:r}=t.doc.type.schema,i=t.doc.resolve(e-n),s=i.node().type;if(r&&s.inlineContent){let d=s.whitespace=="pre",u=!!s.contentMatch.matchType(r);d&&!u?o=!1:!d&&u&&(o=!0)}let a=t.steps.length;if(o===!1){let d=t.doc.resolve(e+n);uoe(t,d.node(),d.before(),a)}s.inlineContent&&OH(t,e+n-1,s,i.node().contentMatchAt(i.index()),o==null);let l=t.mapping.slice(a),c=l.map(e-n);if(t.step(new Hi(c,l.map(e+n,-1),ge.empty,!0)),o===!0){let d=t.doc.resolve(c);coe(t,d.node(),d.before(),t.steps.length)}return t}function ZIe(t,e,n){let o=t.resolve(e);if(o.parent.canReplaceWith(o.index(),o.index(),n))return e;if(o.parentOffset==0)for(let r=o.depth-1;r>=0;r--){let i=o.index(r);if(o.node(r).canReplaceWith(i,i,n))return o.before(r+1);if(i>0)return null}if(o.parentOffset==o.parent.content.size)for(let r=o.depth-1;r>=0;r--){let i=o.indexAfter(r);if(o.node(r).canReplaceWith(i,i,n))return o.after(r+1);if(i<o.node(r).childCount)return null}return null}function IR(t,e,n){let o=t.resolve(e);if(!n.content.size)return e;let r=n.content;for(let i=0;i<n.openStart;i++)r=r.firstChild.content;for(let i=1;i<=(n.openStart==0&&n.size?2:1);i++)for(let s=o.depth;s>=0;s--){let a=s==o.depth?0:o.pos<=(o.start(s+1)+o.end(s+1))/2?-1:1,l=o.index(s)+(a>0?1:0),c=o.node(s),d=!1;if(i==1)d=c.canReplace(l,l,r);else{let u=c.contentMatchAt(l).findWrapping(r.firstChild.type);d=u&&c.canReplaceWith(l,l,u[0])}if(d)return a==0?o.pos:a<0?o.before(s+1):o.after(s+1)}return null}function PP(t,e,n=e,o=ge.empty){if(e==n&&!o.size)return null;let r=t.resolve(e),i=t.resolve(n);return poe(r,i,o)?new Hi(e,n,o):new RH(r,i,o).fit()}function poe(t,e,n){return!n.openStart&&!n.openEnd&&t.start()==e.start()&&t.parent.canReplace(t.index(),e.index(),n.content)}var RH=class{constructor(e,n,o){this.$from=e,this.$to=n,this.unplaced=o,this.frontier=[],this.placed=re.empty;for(let r=0;r<=e.depth;r++){let i=e.node(r);this.frontier.push({type:i.type,match:i.contentMatchAt(e.indexAfter(r))})}for(let r=e.depth;r>0;r--)this.placed=re.from(e.node(r).copy(this.placed))}get depth(){return this.frontier.length-1}fit(){for(;this.unplaced.size;){let c=this.findFittable();c?this.placeNodes(c):this.openMore()||this.dropNode()}let e=this.mustMoveInline(),n=this.placed.size-this.depth-this.$from.depth,o=this.$from,r=this.close(e<0?this.$to:o.doc.resolve(e));if(!r)return null;let i=this.placed,s=o.depth,a=r.depth;for(;s&&a&&i.childCount==1;)i=i.firstChild.content,s--,a--;let l=new ge(i,s,a);return e>-1?new Rr(o.pos,e,this.$to.pos,this.$to.end(),l,n):l.size||o.pos!=this.$to.pos?new Hi(o.pos,r.pos,l):null}findFittable(){let e=this.unplaced.openStart;for(let n=this.unplaced.content,o=0,r=this.unplaced.openEnd;o<e;o++){let i=n.firstChild;if(n.childCount>1&&(r=0),i.type.spec.isolating&&r<=o){e=o;break}n=i.co
Line truncated
|
||||
`).scrollIntoView()),!0)};function WH(t){for(let e=0;e<t.edgeCount;e++){let{type:n}=t.edge(e);if(n.isTextblock&&!n.hasRequiredAttrs())return n}return null}var YH=(t,e)=>{let{$head:n,$anchor:o}=t.selection;if(!n.parent.type.spec.code||!n.sameParent(o))return!1;let r=n.node(-1),i=n.indexAfter(-1),s=WH(r.contentMatchAt(i));if(!s||!r.canReplaceWith(i,i,s))return!1;if(e){let a=n.after(),l=t.tr.replaceWith(a,a,s.createAndFill());l.setSelection(Xe.near(l.doc.resolve(a),1)),e(l.scrollIntoView())}return!0},qH=(t,e)=>{let n=t.selection,{$from:o,$to:r}=n;if(n instanceof Fs||o.parent.inlineContent||r.parent.inlineContent)return!1;let i=WH(r.parent.contentMatchAt(r.indexAfter()));if(!i||!i.isTextblock)return!1;if(e){let s=(!o.parentOffset&&r.index()<r.parent.childCount?o:r).pos,a=t.tr.insert(s,i.createAndFill());a.setSelection(Ee.create(a.doc,s+1)),e(a.scrollIntoView())}return!0},XH=(t,e)=>{let{$cursor:n}=t.selection;if(!n||n.parent.content.size)return!1;if(n.depth>1&&n.after()!=n.end(-1)){let i=n.before();if(dl(t.doc,i))return e&&e(t.tr.split(i).scrollIntoView()),!0}let o=n.blockRange(),r=o&&Zp(o);return r==null?!1:(e&&e(t.tr.lift(o,r).scrollIntoView()),!0)};function i_e(t){return(e,n)=>{let{$from:o,$to:r}=e.selection;if(e.selection instanceof ke&&e.selection.node.isBlock)return!o.parentOffset||!dl(e.doc,o.pos)?!1:(n&&n(e.tr.split(o.pos).scrollIntoView()),!0);if(!o.depth)return!1;let i=[],s,a,l=!1,c=!1;for(let f=o.depth;;f--)if(o.node(f).isBlock){l=o.end(f)==o.pos+(o.depth-f),c=o.start(f)==o.pos-(o.depth-f),a=WH(o.node(f-1).contentMatchAt(o.indexAfter(f-1)));let m=t&&t(r.parent,l,o);i.unshift(m||(l&&a?{type:a}:null)),s=f;break}else{if(f==1)return!1;i.unshift(null)}let d=e.tr;(e.selection instanceof Ee||e.selection instanceof Fs)&&d.deleteSelection();let u=d.mapping.map(o.pos),p=dl(d.doc,u,i.length,i);if(p||(i[0]=a?{type:a}:null,p=dl(d.doc,u,i.length,i)),!p)return!1;if(d.split(u,i.length,i),!l&&c&&o.node(s).type!=a){let f=d.mapping.map(o.before(s)),h=d.doc.resolve(f);a&&o.node(s-1).canReplaceWith(h.index(),h.index()+1,a)&&d.setNodeMarkup(d.mapping.map(o.before(s)),a)}return n&&n(d.scrollIntoView()),!0}}var s_e=i_e();var Ooe=(t,e)=>{let{$from:n,to:o}=t.selection,r,i=n.sharedDepth(o);return i==0?!1:(r=n.before(i),e&&e(t.tr.setSelection(ke.create(t.doc,r))),!0)},a_e=(t,e)=>(e&&e(t.tr.setSelection(new Fs(t.doc))),!0);function l_e(t,e,n){let o=e.nodeBefore,r=e.nodeAfter,i=e.index();return!o||!r||!o.type.compatibleContent(r.type)?!1:!o.content.size&&e.parent.canReplace(i-1,i)?(n&&n(t.tr.delete(e.pos-o.nodeSize,e.pos).scrollIntoView()),!0):!e.parent.canReplace(i,i+1)||!(r.isTextblock||zl(t.doc,e.pos))?!1:(n&&n(t.tr.join(e.pos).scrollIntoView()),!0)}function Loe(t,e,n,o){let r=e.nodeBefore,i=e.nodeAfter,s,a,l=r.type.spec.isolating||i.type.spec.isolating;if(!l&&l_e(t,e,n))return!0;let c=!l&&e.parent.canReplace(e.index(),e.index()+1);if(c&&(s=(a=r.contentMatchAt(r.childCount)).findWrapping(i.type))&&a.matchType(s[0]||i.type).validEnd){if(n){let f=e.pos+i.nodeSize,h=re.empty;for(let g=s.length-1;g>=0;g--)h=re.from(s[g].create(null,h));h=re.from(r.copy(h));let m=t.tr.step(new Rr(e.pos-1,f,e.pos,f,new ge(h,1,0),s.length,!0)),y=m.doc.resolve(f+2*s.length);y.nodeAfter&&y.nodeAfter.type==r.type&&zl(m.doc,y.pos)&&m.join(y.pos),n(m.scrollIntoView())}return!0}let d=i.type.spec.isolating||o>0&&l?null:Xe.findFrom(e,1),u=d&&d.$from.blockRange(d.$to),p=u&&Zp(u);if(p!=null&&p>=e.depth)return n&&n(t.tr.lift(u,p).scrollIntoView()),!0;if(c&&x0(i,"start",!0)&&x0(r,"end")){let f=r,h=[];for(;h.push(f),!f.isTextblock;)f=f.lastChild;let m=i,y=1;for(;!m.isTextblock;m=m.firstChild)y++;if(f.canReplace(f.childCount,f.childCount,m.content)){if(n){let g=re.empty;for(let b=h.length-1;b>=0;b--)g=re.from(h[b].copy(g));let S=t.tr.step(new Rr(e.pos-h.length,e.pos+i.nodeSize,e.pos+y,e.pos+i.nodeSize-y,new ge(g,h.length,0),0,!0));n(S.scrollIntoView())}return!0}}return!1}function Noe(t){return function(e,n){let o=e.selection,r=t<0?o.$from:o.$to,i=r.depth;for(;r.node(i).isInline;){if(!i)return!1;i--}return r.node(i).isTextblock?(n&&n(e.tr.setSelection(Ee.create(e.doc,t<0?r.start(i):r.end(i)))),!0):!1}}var ZH=Noe(-1),JH=Noe(1);function Foe(t,e=null){return function(n,o){let{$from:r,$to:i}=n.selection,s=r.blockRange(i),a=s&&v0(s,t,e);return a?(o&&o(n.tr.wrap(s,a).scrollIntoView()),!0):!1}}function QH(t,e=null){return function(n,o){let r=!1;for(let i=0;i<n.selection.ranges.length&&!r;i++){let{$from:{pos:s},$to:{pos:a}}=n.selection.ranges[i];n.doc.nodesBetween(s,a,(l,c)=>{if(r)return!1;if(!(!l.isTextblock||l.hasMarkup(t,e)))if(l.type==t)r=!0;else{let d=n.doc.resolve(c),u=d.index();r=d.parent.canReplaceWith(u,u+1,t)}})}if(!r)return!1;if(o){let i=n.tr;for(let s=0;s<n.selection.ranges.length;s++){let{$from:{pos:a},$to:{pos:l}}=n.selection.ranges[s];i.setBlockType(a,l,t,e)}o(i.scrollIntoView())}return!0}}function e6(...t){return function(e,n,o){for(let r=0;r<t.length;r++)if(t[r](e,n,o))return!0;return!1}}var zH=e6(Poe,UH,HH),Coe=e6(Poe,jH,$H),am={Enter:e6(GH,qH,XH,s_e),"Mo
Line truncated
|
||||
`),u&&h==f.nodeValue.length)for(let m=f,y;m;m=m.parentNode){if(y=m.nextSibling){y.nodeName=="BR"&&(a=l={node:y.parentNode,offset:Vi(y)+1});break}let g=m.pmViewDesc;if(g&&g.node&&g.node.isBlock)break}}else{let m=f.childNodes[h-1];u=m&&(m.nodeName=="BR"||m.contentEditable=="false")}}if(Vl&&d.focusNode&&d.focusNode!=l.node&&d.focusNode.nodeType==1){let f=d.focusNode.childNodes[d.focusOffset];f&&f.contentEditable=="false"&&(r=!0)}if(!(r||u&&cs)&&Qy(a.node,a.offset,d.anchorNode,d.anchorOffset)&&Qy(l.node,l.offset,d.focusNode,d.focusOffset))return;let p=!1;if((c.extend||e==n)&&!(u&&Vl)){c.collapse(a.node,a.offset);try{e!=n&&c.extend(l.node,l.offset),p=!0}catch{}}if(!p){if(e>n){let h=a;a=l,l=h}let f=document.createRange();f.setEnd(l.node,l.offset),f.setStart(a.node,a.offset),c.removeAllRanges(),c.addRange(f)}}ignoreMutation(e){return!this.contentDOM&&e.type!="selection"}get contentLost(){return this.contentDOM&&this.contentDOM!=this.dom&&!this.dom.contains(this.contentDOM)}markDirty(e,n){for(let o=0,r=0;r<this.children.length;r++){let i=this.children[r],s=o+i.size;if(o==s?e<=s&&n>=o:e<s&&n>o){let a=o+i.border,l=s-i.border;if(e>=a&&n<=l){this.dirty=e==o||n==s?Wy:Woe,e==a&&n==l&&(i.contentLost||i.dom.parentNode!=this.contentDOM)?i.dirty=Fc:i.markDirty(e-a,n-a);return}else i.dirty=i.dom==i.contentDOM&&i.dom.parentNode==this.contentDOM&&!i.children.length?Wy:Fc}o=s}this.dirty=Wy}markParentsDirty(){let e=1;for(let n=this.parent;n;n=n.parent,e++){let o=e==1?Wy:Woe;n.dirty<o&&(n.dirty=o)}}get domAtom(){return!1}get ignoreForCoords(){return!1}get ignoreForSelection(){return!1}isText(e){return!1}},kR=class extends eS{constructor(e,n,o,r){let i,s=n.type.toDOM;if(typeof s=="function"&&(s=s(o,()=>{if(!i)return r;if(i.parent)return i.parent.posBeforeChild(i)})),!n.type.spec.raw){if(s.nodeType!=1){let a=document.createElement("span");a.appendChild(s),s=a}s.contentEditable="false",s.classList.add("ProseMirror-widget")}super(e,[],s,null),this.widget=n,this.widget=n,i=this}matchesWidget(e){return this.dirty==jl&&e.type.eq(this.widget.type)}parseRule(){return{ignore:!0}}stopEvent(e){let n=this.widget.spec.stopEvent;return n?n(e):!1}ignoreMutation(e){return e.type!="selection"||this.widget.spec.ignoreSelection}destroy(){this.widget.type.destroy(this.dom),super.destroy()}get domAtom(){return!0}get ignoreForSelection(){return!!this.widget.type.spec.relaxedSide}get side(){return this.widget.type.side}},d6=class extends eS{constructor(e,n,o,r){super(e,[],n,null),this.textDOM=o,this.text=r}get size(){return this.text.length}localPosFromDOM(e,n){return e!=this.textDOM?this.posAtStart+(n?this.size:0):this.posAtStart+n}domFromPos(e){return{node:this.textDOM,offset:e}}ignoreMutation(e){return e.type==="characterData"&&e.target.nodeValue==e.oldValue}},qy=class t extends eS{constructor(e,n,o,r,i){super(e,[],o,r),this.mark=n,this.spec=i}static create(e,n,o,r){let i=r.nodeViews[n.type.name],s=i&&i(n,r,o);return(!s||!s.dom)&&(s=Yp.renderSpec(document,n.type.spec.toDOM(n,o),null,n.attrs)),new t(e,n,s.dom,s.contentDOM||s.dom,s)}parseRule(){return this.dirty&Fc||this.mark.type.spec.reparseInView?null:{mark:this.mark.type.name,attrs:this.mark.attrs,contentElement:this.contentDOM}}matchesMark(e){return this.dirty!=Fc&&this.mark.eq(e)}markDirty(e,n){if(super.markDirty(e,n),this.dirty!=jl){let o=this.parent;for(;!o.node;)o=o.parent;o.dirty<this.dirty&&(o.dirty=this.dirty),this.dirty=jl}}slice(e,n,o){let r=t.create(this.parent,this.mark,!0,o),i=this.children,s=this.size;n<s&&(i=m6(i,n,s,o)),e>0&&(i=m6(i,0,e,o));for(let a=0;a<i.length;a++)i[a].parent=r;return r.children=i,r}ignoreMutation(e){return this.spec.ignoreMutation?this.spec.ignoreMutation(e):super.ignoreMutation(e)}destroy(){this.spec.destroy&&this.spec.destroy(),super.destroy()}},dm=class t extends eS{constructor(e,n,o,r,i,s,a){super(e,[],i,s),this.node=n,this.outerDeco=o,this.innerDeco=r,this.nodeDOM=a}static create(e,n,o,r,i,s){let a=i.nodeViews[n.type.name],l,c=a&&a(n,i,()=>{if(!l)return s;if(l.parent)return l.parent.posBeforeChild(l)},o,r),d=c&&c.dom,u=c&&c.contentDOM;if(n.isText){if(!d)d=document.createTextNode(n.text);else if(d.nodeType!=3)throw new RangeError("Text must be rendered as a DOM text node")}else d||({dom:d,contentDOM:u}=Yp.renderSpec(document,n.type.spec.toDOM(n),null,n.attrs));!u&&!n.isText&&d.nodeName!="BR"&&(d.hasAttribute("contenteditable")||(d.contentEditable="false"),n.type.spec.draggable&&(d.draggable=!0));let p=d;return d=_re(d,o,n),c?l=new p6(e,n,o,r,d,u||null,p,c):n.isText?new RR(e,n,o,r,d,p):new t(e,n,o,r,d,u||null,p)}parseRule(e){if(this.node.type.spec.reparseInView)return null;let n={node:this.node.type.name,attrs:this.node.attrs};if(this.node.type.whitespace=="pre"&&(n.preserveWhitespace="full"),!this.contentDOM)n.getContent=()=>this.node.content;else if(!this.contentLost)n.contentElement=this.contentDOM;else{for(let o=this.children.length-1;o>=0;o--){let r=this.children[o];if(this.dom.contains(r.dom.parentNode)){n.contentElement=r.dom.parentNode;break}}if(!n.co
Line truncated
|
||||
|
||||
`);return{dom:l,text:p,slice:e}}function Dre(t,e,n,o,r){let i=r.parent.type.spec.code,s,a;if(!n&&!e)return null;let l=!!e&&(o||i||!n);if(l){if(t.someProp("transformPastedText",p=>{e=p(e,i||o,t)}),i)return a=new ge(re.from(t.state.schema.text(e.replace(/\r\n?/g,`
|
||||
`))),0,0),t.someProp("transformPasted",p=>{a=p(a,t,!0)}),a;let u=t.someProp("clipboardTextParser",p=>p(e,r,o,t));if(u)a=u;else{let p=r.marks(),{schema:f}=t.state,h=Yp.fromSchema(f);s=document.createElement("div"),e.split(/(?:\r\n?|\n)+/).forEach(m=>{let y=s.appendChild(document.createElement("p"));m&&y.appendChild(h.serializeNode(f.text(m,p)))})}}else t.someProp("transformPastedHTML",u=>{n=u(n,t)}),s=nMe(n),NP&&oMe(s);let c=s&&s.querySelector("[data-pm-slice]"),d=c&&/^(\d+) (\d+)(?: -(\d+))? (.*)/.exec(c.getAttribute("data-pm-slice")||"");if(d&&d[3])for(let u=+d[3];u>0;u--){let p=s.firstChild;for(;p&&p.nodeType!=1;)p=p.nextSibling;if(!p)break;s=p}if(a||(a=(t.someProp("clipboardParser")||t.someProp("domParser")||Nc.fromSchema(t.state.schema)).parseSlice(s,{preserveWhitespace:!!(l||d),context:r,ruleFromNode(p){return p.nodeName=="BR"&&!p.nextSibling&&p.parentNode&&!Q_e.test(p.parentNode.nodeName)?{ignore:!0}:null}})),d)a=rMe(sre(a,+d[1],+d[2]),d[4]);else if(a=ge.maxOpen(eMe(a.content,r),!0),a.openStart||a.openEnd){let u=0,p=0;for(let f=a.content.firstChild;u<a.openStart&&!f.type.spec.isolating;u++,f=f.firstChild);for(let f=a.content.lastChild;p<a.openEnd&&!f.type.spec.isolating;p++,f=f.lastChild);a=sre(a,u,p)}return t.someProp("transformPasted",u=>{a=u(a,t,l)}),a}var Q_e=/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var)$/i;function eMe(t,e){if(t.childCount<2)return t;for(let n=e.depth;n>=0;n--){let r=e.node(n).contentMatchAt(e.index(n)),i,s=[];if(t.forEach(a=>{if(!s)return;let l=r.findWrapping(a.type),c;if(!l)return s=null;if(c=s.length&&i.length&&Lre(l,i,a,s[s.length-1],0))s[s.length-1]=c;else{s.length&&(s[s.length-1]=Nre(s[s.length-1],i.length));let d=Ore(a,l);s.push(d),r=r.matchType(d.type),i=l}}),s)return re.from(s)}return t}function Ore(t,e,n=0){for(let o=e.length-1;o>=n;o--)t=e[o].create(null,re.from(t));return t}function Lre(t,e,n,o,r){if(r<t.length&&r<e.length&&t[r]==e[r]){let i=Lre(t,e,n,o.lastChild,r+1);if(i)return o.copy(o.content.replaceChild(o.childCount-1,i));if(o.contentMatchAt(o.childCount).matchType(r==t.length-1?n.type:t[r+1]))return o.copy(o.content.append(re.from(Ore(n,t,r+1))))}}function Nre(t,e){if(e==0)return t;let n=t.content.replaceChild(t.childCount-1,Nre(t.lastChild,e-1)),o=t.contentMatchAt(t.childCount).fillBefore(re.empty,!0);return t.copy(n.append(o))}function S6(t,e,n,o,r,i){let s=e<0?t.firstChild:t.lastChild,a=s.content;return t.childCount>1&&(i=0),r<o-1&&(a=S6(a,e,n,o,r+1,i)),r>=n&&(a=e<0?s.contentMatchAt(0).fillBefore(a,i<=r).append(a):a.append(s.contentMatchAt(s.childCount).fillBefore(re.empty,!0))),t.replaceChild(e<0?0:t.childCount-1,s.copy(a))}function sre(t,e,n){return e<t.openStart&&(t=new ge(S6(t.content,-1,e,t.openStart,0,t.openEnd),e,t.openEnd)),n<t.openEnd&&(t=new ge(S6(t.content,1,n,t.openEnd,0,0),t.openStart,n)),t}var Fre={thead:["table"],tbody:["table"],tfoot:["table"],caption:["table"],colgroup:["table"],col:["table","colgroup"],tr:["table","tbody"],td:["table","tbody","tr"],th:["table","tbody","tr"]};function Bre(){return document.implementation.createHTMLDocument("title")}var o6=null;function tMe(t){let e=window.trustedTypes;return e?(o6||(o6=e.defaultPolicy||e.createPolicy("ProseMirrorClipboard",{createHTML:n=>n})),o6.createHTML(t)):t}function nMe(t){let e=/^(\s*<meta [^>]*>)*/.exec(t);e&&(t=t.slice(e[0].length));let n=Bre(),o=n.body,r=/<([a-z][^>\s]+)/i.exec(t),i;if((i=r&&Fre[r[1].toLowerCase()])&&(t=i.map(s=>"<"+s+">").join("")+t+i.map(s=>"</"+s+">").reverse().join("")),o.innerHTML=tMe(t),i)for(let s=0;s<i.length;s++)o=o.querySelector(i[s])||o;for(let s=0;s<n.styleSheets.length;s++){let a=n.styleSheets[s];for(let l=0;l<a.rules.length;l++){let c=a.rules[l];if(c instanceof CSSStyleRule){let d=o.querySelectorAll(c.selectorText);for(let u=0;u<d.length;u++)d[u].style.cssText+=c.style.cssText}}}return o}function oMe(t){let e=t.querySelectorAll(ji?"span:not([class]):not([style])":"span.Apple-converted-space");for(let n=0;n<e.length;n++){let o=e[n];o.childNodes.length==1&&o.textContent=="\xA0"&&o.parentNode&&o.parentNode.replaceChild(t.ownerDocument.createTextNode(" "),o)}}function rMe(t,e){if(!t.size)return t;let n=t.content.firstChild.type.schema,o;try{o=JSON.parse(e)}catch{return t}let{content:r,openStart:i,openEnd:s}=t;for(let a=o.length-2;a>=0;a-=2){let l=n.nodes[o[a]];if(!l||l.hasRequiredAttrs())break;r=re.from(l.create(o[a+1],r)),i++,s++}return new ge(r,i,s)}var Bs={},zs={},iMe={touchstart:!0,touchmove:!0},b6=class{constructor(){this.shiftKey=!1,this.mouseDown=null,this.lastKeyCode=null,this.lastKeyCodeTime=0,this.lastClick={time:0,x:0,y:0,type:"",button:0},this.lastSelectionOrigin=null,this.lastSelectionTime=0,this.lastIOSEnter=0,this.lastIOSEnterFallbackTimeout=-1,this.lastFocus=0,this.lastTouch=0,this.lastChromeDelete=0,this.composing=!1,this.compositionNode=null,this.composingTimeout=-1,this.compositionNodes=[],this.compositionEndedAt=-2e8,this.compositionID=1,this.badSafariComposition=!1,this.co
Line truncated
|
||||
|
||||
`,textSerializers:s={}}=n||{},a="";return t.nodesBetween(o,r,(l,c,d,u)=>{var p;l.isBlock&&c>o&&(a+=i);let f=s?.[l.type.name];if(f)return d&&(a+=f({node:l,pos:c,parent:d,index:u,range:e})),!1;l.isText&&(a+=(p=l?.text)==null?void 0:p.slice(Math.max(o,c)-c,r-c))}),a}function Tie(t,e){let n={from:0,to:t.content.size};return xie(t,n,e)}function W6(t){return Object.fromEntries(Object.entries(t.nodes).filter(([,e])=>e.spec.toText).map(([e,n])=>[e,n.spec.toText]))}function Cie(t,e,n){let{blockSeparator:o=`
|
||||
|
||||
`,textSerializers:r={}}=n||{},i=$P(e),s=Si.fromJSON(i,t);return Tie(s,{blockSeparator:o,textSerializers:{...W6(i),...r}})}function GAe(t,e){let n=nr(e,t.schema),{from:o,to:r}=t.selection,i=[];t.doc.nodesBetween(o,r,a=>{i.push(a)});let s=i.reverse().find(a=>a.type.name===n.name);return s?{...s.attrs}:{}}function Y6(t,e){let n=QR(typeof e=="string"?e:e.name,t.schema);return n==="node"?GAe(t,e):n==="mark"?mie(t,e):{}}function WAe(t,e=JSON.stringify){let n={};return t.filter(o=>{let r=e(o);return Object.prototype.hasOwnProperty.call(n,r)?!1:n[r]=!0})}function YAe(t){let e=WAe(t);return e.length===1?e:e.filter((n,o)=>!e.filter((i,s)=>s!==o).some(i=>n.oldRange.from>=i.oldRange.from&&n.oldRange.to<=i.oldRange.to&&n.newRange.from>=i.newRange.from&&n.newRange.to<=i.newRange.to))}function KP(t){let{mapping:e,steps:n}=t,o=[];return e.maps.forEach((r,i)=>{let s=[];if(r.ranges.length)r.forEach((a,l)=>{s.push({from:a,to:l})});else{let{from:a,to:l}=n[i];if(a===void 0||l===void 0)return;s.push({from:a,to:l})}s.forEach(({from:a,to:l})=>{let c=e.slice(i).map(a,-1),d=e.slice(i).map(l),u=e.invert().map(c,-1),p=e.invert().map(d);o.push({oldRange:{from:u,to:p},newRange:{from:c,to:d}})})}),YAe(o)}function n2(t,e,n){let o=[];return t===e?n.resolve(t).marks().forEach(r=>{let i=n.resolve(t),s=jP(i,r.type);s&&o.push({mark:r,...s})}):n.nodesBetween(t,e,(r,i)=>{!r||r?.nodeSize===void 0||o.push(...r.marks.map(s=>({from:i,to:i+r.nodeSize,mark:s})))}),o}var Pie=(t,e,n,o=20)=>{let r=t.doc.resolve(n),i=o,s=null;for(;i>0&&s===null;){let a=r.node(i);a?.type.name===e?s=a:i-=1}return[s,i]};function zP(t,e){return e.nodes[t]||e.marks[t]||null}function YR(t,e,n){return Object.fromEntries(Object.entries(n).filter(([o])=>{let r=t.find(i=>i.type===e&&i.name===o);return r?r.attribute.keepOnSplit:!1}))}var qAe=(t,e=500)=>{let n="",o=t.parentOffset;return t.parent.nodesBetween(Math.max(0,o-e),o,(r,i,s,a)=>{var l,c;let d=((c=(l=r.type.spec).toText)==null?void 0:c.call(l,{node:r,pos:i,parent:s,index:a}))||r.textContent||"%leaf%";n+=r.isAtom&&!r.isText?d:d.slice(0,Math.max(0,o-i))}),n};function H6(t,e,n={}){let{empty:o,ranges:r}=t.selection,i=e?rf(e,t.schema):null;if(o)return!!(t.storedMarks||t.selection.$from.marks()).filter(u=>i?i.name===u.type.name:!0).find(u=>qR(u.attrs,n,{strict:!1}));let s=0,a=[];if(r.forEach(({$from:u,$to:p})=>{let f=u.pos,h=p.pos;t.doc.nodesBetween(f,h,(m,y)=>{if(i&&m.inlineContent&&!m.type.allowsMarkType(i))return!1;if(!m.isText&&!m.marks.length)return;let g=Math.max(f,y),S=Math.min(h,y+m.nodeSize),b=S-g;s+=b,a.push(...m.marks.map(v=>({mark:v,from:g,to:S})))})}),s===0)return!1;let l=a.filter(u=>i?i.name===u.mark.type.name:!0).filter(u=>qR(u.mark.attrs,n,{strict:!1})).reduce((u,p)=>u+p.to-p.from,0),c=a.filter(u=>i?u.mark.type!==i&&u.mark.type.excludes(i):!0).reduce((u,p)=>u+p.to-p.from,0);return(l>0?l+c:l)>=s}function XAe(t,e,n={}){if(!e)return od(t,null,n)||H6(t,null,n);let o=QR(e,t.schema);return o==="node"?od(t,e,n):o==="mark"?H6(t,e,n):!1}var Eie=(t,e)=>{let{$from:n,$to:o,$anchor:r}=t.selection;if(e){let i=e2(a=>a.type.name===e)(t.selection);if(!i)return!1;let s=t.doc.resolve(i.pos+1);return r.pos+1===s.end()}return!(o.parentOffset<o.parent.nodeSize-2||n.pos!==o.pos)},Iie=t=>{let{$from:e,$to:n}=t.selection;return!(e.parentOffset>0||e.pos!==n.pos)};function iie(t,e){return Array.isArray(e)?e.some(n=>(typeof n=="string"?n:n.name)===t.name):e}function N6(t,e){let{nodeExtensions:n}=M0(e),o=n.find(s=>s.name===t);if(!o)return!1;let r={name:o.name,options:o.options,storage:o.storage},i=mn(Oe(o,"group",r));return typeof i!="string"?!1:i.split(" ").includes("list")}function k0(t,{checkChildren:e=!0,ignoreWhitespace:n=!1}={}){var o;if(n){if(t.type.name==="hardBreak")return!0;if(t.isText)return!/\S/.test((o=t.text)!=null?o:"")}if(t.isText)return!t.text;if(t.isAtom||t.isLeaf)return!1;if(t.content.childCount===0)return!0;if(e){let r=!0;return t.content.forEach(i=>{r!==!1&&(k0(i,{ignoreWhitespace:n,checkChildren:e})||(r=!1))}),r}return!1}function o2(t){return t instanceof ke}var _ie=class Mie{constructor(e){this.position=e}static fromJSON(e){return new Mie(e.position)}toJSON(){return{position:this.position}}};function ZAe(t,e){let n=e.mapping.mapResult(t.position);return{position:new _ie(n.pos),mapResult:n}}function JAe(t){return new _ie(t)}function QAe(t,e,n){var o;let{selection:r}=e,i=null;if(uie(r)&&(i=r.$cursor),i){let a=(o=t.storedMarks)!=null?o:i.marks();return i.parent.type.allowsMarkType(n)&&(!!n.isInSet(a)||!a.some(c=>c.type.excludes(n)))}let{ranges:s}=r;return s.some(({$from:a,$to:l})=>{let c=a.depth===0?t.doc.inlineContent&&t.doc.type.allowsMarkType(n):!1;return t.doc.nodesBetween(a.pos,l.pos,(d,u,p)=>{if(c)return!1;if(d.isInline){let f=!p||p.type.allowsMarkType(n),h=!!n.isInSet(d.marks)||!d.marks.some(m=>m.type.excludes(n));c=f&&h}return!c}),c})}var eke=(t,e={})=>({tr:n,state:o,dispatch:r})=>{let{selection:i}=n,{empty:s,ranges:a}=i,l=rf(t,o.schema);if(r)if(s){let c=mie(o,l);n.addStoredMark(l.create({...c,...e}))}else a.forEach(
Line truncated
|
||||
|
||||
`);return`:::${c}${m}
|
||||
|
||||
${y}
|
||||
|
||||
:::`}}}function _ke(t){if(!t.trim())return{};let e={},n=/(\w+)=(?:"([^"]*)"|'([^']*)')/g,o=n.exec(t);for(;o!==null;){let[,r,i,s]=o;e[r]=i||s,o=n.exec(t)}return e}function Mke(t){return Object.entries(t).filter(([,e])=>e!=null).map(([e,n])=>`${e}="${n}"`).join(" ")}function Ake(t){let{nodeName:e,name:n,getContent:o,parseAttributes:r=_ke,serializeAttributes:i=Mke,defaultAttributes:s={},selfClosing:a=!1,allowedAttributes:l}=t,c=n||e,d=p=>{if(!l)return p;let f={};return l.forEach(h=>{let m=typeof h=="string"?h:h.name,y=typeof h=="string"?void 0:h.skipIfDefault;if(m in p){let g=p[m];if(y!==void 0&&g===y)return;f[m]=g}}),f},u=c.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");return{parseMarkdown:(p,f)=>{let h={...s,...p.attributes};if(a)return f.createNode(e,h);let m=o?o(p):p.content||"";return m?f.createNode(e,h,[f.createTextNode(m)]):f.createNode(e,h,[])},markdownTokenizer:{name:e,level:"inline",start(p){let f=a?new RegExp(`\\[${u}\\s*[^\\]]*\\]`):new RegExp(`\\[${u}\\s*[^\\]]*\\][\\s\\S]*?\\[\\/${u}\\]`),h=p.match(f),m=h?.index;return m!==void 0?m:-1},tokenize(p,f,h){let m=a?new RegExp(`^\\[${u}\\s*([^\\]]*)\\]`):new RegExp(`^\\[${u}\\s*([^\\]]*)\\]([\\s\\S]*?)\\[\\/${u}\\]`),y=p.match(m);if(!y)return;let g="",S="";if(a){let[,v]=y;S=v}else{let[,v,w]=y;S=v,g=w||""}let b=r(S.trim());return{type:e,raw:y[0],content:g.trim(),attributes:b}}},renderMarkdown:p=>{let f="";o?f=o(p):p.content&&p.content.length>0&&(f=p.content.filter(g=>g.type==="text").map(g=>g.text).join(""));let h=d(p.attrs||{}),m=i(h),y=m?` ${m}`:"";return a?`[${c}${y}]`:`[${c}${y}]${f}[/${c}]`}}}function r2(t,e,n){var o,r,i,s;let a=t.split(`
|
||||
`),l=[],c="",d=0,u=e.baseIndentSize||2;for(;d<a.length;){let p=a[d],f=p.match(e.itemPattern);if(!f){if(l.length>0)break;if(p.trim()===""){d+=1,c=`${c}${p}
|
||||
`;continue}else return}let h=e.extractItemData(f),{indentLevel:m,mainContent:y}=h;c=`${c}${p}
|
||||
`;let g=[y];for(d+=1;d<a.length;){let w=a[d];if(w.trim()===""){let C=a.slice(d+1).findIndex(E=>E.trim()!=="");if(C===-1)break;if((((r=(o=a[d+1+C].match(/^(\s*)/))==null?void 0:o[1])==null?void 0:r.length)||0)>m){g.push(w),c=`${c}${w}
|
||||
`,d+=1;continue}else break}if((((s=(i=w.match(/^(\s*)/))==null?void 0:i[1])==null?void 0:s.length)||0)>m)g.push(w),c=`${c}${w}
|
||||
`,d+=1;else break}let S,b=g.slice(1);if(b.length>0){let w=b.map(T=>T.slice(m+u)).join(`
|
||||
`);w.trim()&&(e.customNestedParser?S=e.customNestedParser(w):S=n.blockTokens(w))}let v=e.createToken(h,S);l.push(v)}if(l.length!==0)return{items:l,raw:c}}function GP(t,e,n,o){if(!t||!Array.isArray(t.content))return"";let r=typeof n=="function"?n(o):n,[i,...s]=t.content,a=e.renderChildren([i]),l=`${r}${a}`;return s&&s.length>0&&s.forEach((c,d)=>{var u,p;let f=(p=(u=e.renderChild)==null?void 0:u.call(e,c,d+1))!=null?p:e.renderChildren([c]);if(f!=null){let h=f.split(`
|
||||
`).map(m=>m?e.indent(m):e.indent("")).join(`
|
||||
`);l+=c.type==="paragraph"?`
|
||||
|
||||
${h}`:`
|
||||
${h}`}}),l}function Die(t,e){let n={...t};return KR(t)&&KR(e)&&Object.keys(e).forEach(o=>{KR(e[o])&&KR(t[o])?n[o]=Die(t[o],e[o]):n[o]=e[o]}),n}function kke(t,e,n={}){let{state:o}=e,{doc:r,tr:i}=o,s=t;r.descendants((a,l)=>{let c=i.mapping.map(l),d=i.mapping.map(l)+a.nodeSize,u=null;if(a.marks.forEach(f=>{if(f!==s)return!1;u=f}),!u)return;let p=!1;if(Object.keys(n).forEach(f=>{n[f]!==u.attrs[f]&&(p=!0)}),p){let f=t.type.create({...t.attrs,...n});i.removeMark(c,d,t.type),i.addMark(c,d,f)}}),i.docChanged&&e.view.dispatch(i)}var R0=class{constructor(t){var e;this.find=t.find,this.handler=t.handler,this.undoable=(e=t.undoable)!=null?e:!0}},Rke=(t,e)=>{if(j6(e))return e.exec(t);let n=e(t);if(!n)return null;let o=[n.text];return o.index=n.index,o.input=t,o.data=n.data,n.replaceWith&&(n.text.includes(n.replaceWith)||console.warn('[tiptap warn]: "inputRuleMatch.replaceWith" must be part of "inputRuleMatch.text".'),o.push(n.replaceWith)),o};function GR(t){var e;let{editor:n,from:o,to:r,text:i,rules:s,plugin:a}=t,{view:l}=n;if(l.composing)return!1;let c=l.state.doc.resolve(o);if(c.parent.type.spec.code||(e=c.nodeBefore||c.nodeAfter)!=null&&e.marks.find(p=>p.type.spec.code))return!1;let d=!1,u=qAe(c)+i;return s.forEach(p=>{if(d)return;let f=Rke(u,p.find);if(!f)return;let h=f[0].length-i.length;if(h>0){let T=c.parentOffset-h;if(T<0||c.parent.textBetween(T,c.parentOffset)!==f[0].slice(0,h))return}let m=l.state.tr,y=ZR({state:l.state,transaction:m}),g={from:o-(f[0].length-i.length),to:r},{commands:S,chain:b,can:v}=new JR({editor:n,state:y});p.handler({state:y,range:g,match:f,commands:S,chain:b,can:v})===null||!m.steps.length||(p.undoable&&m.setMeta(a,{transform:m,from:o,to:r,text:i}),l.dispatch(m),d=!0)}),d}function Dke(t){let{editor:e,rules:n}=t,o=new Ye({state:{init(){return null},apply(r,i,s){let a=r.getMeta(o);if(a)return a;let l=r.getMeta("applyInputRules");return l&&setTimeout(()=>{let{text:d}=l;typeof d=="string"?d=d:d=t2(re.from(d),s.schema);let{from:u}=l,p=u+d.length;GR({editor:e,from:u,to:p,text:d,rules:n,plugin:o})}),r.selectionSet||r.docChanged?null:i}},props:{handleTextInput(r,i,s,a){return GR({editor:e,from:i,to:s,text:a,rules:n,plugin:o})},handleDOMEvents:{compositionend:r=>(setTimeout(()=>{let{$cursor:i}=r.state.selection;i&&GR({editor:e,from:i.pos,to:i.pos,text:"",rules:n,plugin:o})}),!1)},handleKeyDown(r,i){if(i.key!=="Enter")return!1;let{$cursor:s}=r.state.selection;return s?GR({editor:e,from:s.pos,to:s.pos,text:`
|
||||
`,rules:n,plugin:o}):!1}},isInputRules:!0});return o}var Z6=class{constructor(t={}){this.type="extendable",this.parent=null,this.child=null,this.name="",this.config={name:this.name},this.config={...this.config,...t},this.name=this.config.name}get options(){return{...mn(Oe(this,"addOptions",{name:this.name}))}}get storage(){return{...mn(Oe(this,"addStorage",{name:this.name,options:this.options}))}}configure(t={}){let e=this.extend({...this.config,addOptions:()=>Die(this.options,t)});return e.name=this.name,e.parent=this.parent,this.child=null,e}extend(t={}){let e=new this.constructor({...this.config,...t});return e.parent=this,this.child=e,e.name="name"in t?t.name:e.parent.name,e}},Us=class Oie extends Z6{constructor(){super(...arguments),this.type="mark"}static create(e={}){let n=typeof e=="function"?e():e;return new Oie(n)}static handleExit({editor:e,mark:n}){let{tr:o}=e.state,r=e.state.selection.$from;if(r.pos===r.end()){let s=r.marks();if(!!!s.find(c=>c?.type.name===n.name))return!1;let l=s.find(c=>c?.type.name===n.name);return l&&o.removeStoredMark(l),o.insertText(" ",r.pos),e.view.dispatch(o),!0}return!1}configure(e){return super.configure(e)}extend(e){let n=typeof e=="function"?e():e;return super.extend(n)}},J6=class{constructor(t){this.find=t.find,this.handler=t.handler}},Oke=(t,e,n)=>{if(j6(e))return[...t.matchAll(e)];let o=e(t,n);return o?o.map(r=>{let i=[r.text];return i.index=r.index,i.input=t,i.data=r.data,r.replaceWith&&(r.text.includes(r.replaceWith)||console.warn('[tiptap warn]: "pasteRuleMatch.replaceWith" must be part of "pasteRuleMatch.text".'),i.push(r.replaceWith)),i}):[]};function Lke(t){let{editor:e,state:n,from:o,to:r,rule:i,pasteEvent:s,dropEvent:a}=t,{commands:l,chain:c,can:d}=new JR({editor:e,state:n}),u=[];return n.doc.nodesBetween(o,r,(f,h)=>{var m,y,g,S,b;if((y=(m=f.type)==null?void 0:m.spec)!=null&&y.code||!(f.isText||f.isTextblock||f.isInline))return;let v=(b=(S=(g=f.content)==null?void 0:g.size)!=null?S:f.nodeSize)!=null?b:0,w=Math.max(o,h),T=Math.min(r,h+v);if(w>=T)return;let C=f.isText?f.text||"":f.textBetween(w-h,T-h,void 0,"\uFFFC");Oke(C,i.find,s).forEach(M=>{if(M.index===void 0)return;let E=w+M.index+1,I=E+M[0].length,R={from:n.tr.mapping.map(E),to:n.tr.mapping.map(I)},D=i.handler({state:n,range:R,match:M,commands:l,chain:c,can:d,pasteEvent:s,dropEvent:a});u.push(D)})}),u.every(f=>f!==null)}var WR=null,Nke=t=>{var e;let n=new ClipboardEvent("paste",{clipboardData:new DataTransfer});return(e=n.clipboardData)==null||e.setData("text/html",t),n};function Fke(t){let{editor:e,rules:n}=t,o=null,r=!1,i=!1,s=typeof ClipboardEvent<"u"?new ClipboardEvent("paste"):null,a;try{a=typeof DragEvent<"u"?new DragEvent("drop"):null}catch{a=null}let l=({state:d,from:u,to:p,rule:f,pasteEvt:h})=>{let m=d.tr,y=ZR({state:d,transaction:m});if(!(!Lke({editor:e,state:y,from:Math.max(u-1,0),to:p.b-1,rule:f,pasteEvent:h,dropEvent:a})||!m.steps.length)){try{a=typeof DragEvent<"u"?new DragEvent("drop"):null}catch{a=null}return s=typeof ClipboardEvent<"u"?new ClipboardEvent("paste"):null,m}};return n.map(d=>new Ye({view(u){let p=h=>{var m;o=(m=u.dom.parentElement)!=null&&m.contains(h.target)?u.dom.parentElement:null,o&&(WR=e)},f=()=>{WR&&(WR=null)};return window.addEventListener("dragstart",p),window.addEventListener("dragend",f),{destroy(){window.removeEventListener("dragstart",p),window.removeEventListener("dragend",f)}}},props:{handleDOMEvents:{drop:(u,p)=>{if(i=o===u.dom.parentElement,a=p,!i){let f=WR;f?.isEditable&&setTimeout(()=>{let h=f.state.selection;h&&f.commands.deleteRange({from:h.from,to:h.to})},10)}return!1},paste:(u,p)=>{var f;let h=(f=p.clipboardData)==null?void 0:f.getData("text/html");return s=p,r=!!h?.includes("data-pm-slice"),!1}}},appendTransaction:(u,p,f)=>{let h=u[0],m=h.getMeta("uiEvent")==="paste"&&!r,y=h.getMeta("uiEvent")==="drop"&&!i,g=h.getMeta("applyPasteRules"),S=!!g;if(!m&&!y&&!S)return;if(S){let{text:w}=g;typeof w=="string"?w=w:w=t2(re.from(w),f.schema);let{from:T}=g,C=T+w.length,P=Nke(w);return l({rule:d,state:f,from:T,to:{b:C},pasteEvt:P})}let b=p.doc.content.findDiffStart(f.doc.content),v=p.doc.content.findDiffEnd(f.doc.content);if(!(!Tke(b)||!v||b===v.b))return l({rule:d,state:f,from:b,to:v,pasteEvt:s})}}))}var i2=class{constructor(t,e){this.splittableMarks=[],this.nonClearableMarks=[],this.editor=e,this.baseExtensions=t,this.extensions=G6(t),this.schema=bie(this.extensions,e),this.setupExtensions()}get commands(){return this.extensions.reduce((t,e)=>{let n={name:e.name,options:e.options,storage:this.editor.extensionStorage[e.name],editor:this.editor,type:zP(e.name,this.schema)},o=Oe(e,"addCommands",n);return o?{...t,...o()}:t},{})}get plugins(){let{editor:t}=this;return VP([...this.extensions].reverse()).flatMap(o=>{let r={name:o.name,options:o.options,storage:this.editor.extensionStorage[o.name],editor:t,type:zP(o.name,this.schema)},i=[],s=Oe(o,"addKeyboardShortcuts",r),a={};if(o.type==="mark"&&Oe(o,"exitable",r)&&(a.ArrowRight=()=>Us.handleExit({editor:t,mark:o}
Line truncated
|
||||
|
||||
`)}}})]}}),Fie=Ft.create({name:"commands",addCommands(){return{...lie}}}),Bie=Ft.create({name:"delete",onUpdate({transaction:t,appendedTransactions:e}){var n,o,r;let i=()=>{var s,a,l,c;if((c=(l=(a=(s=this.editor.options.coreExtensionOptions)==null?void 0:s.delete)==null?void 0:a.filterTransaction)==null?void 0:l.call(a,t))!=null?c:t.getMeta("y-sync$"))return;let d=$6(t.before,[t,...e]);KP(d).forEach(f=>{d.mapping.mapResult(f.oldRange.from).deletedAfter&&d.mapping.mapResult(f.oldRange.to).deletedBefore&&d.before.nodesBetween(f.oldRange.from,f.oldRange.to,(h,m)=>{let y=m+h.nodeSize-2,g=f.oldRange.from<=m&&y<=f.oldRange.to;this.editor.emit("delete",{type:"node",node:h,from:m,to:y,newFrom:d.mapping.map(m),newTo:d.mapping.map(y),deletedRange:f.oldRange,newRange:f.newRange,partial:!g,editor:this.editor,transaction:t,combinedTransform:d})})});let p=d.mapping;d.steps.forEach((f,h)=>{var m,y;if(f instanceof Xp){let g=p.slice(h).map(f.from,-1),S=p.slice(h).map(f.to),b=p.invert().map(g,-1),v=p.invert().map(S),w=g>0?(m=d.doc.nodeAt(g-1))==null?void 0:m.marks.some(C=>C.eq(f.mark)):!1,T=(y=d.doc.nodeAt(S))==null?void 0:y.marks.some(C=>C.eq(f.mark));this.editor.emit("delete",{type:"mark",mark:f.mark,from:f.from,to:f.to,deletedRange:{from:b,to:v},newRange:{from:g,to:S},partial:!!(T||w),editor:this.editor,transaction:t,combinedTransform:d})}})};(r=(o=(n=this.editor.options.coreExtensionOptions)==null?void 0:n.delete)==null?void 0:o.async)==null||r?setTimeout(i,0):i()}}),zie=Ft.create({name:"drop",addProseMirrorPlugins(){return[new Ye({key:new Ct("tiptapDrop"),props:{handleDrop:(t,e,n,o)=>{this.editor.emit("drop",{editor:this.editor,event:e,slice:n,moved:o})}}})]}}),Uie=Ft.create({name:"editable",addProseMirrorPlugins(){return[new Ye({key:new Ct("editable"),props:{editable:()=>this.editor.options.editable}})]}}),Hie=new Ct("focusEvents"),Vie=Ft.create({name:"focusEvents",addProseMirrorPlugins(){let{editor:t}=this;return[new Ye({key:Hie,props:{handleDOMEvents:{focus:(e,n)=>{t.isFocused=!0;let o=t.state.tr.setMeta("focus",{event:n}).setMeta("addToHistory",!1);return e.dispatch(o),!1},blur:(e,n)=>{t.isFocused=!1;let o=t.state.tr.setMeta("blur",{event:n}).setMeta("addToHistory",!1);return e.dispatch(o),!1}}}})]}}),jie=Ft.create({name:"keymap",addKeyboardShortcuts(){let t=()=>this.editor.commands.first(({commands:s})=>[()=>s.undoInputRule(),()=>s.command(({tr:a})=>{let{selection:l,doc:c}=a,{empty:d,$anchor:u}=l,{pos:p,parent:f}=u,h=u.parent.isTextblock&&p>0?a.doc.resolve(p-1):u,m=h.parent.type.spec.isolating,y=u.pos-u.parentOffset,g=m&&h.parent.childCount===1?y===u.pos:Xe.atStart(c).from===p;return!d||!f.type.isTextblock||f.textContent.length||!g||g&&u.parent.type.name==="paragraph"?!1:s.clearNodes()}),()=>s.deleteSelection(),()=>s.joinBackward(),()=>s.selectNodeBackward()]),e=()=>this.editor.commands.first(({commands:s})=>[()=>s.deleteSelection(),()=>s.deleteCurrentNode(),()=>s.joinForward(),()=>s.selectNodeForward()]),o={Enter:()=>this.editor.commands.first(({commands:s})=>[()=>s.newlineInCode(),()=>s.createParagraphNear(),()=>s.liftEmptyBlock(),()=>s.splitBlock()]),"Mod-Enter":()=>this.editor.commands.exitCode(),Backspace:t,"Mod-Backspace":t,"Shift-Backspace":t,Delete:e,"Mod-Delete":e,"Mod-a":()=>this.editor.commands.selectAll()},r={...o},i={...o,"Ctrl-h":t,"Alt-Backspace":t,"Ctrl-d":e,"Ctrl-Alt-Backspace":e,"Alt-Delete":e,"Alt-d":e,"Ctrl-a":()=>this.editor.commands.selectTextblockStart(),"Ctrl-e":()=>this.editor.commands.selectTextblockEnd()};return XR()||hie()?i:r},addProseMirrorPlugins(){return[new Ye({key:new Ct("clearDocument"),appendTransaction:(t,e,n)=>{if(t.some(m=>m.getMeta("composition")))return;let o=t.some(m=>m.docChanged)&&!e.doc.eq(n.doc),r=t.some(m=>m.getMeta("preventClearDocument"));if(!o||r)return;let{empty:i,from:s,to:a}=e.selection,l=Xe.atStart(e.doc).from,c=Xe.atEnd(e.doc).to;if(i||!(s===l&&a===c)||!k0(n.doc))return;let p=n.tr,f=ZR({state:n,transaction:p}),{commands:h}=new JR({editor:this.editor,state:f});if(h.clearNodes(),!!p.steps.length)return p}})]}}),$ie=Ft.create({name:"paste",addProseMirrorPlugins(){return[new Ye({key:new Ct("tiptapPaste"),props:{handlePaste:(t,e,n)=>{this.editor.emit("paste",{editor:this.editor,event:e,slice:n})}}})]}}),Kie=Ft.create({name:"tabindex",addOptions(){return{value:void 0}},addProseMirrorPlugins(){return[new Ye({key:new Ct("tabindex"),props:{attributes:()=>{var t;return!this.editor.isEditable&&this.options.value===void 0?{}:{tabindex:(t=this.options.value)!=null?t:"0"}}}})]}}),Gie=Ft.create({name:"textDirection",addOptions(){return{direction:void 0}},addGlobalAttributes(){if(!this.options.direction)return[];let{nodeExtensions:t}=M0(this.extensions);return[{types:t.filter(e=>e.name!=="text").map(e=>e.name),attributes:{dir:{default:this.options.direction,parseHTML:e=>{let n=e.getAttribute("dir");return n&&(n==="ltr"||n==="rtl"||n==="auto")?n:this.options.direction},renderHTML:e=>e.dir?{dir:e.dir}:{}}}}]},addProseMirrorPlugins(){return[new Ye({key:new Ct("textDirection"),pr
Line truncated
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
white-space: break-spaces;
|
||||
-webkit-font-variant-ligatures: none;
|
||||
font-variant-ligatures: none;
|
||||
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ProseMirror [contenteditable="false"] [contenteditable="true"] {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
img.ProseMirror-separator {
|
||||
display: inline !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 20px;
|
||||
border-top: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection *::-moz-selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.ProseMirror-focused .ProseMirror-gapcursor {
|
||||
display: block;
|
||||
}`,Wie=class extends wke{constructor(t={}){super(),this.css=null,this.className="tiptap",this.editorView=null,this.isFocused=!1,this.destroyed=!1,this.isInitialized=!1,this.extensionStorage={},this.instanceId=Math.random().toString(36).slice(2,9),this.options={element:typeof document<"u"?document.createElement("div"):null,content:"",injectCSS:!0,injectNonce:void 0,extensions:[],autofocus:!1,editable:!0,textDirection:void 0,editorProps:{},parseOptions:{},coreExtensionOptions:{},enableInputRules:!0,enablePasteRules:!0,enableCoreExtensions:!0,enableContentCheck:!1,emitContentError:!1,onBeforeCreate:()=>null,onCreate:()=>null,onMount:()=>null,onUnmount:()=>null,onUpdate:()=>null,onSelectionUpdate:()=>null,onTransaction:()=>null,onFocus:()=>null,onBlur:()=>null,onDestroy:()=>null,onContentError:({error:n})=>{throw n},onPaste:()=>null,onDrop:()=>null,onDelete:()=>null,enableExtensionDispatchTransaction:!0},this.isCapturingTransaction=!1,this.capturedTransaction=null,this.utils={getUpdatedPosition:ZAe,createMappablePosition:JAe},this.setOptions(t),this.createExtensionManager(),this.createCommandManager(),this.createSchema(),this.on("beforeCreate",this.options.onBeforeCreate),this.emit("beforeCreate",{editor:this}),this.on("mount",this.options.onMount),this.on("unmount",this.options.onUnmount),this.on("contentError",this.options.onContentError),this.on("create",this.options.onCreate),this.on("update",this.options.onUpdate),this.on("selectionUpdate",this.options.onSelectionUpdate),this.on("transaction",this.options.onTransaction),this.on("focus",this.options.onFocus),this.on("blur",this.options.onBlur),this.on("destroy",this.options.onDestroy),this.on("drop",({event:n,slice:o,moved:r})=>this.options.onDrop(n,o,r)),this.on("paste",({event:n,slice:o})=>this.options.onPaste(n,o)),this.on("delete",this.options.onDelete);let e=this.createDoc();if(!this.editorState){let n=z6(e,this.options.autofocus);this.editorState=IP.create({doc:e,schema:this.schema,selection:n||void 0})}this.options.element&&this.mount(this.options.element)}mount(t){if(typeof document>"u")throw new Error("[tiptap error]: The editor cannot be mounted because there is no 'document' defined in this environment.");this.createView(t),this.emit("mount",{editor:this}),this.css&&!document.head.contains(this.css)&&document.head.appendChild(this.css),window.setTimeout(()=>{this.isDestroyed||(this.options.autofocus!==!1&&this.options.autofocus!==null&&this.commands.focus(this.options.autofocus),this.emit("create",{editor:this}),this.isInitialized=!0)},0)}unmount(){if(this.editorView){let t=this.editorView.dom;t?.editor&&delete t.editor,this.editorView.destroy()}if(this.editorView=null,this.isInitialized=!1,this.css&&!document.querySelectorAll(`.${this.className}`).length)try{typeof this.css.remove=="function"?this.css.remove():this.css.parentNode&&this.css.parentNode.removeChild(this.css)}catch(t){console.warn("Failed to remove CSS element:",t)}this.css=null,this.emit("unmount",{editor:this})}get storage(){return this.extensionStorage}get commands(){return this.commandManager.commands}chain(){return this.commandManager.chain()}can(){return this.commandManager.can()}injectCSS(){this.options.injectCSS&&typeof document<"u"&&(this.css=xke(zke,this.options.injectNonce))}setOptions(t={}){this.options={...this.options,...t},!(!this.editorView||!this.state||this.isDestroyed)&&(this.options.editorProps&&this.view.setProps(this.options.editorProps),this.view.updateState(this.state))}setEditable(t,e=!0){this.setOptions({editable:t}),e&&this.emit("update",{editor:this,transaction:this.state.tr,appendedTransactions:[]})}get isEditable(){return this.options.editable&&this.view&&this.view.editable}get view(){return this.editorView?this.editorView:new Proxy({state:this.editorState,updateState:t=>{this.editorState=t},dispatch:t=>{this.dispatchTransaction(t)},composing:!1,dragging:null,editable:!0,isDestroyed:!1},{get:(t,e)=>{if(this.editorView)return this.editorView[e];if(e==="state")return this.editorState;if(e in t)return Reflect.get(t,e);throw new Error(`[tiptap error]: The editor view is not available. Cannot access view['${e}']. The editor may not be mounted yet.`)}})}get state(){return this.editorView&&(this.editorState=this.view.state),this.editorState}registerPlugin(t,e){let n=yie(e)?e(t,[...this.state.plugins]):[...this.state.plugins,t],o=this.state.reconfigure({plugins:n});return this.view.updateState(o),o}unregisterPlugin(t){if(this.isDestroyed)return;let e=this.state.plugins,n=e;if([].concat(t).forEach(r=>{let i=typeof r=="string"?`${r}$`:r.key;n=n.filter(s=>!s.key.startsWith(i))}),e.length===n.length)return;let o=this.state.reconfigure({plugins:n});return this.view.updateState(o),o}createExtensionManager(){var t,e,n,o;let i=[...this.options.enableCoreExtensions?[Uie,Nie.configure({blockSeparator:(e=(t=this.options.coreExtensionOptions)==null?void 0:t.clipboardTextSerializer)==null?void 0:e.blockSeparator}),Fie,Vie,jie,Kie.configure({value:(o=(n=this.options.coreExt
Line truncated
|
||||
|
||||
`,textSerializers:n={}}=t||{};return Tie(this.state.doc,{blockSeparator:e,textSerializers:{...W6(this.schema),...n}})}get isEmpty(){return k0(this.state.doc)}destroy(){this.destroyed||(this.destroyed=!0,this.emit("destroy"),this.unmount(),this.removeAllListeners(),this.extensionManager.destroy(),this.extensionManager=null,this.schema=null,this.commandManager=null,this.extensionStorage={})}get isDestroyed(){var t,e;return(e=(t=this.editorView)==null?void 0:t.isDestroyed)!=null?e:!0}$node(t,e){var n;return((n=this.$doc)==null?void 0:n.querySelector(t,e))||null}$nodes(t,e){var n;return((n=this.$doc)==null?void 0:n.querySelectorAll(t,e))||null}$pos(t){let e=this.state.doc.resolve(t),n=t>0&&e.nodeAfter&&!e.nodeAfter.isText&&e.nodeAfter.isAtom?e.nodeAfter:null;return new Bke(e,this,!1,n)}get $doc(){return this.$pos(0)}};function xa(t){return new R0({find:t.find,handler:({state:e,range:n,match:o})=>{let r=mn(t.getAttributes,void 0,o);if(r===!1||r===null)return null;let{tr:i}=e,s=o[o.length-1],a=o[0];if(s){let l=a.search(/\S/),c=n.from+a.indexOf(s),d=c+s.length;if(n2(n.from,n.to,e.doc).filter(f=>f.mark.type.excluded.find(m=>m===t.type&&m!==f.mark.type)).filter(f=>f.to>c).length)return null;d<n.to&&i.delete(d,n.to),c>n.from&&i.delete(n.from+l,c);let p=n.from+l+s.length;i.addMark(n.from+l,p,t.type.create(r||{})),i.removeStoredMark(t.type)}},undoable:t.undoable})}function Yie(t){return new R0({find:t.find,handler:({state:e,range:n,match:o})=>{let r=mn(t.getAttributes,void 0,o)||{},{tr:i}=e,s=n.from,a=n.to,l=t.type.create(r);if(o[1]){let c=o[0].lastIndexOf(o[1]),d=s+c;d>a?d=a:a=d+o[1].length;let u=o[0][o[0].length-1];i.insertText(u,s+o[0].length-1),i.replaceWith(d,a,l)}else if(o[0]){let c=t.type.isInline?s:s-1;i.insert(c,t.type.create(r)).delete(i.mapping.map(s),i.mapping.map(a))}i.scrollIntoView()},undoable:t.undoable})}function WP(t){return new R0({find:t.find,handler:({state:e,range:n,match:o})=>{let r=e.doc.resolve(n.from),i=mn(t.getAttributes,void 0,o)||{};if(!r.node(-1).canReplaceWith(r.index(-1),r.indexAfter(-1),t.type))return null;e.tr.delete(n.from,n.to).setBlockType(n.from,n.from,t.type,i)},undoable:t.undoable})}function rd(t){return new R0({find:t.find,handler:({state:e,range:n,match:o,chain:r})=>{let i=mn(t.getAttributes,void 0,o)||{},s=e.tr.delete(n.from,n.to),l=s.doc.resolve(n.from).blockRange(),c=l&&v0(l,t.type,i);if(!c)return null;if(s.wrap(l,c),t.keepMarks&&t.editor){let{selection:u,storedMarks:p}=e,{splittableMarks:f}=t.editor.extensionManager,h=p||u.$to.parentOffset&&u.$from.marks();if(h){let m=h.filter(y=>f.includes(y.type.name));s.ensureMarks(m)}}if(t.keepAttributes){let u=t.type.name==="bulletList"||t.type.name==="orderedList"?"listItem":"taskList";r().updateAttributes(u,i).run()}let d=s.doc.resolve(n.from-1).nodeBefore;d&&d.type===t.type&&zl(s.doc,n.from-1)&&(!t.joinPredicate||t.joinPredicate(o,d))&&s.join(n.from-1)},undoable:t.undoable})}var co=class qie extends Z6{constructor(){super(...arguments),this.type="node"}static create(e={}){let n=typeof e=="function"?e():e;return new qie(n)}configure(e){return super.configure(e)}extend(e){let n=typeof e=="function"?e():e;return super.extend(n)}};function ds(t){return new J6({find:t.find,handler:({state:e,range:n,match:o,pasteEvent:r})=>{let i=mn(t.getAttributes,void 0,o,r);if(i===!1||i===null)return null;let{tr:s}=e,a=o[o.length-1],l=o[0],c=n.to;if(a){let d=l.search(/\S/),u=n.from+l.indexOf(a),p=u+a.length;if(n2(n.from,n.to,e.doc).filter(m=>m.mark.type.excluded.find(g=>g===t.type&&g!==m.mark.type)).filter(m=>m.to>u).length)return null;p<n.to&&s.delete(p,n.to),u>n.from&&s.delete(n.from+d,u),c=n.from+d+a.length,s.addMark(n.from+d,c,t.type.create(i||{})),o.index!==void 0&&o.input!==void 0&&o.index+o[0].length>=o.input.length||s.removeStoredMark(t.type)}}})}var Uke=new Mt;function Hke(t){return Uke.get(t,()=>$P(t.extensions??[]))}function id(t,e,n){let{tipTapConfig:o,addFontsFromNode:r}=t.getTextOptions();ne(o,"textOptions.tipTapConfig must be set to use rich text"),ne(r,"textOptions.addFontsFromNode must be set to use rich text");let i=Hke(o),s=Si.fromJSON(i,e),a=new Set;function l(d){a.add(d)}function c(d,u){u=r(d,u,l);for(let p of d.children)c(p,u)}return c(s,n),Array.from(a)}li("@tldraw/editor","5.2.5","esm");var Vke=t=>t*.65+wM(t*ct/2)*.35,jke=t=>({size:t,thinning:.5,streamline:Hr(t,[9,16],[.64,.74],!0),smoothing:.62,easing:jo.easeOutSine,simulatePressure:!0}),$ke=t=>({size:1+t*1.2,thinning:.62,streamline:.62,smoothing:.62,simulatePressure:!1,easing:Vke}),Xie=t=>({size:t,thinning:0,streamline:Hr(t,[9,16],[.64,.74],!0),smoothing:.62,simulatePressure:!1,easing:jo.linear}),Kke=t=>({size:t,thinning:0,streamline:.62,smoothing:.62,simulatePressure:!1,easing:jo.linear});function s2({strokeWidth:t,showAsComplete:e}){return{size:1+t,thinning:0,streamline:.5,smoothing:.5,simulatePressure:!1,easing:jo.easeOutSine,last:e}}function a2(t,e,n,o){let r=t.isComplete||n;return o?t.isPen?{...Kke(e),last:r}:{...Xie(e),last:r}:t.dash==="draw"?t.isPen?{...$ke(e),last:r}:
Line truncated
|
||||
`).map(u=>u.trim()===""?n:`${n} ${u}`);o.push(d.join(`
|
||||
`))}),o.join(`
|
||||
${n}
|
||||
`)},addCommands(){return{setBlockquote:()=>({commands:t})=>t.wrapIn(this.name),toggleBlockquote:()=>({commands:t})=>t.toggleWrap(this.name),unsetBlockquote:()=>({commands:t})=>t.lift(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-b":()=>this.editor.commands.toggleBlockquote(),Backspace:()=>hRe(this.editor,this.type)}},addInputRules(){return[rd({find:mRe,type:this.type})]}});var gRe=/(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/,yRe=/(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))/g,SRe=/(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))$/,bRe=/(?:^|\s)(__(?!\s+__)((?:[^_]+))__(?!\s+__))/g,mse=Us.create({name:"bold",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"strong"},{tag:"b",getAttrs:t=>t.style.fontWeight!=="normal"&&null},{style:"font-weight=400",clearMark:t=>t.type.name===this.name},{style:"font-weight",getAttrs:t=>/^(bold(er)?|[5-9]\d{2,})$/.test(t)&&null}]},renderHTML({HTMLAttributes:t}){return H0("strong",{...It(this.options.HTMLAttributes,t),children:H0("slot",{})})},markdownTokenName:"strong",parseMarkdown:(t,e)=>e.applyMark("bold",e.parseInline(t.tokens||[])),markdownOptions:{htmlReopen:{open:"<strong>",close:"</strong>"}},renderMarkdown:(t,e)=>`**${e.renderChildren(t)}**`,addCommands(){return{setBold:()=>({commands:t})=>t.setMark(this.name),toggleBold:()=>({commands:t})=>t.toggleMark(this.name),unsetBold:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-b":()=>this.editor.commands.toggleBold(),"Mod-B":()=>this.editor.commands.toggleBold()}},addInputRules(){return[xa({find:gRe,type:this.type}),xa({find:SRe,type:this.type})]},addPasteRules(){return[ds({find:yRe,type:this.type}),ds({find:bRe,type:this.type})]}});var dV=4,vRe=/^```([a-z]+)?[\s\n]$/,wRe=/^~~~([a-z]+)?[\s\n]$/,gse=co.create({name:"codeBlock",addOptions(){return{languageClassPrefix:"language-",exitOnTripleEnter:!0,exitOnArrowDown:!0,exitOnArrowUp:!0,defaultLanguage:null,enableTabIndentation:!1,tabSize:dV,HTMLAttributes:{}}},content:"text*",marks:"",group:"block",code:!0,defining:!0,addAttributes(){return{language:{default:this.options.defaultLanguage,parseHTML:t=>{var e;let{languageClassPrefix:n}=this.options;if(!n)return null;let i=[...((e=t.firstElementChild)==null?void 0:e.classList)||[]].filter(s=>s.startsWith(n)).map(s=>s.replace(n,""))[0];return i||null},rendered:!1}}},parseHTML(){return[{tag:"pre",preserveWhitespace:"full"}]},renderHTML({node:t,HTMLAttributes:e}){return["pre",It(this.options.HTMLAttributes,e),["code",{class:t.attrs.language?this.options.languageClassPrefix+t.attrs.language:null},0]]},markdownTokenName:"code",parseMarkdown:(t,e)=>{var n,o;return((n=t.raw)==null?void 0:n.startsWith("```"))===!1&&((o=t.raw)==null?void 0:o.startsWith("~~~"))===!1&&t.codeBlockStyle!=="indented"?[]:e.createNode("codeBlock",{language:t.lang||null},t.text?[e.createTextNode(t.text)]:[])},renderMarkdown:(t,e)=>{var n;let o="",r=((n=t.attrs)==null?void 0:n.language)||"";return t.content?o=[`\`\`\`${r}`,e.renderChildren(t.content),"```"].join(`
|
||||
`):o=`\`\`\`${r}
|
||||
|
||||
\`\`\``,o},addCommands(){return{setCodeBlock:t=>({commands:e})=>e.setNode(this.name,t),toggleCodeBlock:t=>({commands:e})=>e.toggleNode(this.name,"paragraph",t)}},addKeyboardShortcuts(){return{"Mod-Alt-c":()=>this.editor.commands.toggleCodeBlock(),Backspace:()=>{let{empty:t,$anchor:e}=this.editor.state.selection,n=e.pos===1;return!t||e.parent.type.name!==this.name?!1:n||!e.parent.textContent.length?this.editor.commands.clearNodes():!1},Tab:({editor:t})=>{var e;if(!this.options.enableTabIndentation)return!1;let n=(e=this.options.tabSize)!=null?e:dV,{state:o}=t,{selection:r}=o,{$from:i,empty:s}=r;if(i.parent.type!==this.type)return!1;let a=" ".repeat(n);return s?t.commands.insertContent(a):t.commands.command(({tr:l})=>{let{from:c,to:d}=r,f=o.doc.textBetween(c,d,`
|
||||
`,`
|
||||
`).split(`
|
||||
`).map(h=>a+h).join(`
|
||||
`);return l.replaceWith(c,d,o.schema.text(f)),!0})},"Shift-Tab":({editor:t})=>{var e;if(!this.options.enableTabIndentation)return!1;let n=(e=this.options.tabSize)!=null?e:dV,{state:o}=t,{selection:r}=o,{$from:i,empty:s}=r;return i.parent.type!==this.type?!1:s?t.commands.command(({tr:a})=>{var l;let{pos:c}=i,d=i.start(),u=i.end(),f=o.doc.textBetween(d,u,`
|
||||
`,`
|
||||
`).split(`
|
||||
`),h=0,m=0,y=c-d;for(let T=0;T<f.length;T+=1){if(m+f[T].length>=y){h=T;break}m+=f[T].length+1}let S=((l=f[h].match(/^ */))==null?void 0:l[0])||"",b=Math.min(S.length,n);if(b===0)return!0;let v=d;for(let T=0;T<h;T+=1)v+=f[T].length+1;return a.delete(v,v+b),c-v<=b&&a.setSelection(Ee.create(a.doc,v)),!0}):t.commands.command(({tr:a})=>{let{from:l,to:c}=r,p=o.doc.textBetween(l,c,`
|
||||
`,`
|
||||
`).split(`
|
||||
`).map(f=>{var h;let m=((h=f.match(/^ */))==null?void 0:h[0])||"",y=Math.min(m.length,n);return f.slice(y)}).join(`
|
||||
`);return a.replaceWith(l,c,o.schema.text(p)),!0})},Enter:({editor:t})=>{if(!this.options.exitOnTripleEnter)return!1;let{state:e}=t,{selection:n}=e,{$from:o,empty:r}=n;if(!r||o.parent.type!==this.type)return!1;let i=o.parentOffset===o.parent.nodeSize-2,s=o.parent.textContent.endsWith(`
|
||||
|
||||
`);return!i||!s?!1:t.chain().command(({tr:a})=>(a.delete(o.pos-2,o.pos),!0)).exitCode().run()},ArrowUp:({editor:t})=>{if(!this.options.exitOnArrowUp)return!1;let{state:e}=t,{selection:n}=e,{$from:o,empty:r}=n;if(!r||o.parent.type!==this.type||o.parentOffset!==0)return!1;let i=o.before();return i>0?!1:t.commands.insertDefaultBlock({pos:i})},ArrowDown:({editor:t})=>{if(!this.options.exitOnArrowDown)return!1;let{state:e}=t,{selection:n,doc:o}=e,{$from:r,empty:i}=n;if(!i||r.parent.type!==this.type||!(r.parentOffset===r.parent.nodeSize-2))return!1;let a=r.after();return a===void 0?!1:o.nodeAt(a)?t.commands.command(({tr:c})=>(c.setSelection(Xe.near(o.resolve(a))),!0)):t.commands.exitCode()}}},addInputRules(){return[WP({find:vRe,type:this.type,getAttributes:t=>({language:t[1]})}),WP({find:wRe,type:this.type,getAttributes:t=>({language:t[1]})})]},addProseMirrorPlugins(){return[new Ye({key:new Ct("codeBlockVSCodeHandler"),props:{handlePaste:(t,e)=>{if(!e.clipboardData||this.editor.isActive(this.type.name))return!1;let n=e.clipboardData.getData("text/plain"),o=e.clipboardData.getData("vscode-editor-data"),r=o?JSON.parse(o):void 0,i=r?.mode;if(!n||!i)return!1;let{tr:s,schema:a}=t.state,l=a.text(n.replace(/\r\n?/g,`
|
||||
`));return s.replaceSelectionWith(this.type.create({language:i},l)),s.selection.$from.parent.type!==this.type&&s.setSelection(Ee.near(s.doc.resolve(Math.max(0,s.selection.from-2)))),s.setMeta("paste",!0),t.dispatch(s),!0}}})]}});var yse=co.create({name:"doc",topNode:!0,content:"block+",renderMarkdown:(t,e)=>t.content?e.renderChildren(t.content,`
|
||||
|
||||
`):""});var Sse=co.create({name:"hardBreak",markdownTokenName:"br",addOptions(){return{keepMarks:!0,HTMLAttributes:{}}},inline:!0,group:"inline",selectable:!1,linebreakReplacement:!0,parseHTML(){return[{tag:"br"}]},renderHTML({HTMLAttributes:t}){return["br",It(this.options.HTMLAttributes,t)]},renderText(){return`
|
||||
`},renderMarkdown:()=>`
|
||||
`,parseMarkdown:()=>({type:"hardBreak"}),addCommands(){return{setHardBreak:()=>({commands:t,chain:e,state:n,editor:o})=>t.first([()=>t.exitCode(),()=>t.command(()=>{let{selection:r,storedMarks:i}=n;if(r.$from.parent.type.spec.isolating)return!1;let{keepMarks:s}=this.options,{splittableMarks:a}=o.extensionManager,l=i||r.$to.parentOffset&&r.$from.marks();return e().insertContent({type:this.name}).command(({tr:c,dispatch:d})=>{if(d&&l&&s){let u=l.filter(p=>a.includes(p.type.name));c.ensureMarks(u)}return!0}).scrollIntoView().run()})])}},addKeyboardShortcuts(){return{"Mod-Enter":()=>this.editor.commands.setHardBreak(),"Shift-Enter":()=>this.editor.commands.setHardBreak()}}});var bse=co.create({name:"heading",addOptions(){return{levels:[1,2,3,4,5,6],HTMLAttributes:{}}},content:"inline*",group:"block",defining:!0,addAttributes(){return{level:{default:1,rendered:!1}}},parseHTML(){return this.options.levels.map(t=>({tag:`h${t}`,attrs:{level:t}}))},renderHTML({node:t,HTMLAttributes:e}){return[`h${this.options.levels.includes(t.attrs.level)?t.attrs.level:this.options.levels[0]}`,It(this.options.HTMLAttributes,e),0]},parseMarkdown:(t,e)=>e.createNode("heading",{level:t.depth||1},e.parseInline(t.tokens||[])),renderMarkdown:(t,e)=>{var n;let o=(n=t.attrs)!=null&&n.level?parseInt(t.attrs.level,10):1,r="#".repeat(o);return t.content?`${r} ${e.renderChildren(t.content)}`:""},addCommands(){return{setHeading:t=>({commands:e})=>this.options.levels.includes(t.level)?e.setNode(this.name,t):!1,toggleHeading:t=>({commands:e})=>this.options.levels.includes(t.level)?e.toggleNode(this.name,"paragraph",t):!1}},addKeyboardShortcuts(){return this.options.levels.reduce((t,e)=>({...t,[`Mod-Alt-${e}`]:()=>this.editor.commands.toggleHeading({level:e})}),{})},addInputRules(){return this.options.levels.map(t=>WP({find:new RegExp(`^(#{${Math.min(...this.options.levels)},${t}})\\s$`),type:this.type,getAttributes:{level:t}}))}});var vse=co.create({name:"horizontalRule",addOptions(){return{HTMLAttributes:{},nextNodeType:"paragraph"}},group:"block",parseHTML(){return[{tag:"hr"}]},renderHTML({HTMLAttributes:t}){return["hr",It(this.options.HTMLAttributes,t)]},markdownTokenName:"hr",parseMarkdown:(t,e)=>e.createNode("horizontalRule"),renderMarkdown:()=>"---",addCommands(){return{setHorizontalRule:()=>({chain:t,state:e})=>{if(!kie(e,e.schema.nodes[this.name]))return!1;let{selection:n}=e,{$to:o}=n,r=t();return o2(n)?r.insertContentAt(o.pos,{type:this.name}):r.insertContent({type:this.name}),r.command(({state:i,tr:s,dispatch:a})=>{if(a){let{$to:l}=s.selection,c=l.end();if(l.nodeAfter)l.nodeAfter.isTextblock?s.setSelection(Ee.create(s.doc,l.pos+1)):l.nodeAfter.isBlock?s.setSelection(ke.create(s.doc,l.pos)):s.setSelection(Ee.create(s.doc,l.pos));else{let d=i.schema.nodes[this.options.nextNodeType]||l.parent.type.contentMatch.defaultType,u=d?.create();u&&(s.insert(c,u),s.setSelection(Ee.create(s.doc,c+1)))}s.scrollIntoView()}return!0}).run()}}},addInputRules(){return[Yie({find:/^(?:---|—-|___\s|\*\*\*\s)$/,type:this.type})]}});var xRe=/(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/,TRe=/(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))/g,CRe=/(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/,PRe=/(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))/g,wse=Us.create({name:"italic",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"em"},{tag:"i",getAttrs:t=>t.style.fontStyle!=="normal"&&null},{style:"font-style=normal",clearMark:t=>t.type.name===this.name},{style:"font-style=italic"}]},renderHTML({HTMLAttributes:t}){return["em",It(this.options.HTMLAttributes,t),0]},addCommands(){return{setItalic:()=>({commands:t})=>t.setMark(this.name),toggleItalic:()=>({commands:t})=>t.toggleMark(this.name),unsetItalic:()=>({commands:t})=>t.unsetMark(this.name)}},markdownTokenName:"em",parseMarkdown:(t,e)=>e.applyMark("italic",e.parseInline(t.tokens||[])),markdownOptions:{htmlReopen:{open:"<em>",close:"</em>"}},renderMarkdown:(t,e)=>`*${e.renderChildren(t)}*`,addKeyboardShortcuts(){return{"Mod-i":()=>this.editor.commands.toggleItalic(),"Mod-I":()=>this.editor.commands.toggleItalic()}},addInputRules(){return[xa({find:xRe,type:this.type}),xa({find:CRe,type:this.type})]},addPasteRules(){return[ds({find:TRe,type:this.type}),ds({find:PRe,type:this.type})]}});var ERe="aaa1rp3bb0ott3vie4c1le2ogado5udhabi7c0ademy5centure6ountant0s9o1tor4d0s1ult4e0g1ro2tna4f0l1rica5g0akhan5ency5i0g1rbus3force5tel5kdn3l0ibaba4pay4lfinanz6state5y2sace3tom5m0azon4ericanexpress7family11x2fam3ica3sterdam8nalytics7droid5quan4z2o0l2partments8p0le4q0uarelle8r0ab1mco4chi3my2pa2t0e3s0da2ia2sociates9t0hleta5torney7u0ction5di0ble3o3spost5thor3o0s4w0s2x0a2z0ure5ba0by2idu3namex4d1k2r0celona5laycard4s5efoot5gains6seball5ketball8uhaus5yern5b0c1t1va3cg1n2d1e0ats2uty4er2rlin4st0buy5t2f1g1h0arti5i0ble3d1ke2ng0o3o1z2j1lack0friday9ockbuster8g1omberg7ue3m0s1w2n0pparibas9o0ats3ehringer8fa2m1nd2o0k0ing5sch2tik2on4t1utique6x2r0adesco6idgestone9oadway5ker3ther5ussels7s1t1uild0ers6siness6y1zz3v1w1y1z0h3ca0b1fe2l0l1vinklein9m0era3p2non3p
Line truncated
|
||||
`,RRe="\uFE0F",DRe="\u200D",gV="\uFFFC",v2=null,w2=null;function ORe(t=[]){let e={};Ca.groups=e;let n=new Ca;v2==null&&(v2=Cse(ERe)),w2==null&&(w2=Cse(IRe)),ae(n,"'",z2),ae(n,"{",oE),ae(n,"}",rE),ae(n,"[",C2),ae(n,"]",P2),ae(n,"(",E2),ae(n,")",I2),ae(n,"<",_2),ae(n,">",M2),ae(n,"\uFF08",A2),ae(n,"\uFF09",k2),ae(n,"\u300C",R2),ae(n,"\u300D",D2),ae(n,"\u300E",O2),ae(n,"\u300F",L2),ae(n,"\uFF1C",N2),ae(n,"\uFF1E",F2),ae(n,"&",B2),ae(n,"*",U2),ae(n,"@",gm),ae(n,"`",V2),ae(n,"^",j2),ae(n,":",iS),ae(n,",",_V),ae(n,"$",$2),ae(n,".",ad),ae(n,"=",K2),ae(n,"!",MV),ae(n,"-",Gl),ae(n,"%",iE),ae(n,"|",G2),ae(n,"+",W2),ae(n,"#",Y2),ae(n,"?",sE),ae(n,'"',AV),ae(n,"/",ld),ae(n,";",kV),ae(n,"~",aE),ae(n,"_",q2),ae(n,"\\",H2),ae(n,"\u30FB",Ase);let o=or(n,cf,EV,{[SV]:!0});or(o,cf,o);let r=or(o,lf,_se,{[nE]:!0}),i=or(o,eE,Mse,{[tE]:!0}),s=or(n,lf,uf,{[bV]:!0});or(s,cf,r),or(s,lf,s),or(r,cf,r),or(r,lf,r);let a=or(n,eE,xV,{[vV]:!0});or(a,lf),or(a,cf,i),or(a,eE,a),or(i,cf,i),or(i,lf),or(i,eE,i);let l=ae(n,mV,IV,{[pV]:!0}),c=ae(n,Tse,PV,{[pV]:!0}),d=or(n,hV,PV,{[pV]:!0});ae(n,gV,d),ae(c,mV,l),ae(c,gV,d),or(c,hV,d),ae(d,Tse),ae(d,mV),or(d,hV,d),ae(d,gV,d);let u=or(n,fV,kse,{[Ise]:!0});ae(u,"#"),or(u,fV,u),ae(u,RRe,u);let p=ae(u,DRe);ae(p,"#"),or(p,fV,u);let f=[[lf,s],[cf,r]],h=[[lf,null],[eE,a],[cf,i]];for(let m=0;m<v2.length;m++)mm(n,v2[m],TV,uf,f);for(let m=0;m<w2.length;m++)mm(n,w2[m],CV,xV,h);rS(TV,{tld:!0,ascii:!0},e),rS(CV,{utld:!0,alpha:!0},e),mm(n,"file",T2,uf,f),mm(n,"mailto",T2,uf,f),mm(n,"http",V0,uf,f),mm(n,"https",V0,uf,f),mm(n,"ftp",V0,uf,f),mm(n,"ftps",V0,uf,f),rS(T2,{scheme:!0,ascii:!0},e),rS(V0,{slashscheme:!0,ascii:!0},e),t=t.sort((m,y)=>m[0]>y[0]?1:-1);for(let m=0;m<t.length;m++){let y=t[m][0],S=t[m][1]?{[_Re]:!0}:{[MRe]:!0};y.indexOf("-")>=0?S[wV]=!0:lf.test(y)?cf.test(y)?S[nE]=!0:S[bV]=!0:S[SV]=!0,xse(n,y,y,S)}return xse(n,"localhost",lE,{ascii:!0}),n.jd=new Ca(X2),{start:n,tokens:Object.assign({groups:e},Rse)}}function Dse(t,e){let n=LRe(e.replace(/[A-Z]/g,a=>a.toLowerCase())),o=n.length,r=[],i=0,s=0;for(;s<o;){let a=t,l=null,c=0,d=null,u=-1,p=-1;for(;s<o&&(l=a.go(n[s]));)a=l,a.accepts()?(u=0,p=0,d=a):u>=0&&(u+=n[s].length,p++),c+=n[s].length,i+=n[s].length,s++;i-=u,s-=p,c-=u,r.push({t:d.t,v:e.slice(i-c,i),s:i-c,e:i})}return r}function LRe(t){let e=[],n=t.length,o=0;for(;o<n;){let r=t.charCodeAt(o),i,s=r<55296||r>56319||o+1===n||(i=t.charCodeAt(o+1))<56320||i>57343?t[o]:t.slice(o,o+2);e.push(s),o+=s.length}return e}function mm(t,e,n,o,r){let i,s=e.length;for(let a=0;a<s-1;a++){let l=e[a];t.j[l]?i=t.j[l]:(i=new Ca(o),i.jr=r.slice(),t.j[l]=i),t=i}return i=new Ca(n),i.jr=r.slice(),t.j[e[s-1]]=i,i}function Cse(t){let e=[],n=[],o=0,r="0123456789";for(;o<t.length;){let i=0;for(;r.indexOf(t[o+i])>=0;)i++;if(i>0){e.push(n.join(""));for(let s=parseInt(t.substring(o,o+i),10);s>0;s--)n.pop();o+=i}else n.push(t[o]),o++}return e}var cE={defaultProtocol:"http",events:null,format:Pse,formatHref:Pse,nl2br:!1,tagName:"a",target:null,rel:null,validate:!0,truncate:1/0,className:null,attributes:null,ignoreTags:[],render:null};function RV(t,e=null){let n=Object.assign({},cE);t&&(n=Object.assign(n,t instanceof RV?t.o:t));let o=n.ignoreTags,r=[];for(let i=0;i<o.length;i++)r.push(o[i].toUpperCase());this.o=n,e&&(this.defaultRender=e),this.ignoreTags=r}RV.prototype={o:cE,ignoreTags:[],defaultRender(t){return t},check(t){return this.get("validate",t.toString(),t)},get(t,e,n){let o=e!=null,r=this.o[t];return r&&(typeof r=="object"?(r=n.t in r?r[n.t]:cE[t],typeof r=="function"&&o&&(r=r(e,n))):typeof r=="function"&&o&&(r=r(e,n.t,n)),r)},getObj(t,e,n){let o=this.o[t];return typeof o=="function"&&e!=null&&(o=o(e,n.t,n)),o},render(t){let e=t.render(this);return(this.get("render",null,t)||this.defaultRender)(e,t.t,t)}};function Pse(t){return t}function Ose(t,e){this.t="token",this.v=t,this.tk=e}Ose.prototype={isLink:!1,toString(){return this.v},toHref(t){return this.toString()},toFormattedString(t){let e=this.toString(),n=t.get("truncate",e,this),o=t.get("format",e,this);return n&&o.length>n?o.substring(0,n)+"\u2026":o},toFormattedHref(t){return t.get("formatHref",this.toHref(t.get("defaultProtocol")),this)},startIndex(){return this.tk[0].s},endIndex(){return this.tk[this.tk.length-1].e},toObject(t=cE.defaultProtocol){return{type:this.t,value:this.toString(),isLink:this.isLink,href:this.toHref(t),start:this.startIndex(),end:this.endIndex()}},toFormattedObject(t){return{type:this.t,value:this.toFormattedString(t),isLink:this.isLink,href:this.toFormattedHref(t),start:this.startIndex(),end:this.endIndex()}},validate(t){return t.get("validate",this.toString(),this)},render(t){let e=this,n=this.toHref(t.get("defaultProtocol")),o=t.get("formatHref",n,this),r=t.get("tagName",n,e),i=this.toFormattedString(t),s={},a=t.get("className",n,e),l=t.get("target",n,e),c=t.get("rel",n,e),d=t.getObj("attributes",n,e),u=t.getObj("events",n,e);return s.href=o,a&&(s.class=a),l&&(s.target=l),c&&(s.rel=c),d&&Object.assign(s,d),{tagName:r,attributes:s,content:i,eveLine truncated
|
||||
1. Must only contain digits, lowercase ASCII letters or "-"
|
||||
2. Cannot start or end with "-"
|
||||
3. "-" cannot repeat`);Po.customSchemes.push([t,e])}function VRe(){Po.scanner=ORe(Po.customSchemes);for(let t=0;t<Po.tokenQueue.length;t++)Po.tokenQueue[t][1]({scanner:Po.scanner});Po.parser=BRe(Po.scanner.tokens);for(let t=0;t<Po.pluginQueue.length;t++)Po.pluginQueue[t][1]({scanner:Po.scanner,parser:Po.parser});return Po.initialized=!0,Po}function J2(t){return Po.initialized||VRe(),zRe(Po.parser.start,t,Dse(Po.scanner.start,t))}J2.scan=Dse;function Q2(t,e=null,n=null){if(e&&typeof e=="object"){if(n)throw Error(`linkifyjs: Invalid link type ${e}; must be a string`);n=e,e=null}let o=new RV(n),r=J2(t),i=[];for(let s=0;s<r.length;s++){let a=r[s];a.isLink&&(!e||a.t===e)&&o.check(a)&&i.push(a.toFormattedObject(o))}return i}var OV="[\0- \xA0\u1680\u180E\u2000-\u2029\u205F\u3000]",jRe=new RegExp(OV),$Re=new RegExp(`${OV}$`),KRe=new RegExp(OV,"g");function GRe(t){return t.length===1?t[0].isLink:t.length===3&&t[1].isLink?["()","[]"].includes(t[0].value+t[2].value):!1}function WRe(t){return new Ye({key:new Ct("autolink"),appendTransaction:(e,n,o)=>{let r=e.some(c=>c.docChanged)&&!n.doc.eq(o.doc),i=e.some(c=>c.getMeta("preventAutolink"));if(!r||i)return;let{tr:s}=o,a=$6(n.doc,[...e]);if(KP(a).forEach(({newRange:c})=>{let d=gie(o.doc,c,f=>f.isTextblock),u,p;if(d.length>1)u=d[0],p=o.doc.textBetween(u.pos,u.pos+u.node.nodeSize,void 0," ");else if(d.length){let f=o.doc.textBetween(c.from,c.to," "," ");if(!$Re.test(f))return;u=d[0],p=o.doc.textBetween(u.pos,c.to,void 0," ")}if(u&&p){let f=p.split(jRe).filter(Boolean);if(f.length<=0)return!1;let h=f[f.length-1],m=u.pos+p.lastIndexOf(h);if(!h)return!1;let y=J2(h).map(g=>g.toObject(t.defaultProtocol));if(!GRe(y))return!1;y.filter(g=>g.isLink).map(g=>({...g,from:m+g.start+1,to:m+g.end+1})).filter(g=>o.schema.marks.code?!o.doc.rangeHasMark(g.from,g.to,o.schema.marks.code):!0).filter(g=>t.validate(g.value)).filter(g=>t.shouldAutoLink(g.value)).forEach(g=>{n2(g.from,g.to,o.doc).some(S=>S.mark.type===t.type)||s.addMark(g.from,g.to,t.type.create({href:g.href}))})}}),!!s.steps.length)return s}})}function YRe(t){return new Ye({key:new Ct("handleClickLink"),props:{handleClick:(e,n,o)=>{var r,i;if(o.button!==0||!e.editable)return!1;let s=null;if(o.target instanceof HTMLAnchorElement)s=o.target;else{let l=o.target;if(!l)return!1;let c=t.editor.view.dom;s=l.closest("a"),s&&!c.contains(s)&&(s=null)}if(!s)return!1;let a=!1;if(t.enableClickSelection&&(a=t.editor.commands.extendMarkRange(t.type.name)),t.openOnClick){let l=Y6(e.state,t.type.name),c=(r=s.href)!=null?r:l.href,d=(i=s.target)!=null?i:l.target;c&&(window.open(c,d),a=!0)}return a}}})}var qRe=/\[([^[\]]+)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+(?:(["'])(.*?)\3|“(.*?)”|‘(.*?)’))?\)$/,XRe=/\[([^[\]]+)\]\(((?:[^\s()]|\([^\s()]*\))+)(?:\s+(?:(["'])(.*?)\3|“(.*?)”|‘(.*?)’))?\)/g;function Nse(t,e){let n=0;for(let o=e-1;o>=0&&t[o]==="\\";o-=1)n+=1;return n%2===1}function ZRe(t,e){let n=0,o=0;for(;o<e;){if(t[o]!=="`"){o+=1;continue}if(n===0&&Nse(t,o)){o+=1;continue}let r=0;for(;o<e&&t[o]==="`";)r+=1,o+=1;n===0?n=r:r===n&&(n=0)}return n>0}function Fse(t,e,n){var o,r;let[,i,s]=e;return(e.index?t[e.index-1]:void 0)==="!"||Nse(t,(o=e.index)!=null?o:0)||ZRe(t,(r=e.index)!=null?r:0)?!1:!!i.trim()&&n(s)}function Bse(t){var e,n;let[o,r,i,,s,a,l]=t,c=(e=s??a)!=null?e:l;return{index:(n=t.index)!=null?n:0,text:o,replaceWith:r,data:{href:i,title:c||null,markdown:!0}}}function JRe(t,e){return t.index<e.index+e.text.length&&e.index<t.index+t.text.length}function zse(t){var e,n,o;return{href:(e=t.data)==null?void 0:e.href,title:(o=(n=t.data)==null?void 0:n.title)!=null?o:null}}function QRe(t){let e=xa({find:n=>{let o=qRe.exec(n);return!o||!Fse(n,o,t.isAllowedHref)?null:Bse(o)},type:t.type,getAttributes:zse});return new R0({find:e.find,handler:n=>{let o=e.handler(n);return o!==null&&n.state.tr.steps.length&&n.state.tr.setMeta("preventAutolink",!0),o}})}function e2e(t){let e=ds({find:n=>{var o,r;let i=[];for(let a of n.matchAll(XRe))Fse(n,a,t.isAllowedHref)&&i.push(Bse(a));let s=((r=(o=t.findPlainUrls)==null?void 0:o.call(t,n))!=null?r:[]).filter(a=>!i.some(l=>JRe(l,a)));return[...i,...s]},type:t.type,getAttributes:zse});return new J6({find:e.find,handler:n=>{var o;let r=e.handler(n);return r!==null&&n.state.tr.steps.length&&((o=n.match.data)!=null&&o.markdown)&&n.state.tr.setMeta("preventAutolink",!0),r}})}function t2e(t){return new Ye({key:new Ct("handlePasteLink"),props:{handlePaste:(e,n,o)=>{let{shouldAutoLink:r}=t,{state:i}=e,{selection:s}=i,{empty:a}=s;if(a)return!1;let l="";o.content.forEach(d=>{l+=d.textContent});let c=Q2(l,{defaultProtocol:t.defaultProtocol}).find(d=>d.isLink&&d.value===l);return!l||!c||r!==void 0&&!r(c.value)?!1:t.editor.commands.setMark(t.type,{href:c.href})}}})}function df(t,e){let n=["http","https","ftp","ftps","mailto","tel","callto","sms","cid","xmpp"];return e&&e.forEach(o=>{let r=typeof o=="string"?o:o.scheme;r&&n.push(r)}),!t||t.replace(KRe,"").match(new RegExp(`^(?:(?:${n.map(o=>o.replace(/[-/\\^$*+?.()|[\]{Line truncated
|
||||
`):"",markdownOptions:{indentsContent:!0},addCommands(){return{toggleBulletList:()=>({commands:t,chain:e})=>this.options.keepAttributes?e().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(r2e,this.editor.getAttributes(Hse)).run():t.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{"Mod-Shift-8":()=>this.editor.commands.toggleBulletList()}},addInputRules(){let t=rd({find:Vse,type:this.type});return(this.options.keepMarks||this.options.keepAttributes)&&(t=rd({find:Vse,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:()=>this.editor.getAttributes(Hse),editor:this.editor})),[t]}}),i2e=(t,e,n)=>{let{selection:o}=t;if(!o.empty)return null;let{$from:r}=o;if(!r.parent.isTextblock||r.parentOffset!==r.parent.content.size)return null;let i=-1;for(let f=r.depth;f>0;f-=1)if(r.node(f).type.name===e){i=f;break}if(i<0)return null;let s=r.node(i),a=r.index(i);if(a+1>=s.childCount)return null;let l=s.child(a+1);if(!n.includes(l.type.name))return null;let c=t.schema.nodes[e],d=!1;if(l.forEach(f=>{f.type===c&&f.childCount>1&&(d=!0)}),!d)return null;let u=t.doc.resolve(r.after()).nodeAfter;if(!u||!n.includes(u.type.name))return null;let p=[];return u.forEach(f=>{p.push(f)}),p.length===0?null:{listItemDepth:i,nestedList:u,nestedListPos:r.after(),insertPos:r.after(i),items:p}},s2e=(t,e,n,o)=>{let r=i2e(t,n,o);if(!r)return!1;let{selection:i}=t,{nestedList:s,nestedListPos:a,insertPos:l,items:c}=r,d=t.tr;d.delete(a,a+s.nodeSize);let u=d.mapping.map(l);return d.insert(u,re.from(c)),d.setSelection(i.map(d.doc,d.mapping)),e&&e(d),!0},a2e=(t,e,n)=>s2e(t.state,t.view.dispatch,e,n),Gse=(t,e)=>Ft.create({name:`${t}BranchingDeleteKeymap`,priority:101,addKeyboardShortcuts(){let n=()=>a2e(this.editor,t,e);return{Delete:n,"Mod-Delete":n}}}),Wse=[[1e3,"m"],[900,"cm"],[500,"d"],[400,"cd"],[100,"c"],[90,"xc"],[50,"l"],[40,"xl"],[10,"x"],[9,"ix"],[5,"v"],[4,"iv"],[1,"i"]],eD="abcdefghijklmnopqrstuvwxyz",l2e="[a-zA-Z]{1,2}",Yse=String.raw`\d+|[ivxlcdmIVXLCDM]+|${l2e}`;function nD(t){let e=t,n="";for(let[o,r]of Wse)for(;e>=o;)n+=r,e-=o;return n}function zV(t){return nD(t).toUpperCase()}function qse(t){let e=t.toLowerCase(),n=0,o=0;for(;n<e.length;){let r=!1;for(let[i,s]of Wse)if(e.startsWith(s,n)){o+=i,n+=s.length,r=!0;break}if(!r)return 0}return o}function c2e(t){if(!/^[ivxlcdmIVXLCDM]+$/.test(t))return!1;let e=qse(t);return e<=0?!1:(t===t.toLowerCase()?nD(e):zV(e))===t}function u2e(t){let e=t.toLowerCase();if(e.length===1)return e.charCodeAt(0)-97+1;if(e.length===2){let n=e.charCodeAt(0)-97,o=e.charCodeAt(1)-97;return(n+1)*26+o+1}return 0}function tD(t){if(t<=26)return eD[t-1];let e=Math.floor((t-1)/26)-1,n=(t-1)%26;return e<0?eD[n]:eD[e]+eD[n]}function oD(t){if(!(!t||/^\d+$/.test(t))){if(c2e(t))return t===t.toLowerCase()?"i":"I";if(/^[a-z]{1,2}$/.test(t))return"a";if(/^[A-Z]{1,2}$/.test(t))return"A"}}function UV(t){if(/^\d+$/.test(t))return parseInt(t,10);let e=oD(t);if(e==="i"||e==="I")return qse(t);if(e==="a"||e==="A"){let o=u2e(t);return o>0?o:1}let n=parseInt(t,10);return Number.isNaN(n)?1:n}function d2e(t,e){if(t==="numeric")return String(e);switch(t){case"a":return tD(e);case"A":return tD(e).toUpperCase();case"i":return nD(e);case"I":return zV(e);default:return String(e)}}function p2e(t){var e;if(t.length===0)return!1;let n=(e=oD(t[0]))!=null?e:"numeric",o=UV(t[0]);if(o<1)return!1;for(let r=0;r<t.length;r++){let i=d2e(n,o+r);if(t[r]!==i)return!1}return!0}function f2e(t){return{type:oD(t),start:UV(t)}}function h2e(t){let{type:e,start:n}=f2e(t),o={};return e&&(o.type=e),n!==1&&(o.start=n),o}function m2e(t,e,n=". "){let o=e+1;if(!t||t==="1")return`${o}${n}`;switch(t){case"a":return`${tD(o)}${n}`;case"A":return`${tD(o).toUpperCase()}${n}`;case"i":return`${nD(o)}${n}`;case"I":return`${zV(o)}${n}`;default:return`${o}${n}`}}function g2e(t){var e,n;let o=(e=t.tokens)==null?void 0:e[0];return!!(t.text&&((n=t.tokens)==null?void 0:n.length)===1&&o?.type==="list"&&o.ordered&&o.raw===t.text)}function y2e(t,e){return e.tokenizeInline?e.parseInline(e.tokenizeInline(t)):e.parseInline([{type:"text",raw:t,text:t}])}var HV=co.create({name:"listItem",addOptions(){return{HTMLAttributes:{},bulletListTypeName:"bulletList",orderedListTypeName:"orderedList"}},content:"paragraph block*",defining:!0,parseHTML(){return[{tag:"li"}]},renderHTML({HTMLAttributes:t}){return["li",It(this.options.HTMLAttributes,t),0]},markdownTokenName:"list_item",parseMarkdown:(t,e)=>{var n;if(t.type!=="list_item")return[];let o=(n=e.parseBlockChildren)!=null?n:e.parseChildren,r=[];if(t.tokens&&t.tokens.length>0){if(g2e(t))return{type:"listItem",content:[{type:"paragraph",content:y2e(t.text||"",e)}]};if(t.tokens.some(s=>s.type==="paragraph"))r=o(t.tokens);else{let s=t.tokens[0];if(s&&s.type==="text"&&s.tokens&&s.tokens.length>0){if(r=[{type:"paragraph",content:e.parseInline(s.tokens)}],t.tokens.length>1){let l=t.tokens.slice(1),c=o(l);r.push(.
Line truncated
|
||||
`).trim(),contentLines:h,raw:y.join(`
|
||||
`)}),o=m,n=m}return[e,o]}var _2e=new RegExp(`^(${Yse})([.)])\\s+(.+)$`);function M2e(t){let e=t.split(`
|
||||
`).filter(i=>i.trim().length>0);if(e.length===0)return null;let n=[];for(let i of e){let s=i.trim().match(_2e);if(!s)return null;n.push({marker:s[1],content:s[3]})}let o=n.map(i=>i.marker);return p2e(o)?{type:"orderedList",attrs:h2e(n[0].marker),content:n.map(i=>({type:"listItem",content:[{type:"paragraph",content:[{type:"text",text:i.content}]}]}))}:null}function Qse(t,e,n){let o=[],r=0;for(;r<t.length;){let i=t[r];if(i.indent===e){let{paragraphLines:s,blockLines:a}=E2e(i.contentLines),l=s.join(`
|
||||
`).trim(),c=[];l&&c.push({type:"paragraph",raw:l,tokens:n.inlineTokens(l)});let d=a.join(`
|
||||
`).trim();if(d){let f=n.blockTokens(d);c.push(...f)}let u=r+1,p=[];for(;u<t.length&&t[u].indent>e;)p.push(t[u]),u+=1;if(p.length>0){let f=Math.min(...p.map(m=>m.indent)),h=Qse(p,f,n);c.push({type:"list",ordered:!0,start:p[0].number,typeMarker:p[0].type,items:h,raw:p.map(m=>m.raw).join(`
|
||||
`)})}o.push({type:"list_item",raw:i.raw,tokens:c}),r=u}else r+=1}return o}function A2e(t,e){return t.map(n=>{if(n.type!=="list_item")return e.parseChildren([n])[0];let o=[];return n.tokens&&n.tokens.length>0&&n.tokens.forEach(r=>{if(r.type==="paragraph"||r.type==="list"||r.type==="blockquote"||r.type==="code")o.push(...e.parseChildren([r]));else if(r.type==="text"&&r.tokens){let i=e.parseChildren([r]);o.push({type:"paragraph",content:i})}else{let i=e.parseChildren([r]);i.length>0&&o.push(...i)}}),{type:"listItem",content:o}})}var k2e="listItem",jse="textStyle",$se=/^(\d+)\.\s$/;function Kse(t){let e=t.match(/list-style-type\s*:\s*([^;]+)/i);if(!e)return null;switch(e[1].trim().toLowerCase()){case"upper-roman":return"I";case"lower-roman":return"i";case"upper-alpha":case"upper-latin":return"A";case"lower-alpha":case"lower-latin":return"a";default:return null}}var $V=co.create({name:"orderedList",addOptions(){return{itemTypeName:"listItem",HTMLAttributes:{},keepMarks:!1,keepAttributes:!1}},group:"block list",content(){return`${this.options.itemTypeName}+`},addAttributes(){return{start:{default:1,parseHTML:t=>t.hasAttribute("start")?parseInt(t.getAttribute("start")||"",10):1},type:{default:null,parseHTML:t=>{let e=t.getAttribute("type");if(e)return e;let n=t.getAttribute("style");if(n){let r=Kse(n);if(r)return r}let o=t.querySelector("li");if(o){let r=o.getAttribute("style");if(r){let i=Kse(r);if(i)return i}}return null}}}},parseHTML(){return[{tag:"ol"}]},renderHTML({HTMLAttributes:t}){let{start:e,type:n,...o}=t,r=It(this.options.HTMLAttributes,o);return e!==1&&(r.start=e),n&&n!=="1"&&(r.type=n),["ol",r,0]},markdownTokenName:"list",parseMarkdown:(t,e)=>{if(t.type!=="list"||!t.ordered)return[];let n=t.start||1,o=t.typeMarker,r=t.items?A2e(t.items,e):[],i={};return n!==1&&(i.start=n),o&&(i.type=o),Object.keys(i).length>0?{type:"orderedList",attrs:i,content:r}:{type:"orderedList",content:r}},renderMarkdown:(t,e)=>t.content?e.renderChildren(t.content,`
|
||||
`):"",markdownTokenizer:{name:"orderedList",level:"block",start:()=>-1,tokenize:(t,e,n)=>{var o,r;let i=t.split(`
|
||||
`),[s,a]=I2e(i);if(s.length===0)return;let l=Qse(s,s[0].indent,n);if(l.length===0)return;let c=((o=s[0])==null?void 0:o.number)||1,d=(r=s[0])==null?void 0:r.type;return{type:"list",ordered:!0,start:c,typeMarker:d,items:l,raw:i.slice(0,a).join(`
|
||||
`)}}},markdownOptions:{indentsContent:!0},addCommands(){return{toggleOrderedList:()=>({commands:t,chain:e})=>this.options.keepAttributes?e().toggleList(this.name,this.options.itemTypeName,this.options.keepMarks).updateAttributes(k2e,this.editor.getAttributes(jse)).run():t.toggleList(this.name,this.options.itemTypeName,this.options.keepMarks)}},addKeyboardShortcuts(){return{"Mod-Shift-7":()=>this.editor.commands.toggleOrderedList()}},addProseMirrorPlugins(){return[new Ye({props:{handlePaste:(t,e)=>{var n,o;let r=(n=e.clipboardData)==null?void 0:n.getData("text/html");if(r?.trim())return!1;let i=(o=e.clipboardData)==null?void 0:o.getData("text/plain");if(!i)return!1;let s=M2e(i);if(!s)return!1;try{let a=t.state.schema.nodeFromJSON(s),l=t.state.tr.replaceSelectionWith(a);return t.dispatch(l),!0}catch{return!1}}}})]},addInputRules(){let t=(n,o)=>(!o.attrs.type||o.attrs.type==="1")&&o.childCount+o.attrs.start===+n[1],e=rd({find:$se,type:this.type,getAttributes:n=>({start:+n[1]}),joinPredicate:t});return(this.options.keepMarks||this.options.keepAttributes)&&(e=rd({find:$se,type:this.type,keepMarks:this.options.keepMarks,keepAttributes:this.options.keepAttributes,getAttributes:n=>({start:+n[1],...this.editor.getAttributes(jse)}),joinPredicate:t,editor:this.editor})),[e]}}),R2e=/^\s*(\[([( |x])?\])\s$/,D2e=co.create({name:"taskItem",addOptions(){return{nested:!1,HTMLAttributes:{},taskListTypeName:"taskList",a11y:void 0}},content(){return this.options.nested?"paragraph block*":"paragraph+"},defining:!0,addAttributes(){return{checked:{default:!1,keepOnSplit:!1,parseHTML:t=>{let e=t.getAttribute("data-checked");return e===""||e==="true"},renderHTML:t=>({"data-checked":t.checked})}}},parseHTML(){return[{tag:`li[data-type="${this.name}"]`,priority:51}]},renderHTML({node:t,HTMLAttributes:e}){return["li",It(this.options.HTMLAttributes,e,{"data-type":this.name}),["label",["input",{type:"checkbox",checked:t.attrs.checked?"checked":null}],["span"]],["div",0]]},parseMarkdown:(t,e)=>{let n=[];if(t.tokens&&t.tokens.length>0?n.push(e.createNode("paragraph",{},e.parseInline(t.tokens))):t.text?n.push(e.createNode("paragraph",{},[e.createNode("text",{text:t.text})])):n.push(e.createNode("paragraph",{},[])),t.nestedTokens&&t.nestedTokens.length>0){let o=e.parseChildren(t.nestedTokens);n.push(...o)}return e.createNode("taskItem",{checked:t.checked||!1},n)},renderMarkdown:(t,e)=>{var n;let r=`- [${(n=t.attrs)!=null&&n.checked?"x":" "}] `;return GP(t,e,r)},addExtensions(){return this.options.nested?[Gse(this.name,[this.options.taskListTypeName])]:[]},addKeyboardShortcuts(){let t={Enter:()=>this.editor.commands.splitListItem(this.name),"Shift-Tab":()=>this.editor.commands.liftListItem(this.name)};return this.options.nested?{...t,Tab:()=>this.editor.commands.sinkListItem(this.name)}:t},addNodeView(){return({node:t,HTMLAttributes:e,getPos:n,editor:o})=>{let r=document.createElement("li"),i=document.createElement("label"),s=document.createElement("span"),a=document.createElement("input"),l=document.createElement("div"),c=u=>{var p,f;a.ariaLabel=((f=(p=this.options.a11y)==null?void 0:p.checkboxLabel)==null?void 0:f.call(p,u,a.checked))||`Task item checkbox for ${u.textContent||"empty task item"}`};c(t),i.contentEditable="false",a.type="checkbox",a.addEventListener("mousedown",u=>u.preventDefault()),a.addEventListener("change",u=>{if(!o.isEditable&&!this.options.onReadOnlyChecked){a.checked=!a.checked;return}let{checked:p}=u.target;o.isEditable&&typeof n=="function"&&o.chain().focus(void 0,{scrollIntoView:!1}).command(({tr:f})=>{let h=n();if(typeof h!="number")return!1;let m=f.doc.nodeAt(h);return f.setNodeMarkup(h,void 0,{...m?.attrs,checked:p}),!0}).run(),!o.isEditable&&this.options.onReadOnlyChecked&&(this.options.onReadOnlyChecked(t,p)||(a.checked=!a.checked))}),Object.entries(this.options.HTMLAttributes).forEach(([u,p])=>{r.setAttribute(u,p)}),r.dataset.checked=t.attrs.checked,a.checked=t.attrs.checked,i.append(a,s),r.append(i,l),Object.entries(e).forEach(([u,p])=>{r.setAttribute(u,p)});let d=new Set(Object.keys(e));return{dom:r,contentDOM:l,update:u=>{if(u.type!==this.type)return!1;r.dataset.checked=u.attrs.checked,a.checked=u.attrs.checked,c(u);let p=o.extensionManager.attributes,f=A0(u,p),h=new Set(Object.keys(f)),m=this.options.HTMLAttributes;return d.forEach(y=>{h.has(y)||(y in m?r.setAttribute(y,m[y]):r.removeAttribute(y))}),Object.entries(f).forEach(([y,g])=>{g==null?y in m?r.setAttribute(y,m[y]):r.removeAttribute(y):r.setAttribute(y,g)}),d=h,!0}}}},addInputRules(){return[rd({find:R2e,type:this.type,getAttributes:t=>({checked:t[t.length-1]==="x"})})]}}),O2e=co.create({name:"taskList",addOptions(){return{itemTypeName:"taskItem",HTMLAttributes:{}}},group:"block list",content(){return`${this.options.itemTypeName}+`},parseHTML(){return[{tag:`ul[data-type="${this.name}"]`,priority:51}]},renderHTML({HTMLAttributes:t}){return["ul",It(this.options.HTMLAttributes,t,{"data-type":this.name}),0]},parseMarkdown:(t,e)=>e.createNode("taskList"
Line truncated
|
||||
`):"",markdownTokenizer:{name:"taskList",level:"block",start(t){var e;let n=(e=t.match(/^\s*[-+*]\s+\[([ xX])\]\s+/))==null?void 0:e.index;return n!==void 0?n:-1},tokenize(t,e,n){let o=i=>{let s=r2(i,{itemPattern:/^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,extractItemData:a=>({indentLevel:a[1].length,mainContent:a[4],checked:a[3].toLowerCase()==="x"}),createToken:(a,l)=>({type:"taskItem",raw:"",mainContent:a.mainContent,indentLevel:a.indentLevel,checked:a.checked,text:a.mainContent,tokens:n.inlineTokens(a.mainContent),nestedTokens:l}),customNestedParser:o},n);if(s){let a={type:"taskList",raw:s.raw,items:s.items},l=i.slice(s.raw.length);return l.trim()?[a,...n.blockTokens(l)]:[a]}return n.blockTokens(i)},r=r2(t,{itemPattern:/^(\s*)([-+*])\s+\[([ xX])\]\s+(.*)$/,extractItemData:i=>({indentLevel:i[1].length,mainContent:i[4],checked:i[3].toLowerCase()==="x"}),createToken:(i,s)=>({type:"taskItem",raw:"",mainContent:i.mainContent,indentLevel:i.indentLevel,checked:i.checked,text:i.mainContent,tokens:n.inlineTokens(i.mainContent),nestedTokens:s}),customNestedParser:o},n);if(r)return{type:"taskList",raw:r.raw,items:r.items}}},markdownOptions:{indentsContent:!0},addCommands(){return{toggleTaskList:()=>({commands:t})=>t.toggleList(this.name,this.options.itemTypeName)}},addKeyboardShortcuts(){return{"Mod-Shift-9":()=>this.editor.commands.toggleTaskList()}}}),jft=Ft.create({name:"listKit",addExtensions(){let t=[];return this.options.bulletList!==!1&&t.push(BV.configure(this.options.bulletList)),this.options.listItem!==!1&&t.push(HV.configure(this.options.listItem)),this.options.listKeymap!==!1&&t.push(jV.configure(this.options.listKeymap)),this.options.orderedList!==!1&&t.push($V.configure(this.options.orderedList)),this.options.taskItem!==!1&&t.push(D2e.configure(this.options.taskItem)),this.options.taskList!==!1&&t.push(O2e.configure(this.options.taskList)),t}});var iD=" ",KV="\xA0",eae=co.create({name:"paragraph",priority:1e3,addOptions(){return{HTMLAttributes:{}}},group:"block",content:"inline*",parseHTML(){return[{tag:"p"}]},renderHTML({HTMLAttributes:t}){return["p",It(this.options.HTMLAttributes,t),0]},parseMarkdown:(t,e)=>{let n=t.tokens||[];if(n.length===1&&n[0].type==="image")return e.parseChildren([n[0]]);let o=e.parseInline(n);return n.length===1&&n[0].type==="text"&&(n[0].raw===iD||n[0].text===iD||n[0].raw===KV||n[0].text===KV)&&o.length===1&&o[0].type==="text"&&(o[0].text===iD||o[0].text===KV)?e.createNode("paragraph",void 0,[]):e.createNode("paragraph",void 0,o)},renderMarkdown:(t,e,n)=>{var o,r;if(!t)return"";let i=Array.isArray(t.content)?t.content:[];if(i.length===0){let s=Array.isArray((o=n?.previousNode)==null?void 0:o.content)?n.previousNode.content:[];return((r=n?.previousNode)==null?void 0:r.type)==="paragraph"&&s.length===0?iD:""}return e.renderChildren(i)},addCommands(){return{setParagraph:()=>({commands:t})=>t.setNode(this.name)}},addKeyboardShortcuts(){return{"Mod-Alt-0":()=>this.editor.commands.setParagraph()}}});var L2e=/(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))$/,N2e=/(?:^|\s)(~~(?!\s+~~)((?:[^~]+))~~(?!\s+~~))/g,tae=Us.create({name:"strike",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"s"},{tag:"del"},{tag:"strike"},{style:"text-decoration",consuming:!1,getAttrs:t=>t.includes("line-through")?{}:!1}]},renderHTML({HTMLAttributes:t}){return["s",It(this.options.HTMLAttributes,t),0]},markdownTokenName:"del",parseMarkdown:(t,e)=>e.applyMark("strike",e.parseInline(t.tokens||[])),renderMarkdown:(t,e)=>`~~${e.renderChildren(t)}~~`,addCommands(){return{setStrike:()=>({commands:t})=>t.setMark(this.name),toggleStrike:()=>({commands:t})=>t.toggleMark(this.name),unsetStrike:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-Shift-s":()=>this.editor.commands.toggleStrike()}},addInputRules(){return[xa({find:L2e,type:this.type})]},addPasteRules(){return[ds({find:N2e,type:this.type})]}});var nae=co.create({name:"text",group:"inline",parseMarkdown:t=>({type:"text",text:t.text||""}),renderMarkdown:t=>t.text||""});var oae=Us.create({name:"underline",addOptions(){return{HTMLAttributes:{}}},parseHTML(){return[{tag:"u"},{style:"text-decoration",consuming:!1,getAttrs:t=>t.includes("underline")?{}:!1}]},renderHTML({HTMLAttributes:t}){return["u",It(this.options.HTMLAttributes,t),0]},parseMarkdown(t,e){return e.applyMark(this.name||"underline",e.parseInline(t.tokens||[]))},renderMarkdown(t,e){return`++${e.renderChildren(t)}++`},markdownTokenizer:{name:"underline",level:"inline",start(t){return t.indexOf("++")},tokenize(t,e,n){let r=/^(\+\+)([\s\S]+?)(\+\+)/.exec(t);if(!r)return;let i=r[2].trim();return{type:"underline",raw:r[0],text:i,tokens:n.inlineTokens(i)}}},addCommands(){return{setUnderline:()=>({commands:t})=>t.setMark(this.name),toggleUnderline:()=>({commands:t})=>t.toggleMark(this.name),unsetUnderline:()=>({commands:t})=>t.unsetMark(this.name)}},addKeyboardShortcuts(){return{"Mod-u":()=>this.editor.commands.toggleUnderline(),"Mod-U":()=>this.editor.commands.tog
Line truncated
|
||||
`})})}function pD(t,e){let n=t.getTextOptions().tipTapConfig?.extensions??pf;return wie(e,n)}function $0(t,e,n){for(let s of t.marks)s.type.name==="bold"&&e.weight!=="bold"&&(e={...e,weight:"bold"}),s.type.name==="italic"&&e.style!=="italic"&&(e={...e,style:"italic"}),s.type.name==="code"&&e.family!=="tldraw_mono"&&(e={...e,family:"tldraw_mono"});let o=Gt(ym,e.family);if(!o)return e;let r=Gt(o,e.style);if(!r)return e;let i=Gt(r,e.weight);return i&&n(i),e}var K0=class t{static fixNewLines=/\r?\n|\r/g;static normalizeText(e){return e.replace(t.fixNewLines,`
|
||||
`)}static normalizeTextForDom(e){return e.replace(t.fixNewLines,`
|
||||
`).split(`
|
||||
`).map(n=>n||" ").join(`
|
||||
`)}};var fDe=/[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;function fD(t){return fDe.test(t)}function hDe(t){return t.replace(/\t/g," ")}function mDe(t){let e=t.split(`
|
||||
`);for(;e[0]&&e[0].trim().length===0;)e.shift();let n=1/0;for(let o of e)if(o.trim().length>0){let r=o.length-o.trimStart().length;n=Math.min(n,r)}return e.map(o=>o.slice(n)).join(`
|
||||
`)}var Tae={"&":"&",""":'"',"'":"'","":"'",""":'"',"&":"&","'":"'","–":"\u2013","—":"\u2014","‘":"\u2018","’":"\u2019","“":"\u201C","”":"\u201D","…":"\u2026"},gDe=new RegExp(Object.keys(Tae).join("|"),"g");function t8(t){return t.replace(gDe,e=>Tae[e])}function yDe(t){return t.replace(/[ \t]+$/gm,"").replace(/\n+$/,"")}function Cae(t){return yDe(mDe(hDe(t)))}function Pae(t,e){return t.length<=e?t:t.substring(0,e-3)+"..."}var SD=5e3,i8=10*1024*1024;function bD(t,e){t.registerExternalAssetHandler("file",async n=>s8(t,n,e)),t.registerExternalAssetHandler("url",async n=>l8(t,n,e)),t.registerExternalContentHandler("svg-text",async n=>c8(t,n)),t.registerExternalContentHandler("embed",n=>u8(t,n)),t.registerExternalContentHandler("files",async n=>d8(t,n,e)),t.registerExternalContentHandler("file-replace",async n=>a8(t,n,e)),t.registerExternalContentHandler("text",async n=>lS(t,n)),t.registerExternalContentHandler("url",async n=>p8(t,n,e)),t.registerExternalContentHandler("tldraw",async n=>f8(t,n)),t.registerExternalContentHandler("excalidraw",async n=>h8(t,n))}async function s8(t,{file:e,assetId:n},o){fE(t,e,o)||ne(!1,"File checks failed");let i=await m8(e);i||ne(!1,"SVG file contained no safe content");let s=await hE(t,i,n);s||ne(!1,`No asset util found for MIME type "${e.type}"`);let a=await t.uploadAsset(s,i);return s.props.src=a.src,a.meta&&(s.meta={...s.meta,...a.meta}),ns.create(s)}async function a8(t,{file:e,shapeId:n},o){fE(t,e,o)||ne(!1,"File checks failed");let i=await m8(e);if(!i)return;let s=t.getShape(n);s||ne(!1,"Shape not found");let a=xC(await i.arrayBuffer()),l=ns.createId(a);t.createTemporaryAssetPreview(l,i);let c=await hE(t,i,l);if(!c)return;if(t.createAssets([c]),s.type==="image"){let u=s,p=u.props.crop,f=c.props.w,h=c.props.h,m=u.x,y=u.y,g=p;if(p){let S=lse(u,c.props.w,c.props.h);g=S.crop,f=S.w,h=S.h,m=S.x,y=S.y}t.updateShapes([{id:u.id,type:u.type,props:{assetId:l,crop:g,w:f,h},x:m,y}])}else s.type==="video"&&t.updateShapes([{id:s.id,type:s.type,props:{assetId:l,w:c.props.w,h:c.props.h}}]);let d=await t.getAssetForExternalContent({type:"file",file:i,assetId:l});return t.updateAssets([{...d,id:l}]),d}async function l8(t,{url:e},{toasts:n,msg:o}){let r;try{let s=await(await mo(e,{method:"GET",mode:"no-cors"})).text(),a=new DOMParser().parseFromString(s,"text/html");r={image:a.head.querySelector('meta[property="og:image"]')?.getAttribute("content")??"",favicon:a.head.querySelector('link[rel="apple-touch-icon"]')?.getAttribute("href")??a.head.querySelector('link[rel="icon"]')?.getAttribute("href")??"",title:a.head.querySelector('meta[property="og:title"]')?.getAttribute("content")??e,description:a.head.querySelector('meta[property="og:description"]')?.getAttribute("content")??""},r.image.startsWith("http")||(r.image=new URL(r.image,e).href),r.favicon.startsWith("http")||(r.favicon=new URL(r.favicon,e).href)}catch(i){console.error(i),n.addToast({title:o("assets.url.failed"),severity:"error"}),r={image:"",favicon:"",title:"",description:""}}return{id:ns.createId(Qd(e)),typeName:"asset",type:"bookmark",props:{src:e,description:r.description,image:r.image,favicon:r.favicon,title:r.title},meta:{}}}async function c8(t,{point:e,text:n}){let{sanitizeSvg:o}=await Promise.resolve().then(()=>(yD(),r8));if(n=o(n),!n)return;let r=e??(t.inputs.getShiftKey()?t.inputs.getCurrentPagePoint():t.getViewportPageBounds().center),i=new DOMParser().parseFromString(n,"image/svg+xml").querySelector("svg");if(!i)throw new Error("No <svg/> element present");let s=parseFloat(i.getAttribute("width")||"0"),a=parseFloat(i.getAttribute("height")||"0");if(!(s&&a)){let c=t.getContainerDocument();c.body.appendChild(i);let d=i.getBoundingClientRect();c.body.removeChild(i),s=d.width,a=d.height}let l=await t.getAssetForExternalContent({type:"file",file:new File([n],"asset.svg",{type:"image/svg+xml"})});if(!l)throw Error("Could not create an asset");vD(t,[l],r)}function u8(t,{point:e,url:n,embed:o}){let r=e??(t.inputs.getShiftKey()?t.inputs.getCurrentPagePoint():t.getViewportPageBounds().center),{width:i,height:s}=o,a=Ue(),l=Go(new x(r.x-(i||450)/2,r.y-(s||450)/2),t),c={id:a,type:"embed",x:l.x,y:l.y,props:{w:i,h:s,url:n}};t.canCreateShape(c)&&t.createShape(c).select(a)}async function d8(t,{point:e,files:n},o){let{toasts:r,msg:i}=o;if(n.length>t.options.maxFilesAtOnce){r.addToast({title:i("assets.files.amount-too-many"),severity:"error"});return}let s=e??(t.inputs.getShiftKey()?t.inputs.getCurrentPagePoint():t.getViewportPageBounds().center),a=new x(s.x,s.y),l=[],c=[];for(let d of n){if(!fE(t,d,o))continue;let p=await m8(d);if(!p){r.addToast({title:i("assets.files.upload-failed"),severity:"error"});continue}let f=await hE(t,p);f&&(f.type==="image"&&t.createTemporaryAssetPreview(f.id,p),l.push(f),c.push({asset:f,file:p}))}Promise.allSettled(c.map(async d=>{try{let u=await t.getAssetForExternalContent({type:"file",file:d.file});if(!u)throw Error("Could not create an
Line truncated
|
||||
.`.concat(V8,` {
|
||||
overflow: hidden `).concat(o,`;
|
||||
padding-right: `).concat(a,"px ").concat(o,`;
|
||||
}
|
||||
body[`).concat(ex,`] {
|
||||
overflow: hidden `).concat(o,`;
|
||||
overscroll-behavior: contain;
|
||||
`).concat([e&&"position: relative ".concat(o,";"),n==="margin"&&`
|
||||
padding-left: `.concat(r,`px;
|
||||
padding-top: `).concat(i,`px;
|
||||
padding-right: `).concat(s,`px;
|
||||
margin-left:0;
|
||||
margin-top:0;
|
||||
margin-right: `).concat(a,"px ").concat(o,`;
|
||||
`),n==="padding"&&"padding-right: ".concat(a,"px ").concat(o,";")].filter(Boolean).join(""),`
|
||||
}
|
||||
|
||||
.`).concat(pS,` {
|
||||
right: `).concat(a,"px ").concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(fS,` {
|
||||
margin-right: `).concat(a,"px ").concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(pS," .").concat(pS,` {
|
||||
right: 0 `).concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(fS," .").concat(fS,` {
|
||||
margin-right: 0 `).concat(o,`;
|
||||
}
|
||||
|
||||
body[`).concat(ex,`] {
|
||||
`).concat(j8,": ").concat(a,`px;
|
||||
}
|
||||
`)},Ule=function(){var t=parseInt(document.body.getAttribute(ex)||"0",10);return isFinite(t)?t:0},HOe=function(){tx.useEffect(function(){return document.body.setAttribute(ex,(Ule()+1).toString()),function(){var t=Ule()-1;t<=0?document.body.removeAttribute(ex):document.body.setAttribute(ex,t.toString())}},[])},J8=function(t){var e=t.noRelative,n=t.noImportant,o=t.gapMode,r=o===void 0?"margin":o;HOe();var i=tx.useMemo(function(){return Z8(r)},[r]);return tx.createElement(zOe,{styles:UOe(i,!e,r,n?"":"!important")})};var Q8=!1;if(typeof window<"u")try{wE=Object.defineProperty({},"passive",{get:function(){return Q8=!0,!0}}),window.addEventListener("test",wE,wE),window.removeEventListener("test",wE,wE)}catch{Q8=!1}var wE,hS=Q8?{passive:!1}:!1;var VOe=function(t){return t.tagName==="TEXTAREA"},Hle=function(t,e){if(!(t instanceof Element))return!1;var n=window.getComputedStyle(t);return n[e]!=="hidden"&&!(n.overflowY===n.overflowX&&!VOe(t)&&n[e]==="visible")},jOe=function(t){return Hle(t,"overflowY")},$Oe=function(t){return Hle(t,"overflowX")},e9=function(t,e){var n=e.ownerDocument,o=e;do{typeof ShadowRoot<"u"&&o instanceof ShadowRoot&&(o=o.host);var r=Vle(t,o);if(r){var i=jle(t,o),s=i[1],a=i[2];if(s>a)return!0}o=o.parentNode}while(o&&o!==n.body);return!1},KOe=function(t){var e=t.scrollTop,n=t.scrollHeight,o=t.clientHeight;return[e,n,o]},GOe=function(t){var e=t.scrollLeft,n=t.scrollWidth,o=t.clientWidth;return[e,n,o]},Vle=function(t,e){return t==="v"?jOe(e):$Oe(e)},jle=function(t,e){return t==="v"?KOe(e):GOe(e)},WOe=function(t,e){return t==="h"&&e==="rtl"?-1:1},$le=function(t,e,n,o,r){var i=WOe(t,window.getComputedStyle(e).direction),s=i*o,a=n.target,l=e.contains(a),c=!1,d=s>0,u=0,p=0;do{if(!a)break;var f=jle(t,a),h=f[0],m=f[1],y=f[2],g=m-y-i*h;(h||g)&&Vle(t,a)&&(u+=g,p+=h);var S=a.parentNode;a=S&&S.nodeType===Node.DOCUMENT_FRAGMENT_NODE?S.host:S}while(!l&&a!==document.body||l&&(e.contains(a)||e===a));return(d&&(r&&Math.abs(u)<1||!r&&s>u)||!d&&(r&&Math.abs(p)<1||!r&&-s>p))&&(c=!0),c};var OD=function(t){return"changedTouches"in t?[t.changedTouches[0].clientX,t.changedTouches[0].clientY]:[0,0]},Kle=function(t){return[t.deltaX,t.deltaY]},Gle=function(t){return t&&"current"in t?t.current:t},YOe=function(t,e){return t[0]===e[0]&&t[1]===e[1]},qOe=function(t){return`
|
||||
.block-interactivity-`.concat(t,` {pointer-events: none;}
|
||||
.allow-interactivity-`).concat(t,` {pointer-events: all;}
|
||||
`)},XOe=0,nx=[];function Wle(t){var e=Io.useRef([]),n=Io.useRef([0,0]),o=Io.useRef(),r=Io.useState(XOe++)[0],i=Io.useState(vE)[0],s=Io.useRef(t);Io.useEffect(function(){s.current=t},[t]),Io.useEffect(function(){if(t.inert){document.body.classList.add("block-interactivity-".concat(r));var m=kle([t.lockRef.current],(t.shards||[]).map(Gle),!0).filter(Boolean);return m.forEach(function(y){return y.classList.add("allow-interactivity-".concat(r))}),function(){document.body.classList.remove("block-interactivity-".concat(r)),m.forEach(function(y){return y.classList.remove("allow-interactivity-".concat(r))})}}},[t.inert,t.lockRef.current,t.shards]);var a=Io.useCallback(function(m,y){if("touches"in m&&m.touches.length===2||m.type==="wheel"&&m.ctrlKey)return!s.current.allowPinchZoom;var g=OD(m),S=n.current,b="deltaX"in m?m.deltaX:S[0]-g[0],v="deltaY"in m?m.deltaY:S[1]-g[1],w,T=m.target,C=Math.abs(b)>Math.abs(v)?"h":"v";if("touches"in m&&C==="h"&&T.type==="range")return!1;var P=window.getSelection(),M=P&&P.anchorNode,E=M?M===T||M.contains(T):!1;if(E)return!1;var I=e9(C,T);if(!I)return!0;if(I?w=C:(w=C==="v"?"h":"v",I=e9(C,T)),!I)return!1;if(!o.current&&"changedTouches"in m&&(b||v)&&(o.current=w),!w)return!0;var R=o.current||w;return $le(R,y,m,R==="h"?b:v,!0)},[]),l=Io.useCallback(function(m){var y=m;if(!(!nx.length||nx[nx.length-1]!==i)){var g="deltaY"in y?Kle(y):OD(y),S=e.current.filter(function(w){return w.name===y.type&&(w.target===y.target||y.target===w.shadowParent)&&YOe(w.delta,g)})[0];if(S&&S.should){y.cancelable&&y.preventDefault();return}if(!S){var b=(s.current.shards||[]).map(Gle).filter(Boolean).filter(function(w){return w.contains(y.target)}),v=b.length>0?a(y,b[0]):!s.current.noIsolation;v&&y.cancelable&&y.preventDefault()}}},[]),c=Io.useCallback(function(m,y,g,S){var b={name:m,delta:y,target:g,should:S,shadowParent:ZOe(g)};e.current.push(b),setTimeout(function(){e.current=e.current.filter(function(v){return v!==b})},1)},[]),d=Io.useCallback(function(m){n.current=OD(m),o.current=void 0},[]),u=Io.useCallback(function(m){c(m.type,Kle(m),m.target,a(m,t.lockRef.current))},[]),p=Io.useCallback(function(m){c(m.type,OD(m),m.target,a(m,t.lockRef.current))},[]);Io.useEffect(function(){return nx.push(i),t.setCallbacks({onScrollCapture:u,onWheelCapture:u,onTouchMoveCapture:p}),document.addEventListener("wheel",l,hS),document.addEventListener("touchmove",l,hS),document.addEventListener("touchstart",d,hS),function(){nx=nx.filter(function(m){return m!==i}),document.removeEventListener("wheel",l,hS),document.removeEventListener("touchmove",l,hS),document.removeEventListener("touchstart",d,hS)}},[]);var f=t.removeScrollBar,h=t.inert;return Io.createElement(Io.Fragment,null,h?Io.createElement(i,{styles:qOe(r)}):null,f?Io.createElement(J8,{noRelative:t.noRelative,gapMode:t.gapMode}):null)}function ZOe(t){for(var e=null;t!==null;)t instanceof ShadowRoot&&(e=t.host,t=t.host),t=t.parentNode;return e}var Yle=G8(DD,Wle);var qle=LD.forwardRef(function(t,e){return LD.createElement(bE,_a({},t,{ref:e,sideCar:Yle}))});qle.classNames=bE.classNames;var mf=qle;var JOe=function(t){if(typeof document>"u")return null;var e=Array.isArray(t)?t[0]:t;return e.ownerDocument.body},ox=new WeakMap,ND=new WeakMap,FD={},t9=0,Xle=function(t){return t&&(t.host||Xle(t.parentNode))},QOe=function(t,e){return e.map(function(n){if(t.contains(n))return n;var o=Xle(n);return o&&t.contains(o)?o:(console.error("aria-hidden",n,"in not contained inside",t,". Doing nothing"),null)}).filter(function(n){return!!n})},eLe=function(t,e,n,o){var r=QOe(e,Array.isArray(t)?t:[t]);FD[n]||(FD[n]=new WeakMap);var i=FD[n],s=[],a=new Set,l=new Set(r),c=function(u){!u||a.has(u)||(a.add(u),c(u.parentNode))};r.forEach(c);var d=function(u){!u||l.has(u)||Array.prototype.forEach.call(u.children,function(p){if(a.has(p))d(p);else try{var f=p.getAttribute(o),h=f!==null&&f!=="false",m=(ox.get(p)||0)+1,y=(i.get(p)||0)+1;ox.set(p,m),i.set(p,y),s.push(p),m===1&&h&&ND.set(p,!0),y===1&&p.setAttribute(n,"true"),h||p.setAttribute(o,"true")}catch(g){console.error("aria-hidden: cannot operate on ",p,g)}})};return d(e),a.clear(),t9++,function(){s.forEach(function(u){var p=ox.get(u)-1,f=i.get(u)-1;ox.set(u,p),i.set(u,f),p||(ND.has(u)||u.removeAttribute(o),ND.delete(u)),f||u.removeAttribute(n)}),t9--,t9||(ox=new WeakMap,ox=new WeakMap,ND=new WeakMap,FD={})}},wm=function(t,e,n){n===void 0&&(n="data-aria-hidden");var o=Array.from(Array.isArray(t)?t:[t]),r=e||JOe(t);return r?(o.push.apply(o,Array.from(r.querySelectorAll("[aria-live], script"))),eLe(o,r,n,"aria-hidden")):function(){return null}};var Wo=_(j(),1),tLe=Object.defineProperty,Ma=(t,e)=>tLe(t,"name",{value:e,configurable:!0}),o9="Dialog",[Zle,nLe]=yn(o9),[oLe,$c]=Zle(o9),rLe=Ma(t=>{let{__scopeDialog:e,children:n,open:o,defaultOpen:r,onOpenChange:i,modal:s=!0}=t,a=uo.useRef(null),l=uo.useRef(null),[c,d]=Yn({prop:o,defaultProp:r??!1,onChange:i,caller:o9}),[u,p]=uo.useState(0),[f,h]=uo.useState(0);return(0,Wo.jsx)(oLe,{scope:e,triggerRef:a,conten
Line truncated
|
||||
.${c} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.${c} svg {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media print {
|
||||
html, body {
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tldraw__editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.${c} {
|
||||
display: block !important;
|
||||
background: white;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.${c}__item {
|
||||
padding: 10mm;
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
page-break-after: always;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.${c}__item__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.${c}__item__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.${c}__item__footer {
|
||||
display: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.${c}__item__footer__hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
${d?`
|
||||
/**
|
||||
* Note: Safari doesn't support removing the page margins to remove them all!
|
||||
*/
|
||||
@page {
|
||||
margin:0;
|
||||
}
|
||||
|
||||
.${c} .${c}__item__header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.${c} .${c}__item__footer {
|
||||
display: block;
|
||||
}
|
||||
`:""}
|
||||
}
|
||||
|
||||
`;let p=()=>{r.head.appendChild(a),r.body.appendChild(s)},f=()=>{t.once("tick",()=>{l(s,a)})};i.addEventListener("beforeprint",p),i.addEventListener("afterprint",f);function h(w,T,C){try{s.innerHTML+=`<div class="${c}__item">
|
||||
<div class="${c}__item__header">
|
||||
${w.replace(/</g,"<").replace(/>/g,">")}
|
||||
</div>
|
||||
<div class="${c}__item__main">
|
||||
${C}
|
||||
</div>
|
||||
<div class="${c}__item__footer ${c}__item__footer__${T?"":"hide"}">
|
||||
${T??""}
|
||||
</div>
|
||||
</div>`}catch(P){console.error(P)}}function m(){He.isChromeForIos?(p(),i.print()):He.isSafari?(p(),r.execCommand("print",!1)):i.print()}let y=t.getSelectedShapeIds(),g=t.getCurrentPageId(),S=t.getPages(),v={scale:1,background:!1,darkMode:!1,preserveAspectRatio:"xMidYMid meet"};if(t.getSelectedShapeIds().length>0){let w=await t.getSvgString(y,v);if(w){let T=S.find(C=>C.id===g);h(`tldraw \u2014 ${T?.name}`,null,w.svg),m()}}else if(u){for(let w=0;w<S.length;w++){let T=S[w],C=await t.getSvgString(t.getSortedChildIdsForParent(T.id),v);C&&h(`tldraw \u2014 ${T.name}`,`${w}/${S.length}`,C.svg)}m()}else{let w=t.getCurrentPage(),T=await t.getSvgString(t.getSortedChildIdsForParent(w.id),v);T&&(h(`tldraw \u2014 ${w.name}`,null,T.svg),m())}i.removeEventListener("beforeprint",p),i.removeEventListener("afterprint",f)},[t])}var gj=(0,Ra.createContext)([]);function BS(){let t=nt(),{addToast:e,removeToast:n,clearToasts:o}=ni(),{addDialog:r,clearDialogs:i,removeDialog:s}=nu(),a=J(),l=Vfe(),{cut:c,copy:d,paste:u}=nL(),p=iL(),f=sL(),h=px(),y=_t()<ht.TABLET_SM,g=Rs((0,Ra.useContext)(gj)),S=(0,Ra.useCallback)(async()=>{if(!t)return;let T=await dj({allowMultiple:!0,mimeTypes:g,document:t.getContainerDocument()});T.length&&(t.markHistoryStoppingPoint("insert media"),t.putExternalContent({type:"files",files:T,point:t.getViewportPageBounds().center}))},[t,g]),b=(0,Ra.useCallback)(async T=>{if(!t)return;let C=await dj({allowMultiple:!1,mimeTypes:g?.filter(E=>T?E.startsWith("image/"):E.startsWith("video/")),document:t.getContainerDocument()});if(!C.length)return;let P=t.getOnlySelectedShape();if(!P||T&&P.type!=="image"||!T&&P.type!=="video")return;t.markHistoryStoppingPoint("replace media");let M=C[0];t.replaceExternalContent({type:"file-replace",file:M,shapeId:P.id,isImage:T})},[t,g]),v=(0,Ra.useCallback)(()=>b(!0),[b]),w=(0,Ra.useCallback)(()=>b(!1),[b]);return(0,Ra.useMemo)(()=>({addToast:e,removeToast:n,clearToasts:o,addDialog:r,removeDialog:s,clearDialogs:i,msg:a,isMobile:y,insertMedia:S,replaceImage:v,replaceVideo:w,printSelectionOrPages:l,cut:c,copy:d,paste:u,copyAs:p,exportAs:f,getEmbedDefinition:h}),[e,n,o,r,s,i,a,y,S,v,w,l,c,d,u,p,f,h])}function X3e(t,e){let n={};for(let o of t)if(o.translations)for(let[r,i]of Cr(o.translations)){let s=n[r];s||(s=n[r]={}),Object.assign(s,i)}return{actions:(o,r,i)=>{for(let s of t)s.actions&&(r=s.actions(o,r,i));return r},tools:(o,r,i)=>{for(let s of t)s.tools&&(r=s.tools(o,r,{...e,...i}));return r},translations:n}}function jfe(t){return(0,Ra.useMemo)(()=>t,t)}function aL(t){let e=jfe(t==null?[]:Array.isArray(t)?t:[t]);return(0,Ra.useMemo)(()=>{let n={};for(let o of e)if(o.translations)for(let[r,i]of Cr(o.translations)){let s=n[r];s||(s=n[r]={}),Object.assign(s,i)}return n},[e])}function $fe(t){let e=BS(),n=jfe(t==null?[]:Array.isArray(t)?t:[t]);return(0,Ra.useMemo)(()=>X3e(n,e),[n,e])}var Gfe=Um.createContext(null);function Wfe({overrides:t,children:e}){let n=nt(),o=Qe(),r=sf(),i=J(),s=BS(),a=Um.useCallback((c,d,u)=>{r.announce({msg:i(d.label)}),o("select-tool",{source:c,id:u??d.id})},[r,i,o]),l=Um.useMemo(()=>{if(!n)return{};let c=[{id:"select",label:"tool.select",icon:"tool-pointer",kbd:"v",readonlyOk:!0,onSelect(u){if(n.isIn("select")){let p=n.root.getCurrent();p.exit({},p.id),p.enter({},p.id)}n.setCurrentTool("select"),a(u,this)}},{id:"hand",label:"tool.hand",icon:"tool-hand",kbd:"h",readonlyOk:!0,onSelect(u){n.setCurrentTool("hand"),a(u,this)}},{id:"eraser",label:"tool.eraser",icon:"tool-eraser",kbd:"e",onSelect(u){n.setCurrentTool("eraser"),a(u,this)}},{id:"draw",label:"tool.draw",icon:"tool-pencil",kbd:"d,b,x",onSelect(u){n.setCurrentTool("draw"),a(u,this)}},...[...Pr.values].map(u=>({id:u,label:`tool.${u}`,meta:{geo:u},kbd:u==="rectangle"?"r":u==="ellipse"?"o":void 0,icon:"geo-"+u,onSelect(p){n.run(()=>{n.setStyleForNextShapes(Pr,u),n.setCurrentTool("geo"),a(p,this,`geo-${u}`)})},onDragStart(p,f){let h=n.getResizeScaleFactor();zm(n,f,{createShape:m=>n.createShape({id:m,type:"geo",props:{w:200*h,h:200*h,geo:u,scale:h}})}),o("drag-tool",{source:p,id:"geo"})}})),{id:"arrow",label:"tool.arrow",icon:"tool-arrow",kbd:"a",onSelect(u){n.setCurrentTool("arrow"),a(u,this)},onDragStart(u,p){let f=n.getResizeScaleFactor();zm(n,p,{createShape:h=>n.createShape({id:h,type:"arrow",props:{start:{x:0,y:200*f},end:{x:200*f,y:0},scale:f}})}),o("drag-tool",{source:u,id:"arrow"})}},{id:"line",label:"tool.line",icon:"tool-line",kbd:"l",onSelect(u){n.setCurrentTool("line"),a(u,this)},onDragStart(u,p){let f=n.getResizeScaleFactor();zm(n,p,{createShape:h=>{let[m,y]=Sl(null,null,2);n.createShape({id:h,type:"line",props:{points:{[m]:{id:m,index:m,x:0,y:200*f},[y]:{id:y,index:y,x:200*f,y:0}},scale:f}})}}),o("drag-tool",{source:u,id:"line"})}},{id:"frame",label:"tool.frame",icon:"tool-frame",kbd:"f",onSelect(u){n.setCurrentTool("frame"),a(u,this)},onDragStart(u,p){let f=n.getResizeScaleFactor();zm(n,p,{createShape:h=>n.createShape({id:h,type:"frame",props:{w:320*f,h:180*f}})}),o("drag-tool",{source:u,id:"fr
Line truncated
|
||||
`}},enableCoreExtensions:{textDirection:!1},textDirection:"auto",...E,content:m.current}),R=p.timers.setTimeout(()=>{S.current.caretPosition||S.current.selectAll?I.commands.focus():I.commands.focus("end"),S.current.selectAll=!1,S.current.caretPosition=null},100);return y.current=I,()=>{y.current=null,clearTimeout(R),I.destroy()}},[n,h,w,T,C,b,P,v,p,e,c]),!n||!h?null:(0,c$.jsx)("div",{id:f,ref:u,tabIndex:-1,"data-testid":"rich-text-area",className:"tl-rich-text tl-text tl-text-input",onContextMenu:n?M=>M.stopPropagation():void 0,onPointerDownCapture:M=>M.stopPropagation(),onTouchEnd:M=>M.stopPropagation(),onDragStart:ue,children:(0,c$.jsx)("div",{className:"tl-rich-text",ref:g})})});function S4e(t,e,n){if(n.preventDefault(),dD(t))return;let{state:o,dispatch:r}=e,{$from:i,$to:s}=o.selection,a=n.shiftKey,l=o.tr,c=s.end();for(;c>=i.start();){let u=o.doc.resolve(c).blockRange();if(!u)break;let p=u.start,f=u.end,h=o.doc.textBetween(p,f,`
|
||||
`),m=!1;o.doc.nodesBetween(p,f,y=>y.type.name==="bulletList"||y.type.name==="orderedList"?(m=!0,!1):!0),m||(a?h.startsWith(" ")&&(l=l.delete(p+1,p+2)):l=l.insertText(" ",p+1)),c=p-1}let d=o.selection.map(l.doc,l.mapping);l.setSelection(d),l.docChanged&&r(l)}function II(t){return t==="start-legacy"||t==="middle-legacy"||t==="end-legacy"}var eb=_(H(),1);var Bd=_(H(),1);function AF(t,e,n){let o=u$(t),r=o.isEditing,i=O(),s=(0,Bd.useRef)(null),a=(n||"").trim().length===0;(0,Bd.useEffect)(()=>{function d(u){u.shapeId===t&&s.current?.select?.()}return i.on("select-all-text",d),()=>{i.off("select-all-text",d)}},[i,t,r]),(0,Bd.useEffect)(()=>{r&&(i.getContainerDocument().activeElement!==s.current&&s.current?.focus(),i.getInstanceState().isCoarsePointer&&s.current?.select(),He.isSafari&&(s.current?.blur(),s.current?.focus()))},[i,r]);let l=(0,Bd.useCallback)(d=>{i.getEditingShapeId()===t&&d.key==="Enter"&&(d.ctrlKey||d.metaKey)&&i.complete()},[i,t]),c=(0,Bd.useCallback)(({plaintext:d})=>{if(i.getEditingShapeId()!==t)return;let u=K0.normalizeText(d||"");i.updateShape({id:t,type:e,props:{text:u}})},[i,t,e]);return{rInput:s,handleKeyDown:l,handleChange:c,isEmpty:a,...o}}function _I(t,e){return B("isReadyForEditing",()=>{let n=t.getEditingShapeId();return n!==null&&(n===e||t.getHoveredShapeId()===e)},[t,e])}function u$(t){let e=O(),n=B("isEditing",()=>e.getEditingShapeId()===t,[e]),o=_I(e,t),r=(0,Bd.useCallback)(s=>{e.dispatch({...$r(e,s),type:"pointer",name:"pointer_down",target:"shape",shape:e.getShape(t)}),s.stopPropagation()},[e,t]),i=(0,Bd.useCallback)(s=>{if(e.getEditingShapeId()===t&&s.clipboardData){let a=s.clipboardData.getData("text/html");if(a&&a.includes("<div data-tldraw")){let l=s.clipboardData.getData("text/plain");ue(s),l&&e.getContainerDocument().execCommand("insertText",!1,l)}}},[e,t]);return{handleFocus:()=>{},handleBlur:()=>{},handleInputPointerDown:r,handleDoubleClick:e.markEventAsHandled,handlePaste:i,isEditing:n,isReadyForEditing:o}}function kF(t,e,n){let o=u$(t),r=o.isEditing,i=O(),s=(0,eb.useRef)(null),a=n&&gn(n);(0,eb.useEffect)(()=>{if(!r)return;let d=s.current?.querySelector("[contenteditable]");d&&i.getContainerDocument().activeElement!==s.current&&d.focus()},[i,r]);let l=(0,eb.useCallback)(d=>{i.getEditingShapeId()===t&&d.key==="Enter"&&bo(d)&&i.complete()},[i,t]),c=(0,eb.useCallback)(({richText:d})=>{i.getEditingShapeId()===t&&i.updateShape({id:t,type:e,props:{richText:d}})},[i,t,e]);return{rInput:s,handleKeyDown:l,handleChange:c,isEmpty:a,...o}}var Ud=MI.default.memo(function({shapeId:e,type:n,richText:o,labelColor:r,fontFamily:i,fontSize:s,lineHeight:a,textAlign:l,verticalAlign:c,wrap:d,isSelected:u,padding:p=0,onKeyDown:f,classNamePrefix:h,style:m,textWidth:y,textHeight:g,hasCustomTabBehavior:S,showTextOutline:b=!0}){let v=O(),w=MI.default.useRef(!1),T=II(l),{rInput:C,isEmpty:P,isEditing:M,isReadyForEditing:E,...I}=kF(e,n,o),R=(0,MI.useMemo)(()=>{if(o)return j0(v,o)},[v,o]),D=B("isSelectToolActive",()=>v.getCurrentToolId()==="select",[v]);Pu("isDragging",()=>{v.getInstanceState(),w.current=v.inputs.getIsDragging()},[v]);let N=F=>{let k=v.getContainerWindow().HTMLElement;if(F.target instanceof k&&(F.target.tagName==="A"||F.target.closest("a"))){if(ue(F),!D)return;let L=F.target.closest("a")?.getAttribute("href")??"",U=V=>{V.name!=="pointer_up"||!L||(w.current||vp(L,"_blank",!1),v.off("event",U))};v.on("event",U)}};if(!M&&P)return null;let z=h||"tl-text";return(0,zd.jsx)("div",{className:(0,d$.default)(`${z}-label tl-text-wrapper tl-rich-text-wrapper`,b?"tl-text__outline":"tl-text__no-outline"),"aria-hidden":!M,"data-hastext":!P,"data-isediting":M,"data-textwrap":!!d,"data-isselected":u,style:{fontFamily:i,textAlign:l,justifyContent:l==="center"||T?"center":l==="end"?"flex-end":"flex-start",alignItems:c==="middle"?"center":c==="end"?"flex-end":"flex-start",padding:p,...m},children:(0,zd.jsxs)("div",{className:`${z}-label__inner tl-text-content__wrapper`,style:{fontSize:s,lineHeight:`${Ns(s,a)}px`,minHeight:`${Ns(s,a)}px`,minWidth:Math.ceil(y||0),color:r,width:y?Math.ceil(y):void 0,height:g?Math.ceil(g):void 0},children:[(0,zd.jsx)("div",{className:`${z} tl-text tl-text-content`,dir:"auto",children:o&&(0,zd.jsx)("div",{className:"tl-rich-text","data-is-select-tool-active":D,dangerouslySetInnerHTML:{__html:R||""},onPointerDown:N,"data-is-ready-for-editing":E})}),(E||u)&&(0,zd.jsx)(MF,{ref:C,richText:o,isEditing:M,shapeId:e,...I,hasCustomTabBehavior:S,handleKeyDown:f??I.handleKeyDown})]})})});function Hd({bounds:t,richText:e,fontSize:n,fontFamily:o,lineHeight:r,textAlign:i,verticalAlign:s,wrap:a,labelColor:l,padding:c,showTextOutline:d=!0}){let u=O(),p=j0(u,e),f=II(i),y={display:"flex",fontFamily:o,height:"100%",justifyContent:i==="center"||f?"center":i==="start"?"flex-start":"flex-end",alignItems:s==="middle"?"center":s==="start"?"flex-start":"flex-end",padding:`${c}px`},g={fontSize:`${n}px`,wrap:a?"wrap":"nowrap",color:l,lineHeight:`${Ns(n,r)}px`,textAlign:i,width:"100%",wordWrap:"break-wo
Line truncated
|
||||
a ${o},${o} 0 1,0 ${o*2},0
|
||||
a ${o},${o} 0 1,0 -${o*2},0 `}function C4e({point:t,int:e}){let n=x.Lrp(t,e,.75),o=x.RotWith(n,t,ct/4),r=x.RotWith(n,t,-ct/4),i=x.Lrp(o,r,.5);return i.add(x.Sub(i,t)),`M ${i.x} ${i.y} L ${r.x} ${r.y} ${t.x} ${t.y} L ${o.x} ${o.y} Z`}function P4e({int:t,point:e}){let n=x.Lrp(e,t,.85),o=x.Sub(n,e).div(2),r=x.Add(e,x.Rot(o,Mn)),i=x.Sub(e,x.Rot(o,Mn)),s=x.Add(n,x.Rot(o,Mn)),a=x.Sub(n,x.Rot(o,Mn));return`M ${r.x} ${r.y} L ${s.x} ${s.y} L ${a.x} ${a.y} L ${i.x} ${i.y} Z`}function E4e({int:t,point:e}){let n=x.Sub(t,e).div(2),o=x.Add(e,x.Rot(n,Mn)),r=x.Sub(e,x.Rot(n,Mn));return`M ${o.x} ${o.y} L ${r.x} ${r.y}`}function AI(t,e,n){let o=e==="end"?t.end.arrowhead:t.start.arrowhead;if(o==="none")return;let r=b4e(t,e,n);if(r){switch(o){case"bar":return E4e(r);case"square":return P4e(r);case"diamond":return C4e(r);case"dot":return T4e(r);case"inverted":return x4e(r);case"arrow":return v4e(r);case"triangle":return w4e(r)}return""}}function I4e(t,e){return Ce(t.getShapeUtil("arrow"),e)}function tb(t,e){let n=ri(t,e);switch(n.type){case"straight":return new Ir({start:x.From(n.start.point),end:x.From(n.end.point)});case"arc":return new ll({center:x.Cast(n.handleArc.center),start:x.Cast(n.start.point),end:x.Cast(n.end.point),sweepFlag:n.bodyArc.sweepFlag,largeArcFlag:n.bodyArc.largeArcFlag});case"elbow":return new pa({points:n.route.points});default:ze(n,"type")}}var _4e=la("arrow label size",(t,e)=>{t.fonts.trackFontsForShape(e);let n=0,o=0,r=tb(t,e),i=gn(e.props.richText),s=Pa(t,i?yt("i"):e.props.richText),a=r.bounds,l=I4e(t,e),c=l.labelFontSize*e.props.scale,{w:d,h:u}=t.textMeasure.measureHtml(s,{...Wr,lineHeight:l.labelLineHeight,fontFamily:l.labelFontFamily,fontSize:c,maxWidth:null});n=d,o=u;let p=!1,f=ri(t,e),h=tge(e,t.getCurrentTheme()),m=f.type==="elbow"?Math.max(f.elbow.A.arrowheadOffset+h,32)+Math.max(f.elbow.B.arrowheadOffset+h,32):64;if(a.width>a.height?(n=Math.max(Math.min(d,m),Math.min(a.width-m,d)),p=!0):n>16*c&&(n=16*c,p=!0),p){let{w:y,h:g}=t.textMeasure.measureHtml(s,{...Wr,lineHeight:l.labelLineHeight,fontFamily:l.labelFontFamily,fontSize:c,maxWidth:n});n=y,o=g}return new x(n,o).addScalar(l.labelPadding*2*e.props.scale)},{areRecordsEqual:(t,e)=>{if(t.props===e.props)return!0;let n=g_(t.props,e.props);return n.length===1&&n[0]==="labelPosition"}});function ege(t,e){return _4e.get(t,e.id)??new x(0,0)}function tge(t,e){let n=e.strokeWidth*Pn[t.props.size],o=e.strokeWidth*Pn.s,r=e.strokeWidth*Pn.xl;return(dse+(n-o)*2+(n>=r?20:0))*t.props.scale}function M4e(t,e,n){let o=tb(t,e),r=[],i=[new io({children:[o],debugColor:"lime"})],s=ege(t,e),a=tge(e,t.getCurrentTheme()),l=a/o.length,c,d;if(n.type==="elbow")r.push(n.start.point,n.end.point),c=Y.FromCenter(n.start.point,s).expandBy(a),d=Y.FromCenter(n.end.point,s).expandBy(a);else{let g=o.interpolateAlongEdge(l),S=o.interpolateAlongEdge(1-l);r.push(g,S),c=Y.FromCenter(g,s),d=Y.FromCenter(S,s)}let u=o.intersectPolygon(c.corners),p=o.intersectPolygon(d.corners),f=Qme(n.start.point,u),h=Qme(n.end.point,p),m=f?o.uninterpolateAlongEdge(f):.5,y=h?o.uninterpolateAlongEdge(h):.5;m>y&&(m=.5,y=.5);for(let g of[...u,...p,...r])i.push(new cl({x:g.x-3,y:g.y-3,radius:3,isFilled:!1,debugColor:"magenta",ignore:!0}));return i.push(new Di({points:c.corners,debugColor:"lime",isFilled:!1,ignore:!0}),new Di({points:d.corners,debugColor:"lime",isFilled:!1,ignore:!0})),{start:m,end:y,dbg:i}}function kI(t,e,n){if(!n&&gn(e.props.richText)){let p=tb(t,e).interpolateAlongEdge(.5);return{box:Y.FromCenter(p,new x(0,0)),debugGeom:[]}}let o=[],r=ri(t,e),i={hasStartBinding:!!r.bindings.start,hasEndBinding:!!r.bindings.end,hasStartArrowhead:r.start.arrowhead!=="none",hasEndArrowhead:r.end.arrowhead!=="none"},s=M4e(t,e,r);s.dbg&&o.push(...s.dbg);let a=A4e(e,s,i),c=tb(t,e).interpolateAlongEdge(a),d=ege(t,e);return{box:Y.FromCenter(c,d),debugGeom:o}}function A4e(t,e,n){let{hasEndArrowhead:o,hasEndBinding:r,hasStartBinding:i,hasStartArrowhead:s}=n;return be(t.props.labelPosition,s||i?e.start:0,o||r?e.end:1)}function Qme(t,e){let n=null,o=-1/0;for(let r of e){let i=x.Dist2(t,r);i>o&&(n=r,o=i)}return n}function RF(t,e){let n=ri(t,e);switch(n.type){case"straight":case"arc":return .5;case"elbow":{let o=n.route.midpointHandle,r=tb(t,e);return o&&r?r.uninterpolateAlongEdge(o.point):.5}default:ze(n,"type")}}function DF(t,e){if(!t.isShapeOfType(e,"arrow"))return!1;let n=t.getPointInShapeSpace(e,t.inputs.getCurrentPagePoint()),o=t.getShapeGeometry(e).children[1];return o&&Zt(n,o.vertices)}function p$(t){switch(t.type){case"straight":return new Vt().moveTo(t.start.point.x,t.start.point.y,{offset:0,roundness:0}).lineTo(t.end.point.x,t.end.point.y,{offset:0,roundness:0});case"arc":return new Vt().moveTo(t.start.point.x,t.start.point.y,{offset:0,roundness:0}).circularArcTo(t.bodyArc.radius,!!t.bodyArc.largeArcFlag,!!t.bodyArc.sweepFlag,t.end.point.x,t.end.point.y,{offset:0,roundness:0});case"elbow":{let e=new Vt;e.moveTo(t.start.point.x,t.start.point.y,{offset:0});for(let n=1;n<t.route.points.length;n++){let o=t.ro
Line truncated
|
||||
<html>
|
||||
<head>
|
||||
<base target="_blank">
|
||||
</head>
|
||||
<body>
|
||||
<script src=${`https://gist.github.com/${t}.js`}><\/script>
|
||||
<style type="text/css">
|
||||
* { margin: 0px; }
|
||||
table { height: 100%; background-color: red; }
|
||||
.gist { background-color: none; height: 100%; }
|
||||
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
||||
</style>
|
||||
</body>
|
||||
</html>`})}var Ba=_(j(),1);var Cge=_(on(),1);var BF=_(j(),1);function $4e(t){return t.replace(/\s/g,"\xA0")}function vge(t,e,n){let{padding:o=0}=n;if(e.length===0)return null;let r=Y.From(e[0].box);for(let{box:c}of e)r.union(c);let i=o+(n.offsetX??0),s=(n.offsetY??0)+n.fontSize/2+(n.verticalTextAlign==="start"?o:n.verticalTextAlign==="end"?n.height-o-r.height:(Math.ceil(n.height)-r.height)/2),a=null,l=[];for(let{text:c,box:d}of e)a!==null&&d.y>a&&l.push((0,BF.jsx)("tspan",{alignmentBaseline:"mathematical",x:i,y:d.y+s,children:`
|
||||
`},l.length)),l.push((0,BF.jsx)("tspan",{alignmentBaseline:"mathematical",x:d.x+i,y:d.y+s,unicodeBidi:"plaintext",children:$4e(c)},l.length)),a=d.y;return(0,BF.jsx)("text",{fontSize:n.fontSize,fontFamily:n.fontFamily,fontStyle:n.fontStyle,fontWeight:n.fontWeight,dominantBaseline:"mathematical",alignmentBaseline:"mathematical",stroke:n.stroke,strokeWidth:n.strokeWidth,fill:n.fill,children:l})}var HF=_(j(),1);var BT=_(H(),1);function DI(t,e){return t.match(/^\s*$/)?e:t}function OI(t,e){let r=((hh(t.getShapePageTransform(e.id).rotation())+Math.PI/4)*(2/Math.PI)+4)%4;return Math.floor(r)}var wge=new WeakMap;function b$(t,e,n){let o=wge.get(e.props);if(!o){let r=DI(e.props.name,"Frame")+"\u200B",i=t.textMeasure.measureTextSpans(r,n),s=i[0],a=ko(i);o=a.box.w+a.box.x-s.box.x,wge.set(e.props,o)}return new Y(0,-n.height,o,n.height)}function v$(t,e){return{fontSize:12,fontFamily:e?"Arial":"Inter, sans-serif",textAlign:"start",width:t,height:24,padding:0,lineHeight:1,fontStyle:"normal",fontWeight:"normal",overflow:"truncate-ellipsis",verticalTextAlign:"middle",offsetY:-34,offsetX:0}}function zF(t,e,n){let o=n?"":"px",r=n?"":"deg",i;switch(e){case 0:i="";break;case 3:i=`translate(${Se(t.props.w)}${o}, 0${o}) rotate(90${r})`;break;case 2:i=`translate(${Se(t.props.w)}${o}, ${Se(t.props.h)}${o}) rotate(180${r})`;break;case 1:i=`translate(0${o}, ${Se(t.props.h)}${o}) rotate(270${r})`;break;default:throw Error("labelSide out of bounds")}return i}var UF=_(j(),1);var fc=_(H(),1);var xge=(0,fc.forwardRef)(({id:t,name:e,isEditing:n},o)=>{let r=O(),i=_t(),s=B("isCoarsePointer",()=>r.getInstanceState().isCoarsePointer,[r]),a=i<ht.TABLET_SM&&s,l=(0,fc.useRef)(!1),c=J(),d=(0,fc.useCallback)(m=>{n&&r.markEventAsHandled(m)},[r,n]),u=(0,fc.useCallback)(m=>{m.key==="Enter"&&!m.nativeEvent.isComposing&&(r.markEventAsHandled(m),m.currentTarget.blur(),r.setEditingShape(null))},[r]),p=(0,fc.useCallback)(m=>{let y=r.getShape(t);!y||y.props.name===m||r.updateShapes([{id:t,type:"frame",props:{name:m}}])},[t,r]),f=(0,fc.useCallback)(m=>{p(m.currentTarget.value)},[p]),h=(0,fc.useCallback)(m=>{p(m.currentTarget.value)},[p]);return(0,fc.useEffect)(()=>{if(!n){l.current=!1;return}if(n&&a&&!l.current){l.current=!0;let y=r.getShape(t)?.props.name??"",g=window.prompt(c("action.rename"),y);l.current=!1,g!==null&&p(g),r.setEditingShape(null)}},[n,a,t,c,p,r]),(0,UF.jsxs)("div",{className:`tl-frame-label ${n&&!a?"tl-frame-label__editing":""}`,children:[(0,UF.jsx)("input",{className:"tl-frame-name-input",ref:o,disabled:!n||a,readOnly:!n||a,style:{display:n?void 0:"none"},value:e,autoFocus:!a,onKeyDown:u,onBlur:f,onChange:h,onPointerDown:d,draggable:!1}),DI(e,"Frame")+"\u200B"]})});var Tge=(0,BT.memo)(function({id:e,name:n,width:o,height:r,fill:i,stroke:s,color:a,offsetX:l,showColors:c}){let d=O(),{side:u,translation:p}=B("shape rotation",()=>{let m=d.getShape(e);if(!m)return{side:0,translation:"translate(0, 0)"};let y=OI(d,m);return{side:y,translation:zF(m,y,!1)}},[d,l,e]),f=(0,BT.useRef)(null),h=Ju(e);return(0,BT.useEffect)(()=>{let m=f.current;m&&h&&(m.focus(),m.select())},[f,h]),(0,HF.jsx)("div",{className:"tl-frame-heading",style:{overflow:h?"visible":"hidden",maxWidth:`calc(var(--tl-zoom) * ${Math.ceil(u===0||u===2?o:r)}px + ${c?"0px":"var(--tl-frame-offset-width)"})`,bottom:"100%",transform:`${p} scale(min(var(--tl-scale), 3.5)) translateX(${l}px)`},children:(0,HF.jsx)("div",{className:"tl-frame-heading-hit-area",style:{color:a,backgroundColor:i,boxShadow:`inset 0px 0px 0px 1px ${s}`},children:(0,HF.jsx)(xge,{ref:f,id:e,name:n,isEditing:h})})})});var K4e=12,G4e=32,W4e=-7,Y4e=4,zT=class extends i0{static type="frame";static props=mv;static migrations=gv;options={showColors:!1,resizeChildren:!1,getDefaultDisplayValues(e,n,o,r){let{color:i}=n.props,s=o.colors[r];return{fillColor:ut(s,"black","frameFill"),strokeColor:ut(s,"black","frameStroke"),showColorsFillColor:ut(s,i,"frameFill"),showColorsStrokeColor:ut(s,i,"frameStroke"),headingFillColor:s.negativeSpace,headingStrokeColor:s.negativeSpace,headingTextColor:ut(s,"black","frameText"),showColorsHeadingFillColor:ut(s,i,"frameHeadingFill"),showColorsHeadingStrokeColor:ut(s,i,"frameHeadingStroke"),showColorsHeadingTextColor:ut(s,i,"frameText")}},getCustomDisplayValues(){return{}}};static configure(e){let n=super.configure.call(this,e);return e.showColors&&(n.props={...n.props,color:Lt}),n}canEdit(e,n){return n.type==="click-header"||n.type==="unknown"}canResize(e){return!0}canResizeChildren(e){return this.options.resizeChildren}isExportBoundsContainer(){return!0}getDefaultProps(){return{w:320,h:180,name:"",color:"black"}}getAriaDescriptor(e){return e.props.name}getGeometry(e){let{editor:n}=this,o=n.getEfficientZoomLevel(),r=OI(n,e),i=r%2===1,s=i?e.props.h:e.props.w,a=v$(s,!1),l=b$(n,e,a),c=this.options.showColors,d=K4e/o,u=G4e/o,p=s+(c?1:d),f=l.w/o,h=l.h/o,m=be(f+d,u,p),y=(c?-1:W4e)/o,g=Y4e/o,S=i?h:m,b=i?m:h,v,w;switch(r){case 0:{v=y,w=-(h+g);break}case 1:{v=-(h+g),w=e.props.h-(y+m);break}case 2:{v=e.pro
Line truncated
|
||||
${(o?e.height:0)-(s||0)}px)`:""} ${a}`,transformOrigin:e?"0 0":"center center"}}function hHe({shape:t,src:e}){let n=ki(),o=Dge(t),r=t.props.crop;if(o.transform&&r){let{transform:i,width:s,height:a}=o,l=(r.bottomRight.x-r.topLeft.x)*s,c=(r.bottomRight.y-r.topLeft.y)*a,d=[new x(0,0),new x(l,0),new x(l,c),new x(0,c)],u=FI(t,{width:s,height:a});return(0,to.jsxs)(to.Fragment,{children:[(0,to.jsx)("defs",{children:(0,to.jsx)("clipPath",{id:n,children:r.isCircle?(0,to.jsx)("ellipse",{cx:l/2,cy:c/2,rx:l/2,ry:c/2}):(0,to.jsx)("polygon",{points:d.map(p=>`${p.x},${p.y}`).join(" ")})})}),(0,to.jsx)("g",{clipPath:`url(#${n})`,children:(0,to.jsx)("image",{href:e,width:s,height:a,"aria-label":t.props.altText,style:u?{...u}:{transform:i}})})]})}else return(0,to.jsx)("image",{href:e,width:t.props.w,height:t.props.h,"aria-label":t.props.altText,style:FI(t,{width:t.props.w,height:t.props.h})})}function Oge(t){let e=!1;return{promise:new Promise(o=>{let r=yl();r.onload=()=>{if(e)return;let i=pn().createElement("canvas");i.width=r.width,i.height=r.height;let s=i.getContext("2d");s&&(s.drawImage(r,0,0),o(i.toDataURL()))},r.crossOrigin="anonymous",r.src=t}),cancel:()=>e=!0}}var YF=_(j(),1);var mHe=new Mt,KT=class extends Qo{static type="line";static props=Ev;static migrations=Iv;options={getDefaultDisplayValues(e,n,o,r){let{color:i,size:s}=n.props;return{strokeColor:ut(o.colors[r],i,"solid"),strokeWidth:o.strokeWidth*Pn[s]}},getCustomDisplayValues(){return{}}};hideResizeHandles(e){return!0}hideRotateHandle(e){return!0}hideSelectionBoundsFg(e){return!0}hideSelectionBoundsBg(e){return!0}hideInMinimap(){return!0}getDefaultProps(){let[e,n]=Sc(2);return{dash:"draw",size:"m",color:"black",spline:"line",points:{[e]:{id:e,index:e,x:0,y:0},[n]:{id:n,index:n,x:.1,y:.1}},scale:1}}getGeometry(e){let n=_$(e).toGeometry();return ne(n instanceof fm),n}getHandles(e){return mHe.get(e.props,()=>{let n=this.getGeometry(e),o=BI(e),r=o.map(i=>({...i,id:i.index,type:"vertex",canSnap:!0}));for(let i=0;i<o.length-1;i++){let s=ja(o[i].index,o[i+1].index),l=n.getSegments()[i].interpolateAlongEdge(.5);r.push({id:s,type:"create",index:s,x:l.x,y:l.y,canSnap:!0})}return r.sort(Fn)})}onResize(e,n){let{scaleX:o,scaleY:r}=n;return{props:{points:lr(e.props.points,(i,{id:s,index:a,x:l,y:c})=>({id:s,index:a,x:l*o,y:c*r}))}}}onBeforeCreate(e){let{props:{points:n}}=e,o=Object.keys(n);if(o.length<2)return;let r=n[o[0]];if(o.every(s=>{let a=n[s];return a.x===r.x&&a.y===r.y})){let s=o[o.length-1];return n[s]={...n[s],x:n[s].x+.1,y:n[s].y+.1},e}}onHandleDrag(e,{handle:n}){let o=Go(new x(n.x,n.y),this.editor);return{...e,props:{...e.props,points:{...e.props.points,[n.id]:{id:n.id,index:n.index,x:o.x,y:o.y}}}}}onHandleDragStart(e,{handle:n}){if(n.type==="create")return{...e,props:{...e.props,points:{...e.props.points,[n.index]:{id:n.index,index:n.index,x:n.x,y:n.y}}}}}component(e){let n=Fo(),o=Ce(this,e,n);return(0,YF.jsx)(Ri,{style:{minWidth:50,minHeight:50},children:(0,YF.jsx)(Lge,{shape:e,strokeColor:o.strokeColor,strokeWidth:o.strokeWidth})})}getIndicatorPath(e){let n=Ce(this,e).strokeWidth*e.props.scale,o=_$(e),{dash:r}=e.props;return o.toPath2D({style:r==="draw"?"draw":"solid",strokeWidth:1,passes:1,randomSeed:e.id,offset:0,roundness:n*2})}toSvg(e,n){let o=Ce(this,e,n.colorMode);return(0,YF.jsx)(Lge,{shouldScale:!0,shape:e,strokeColor:o.strokeColor,strokeWidth:o.strokeWidth})}getHandleSnapGeometry(e){let n=BI(e);return{points:n,getSelfSnapPoints:o=>{let r=this.getHandles(e).filter(i=>i.type==="vertex").findIndex(i=>i.id===o.id);return n.filter((i,s)=>Math.abs(s-r)>1).map(x.From)},getSelfSnapOutline:o=>{let r=this.getHandles(e).filter(s=>s.type==="vertex").findIndex(s=>s.id===o.id),i=this.getGeometry(e).getSegments().filter((s,a)=>a!==r-1&&a!==r);return i.length?new io({children:i}):null}}}getInterpolatedProps(e,n,o){let r=BI(e),i=BI(n),s=[],a=[],l=ih;if(r.length>i.length)for(let c=0;c<r.length;c++)s[c]={...r[c]},i[c]===void 0?a[c]={...i[i.length-1],id:l}:a[c]={...i[c],id:l},l=cr(l);else if(i.length>r.length)for(let c=0;c<i.length;c++)a[c]={...i[c]},r[c]===void 0?s[c]={...r[r.length-1],id:l}:s[c]={...r[c],id:l},l=cr(l);else for(let c=0;c<i.length;c++)s[c]=r[c],a[c]=i[c];return{...o>.5?n.props:e.props,points:Object.fromEntries(s.map((c,d)=>{let u=a[d];return[c.id,{...c,x:_e(c.x,u.x,o),y:_e(c.y,u.y,o)}]})),scale:_e(e.props.scale,n.props.scale,o)}}};function BI(t){return Object.values(t.props.points).sort(Fn)}var gHe=new Mt;function _$(t){return gHe.get(t,()=>{let e=BI(t).map(x.From);switch(t.props.spline){case"cubic":return Vt.cubicSplineThroughPoints(e,{endOffsets:0});case"line":return Vt.lineThroughPoints(e,{endOffsets:0})}})}function Lge({shape:t,shouldScale:e=!1,forceSolid:n=!1,strokeColor:o,strokeWidth:r}){let i=_$(t),{dash:s}=t.props,a=1/t.props.scale,l=e?a:1,c=r*t.props.scale;return i.toSvg({style:s,strokeWidth:c,forceSolid:n,randomSeed:t.id,props:{transform:`scale(${l})`,stroke:o,fill:"none"}})}var Jt=_(j(),1);var XF=_(H(),1);var yHe=Object.freezLine truncated
|
||||
0px ${(a+r*c)*Math.max(0,i)}px ${l+r*c}px -${a+r*l}px rgba(15, 23, 31, ${(.3+r*.1).toFixed(2)}),
|
||||
0px ${48*n}px ${10*n}px -${10*n}px inset rgba(15, 23, 44, ${((.022+o()*.005)*((1+i)/2)).toFixed(2)})`}var M$=_(j(),1);var Nge=_(H(),1);var wHe=.3,xHe=la("text size",(t,e)=>{t.fonts.trackFontsForShape(e);let n=t.getShapeUtil(e),o=Ce(n,e);return Fge(t,e.props,o)},{areRecordsEqual:(t,e)=>t.props===e.props}),WT=class extends Qo{static type="text";static props=Av;static migrations=kv;options={extraArrowHorizontalPadding:10,showTextOutline:!0,getDefaultDisplayValues(e,n,o,r){let{color:i,font:s,size:a}=n.props;return{color:ut(o.colors[r],i,"solid"),fontFamily:Ta(o,s),fontSize:o.fontSize*oS[a],lineHeight:o.lineHeight,fontWeight:Wr.fontWeight,fontStyle:Wr.fontStyle,fontVariant:Wr.fontVariant}},getCustomDisplayValues(){return{}}};getDefaultProps(){return{color:"black",size:"m",w:8,font:"draw",textAlign:"start",autoSize:!0,scale:1,richText:yt("")}}getMinDimensions(e){return xHe.get(this.editor,e.id)}getGeometry(e,n){let{scale:o}=e.props,{width:r,height:i}=this.getMinDimensions(e),s=n?.context??"none";return new Bn({x:(s==="@tldraw/arrow-without-arrowhead"?-this.options.extraArrowHorizontalPadding:0)*o,width:(r+(s==="@tldraw/arrow-without-arrowhead"?this.options.extraArrowHorizontalPadding*2:0))*o,height:i*o,isFilled:!0,isLabel:!0})}getFontFaces(e){let n=Sm(this.editor.getCurrentTheme(),e.props.font);return n||id(this.editor,e.props.richText,{family:`tldraw_${e.props.font}`,weight:"normal",style:"normal"})}getText(e){return Ea(this.editor,e.props.richText)}canEdit(e){return!0}isAspectRatioLocked(e){return!0}component(e){let{id:n,props:{richText:o,scale:r,textAlign:i}}=e,{width:s,height:a}=this.getMinDimensions(e),l=e.id===this.editor.getOnlySelectedShapeId(),c=Fo(),d=Ce(this,e,c),u=THe(n);return(0,M$.jsx)(Ud,{shapeId:n,classNamePrefix:"tl-text-shape",type:"text",fontFamily:d.fontFamily,fontSize:d.fontSize,lineHeight:d.lineHeight,textAlign:i==="middle"?"center":i,verticalAlign:"middle",richText:o,labelColor:d.color,isSelected:l,textWidth:s,textHeight:a,showTextOutline:this.options.showTextOutline,style:{transform:`scale(${r})`,transformOrigin:"top left"},wrap:!0,onKeyDown:u})}getIndicatorPath(e){if(e.props.autoSize&&this.editor.getEditingShapeId()===e.id)return;let n=this.editor.getShapeGeometry(e).bounds,o=new Path2D;return o.rect(0,0,n.width,n.height),o}toSvg(e,n){let o=this.editor.getShapeGeometry(e).bounds,r=o.width/(e.props.scale??1),i=o.height/(e.props.scale??1),s=Ce(this,e,n.colorMode),a=Math.ceil(s.fontSize*wHe),l=new Y(-a,-a,r+a*2,i+a*2);return(0,M$.jsx)(Hd,{fontSize:s.fontSize,fontFamily:s.fontFamily,lineHeight:s.lineHeight,textAlign:e.props.textAlign==="middle"?"center":e.props.textAlign,verticalAlign:"middle",richText:e.props.richText,labelColor:s.color,bounds:l,padding:a,showTextOutline:this.options.showTextOutline})}onResize(e,n){let{newPoint:o,initialBounds:r,initialShape:i,scaleX:s,handle:a}=n;if(n.mode==="scale_shape"||a!=="right"&&a!=="left")return{id:e.id,type:e.type,...s0(e,n)};{let l=Math.max(1,Math.abs(r.width*s)),{x:c,y:d}=s<0?x.Sub(o,x.FromAngle(e.rotation).mul(l)):o;return{id:e.id,type:e.type,x:c,y:d,props:{w:l/i.props.scale,autoSize:!1}}}}onEditEnd(e){Ea(this.editor,e.props.richText).trimEnd().length===0&&this.editor.deleteShapes([e.id])}onBeforeUpdate(e,n){if(!n.props.autoSize)return;let o=e.props.size!==n.props.size||e.props.textAlign!==n.props.textAlign||e.props.font!==n.props.font||e.props.scale!==1&&n.props.scale===1,r=!(0,Ho.default)(e.props.richText,n.props.richText);if(!o&&!r)return;let i=this.getMinDimensions(e),s=Ce(this,n),a=Fge(this.editor,n.props,s),l=i.width*e.props.scale,c=i.height*e.props.scale,d=a.width*n.props.scale,u=a.height*n.props.scale,p;switch(n.props.textAlign){case"middle":{p=new x((d-l)/2,r?0:(u-c)/2);break}case"end":{p=new x(d-l,r?0:(u-c)/2);break}default:{if(r)break;p=new x(0,(u-c)/2);break}}if(p){p.rot(n.rotation);let{x:f,y:h}=n;return{...n,x:f-p.x,y:h-p.y,props:{...n.props,w:d}}}else return{...n,props:{...n.props,w:d}}}};function Fge(t,e,n){let{richText:o,w:r}=e,i=16,s=e.autoSize?null:Math.max(i,Math.floor(r)),a=Pa(t,o),l=t.textMeasure.measureHtml(a,{lineHeight:n.lineHeight,fontWeight:n.fontWeight,fontStyle:n.fontStyle,padding:"0px",fontFamily:n.fontFamily,fontSize:n.fontSize,maxWidth:s});return{width:s??Math.max(i,l.w+1),height:Math.max(n.fontSize,l.h)}}function THe(t){let e=O();return(0,Nge.useCallback)(n=>{e.getEditingShapeId()===t&&n.key==="Enter"&&(n.ctrlKey||n.metaKey)&&e.complete()},[e,t])}var Ci=_(j(),1);var Bge=_(on(),1),hc=_(H(),1);var CHe=new Mt,YT=class extends ss{static type="video";static props=Rv;static migrations=Dv;static handledAssetTypes=["video"];options={autoplay:!0,getDefaultDisplayValues(){return{}},getCustomDisplayValues(){return{}}};canEdit(e){return!0}isAspectRatioLocked(e){return!0}getDefaultProps(){return{w:100,h:100,assetId:null,autoplay:this.options.autoplay,url:"",altText:"",time:0,playing:!0}}createShapeForAsset(e,n){let o=e;return{id:Ue(),type:"video",x:n.x,y:n.y,opacity:1,props:{assetId:o.id,w:o.props.w,h:o.props.h}}}getAriaDesc
Line truncated
|
||||
`).map((R,D)=>(0,ib.jsx)("div",{dir:"auto",children:R},D))}),(T||u)&&(0,ib.jsx)(RB,{ref:b,text:o,isEditing:w,shapeId:e,...C,handleKeyDown:f??C.handleKeyDown})]})})});var Ha=_(j(),1);var gu=_(H(),1);var Jge=_(H(),1);var Xge=_(H(),1),sb={fonts:{tldraw_mono:`${Co()}/fonts/IBMPlexMono-Medium.woff2`,tldraw_mono_italic:`${Co()}/fonts/IBMPlexMono-MediumItalic.woff2`,tldraw_mono_bold:`${Co()}/fonts/IBMPlexMono-Bold.woff2`,tldraw_mono_italic_bold:`${Co()}/fonts/IBMPlexMono-BoldItalic.woff2`,tldraw_serif:`${Co()}/fonts/IBMPlexSerif-Medium.woff2`,tldraw_serif_italic:`${Co()}/fonts/IBMPlexSerif-MediumItalic.woff2`,tldraw_serif_bold:`${Co()}/fonts/IBMPlexSerif-Bold.woff2`,tldraw_serif_italic_bold:`${Co()}/fonts/IBMPlexSerif-BoldItalic.woff2`,tldraw_sans:`${Co()}/fonts/IBMPlexSans-Medium.woff2`,tldraw_sans_italic:`${Co()}/fonts/IBMPlexSans-MediumItalic.woff2`,tldraw_sans_bold:`${Co()}/fonts/IBMPlexSans-Bold.woff2`,tldraw_sans_italic_bold:`${Co()}/fonts/IBMPlexSans-BoldItalic.woff2`,tldraw_draw:`${Co()}/fonts/Shantell_Sans-Informal_Regular.woff2`,tldraw_draw_italic:`${Co()}/fonts/Shantell_Sans-Informal_Regular_Italic.woff2`,tldraw_draw_bold:`${Co()}/fonts/Shantell_Sans-Informal_Bold.woff2`,tldraw_draw_italic_bold:`${Co()}/fonts/Shantell_Sans-Informal_Bold_Italic.woff2`}};function Zge(t){sb=t}function DB(t){return(0,Xge.useMemo)(()=>t?{fonts:{...sb.fonts,...t?.fonts}}:sb,[t])}var OB=["align-bottom","align-center-horizontal","align-center-vertical","align-left","align-right","align-top","alt","arrow-arc","arrow-cycle","arrow-elbow","arrow-left","arrowhead-arrow","arrowhead-bar","arrowhead-diamond","arrowhead-dot","arrowhead-none","arrowhead-square","arrowhead-triangle-inverted","arrowhead-triangle","blob","bold","bookmark","bring-forward","bring-to-front","broken","bulletList","check-circle","check","chevron-down","chevron-left","chevron-right","chevron-up","chevrons-ne","chevrons-sw","clipboard-copied","clipboard-copy","code","color","comment","corners","crop","cross-2","cross-circle","dash-dashed","dash-dotted","dash-draw","dash-solid","disconnected","discord","distribute-horizontal","distribute-vertical","dot","dots-horizontal","dots-vertical","download","drag-handle-dots","duplicate","edit","external-link","fill-fill","fill-lined-fill","fill-none","fill-pattern","fill-semi","fill-solid","follow","following","font-draw","font-mono","font-sans","font-serif","geo-arrow-down","geo-arrow-left","geo-arrow-right","geo-arrow-up","geo-check-box","geo-cloud","geo-diamond","geo-ellipse","geo-heart","geo-hexagon","geo-octagon","geo-oval","geo-pentagon","geo-rectangle","geo-rhombus-2","geo-rhombus","geo-star","geo-trapezoid","geo-triangle","geo-x-box","github","group","heading","help-circle","highlight","horizontal-align-end","horizontal-align-middle","horizontal-align-start","info-circle","italic","leading","link","list","lock","manual","menu","minus","mixed","pack","plus","question-mark-circle","question-mark","redo","reset-zoom","rotate-ccw","rotate-cw","send-backward","send-to-back","share-1","size-extra-large","size-large","size-medium","size-small","spline-cubic","spline-line","stack-horizontal","stack-vertical","status-offline","stretch-horizontal","stretch-vertical","strike","text-align-center","text-align-left","text-align-right","toggle-off","toggle-on","tool-arrow","tool-eraser","tool-frame","tool-hand","tool-highlight","tool-laser","tool-line","tool-media","tool-note","tool-pencil","tool-pointer","tool-screenshot","tool-text","trash","twitter","underline","undo","ungroup","unlock","vertical-align-end","vertical-align-middle","vertical-align-start","warning-triangle","zoom-in","zoom-out"];var rC={...sb,icons:Object.fromEntries(OB.map(t=>[t,`${Co()}/icons/icon/0_merged.svg#${t}`])),translations:Object.fromEntries(Pc.map(t=>[t.locale,`${Co()}/translations/${t.locale}.json`])),embedIcons:Object.fromEntries(DS.map(t=>[t.type,`${Co()}/embed-icons/${t.type}.png`]))};function Qge(t){rC=t}function LB(t){return(0,Jge.useMemo)(()=>t?{fonts:Object.assign({...rC.fonts},{...t?.fonts}),icons:Object.assign({...rC.icons},{...t?.icons}),embedIcons:Object.assign({...rC.embedIcons},{...t?.embedIcons}),translations:Object.assign({...rC.translations},{...t?.translations})}:rC,[t])}var k$=_(j(),1);function eye(){let{Spinner:t}=Kn();return(0,k$.jsx)(mR,{children:t?(0,k$.jsx)(t,{}):null})}var wt=_(j(),1);var oye=_(on(),1),hl=_(H(),1);var Ua=_(j(),1);var NB=en(function({overrides:e,components:n,assetUrls:o,onUiEvent:r,forceMobile:i,mediaMimeTypes:s,children:a}){let l=nt();return(0,Ua.jsx)(gj.Provider,{value:s,children:(0,Ua.jsx)(L0,{assetUrls:LB(o),children:(0,Ua.jsx)(B0,{overrides:aL(e),locale:l?.user.getLocale()??Jo.locale,children:(0,Ua.jsx)(AO,{children:(0,Ua.jsx)(UO,{onEvent:r,children:(0,Ua.jsx)(ZO,{children:(0,Ua.jsx)(XO,{context:"tla",children:(0,Ua.jsx)(c2,{children:(0,Ua.jsx)(NO,{forceMobile:i,children:(0,Ua.jsx)(GN,{overrides:n,children:(0,Ua.jsx)(UHe,{overrides:e,children:a})})})})})})})})})})})});function UHe({overrides:t,children:e
Line truncated
|
||||
@font-face {
|
||||
font-family: ${f.family};
|
||||
font-stretch: ${f.stretch};
|
||||
font-weight: ${f.weight};
|
||||
font-style: ${f.style};
|
||||
src: url("${o}") format("${u}")
|
||||
}`,f}yD();var GHe=15.5;function HB(t,e){let n=e;t.run(()=>{n=YHe(n,GHe),t.cancel().cancel().cancel().cancel();let o=t.getPages()[0].id;t.setCurrentPage(o);for(let a of t.getPages().slice(1))t.deletePage(a.id);t.selectAll(),t.deleteShapes(t.getSelectedShapeIds());let r=new Map;Object.values(n.assets??{}).forEach(a=>{switch(a.type){case UB.Image:{let l=ns.createId();r.set(a.id,l);let c={id:l,typeName:"asset",type:"image",props:{w:Ur(a.size[0]),h:Ur(a.size[1]),name:a.fileName??"Untitled",isAnimated:!1,mimeType:null,src:a.src},meta:{}};t.createAssets([c]),WHe(t,c);break}case UB.Video:{let l=ns.createId();r.set(a.id,l),t.createAssets([{id:l,typeName:"asset",type:"video",props:{w:Ur(a.size[0]),h:Ur(a.size[1]),name:a.fileName??"Untitled",isAnimated:!0,mimeType:null,src:a.src},meta:{}}])}break}});let i=new Map;Object.values(n.pages??{}).sort((a,l)=>(a.childIndex??1)<(l.childIndex??1)?-1:1).forEach((a,l)=>{if(l===0)i.set(a.id,t.getCurrentPageId());else{let c=$n.createId();i.set(a.id,c),t.createPage({name:a.name??"Page",id:c})}}),Object.values(n.pages??{}).sort((a,l)=>(a.childIndex??1)<(l.childIndex??1)?-1:1).forEach(a=>{t.setCurrentPage(i.get(a.id));let l=new Map,c=new Map,d=Object.values(a.shapes??{}).sort((p,f)=>p.childIndex<f.childIndex?-1:1).slice(0,t.options.maxShapesPerPage);d.forEach(p=>{if(p.type!==Xo.Group)return;let f=Ue();l.set(p.id,f),c.set(p.id,[])});function u(p){if(l.delete(p.id),c.has(p.parentId)){let h=c.get(p.parentId).filter(m=>m!==p.id);c.set(p.parentId,h)}}d.forEach(p=>{if(p.type===Xo.Group)return;let f=Ue();l.set(p.id,f),p.parentId!==a.id&&(c.has(p.parentId)?c.get(p.parentId).push(p.id):console.warn("parent does not exist",p));let h=i.get(a.id),m={id:f,parentId:h,x:xr(p.point[0]),y:xr(p.point[1]),rotation:0,isLocked:!!p.isLocked};switch(p.type){case Xo.Sticky:{t.createShapes([{...m,type:"note",props:{richText:yt(p.text??""),color:yu(p.style.color),size:sC(p.style.size),font:iC(p.style.font),align:n6e(p.style.textAlign)}}]);break}case Xo.Rectangle:{t.createShapes([{...m,type:"geo",props:{geo:"rectangle",w:Ur(p.size[0]),h:Ur(p.size[1]),richText:yt(p.label??""),fill:zB(p.style.isFilled,p.style.color),labelColor:yu(p.style.color),color:yu(p.style.color),size:sC(p.style.size),font:iC(p.style.font),dash:jI(p.style.dash),align:"middle"}}]);let g=t.getShapePageBounds(m.id);if(t.updateShapes([{id:m.id,type:"geo",props:{richText:yt(p.label??"")}}]),g.width===g.height){let S=t.getShape(m.id),{growY:b}=S.props,v=Ur(S.props.w),w=Ur(S.props.h),T=v+b/2,C=w+b/2;t.updateShapes([{id:m.id,type:"geo",x:xr(S.x)-(T-v)/2,y:xr(S.y)-(C-w)/2,props:{w:T,h:C}}])}break}case Xo.Triangle:{t.createShapes([{...m,type:"geo",props:{geo:"triangle",w:Ur(p.size[0]),h:Ur(p.size[1]),fill:zB(p.style.isFilled,p.style.color),labelColor:yu(p.style.color),color:yu(p.style.color),size:sC(p.style.size),font:iC(p.style.font),dash:jI(p.style.dash),align:"middle"}}]);let g=t.getShapePageBounds(m.id);if(t.updateShapes([{id:m.id,type:"geo",props:{richText:yt(p.label??"")}}]),g.width===g.height){let S=t.getShape(m.id),{growY:b}=S.props,v=Ur(S.props.w),w=Ur(S.props.h),T=v+b/2,C=w+b/2;t.updateShapes([{id:m.id,type:"geo",x:xr(S.x)-(T-v)/2,y:xr(S.y)-(C-w)/2,props:{w:T,h:C}}])}break}case Xo.Ellipse:{t.createShapes([{...m,type:"geo",props:{geo:"ellipse",w:Ur(p.radius[0])*2,h:Ur(p.radius[1])*2,fill:zB(p.style.isFilled,p.style.color),labelColor:yu(p.style.color),color:yu(p.style.color),size:sC(p.style.size),font:iC(p.style.font),dash:jI(p.style.dash),align:"middle"}}]);let g=t.getShapePageBounds(m.id);if(t.updateShapes([{id:m.id,type:"geo",props:{richText:yt(p.label??"")}}]),g.width===g.height){let S=t.getShape(m.id),{growY:b}=S.props,v=Ur(S.props.w),w=Ur(S.props.h),T=v+b/2,C=w+b/2;t.updateShapes([{id:m.id,type:"geo",x:xr(S.x)-(T-v)/2,y:xr(S.y)-(C-w)/2,props:{w:T,h:C}}])}break}case Xo.Draw:{if(p.points.length===0){u(p);break}let g=p.points.map(D$),S=$e.encodePoints(g);t.createShapes([{...m,type:"draw",props:{fill:zB(p.style.isFilled,p.style.color),color:yu(p.style.color),size:sC(p.style.size),dash:jI(p.style.dash),isPen:!1,isComplete:p.isComplete,segments:[{type:"free",path:S}],scale:1,scaleX:1,scaleY:1}}]);break}case Xo.Arrow:{let g=xr(p.bend),S=D$(p.handles.start.point),b=D$(p.handles.end.point),w=x.Dist(S,b)*-g/2;t.createShapes([{...m,type:"arrow",props:{richText:yt(p.label??""),color:yu(p.style.color),labelColor:yu(p.style.color),size:sC(p.style.size),font:iC(p.style.font),dash:jI(p.style.dash),arrowheadStart:yye(p.decorations?.start),arrowheadEnd:yye(p.decorations?.end),start:{x:xr(p.handles.start.point[0]),y:xr(p.handles.start.point[1])},end:{x:xr(p.handles.end.point[0]),y:xr(p.handles.end.point[1])},bend:w}}]);break}case Xo.Text:{t.createShapes([{...m,type:"text",props:{richText:yt(p.text??" "),color:yu(p.style.color),size:r6e(p.style.size),font:iC(p.style.font),textAlign:o6e(p.style.textAlign),scale:p.style.scale??1}}]);break}case Xo.Image:{let g=r.get(p.assetId);if(!g){console.warn("Could not find asset id",p.assetId);return}t.createShapes([{..
Line truncated
|
||||
/*! Bundled license information:
|
||||
|
||||
react/cjs/react.production.js:
|
||||
(**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*)
|
||||
|
||||
react/cjs/react-jsx-runtime.production.js:
|
||||
(**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*)
|
||||
|
||||
classnames/index.js:
|
||||
(*!
|
||||
Copyright (c) 2018 Jed Watson.
|
||||
Licensed under the MIT License (MIT), see
|
||||
http://jedwatson.github.io/classnames
|
||||
*)
|
||||
|
||||
react-dom/cjs/react-dom.production.js:
|
||||
(**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*)
|
||||
|
||||
scheduler/cjs/scheduler.production.js:
|
||||
(**
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*)
|
||||
|
||||
react-dom/cjs/react-dom-client.production.js:
|
||||
(**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*)
|
||||
|
||||
tldraw/dist-esm/lib/utils/svg/sanitizeSvg.mjs:
|
||||
(*!
|
||||
* SVG/attribute allowlists and URI sanitization approach derived from DOMPurify.
|
||||
* DOMPurify — MIT License, Copyright (c) 2015 Mario Heiderich
|
||||
* https://github.com/cure53/DOMPurify/blob/main/LICENSE
|
||||
*)
|
||||
|
||||
@tldraw/utils/dist-esm/lib/bind.mjs:
|
||||
(*!
|
||||
* MIT License: https://github.com/NoHomey/bind-decorator/blob/master/License
|
||||
* Copyright (c) 2016 Ivo Stratev
|
||||
*)
|
||||
|
||||
@tldraw/utils/dist-esm/lib/id.mjs:
|
||||
(*!
|
||||
* MIT License: https://github.com/ai/nanoid/blob/main/LICENSE
|
||||
* Modified code originally from <https://github.com/ai/nanoid>
|
||||
* Copyright 2017 Andrey Sitnik <andrey@sitnik.ru>
|
||||
*
|
||||
* `nanoid` is currently only distributed as an ES module. Some tools (jest, playwright) don't
|
||||
* properly support ESM-only code yet, and tldraw itself is distributed as both an ES module and a
|
||||
* CommonJS module. By including nanoid here, we can make sure it works well in every environment
|
||||
* where tldraw is used. We can also remove some unused features like custom alphabets.
|
||||
*)
|
||||
|
||||
@tldraw/utils/dist-esm/lib/media/apng.mjs:
|
||||
(*!
|
||||
* MIT License: https://github.com/vHeemstra/is-apng/blob/main/license
|
||||
* Copyright (c) Philip van Heemstra
|
||||
*)
|
||||
|
||||
@tldraw/utils/dist-esm/lib/media/gif.mjs:
|
||||
(*!
|
||||
* MIT License
|
||||
* Modified code originally from <https://github.com/qzb/is-animated>
|
||||
* Copyright (c) 2016 Józef Sokołowski <j.k.sokolowski@gmail.com>
|
||||
*)
|
||||
|
||||
@tldraw/utils/dist-esm/lib/media/png.mjs:
|
||||
(*!
|
||||
* MIT License: https://github.com/alexgorbatchev/crc/blob/master/LICENSE
|
||||
* Copyright: 2014 Alex Gorbatchev
|
||||
* Code: crc32, https://github.com/alexgorbatchev/crc/blob/master/src/calculators/crc32.ts
|
||||
*)
|
||||
|
||||
@tldraw/utils/dist-esm/lib/media/webp.mjs:
|
||||
(*!
|
||||
* MIT License: https://github.com/sindresorhus/is-webp/blob/main/license
|
||||
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
*)
|
||||
|
||||
@tldraw/store/dist-esm/lib/ImmutableMap.mjs:
|
||||
(*!
|
||||
* This file was lovingly and delicately extracted from Immutable.js
|
||||
* MIT License: https://github.com/immutable-js/immutable-js/blob/main/LICENSE
|
||||
* Copyright (c) 2014-present, Lee Byron and other contributors.
|
||||
*)
|
||||
|
||||
@tldraw/editor/dist-esm/lib/hooks/useScreenBounds.mjs:
|
||||
(*!
|
||||
* Author: excalidraw
|
||||
* MIT License: https://github.com/excalidraw/excalidraw/blob/master/LICENSE
|
||||
* https://github.com/excalidraw/excalidraw/blob/48c3465b19f10ec755b3eb84e21a01a468e96e43/packages/excalidraw/utils.ts#L600
|
||||
*)
|
||||
|
||||
@tldraw/editor/dist-esm/lib/utils/browserCanvasMaxSize.mjs:
|
||||
(*!
|
||||
* Extracted from https://github.com/jhildenbiddle/canvas-size
|
||||
* MIT License: https://github.com/jhildenbiddle/canvas-size/blob/master/LICENSE
|
||||
* Copyright (c) John Hildenbiddle
|
||||
*)
|
||||
|
||||
@tldraw/editor/dist-esm/lib/hooks/useViewportHeight.mjs:
|
||||
(*!
|
||||
* BSD License: https://github.com/outline/rich-markdown-editor/blob/main/LICENSE
|
||||
* Copyright (c) 2020 General Outline, Inc (https://www.getoutline.com/) and individual contributors.
|
||||
*
|
||||
* Returns the height of the viewport.
|
||||
* This is mainly to account for virtual keyboards on mobile devices.
|
||||
*
|
||||
* N.B. On iOS, you have to take into account the offsetTop as well so that you get an accurate position
|
||||
* while using the virtual keyboard.
|
||||
*)
|
||||
|
||||
tldraw/dist-esm/lib/shapes/shared/PathBuilder.mjs:
|
||||
(*!
|
||||
* Adapted from https://github.com/adobe-webplatform/Snap.svg/tree/master
|
||||
* Apache License: https://github.com/adobe-webplatform/Snap.svg/blob/master/LICENSE
|
||||
* https://github.com/adobe-webplatform/Snap.svg/blob/c8e483c9694517e24b282f8f59f985629f4994ce/dist/snap.svg.js#L5786
|
||||
*)
|
||||
|
||||
tldraw/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs:
|
||||
(*!
|
||||
* The kbd-string splitter (`getKeys`) and the form-input filter pattern in `shouldSkipEvent`
|
||||
* (including its list of non-text INPUT types) are adapted from hotkeys-js, which this hook
|
||||
* previously depended on.
|
||||
*
|
||||
* MIT License: https://github.com/jaywcjlove/hotkeys-js/blob/master/LICENSE
|
||||
* Copyright (c) 2015-present, Kenny Wong
|
||||
* Copyright (c) 2011-2013 Thomas Fuchs (https://github.com/madrobby/keymaster)
|
||||
* Source: https://github.com/jaywcjlove/hotkeys-js
|
||||
*)
|
||||
*/
|
||||
+3895
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "cutdown",
|
||||
"name": "CUTDOWN",
|
||||
"summary": "Punch in/out on a folder of footage and let the lane render the cuts.",
|
||||
"category": "create",
|
||||
"maker": {
|
||||
"name": "OpenRig",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"surface": {
|
||||
"entry": "app/cutdown.html",
|
||||
"path": "/surfaces/cutdown.html",
|
||||
"glyph": "\u2702",
|
||||
"hint": "pick and trim takes"
|
||||
},
|
||||
"provider": {
|
||||
"package": "@openrig/studio-cutdown",
|
||||
"required": true,
|
||||
"_note": "This app is a CLIENT of that provider. Installing the surface alone gets you an HTML file that 502s \u2014 the field rigs-to-apps v1 had no slot for, which is why media-manager was listed there and was not installable.",
|
||||
"run": {
|
||||
"entry": "cutdown-server.mjs",
|
||||
"args": [
|
||||
"--port",
|
||||
"{{port}}",
|
||||
"--footage",
|
||||
"{{root:footage}}",
|
||||
"--bundle",
|
||||
"{{root:project}}"
|
||||
],
|
||||
"companions": [
|
||||
{
|
||||
"label": "cut lane",
|
||||
"entry": "watch-markers.mjs",
|
||||
"args": [
|
||||
"--footage",
|
||||
"{{root:footage}}",
|
||||
"--bundle",
|
||||
"{{root:project}}"
|
||||
],
|
||||
"_note": "The renderer the write verbs enqueue for. Without it stabilize and cut-retry accept work nothing performs \u2014 declared so it starts WITH the provider rather than being remembered."
|
||||
}
|
||||
]
|
||||
},
|
||||
"serves": [
|
||||
"/media/",
|
||||
"/cutprev/",
|
||||
"/cuts/"
|
||||
],
|
||||
"_serves_note": "BYTE-route prefixes. A backend is not only its /api/ verbs \u2014 these were undeclared, so the front proxied only /api/* and clips listed but would not play.",
|
||||
"supplies": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"_supplies_note": "Binaries this provider SHIPS (ffmpeg-static/ffprobe-static), so the host does not need them. requires.binaries still declares the NEED \u2014 that stays legible and true; this says who satisfies it. Without the distinction the installer refuses a working install on any box without a system ffmpeg, which is most of them."
|
||||
},
|
||||
"roots": {
|
||||
"footage": {
|
||||
"required": true
|
||||
},
|
||||
"project": {
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"verbs": [
|
||||
"/api/clips",
|
||||
"/api/cuts",
|
||||
"/api/markers",
|
||||
"/api/pick",
|
||||
"/api/cut-note",
|
||||
"/api/cut-delete",
|
||||
"/api/cut-retry",
|
||||
"/api/stabilize"
|
||||
],
|
||||
"vendor": [],
|
||||
"requires": {
|
||||
"binaries": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"ffmpeg_filters": [
|
||||
"scale",
|
||||
"concat"
|
||||
],
|
||||
"_filters_note": "REQUIRED vs PREFERRED, and the split is the point. scale/concat are load-bearing: no cut renders without them. drawtext only writes the LABEL on a placeholder slate, and the assembler now renders an unlabelled slate and reports the degradation instead of failing the export. So drawtext must NOT block an install \u2014 the Linux ffmpeg-static build (7.0.2) lacks it while the macOS one (6.0) has it, and losing a caption on an unshot beat is not worth refusing a working studio.",
|
||||
"ffmpeg_filters_preferred": [
|
||||
"drawtext"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CUTDOWN · punch the cuts you want</title>
|
||||
<style>
|
||||
:root {
|
||||
/* dark value ladder — canon: app/shell/build-proof.html
|
||||
ground #121214 · card +5% · raised +12% · INVERT = solid white / #111.
|
||||
coral = needs-human/error · indigo = routing/links ·
|
||||
lockgreen = the locked (approved) data state ONLY. */
|
||||
--ink:#121214; --panel:rgba(255,255,255,.05); --edge:rgba(255,255,255,.16);
|
||||
--bone:rgba(255,255,255,.95); --body:rgba(255,255,255,.68);
|
||||
--dim:rgba(255,255,255,.52); --faint:rgba(255,255,255,.38);
|
||||
--raised:rgba(255,255,255,.12);
|
||||
--coral:#ff6b5a; --indigo:#8b93ff; --lockgreen:#7ea36f;
|
||||
--sans:Helvetica,"Helvetica Neue",Arial,sans-serif; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height:100%; margin:0; background:var(--ink); color:var(--bone);
|
||||
font-family:var(--sans); }
|
||||
#app { display:flex; height:100vh; }
|
||||
|
||||
/* clip list */
|
||||
#clips { width:250px; min-width:250px; background:var(--panel);
|
||||
border-right:1px solid var(--edge); overflow-y:auto; }
|
||||
#clips h1 { font-size:12px; letter-spacing:4px; color:var(--dim);
|
||||
padding:16px 14px 8px; margin:0; }
|
||||
.clip { display:flex; justify-content:space-between; align-items:center;
|
||||
padding:8px 14px; cursor:pointer; color:var(--dim); font-size:12px;
|
||||
border-left:2px solid transparent; }
|
||||
.clip:hover { color:var(--bone); }
|
||||
.clip.active { color:#111; border-left-color:var(--bone);
|
||||
background:var(--bone); }
|
||||
.clip .n { font-variant-numeric:tabular-nums; }
|
||||
.clip .badge { color:var(--dim); font-size:11px; }
|
||||
.clip.active .badge { color:#111; }
|
||||
|
||||
/* stage: a normal-proportioned player; the LIST is what grows with the
|
||||
window (product note: four fixed rows is too narrow; cuts should run down the screen) */
|
||||
#main { flex:1; display:flex; flex-direction:column; min-width:0; }
|
||||
#stagewrap { flex:0 0 auto; height:46vh; min-height:240px; display:flex;
|
||||
align-items:center; justify-content:center;
|
||||
background:#000; position:relative; }
|
||||
video { max-width:100%; max-height:100%; outline:none; }
|
||||
#inflag { position:absolute; left:14px; top:12px; color:var(--bone);
|
||||
font-size:12px; letter-spacing:2px; display:none; }
|
||||
|
||||
/* controls */
|
||||
#controls { border-top:1px solid var(--edge); background:var(--panel);
|
||||
padding:12px 16px; display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
|
||||
button { font:inherit; cursor:pointer; border-radius:2px; border:1px solid var(--edge);
|
||||
background:none; color:var(--bone); padding:10px 18px; letter-spacing:1.5px; }
|
||||
button:hover { border-color:var(--dim); }
|
||||
#btnIn { border-color:var(--bone); color:#111; background:var(--bone); font-weight:700; }
|
||||
#btnOut { border-color:var(--edge); color:var(--bone); font-weight:700; opacity:.45; }
|
||||
#btnOut.armed { opacity:1; background:var(--bone); color:#111; border-color:var(--bone); }
|
||||
.hint { color:var(--faint); font-size:11px; letter-spacing:.5px; }
|
||||
#note { flex:1; min-width:160px; background:var(--panel); border:1px solid var(--edge);
|
||||
border-radius:2px; color:var(--bone); font:inherit; font-size:12px; padding:9px 10px; }
|
||||
#note:focus { border-color:rgba(255,255,255,.20); }
|
||||
#note::placeholder { color:var(--faint); }
|
||||
#tc { color:var(--dim); font-variant-numeric:tabular-nums; font-size:13px; }
|
||||
|
||||
/* markers/cuts panel: takes ALL remaining height — a tall browser shows
|
||||
a tall list */
|
||||
#markers { flex:1; min-height:150px; overflow-y:auto; border-top:1px solid var(--edge);
|
||||
background:var(--ink); padding:8px 16px; }
|
||||
.tabs { display:flex; gap:18px; margin:6px 0; align-items:baseline; }
|
||||
.tabbtn { font-size:11px; letter-spacing:3px; color:var(--faint); cursor:default; }
|
||||
.tabbtn.active { color:#111; background:var(--bone); }
|
||||
.filt { display:inline-flex; gap:0; margin-left:auto; }
|
||||
.filt button { font:inherit; font-size:9px; letter-spacing:1.5px; padding:3px 10px;
|
||||
background:none; border:1px solid var(--edge); color:var(--faint); cursor:pointer; }
|
||||
.filt button:first-child { border-radius:2px 0 0 2px; }
|
||||
.filt button:last-child { border-radius:0 2px 2px 0; border-left:0; }
|
||||
.filt button.on { color:#111; border-color:var(--bone); background:var(--bone); }
|
||||
.cut { display:flex; gap:12px; align-items:center; font-size:12px; padding:6px 0;
|
||||
border-bottom:1px dashed var(--edge); flex-wrap:wrap; }
|
||||
.cut .caret { color:var(--faint); cursor:pointer; width:12px; font-size:11px;
|
||||
user-select:none; transition:transform .12s; display:inline-block; }
|
||||
.cut .caret.open { transform:rotate(90deg); color:var(--dim); }
|
||||
.cut .g { width:12px; text-align:center; font-size:12px; }
|
||||
.cut .g.ok { color:var(--body); }
|
||||
.cut .g.busy { color:transparent; }
|
||||
.cut .g.busy::after { content:""; display:inline-block; width:8px; height:8px;
|
||||
vertical-align:middle; border:1.5px solid var(--faint); border-top-color:var(--bone);
|
||||
border-radius:50%; animation:cd-spin .7s linear infinite; }
|
||||
.cut .g.bad { color:var(--coral); }
|
||||
.cut .rng { color:var(--indigo); font-variant-numeric:tabular-nums; font-size:11px;
|
||||
cursor:pointer; white-space:nowrap; }
|
||||
.cut .rng:hover { color:var(--bone); }
|
||||
.cut .xtra { flex-basis:100%; display:none; gap:12px; align-items:center;
|
||||
padding:2px 0 4px 24px; }
|
||||
.cut .xtra.open { display:flex; flex-wrap:wrap; }
|
||||
.cut .xtra .info { color:var(--faint); font-size:10px; letter-spacing:.5px; }
|
||||
.cut .nm { color:var(--bone); cursor:pointer; }
|
||||
.cut .nm:hover { color:var(--indigo); }
|
||||
.cut .st { font-size:10px; letter-spacing:1px; padding:2px 8px; border-radius:2px;
|
||||
border:1px solid var(--edge); color:var(--dim); }
|
||||
.cut .st.ready { color:var(--body); border-color:var(--edge); }
|
||||
.cut .st.cutting { color:var(--dim); border-color:var(--edge); }
|
||||
.cut .st.failed { color:var(--coral); border-color:var(--coral); }
|
||||
.cut .retry { background:none; border:1px solid var(--coral); border-radius:2px;
|
||||
color:var(--coral); font:inherit; font-size:11px; padding:2px 8px; cursor:pointer; }
|
||||
.cut button { padding:4px 10px; font-size:10px; letter-spacing:1px; }
|
||||
/* version chips: label = a version on disk · click = WATCH it (INVERT) ·
|
||||
🔒 = LOCK it as the version the video uses (lock-green — the data state) */
|
||||
.vers { display:inline-flex; gap:5px; flex-wrap:wrap; }
|
||||
.ver { display:inline-flex; align-items:center; gap:5px; font-size:10px;
|
||||
letter-spacing:1px; padding:3px 8px; border:1px solid var(--edge);
|
||||
border-radius:2px; color:var(--dim); cursor:pointer; user-select:none; }
|
||||
.ver:hover { color:var(--bone); border-color:var(--dim); }
|
||||
.ver.on { color:#111; border-color:var(--bone); background:var(--bone); }
|
||||
.ver .lock { opacity:.28; font-size:10px; }
|
||||
.ver .lock:hover { opacity:.8; }
|
||||
.ver.lk { border-color:var(--lockgreen); }
|
||||
.ver.lk .lock { color:var(--lockgreen); opacity:1; }
|
||||
.ver.busy { cursor:default; color:var(--dim); border-color:var(--edge); }
|
||||
.ver.busy::after { content:""; display:inline-block; width:8px; height:8px;
|
||||
vertical-align:middle; margin-left:6px; border:1.5px solid var(--faint);
|
||||
border-top-color:var(--bone); border-radius:50%; animation:cd-spin .7s linear infinite; }
|
||||
.cut .picked { color:var(--lockgreen); font-size:10px; letter-spacing:1px; }
|
||||
.cut .del { color:var(--faint); cursor:pointer; opacity:.45; margin-left:auto; }
|
||||
.cut .del:hover { opacity:1; color:var(--coral); }
|
||||
.mk { display:flex; gap:12px; align-items:baseline; font-size:12px; padding:5px 0;
|
||||
border-bottom:1px dashed var(--edge); }
|
||||
.mk .rng { color:var(--indigo); font-variant-numeric:tabular-nums; cursor:pointer; }
|
||||
.mk .who { color:var(--faint); font-size:10px; }
|
||||
.mk .nt { color:var(--dim); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mk .del { color:var(--faint); cursor:pointer; opacity:.5; }
|
||||
.mk .del:hover { opacity:1; color:var(--coral); }
|
||||
#viewing { padding:4px 10px; font-size:11px; letter-spacing:1px; color:var(--body);
|
||||
background:var(--panel); border-bottom:1px solid var(--edge);
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
#viewing .kind { color:var(--dim); margin-right:8px; }
|
||||
/* playing = transient working/playback state, not the selected control: a
|
||||
RAISED row (not full INVERT) keeps every child legible on a dark surface and
|
||||
lets the inner .ver.on chip stay INVERT (white chip reads on the raised row).
|
||||
Avoids the white-on-white produced by a full invert. */
|
||||
.cut.playing { background:var(--raised); border-color:rgba(255,255,255,.38); }
|
||||
.lathead { font-size:10px; letter-spacing:3px; color:var(--dim); margin:8px 0 2px; }
|
||||
.allhead { font-size:10px; letter-spacing:3px; color:var(--faint); margin:12px 0 2px; }
|
||||
.cut .st.new { color:#111; border-color:var(--bone); background:var(--bone); font-weight:700; }
|
||||
.cut .age { color:var(--faint); font-size:10px; }
|
||||
.cut .meta { color:var(--faint); font-size:10px; letter-spacing:.5px; }
|
||||
/* the smoothing dropdown: vertical, anchored under the + SMOOTH button */
|
||||
.smoothctl { position:relative; display:inline-block; }
|
||||
.levels { display:none; position:absolute; top:calc(100% + 4px); left:0; z-index:20;
|
||||
flex-direction:column; gap:2px; background:var(--panel);
|
||||
border:1px solid var(--edge); border-radius:2px; padding:4px;
|
||||
min-width:110px; box-shadow:0 6px 18px rgba(0,0,0,.5); }
|
||||
.levels.open { display:flex; }
|
||||
.levels button { padding:5px 10px; font-size:10px; letter-spacing:1.5px; text-align:left;
|
||||
border:0; border-radius:2px; color:var(--body); }
|
||||
.levels button:hover { background:rgba(255,255,255,.08); color:var(--bone); }
|
||||
.cut .nt { color:var(--dim); flex:1; min-width:60px; overflow:hidden;
|
||||
text-overflow:ellipsis; white-space:nowrap; }
|
||||
.cut .nt.editable { cursor:text; }
|
||||
.cut .nt.editable:hover { color:var(--bone); text-decoration:underline dashed var(--faint); }
|
||||
.cut .nt.empty { color:var(--faint); }
|
||||
.noteedit { background:var(--panel); border:1px solid rgba(255,255,255,.20); border-radius:2px;
|
||||
color:var(--bone); font:inherit; font-size:11px; padding:3px 8px; flex:1; min-width:120px; }
|
||||
@keyframes cd-spin { to { transform:rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav id="clips"><h1>FOOTAGE</h1><div id="cliplist"></div></nav>
|
||||
<main id="main">
|
||||
<div id="stagewrap">
|
||||
<div id="inflag">● IN SET — punch OUT to close the cut</div>
|
||||
<div id="viewing">nothing loaded — pick a shot</div>
|
||||
<video id="v" controls preload="metadata"></video>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<button id="btnIn" title="I">⌾ PUNCH IN <span class="hint">I</span></button>
|
||||
<button id="btnOut" title="O">⌾ PUNCH OUT <span class="hint">O</span></button>
|
||||
<span id="tc">00:00.0</span>
|
||||
<input id="note" placeholder="note (optional) — saved with the next punch-out; you can also add it later on the cut" />
|
||||
<span class="hint">I = in · O = out · space = play/pause · ←/→ = 2s</span>
|
||||
</div>
|
||||
<div id="markers">
|
||||
<div class="tabs">
|
||||
<span class="tabbtn active">CUTS <span id="cutcount"></span></span>
|
||||
<span class="filt">
|
||||
<button id="fAll" class="on">ALL</button><button id="fClip">THIS SHOT</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="cutlist"></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
(async () => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const v = $("v");
|
||||
let clips = [], active = null, pendingIn = null;
|
||||
|
||||
const fmt = (s) => { const m = Math.floor(s/60), r = (s%60).toFixed(1).padStart(4,"0"); return `${String(m).padStart(2,"0")}:${r}`; };
|
||||
|
||||
async function loadClips() {
|
||||
const r = await (await fetch("/api/clips")).json();
|
||||
clips = r.clips || [];
|
||||
const el = $("cliplist"); el.innerHTML = "";
|
||||
clips.forEach((c) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "clip" + (active === c.name ? " active" : "");
|
||||
d.innerHTML = `<span class="n">${c.name}</span>` + (c.markers ? `<span class="badge">${c.markers} ✂</span>` : "");
|
||||
d.onclick = () => activate(c.name);
|
||||
el.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
// jump the punch view to a moment in a source rush (activating it first
|
||||
// if needed) — the merged list's range-click
|
||||
function jumpToSource(srcName, t) {
|
||||
const target = clips.find((c) => c.name.toLowerCase() === String(srcName || "").toLowerCase());
|
||||
if (!target) return;
|
||||
if (active !== target.name) {
|
||||
activate(target.name);
|
||||
v.addEventListener("loadedmetadata", () => { v.currentTime = t; v.play(); }, { once: true });
|
||||
} else { v.currentTime = t; v.play(); }
|
||||
}
|
||||
|
||||
function activate(name) {
|
||||
active = name; pendingIn = null; nowPlaying = null;
|
||||
$("inflag").style.display = "none"; $("btnOut").classList.remove("armed");
|
||||
v.src = "/media/" + encodeURIComponent(name);
|
||||
$("viewing").innerHTML = `<span class="kind">SHOT</span>${name}`;
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function refresh() { await loadClips(); await loadCuts(); }
|
||||
|
||||
async function punchIn() {
|
||||
if (!active) return;
|
||||
pendingIn = v.currentTime;
|
||||
$("inflag").style.display = "block"; $("btnOut").classList.add("armed");
|
||||
}
|
||||
async function punchOut() {
|
||||
if (!active || pendingIn == null || v.currentTime <= pendingIn) return;
|
||||
const note = $("note").value.trim(); $("note").value = "";
|
||||
await fetch("/api/markers", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ clip: active, in_s: pendingIn, out_s: v.currentTime, note }) });
|
||||
pendingIn = null;
|
||||
$("inflag").style.display = "none"; $("btnOut").classList.remove("armed");
|
||||
refresh();
|
||||
}
|
||||
|
||||
$("btnIn").onclick = punchIn;
|
||||
$("btnOut").onclick = punchOut;
|
||||
v.addEventListener("timeupdate", () => { $("tc").textContent = fmt(v.currentTime || 0); });
|
||||
addEventListener("keydown", (e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
if (e.key === "i" || e.key === "I") { e.preventDefault(); punchIn(); }
|
||||
if (e.key === "o" || e.key === "O") { e.preventDefault(); punchOut(); }
|
||||
if (e.key === " ") { e.preventDefault(); v.paused ? v.play() : v.pause(); }
|
||||
if (e.key === "ArrowRight") { v.currentTime += 2; }
|
||||
if (e.key === "ArrowLeft") { v.currentTime -= 2; }
|
||||
});
|
||||
|
||||
// ---- the merged CUTS panel (markers + cuts = one lifecycle, one list)
|
||||
let watchState = {}; // cut file -> version FILE currently chosen to watch
|
||||
let nowPlaying = null; // cut file currently in the viewer
|
||||
let expanded = new Set(); // cut files with the caret open
|
||||
let filterMode = "all";
|
||||
try { filterMode = localStorage.getItem("cutdown.filter") || "all"; } catch {}
|
||||
function setFilter(m) {
|
||||
filterMode = m;
|
||||
try { localStorage.setItem("cutdown.filter", m); } catch {}
|
||||
$("fAll").classList.toggle("on", m === "all");
|
||||
$("fClip").classList.toggle("on", m === "clip");
|
||||
loadCuts();
|
||||
}
|
||||
$("fAll").onclick = () => setFilter("all");
|
||||
$("fClip").onclick = () => setFilter("clip");
|
||||
const inFilter = (srcName) => filterMode === "all" ||
|
||||
(active && String(srcName || "").toLowerCase() === active.toLowerCase());
|
||||
|
||||
function playCut(file, verFile, verLabel) {
|
||||
v.src = "/cutprev/" + encodeURIComponent(verFile || file);
|
||||
v.play();
|
||||
nowPlaying = file;
|
||||
watchState[file] = verFile || file;
|
||||
$("viewing").innerHTML = `<span class="kind">CUT · ${verLabel || "ORIGINAL"}</span>${file.replace(/^cut-/, "").replace(/\.mp4$/, "")}`;
|
||||
document.querySelectorAll("#cutlist .cut").forEach((el) => el.classList.toggle("playing", el.dataset.file === file));
|
||||
}
|
||||
|
||||
// the user-facing smoothing dial: the user experiments, the level rides
|
||||
// the job into the backend, results come back labeled. Display note:
|
||||
// the tripod level shows as TRIPOD — "lock" (🔒) is reserved for the
|
||||
// curation act of choosing which version the video uses.
|
||||
const LEVELS = [["light", "LIGHT"], ["medium", "MEDIUM"], ["strong", "STRONG"], ["locked", "TRIPOD"]];
|
||||
const VER_LABEL = { original: "ORIGINAL", light: "LIGHT", medium: "MEDIUM",
|
||||
strong: "STRONG", locked: "TRIPOD", smoothed: "SMOOTHED" };
|
||||
const levelName = (meta) => !meta ? "" : (meta.level && VER_LABEL[meta.level]) || (meta.tripod ? "TRIPOD"
|
||||
: meta.smoothing <= 8 ? "LIGHT" : meta.smoothing <= 16 ? "MEDIUM" : "STRONG");
|
||||
function levelMenu(wrap, onPick) {
|
||||
const btn = wrap.querySelector("button");
|
||||
const menu = document.createElement("span");
|
||||
menu.className = "levels";
|
||||
LEVELS.forEach(([lv, label]) => {
|
||||
const b = document.createElement("button");
|
||||
b.textContent = label;
|
||||
b.title = lv === "locked"
|
||||
? "for shots where the camera was MEANT to stay still — cancels hand tremor, cannot cancel real pans or walking"
|
||||
: `${label.toLowerCase()} smoothing`;
|
||||
b.onclick = (e) => { e.stopPropagation(); menu.classList.remove("open"); onPick(lv); };
|
||||
menu.appendChild(b);
|
||||
});
|
||||
wrap.appendChild(menu);
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const was = menu.classList.contains("open");
|
||||
document.querySelectorAll(".levels.open").forEach((m) => m.classList.remove("open"));
|
||||
if (!was) menu.classList.add("open");
|
||||
};
|
||||
}
|
||||
addEventListener("click", () => document.querySelectorAll(".levels.open").forEach((m) => m.classList.remove("open")));
|
||||
const age = (t) => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? "just now" : m + "m ago"; };
|
||||
|
||||
// notes are editable AFTER the cut exists — the primary flow is punch
|
||||
// fast while watching, then name clips on review. While an editor is
|
||||
// open the auto-refresh HOLDS (a re-render would destroy the input
|
||||
// mid-typing — the Safari "kicked out of edit mode" bug).
|
||||
let editingNote = false;
|
||||
function noteEditor(span, c) {
|
||||
span.classList.add("editable");
|
||||
span.title = "click to " + (c.note ? "edit" : "add") + " note";
|
||||
if (!c.note) { span.textContent = "+ note"; span.classList.add("empty"); }
|
||||
span.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
editingNote = true;
|
||||
const inp = document.createElement("input");
|
||||
inp.className = "noteedit"; inp.value = c.note || "";
|
||||
inp.placeholder = "what is this cut?";
|
||||
span.replaceWith(inp); inp.focus();
|
||||
inp.onkeydown = (ev) => {
|
||||
if (ev.key === "Enter") inp.blur();
|
||||
if (ev.key === "Escape") { inp.dataset.cancel = "1"; inp.blur(); }
|
||||
};
|
||||
inp.onblur = async () => {
|
||||
editingNote = false;
|
||||
if (inp.dataset.cancel !== "1" && inp.value.trim() !== (c.note || "")) {
|
||||
await fetch("/api/cut-note", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ clip: c.file, note: inp.value.trim() }) });
|
||||
}
|
||||
loadCuts();
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCuts() {
|
||||
if (editingNote) return; // never re-render an open note editor away
|
||||
const r = await (await fetch("/api/cuts")).json();
|
||||
$("cutcount").textContent = r.cuts.length ? "(" + r.cuts.length + ")" : "";
|
||||
const el = $("cutlist"); el.innerHTML = "";
|
||||
|
||||
// LATEST strip: the thing you just did, pinned on top so it never gets
|
||||
// lost in the name-sorted list below
|
||||
const recent = (r.cuts || []).map((c) => {
|
||||
const cutT = Date.parse(c.cut_at || 0) || 0, stabT = Date.parse(c.stab_at || 0) || 0;
|
||||
return { c, t: Math.max(cutT, stabT), what: c.stabilized && stabT >= cutT ? "smoothed" : "cut" };
|
||||
}).filter((x) => x.t && Date.now() - x.t < 30 * 60 * 1000)
|
||||
.sort((a, b) => b.t - a.t).slice(0, 3);
|
||||
if (recent.length) {
|
||||
const h = document.createElement("div"); h.className = "lathead"; h.textContent = "LATEST"; el.appendChild(h);
|
||||
recent.forEach(({ c, t, what }) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "cut" + (c.file === nowPlaying ? " playing" : "");
|
||||
// for a fresh smooth, target the NEWEST smoothed version
|
||||
const smoothVers = (c.versions || []).filter((x) => x.file && x.key !== "original");
|
||||
const newest = smoothVers.sort((a, b) => (b.at || "").localeCompare(a.at || ""))[0];
|
||||
const lvl = what === "smoothed" && newest ? (newest.key === "smoothed" ? levelName(newest.meta) || "SMOOTHED" : VER_LABEL[newest.key]) : "";
|
||||
d.innerHTML = `<span class="st new">JUST ${what === "smoothed" ? "SMOOTHED" + (lvl ? "·" + lvl : "") : "CUT"}</span>
|
||||
<span class="nm" title="play the ${what === "smoothed" ? "smoothed result" : "new cut"}">${c.file.replace(/^cut-/, "").replace(/\.mp4$/, "")}</span>
|
||||
<span class="nt">${c.note || ""}</span><span class="age">${age(t)}</span>`;
|
||||
d.querySelector(".nm").onclick = () => {
|
||||
if (what === "smoothed" && newest) playCut(c.file, newest.file, lvl);
|
||||
else playCut(c.file, c.file, "ORIGINAL");
|
||||
};
|
||||
noteEditor(d.querySelector(".nt"), c);
|
||||
el.appendChild(d);
|
||||
});
|
||||
const h2 = document.createElement("div"); h2.className = "allhead"; h2.textContent = "ALL CUTS"; el.appendChild(h2);
|
||||
}
|
||||
|
||||
(r.cutting || []).filter((c) => inFilter(c.clip)).forEach((c) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "cut";
|
||||
d.innerHTML = `<span class="caret" style="visibility:hidden">▸</span><span class="g busy" title="cutting from the original">◌</span>
|
||||
<span class="rng" title="jump the shot to this moment">${fmt(c.in_s)}→${fmt(c.out_s)}</span>
|
||||
<span class="nm">${c.clip.replace(/\.(mp4|MP4)$/, "")}</span><span class="nt">cutting…</span>
|
||||
<span class="who">${c.by}</span><span class="del" title="cancel this cut (removes the punch)">✕</span>`;
|
||||
d.querySelector(".rng").onclick = () => jumpToSource(c.clip, c.in_s);
|
||||
d.querySelector(".del").onclick = async () => {
|
||||
if (!confirm("Cancel this cut? The punch marker is removed; the original footage is never touched.")) return;
|
||||
await fetch("/api/markers", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ clip: c.clip, deleted: true, ref: c.marker }) });
|
||||
loadCuts();
|
||||
};
|
||||
el.appendChild(d);
|
||||
});
|
||||
(r.failed || []).filter((c) => inFilter(c.clip)).forEach((c) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "cut";
|
||||
d.innerHTML = `<span class="caret" style="visibility:hidden">▸</span><span class="g bad" title="${(c.error || "cut failed").replace(/"/g, """)}">✕</span>
|
||||
<span class="rng">${fmt(c.in_s)}→${fmt(c.out_s)}</span>
|
||||
<span class="nm">${c.clip.replace(/\.(mp4|MP4)$/, "")}</span><span class="nt">cut failed</span>
|
||||
<button class="retry">RETRY</button><span class="who">${c.by}</span>`;
|
||||
d.querySelector(".retry").onclick = async () => {
|
||||
await fetch("/api/cut-retry", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ tag: c.tag }) });
|
||||
loadCuts();
|
||||
};
|
||||
el.appendChild(d);
|
||||
});
|
||||
(r.cuts || []).filter((c) => inFilter(c.src)).forEach((c) => {
|
||||
const d = document.createElement("div");
|
||||
d.className = "cut" + (c.file === nowPlaying ? " playing" : "");
|
||||
d.dataset.file = c.file;
|
||||
const isOpen = expanded.has(c.file);
|
||||
d.innerHTML = `<span class="caret ${isOpen ? "open" : ""}" title="more">▸</span>
|
||||
<span class="g ok" title="ready">●</span>
|
||||
<span class="rng" title="jump the shot to this moment">${c.in_s != null ? fmt(c.in_s) + "→" + fmt(c.out_s) : ""}</span>
|
||||
<span class="nm" title="play">${c.file.replace(/^cut-/, "").replace(/\.mp4$/, "")}</span>
|
||||
<span class="nt">${c.note || ""}</span><span class="vers"></span>
|
||||
<span class="smoothctl"><button class="stab" title="generate another version of this cut">+ SMOOTH ▾</button></span>
|
||||
<span class="del" title="delete (archived, restorable)">✕</span>
|
||||
<div class="xtra ${isOpen ? "open" : ""}">
|
||||
<button class="dlfull" title="download the full-res master of the version you're watching (big file — for Final Cut etc.)">⇓ MASTER</button>
|
||||
<button class="dllite" title="download the lightweight preview of the version you're watching">⇓ PREVIEW</button>
|
||||
<span class="info">${c.by}${c.cut_at ? " · cut " + c.cut_at.slice(0, 16).replace("T", " ") : ""}${c.src ? " · from " + c.src : ""}</span>
|
||||
</div>`;
|
||||
|
||||
// version chips: click = watch · ○/◉ = lock as the version the video uses
|
||||
const versEl = d.querySelector(".vers");
|
||||
const watched = watchState[c.file] || c.file;
|
||||
(c.versions || []).forEach((ver) => {
|
||||
const chip = document.createElement("span");
|
||||
if (ver.queued) {
|
||||
chip.className = "ver busy";
|
||||
chip.textContent = `${VER_LABEL[ver.key] || ver.key} ${ver.progress != null ? ver.progress + "%" : "…"}`;
|
||||
versEl.appendChild(chip);
|
||||
return;
|
||||
}
|
||||
const label = ver.key === "smoothed" ? (levelName(ver.meta) || "SMOOTHED") : (VER_LABEL[ver.key] || ver.key);
|
||||
const isLocked = (c.pick || "original") === ver.key;
|
||||
chip.className = "ver" + (watched === ver.file ? " on" : "") + (isLocked ? " lk" : "");
|
||||
chip.innerHTML = `${label}<span class="lock" title="${isLocked ? "the locked version — the one the video uses" : "lock this version as the one the video uses (nothing is deleted)"}">${isLocked ? "◉" : "○"}</span>`;
|
||||
chip.title = "watch " + label.toLowerCase();
|
||||
chip.onclick = () => playCut(c.file, ver.file, label);
|
||||
chip.querySelector(".lock").onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
await fetch("/api/pick", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ clip: c.file, choice: ver.key }) });
|
||||
loadCuts();
|
||||
};
|
||||
versEl.appendChild(chip);
|
||||
});
|
||||
|
||||
d.querySelector(".caret").onclick = () => {
|
||||
if (expanded.has(c.file)) expanded.delete(c.file); else expanded.add(c.file);
|
||||
d.querySelector(".caret").classList.toggle("open");
|
||||
d.querySelector(".xtra").classList.toggle("open");
|
||||
};
|
||||
d.querySelector(".rng").onclick = () => { if (c.in_s != null) jumpToSource(c.src, c.in_s); };
|
||||
d.querySelector(".dlfull").onclick = () => location.href = "/master/" + encodeURIComponent(watchState[c.file] || c.file);
|
||||
d.querySelector(".dllite").onclick = () => location.href = "/cutprev/" + encodeURIComponent(watchState[c.file] || c.file) + "?dl=1";
|
||||
d.querySelector(".del").onclick = async () => {
|
||||
if (!confirm("Delete this cut? It moves to the archive (restorable), the original footage is never touched.")) return;
|
||||
await fetch("/api/cut-delete", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clip: c.file }) });
|
||||
loadCuts();
|
||||
};
|
||||
d.querySelector(".nm").onclick = () => {
|
||||
const wf = watchState[c.file] || c.file;
|
||||
const wv = (c.versions || []).find((x) => x.file === wf);
|
||||
playCut(c.file, wf, wv ? (wv.key === "smoothed" ? levelName(wv.meta) || "SMOOTHED" : VER_LABEL[wv.key]) : "ORIGINAL");
|
||||
};
|
||||
noteEditor(d.querySelector(".nt"), c);
|
||||
levelMenu(d.querySelector(".smoothctl"), async (lv) => {
|
||||
const exists = (c.versions || []).some((x) => x.key === lv && x.file);
|
||||
if (exists && !confirm(`A ${VER_LABEL[lv]} version already exists — regenerate it? (the current one is archived)`)) return;
|
||||
await fetch("/api/stabilize", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ clip: c.file, level: lv }) });
|
||||
loadCuts();
|
||||
});
|
||||
el.appendChild(d);
|
||||
});
|
||||
}
|
||||
setInterval(() => { if (!editingNote && $("cutlist").style.display !== "none") loadCuts(); }, 12000);
|
||||
|
||||
await loadClips();
|
||||
$("fAll").classList.toggle("on", filterMode === "all");
|
||||
$("fClip").classList.toggle("on", filterMode === "clip");
|
||||
if (clips.length) activate(clips[0].name);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "files",
|
||||
"name": "FILES",
|
||||
"summary": "Browse, search, tag and edit \u2014 including the studio's own source, so the agent can reshape the app you are looking at.",
|
||||
"category": "studio",
|
||||
"maker": {
|
||||
"name": "OpenRig",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"surface": {
|
||||
"entry": "app/files.html",
|
||||
"path": "/surfaces/files.html",
|
||||
"glyph": "\u25a4",
|
||||
"hint": "browse, search, tag and edit your media and source \u2014 the agent can drive it"
|
||||
},
|
||||
"roots": {
|
||||
"media": {
|
||||
"required": true
|
||||
},
|
||||
"project": {
|
||||
"required": false
|
||||
}
|
||||
},
|
||||
"verbs": [
|
||||
"/api/files/tree",
|
||||
"/api/files/read",
|
||||
"/api/files/write",
|
||||
"/api/files/raw",
|
||||
"/api/files/search",
|
||||
"/api/files/tags",
|
||||
"/api/files/goto",
|
||||
"/api/files/roots"
|
||||
],
|
||||
"vendor": [
|
||||
"marked"
|
||||
],
|
||||
"requires": {
|
||||
"host_capabilities": [
|
||||
"files.read",
|
||||
"files.write"
|
||||
],
|
||||
"_note": "Ultralight: this app has no backend of its own. It rides verbs the STUDIO serves. Declaring that is the difference between 'no provider' and 'provider forgotten'."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Files</title>
|
||||
<script src="/vendor/marked/marked.min.js"></script>
|
||||
<style>
|
||||
/* dark value ladder */
|
||||
:root {
|
||||
--bg:#121214; --fg:rgba(255,255,255,.95); --body:rgba(255,255,255,.68);
|
||||
--dim:rgba(255,255,255,.52); --faint:rgba(255,255,255,.38);
|
||||
--line:rgba(255,255,255,.10); --line2:rgba(255,255,255,.16);
|
||||
--card:rgba(255,255,255,.07); --coral:#ff6b5a; --indigo:#8b93ff;
|
||||
--sans:Helvetica,"Helvetica Neue",Arial,sans-serif;
|
||||
--mono:ui-monospace,"SF Mono",Menlo,monospace;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; height:100vh; background:var(--bg); color:var(--fg);
|
||||
font-family:var(--sans); font-size:13.5px; display:flex; flex-direction:column;
|
||||
-webkit-font-smoothing:antialiased; overflow:hidden; }
|
||||
body::before { content:""; position:fixed; inset:-10%; z-index:0; pointer-events:none;
|
||||
background: radial-gradient(42% 55% at 18% 22%, rgba(139,147,255,.08), transparent 70%),
|
||||
radial-gradient(48% 60% at 82% 18%, rgba(255,107,90,.05), transparent 70%);
|
||||
filter:blur(56px) saturate(1.2) brightness(.9); transform:scale(1.1); }
|
||||
body > * { position:relative; z-index:1; }
|
||||
|
||||
header { display:flex; align-items:center; gap:12px; padding:12px 16px;
|
||||
border-bottom:1px solid var(--line); }
|
||||
header h1 { font-size:12px; letter-spacing:.22em; margin:0; font-weight:700; }
|
||||
#crumb { color:var(--dim); font-size:11px; overflow:hidden; text-overflow:ellipsis;
|
||||
white-space:nowrap; flex:1; }
|
||||
#status { font-size:9.5px; letter-spacing:.16em; text-transform:uppercase; color:var(--faint); }
|
||||
#status.dirty { color:var(--coral); }
|
||||
#status.saved { color:var(--body); }
|
||||
.btn { background:var(--fg); color:#111; border:none; border-radius:2px;
|
||||
font-family:var(--sans); font-size:9px; letter-spacing:.18em; text-transform:uppercase;
|
||||
font-weight:600; padding:7px 14px; cursor:pointer; }
|
||||
.btn:hover { background:rgba(255,255,255,.8); }
|
||||
.btn[disabled] { background:rgba(255,255,255,.2); cursor:default; }
|
||||
.ghost { background:none; border:1px solid var(--line2); border-radius:2px;
|
||||
color:var(--dim); font-family:var(--sans); font-size:9px; letter-spacing:.16em;
|
||||
text-transform:uppercase; padding:6px 12px; cursor:pointer; }
|
||||
.ghost:hover { color:var(--fg); border-color:rgba(255,255,255,.35); }
|
||||
.ghost.on { background:var(--fg); color:#111; border-color:var(--fg); font-weight:600; }
|
||||
|
||||
.body { display:grid; grid-template-columns:250px minmax(0,1fr); flex:1; min-height:0; }
|
||||
.body > * { min-height:0; min-width:0; }
|
||||
#rail { border-right:1px solid var(--line); overflow-y:auto; padding:8px 0;
|
||||
background:rgba(255,255,255,.02); backdrop-filter:blur(14px); }
|
||||
.lens { display:flex; gap:6px; padding:4px 12px 10px; }
|
||||
#rail h3 { margin:10px 12px 4px; font-size:9px; letter-spacing:.2em; color:var(--faint);
|
||||
text-transform:uppercase; font-weight:400; }
|
||||
.node { display:flex; align-items:center; gap:6px; padding:3px 12px; cursor:pointer;
|
||||
color:var(--body); font-size:12px; border-left:2px solid transparent;
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.node:hover { color:var(--fg); }
|
||||
.node.on { color:var(--fg); border-left-color:var(--fg); background:rgba(255,255,255,.06); }
|
||||
.node.kbd { outline:1px solid rgba(255,255,255,.35); outline-offset:-1px; }
|
||||
.node .tri { width:12px; color:var(--faint); flex:none; font-size:12px; }
|
||||
.node .ic { width:14px; text-align:center; flex:none; color:var(--faint); font-size:11px; }
|
||||
.kids { margin-left:14px; }
|
||||
|
||||
#stage { display:flex; flex-direction:column; min-width:0; }
|
||||
#viewer { flex:1; min-height:0; overflow:auto; padding:0; }
|
||||
#empty { display:flex; height:100%; align-items:center; justify-content:center;
|
||||
color:var(--faint); font-size:10.5px; letter-spacing:.2em; text-transform:uppercase; }
|
||||
#ed { width:100%; height:100%; background:transparent; border:0; resize:none;
|
||||
color:var(--fg); font:12.5px/1.6 var(--mono); padding:18px 22px; outline:none; }
|
||||
#md { padding:24px 34px; max-width:860px; font-size:14px; line-height:1.65; color:var(--body); }
|
||||
#md h1,#md h2,#md h3 { color:var(--fg); letter-spacing:-.02em; line-height:1.2; }
|
||||
#md h1 { font-size:26px; } #md h2 { font-size:19px; } #md h3 { font-size:15px; }
|
||||
#md code { font-family:var(--mono); font-size:.9em; background:rgba(255,255,255,.07);
|
||||
padding:1px 5px; border-radius:2px; }
|
||||
#md pre { background:rgba(255,255,255,.05); border:1px solid var(--line);
|
||||
border-radius:2px; padding:12px 14px; overflow-x:auto; }
|
||||
#md pre code { background:none; padding:0; }
|
||||
#md a { color:var(--indigo); }
|
||||
#md blockquote { border-left:2px solid var(--line2); margin-left:0; padding-left:14px; color:var(--dim); }
|
||||
#md table { border-collapse:collapse; } #md td,#md th { border:1px solid var(--line);
|
||||
padding:5px 10px; font-size:12.5px; }
|
||||
#md img { max-width:100%; }
|
||||
#viewer img.media { max-width:100%; max-height:100%; display:block; margin:0 auto; }
|
||||
#viewer video { width:100%; height:100%; background:#000; }
|
||||
#viewer iframe.twin { width:100%; height:100%; border:0; background:#0a0a0c; }
|
||||
</style>
|
||||
<style>
|
||||
/* dark-scheme scrollbars without color-scheme the
|
||||
browser paints LIGHT scrollbars on our dark surfaces */
|
||||
html { color-scheme: dark; }
|
||||
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.22) transparent; }
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 4px; border: 2px solid transparent; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.32); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>FILES</h1>
|
||||
<div id="crumb"></div>
|
||||
<span id="status"></span>
|
||||
<button class="ghost" id="rawBtn" style="display:none">RAW</button>
|
||||
<button class="btn" id="saveBtn" style="display:none">SAVE</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<nav id="rail">
|
||||
<div class="lens">
|
||||
<button class="ghost on" id="lensTree">FOLDERS</button>
|
||||
<button class="ghost" id="lensTags">TAGS</button>
|
||||
</div>
|
||||
<input id="q" placeholder="search — or paste a /path and Enter"
|
||||
style="width:calc(100% - 24px);margin:0 12px 8px;background:rgba(255,255,255,.05);border:none;border-radius:2px;color:var(--fg);font-family:var(--sans);font-size:12px;padding:8px 10px;outline:none" />
|
||||
<div id="tree"></div>
|
||||
</nav>
|
||||
<main id="stage">
|
||||
<div id="viewer"><div id="empty">pick a file — markdown, code, image, video, html</div></div>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (t) => String(t ?? "").replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
||||
const api = async (p, opts) => { const r = await fetch(p, opts); return { status: r.status, ...(await r.json().catch(() => ({}))) }; };
|
||||
const ICONS = { markdown:"M", text:"{}", image:"▣", video:"🎞", audio:"♪", html:"<>", other:"·" };
|
||||
|
||||
const st = { file:null, kind:null, mtime:null, dirty:false, mode:"preview", lens:"tree", root:null, watch:null };
|
||||
|
||||
// ---- rail: lazy tree ----
|
||||
async function renderRoots() {
|
||||
const d = await api("/api/files/tree");
|
||||
$("tree").innerHTML = "<h3>roots</h3>" + (d.roots || []).map((r) =>
|
||||
`<div class="node dir" data-dir="${esc(r.path)}"><span class="tri">▸</span><span class="ic">▦</span>${esc(r.name)}</div>`).join("")
|
||||
+ `<div class="node" id="addRoot" style="color:var(--faint)"><span class="tri"></span><span class="ic">+</span>add a root…</div>`;
|
||||
$("addRoot").onclick = async () => {
|
||||
const p = prompt("Absolute directory to add as a root\n(inside your home; key/secret dirs refused):");
|
||||
if (!p) return;
|
||||
const r = await api("/api/files/roots", { method: "POST",
|
||||
headers: { "content-type": "application/json" }, body: JSON.stringify({ op: "add", path: p.trim() }) });
|
||||
if (!r.ok) { alert(r.error || "refused"); return; }
|
||||
renderRoots();
|
||||
};
|
||||
wireTree();
|
||||
}
|
||||
function wireTree() {
|
||||
for (const el of document.querySelectorAll(".node.dir")) el.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
const open = el.nextElementSibling?.classList?.contains("kids");
|
||||
if (open) { el.nextElementSibling.remove(); el.querySelector(".tri").textContent = "▸"; return; }
|
||||
const d = await api("/api/files/tree?dir=" + encodeURIComponent(el.dataset.dir));
|
||||
if (!d.ok) return;
|
||||
const kids = document.createElement("div");
|
||||
kids.className = "kids";
|
||||
kids.innerHTML = d.dirs.map((x) =>
|
||||
`<div class="node dir" data-dir="${esc(x.path)}"><span class="tri">▸</span><span class="ic">▦</span>${esc(x.name)}</div>`).join("")
|
||||
+ d.files.map((f) =>
|
||||
`<div class="node file" data-path="${esc(f.path)}"><span class="tri"></span><span class="ic">${ICONS[f.kind] || "·"}</span>${esc(f.name)}</div>`).join("");
|
||||
el.after(kids);
|
||||
el.querySelector(".tri").textContent = "▾";
|
||||
st.root = el.dataset.dir.split("/").slice(0, 6).join("/");
|
||||
wireTree();
|
||||
};
|
||||
for (const el of document.querySelectorAll(".node.file")) el.onclick = () => openFile(el.dataset.path, el);
|
||||
}
|
||||
|
||||
async function renderTags() {
|
||||
const roots = (await api("/api/files/tree")).roots || [];
|
||||
const ws = roots.find((r) => /workspace/.test(r.path)) || roots[0];
|
||||
if (!ws) return;
|
||||
const d = await api("/api/files/tags?root=" + encodeURIComponent(ws.path));
|
||||
const base = d.tagsPath ? d.tagsPath.replace(/tags\.jsonl$/, "") : ws.path + "/";
|
||||
$("tree").innerHTML = "<h3>tags</h3>" + (Object.keys(d.tags || {}).length
|
||||
? Object.entries(d.tags).map(([tag, assets]) => `
|
||||
<div class="node dir" data-tag="${esc(tag)}"><span class="tri">▸</span><span class="ic">#</span>${esc(tag)} · ${assets.length}</div>
|
||||
<div class="kids" style="display:none">${assets.map((a) => `
|
||||
<div class="node file" data-path="${esc(base)}media/renders/${esc(a)}"><span class="tri"></span><span class="ic">▶</span>${esc(a)}</div>`).join("")}</div>`).join("")
|
||||
: '<div class="node" style="color:var(--faint)">no tags.jsonl in this workspace</div>');
|
||||
for (const el of document.querySelectorAll(".node[data-tag]")) el.onclick = () => {
|
||||
const kids = el.nextElementSibling;
|
||||
const open = kids.style.display !== "none";
|
||||
kids.style.display = open ? "none" : "";
|
||||
el.querySelector(".tri").textContent = open ? "▸" : "▾";
|
||||
};
|
||||
for (const el of document.querySelectorAll(".node.file")) el.onclick = () => openFile(el.dataset.path, el);
|
||||
}
|
||||
|
||||
// ---- viewer ----
|
||||
function setActive(el) {
|
||||
document.querySelectorAll(".node.on").forEach((x) => x.classList.remove("on"));
|
||||
if (el) el.classList.add("on");
|
||||
}
|
||||
async function openFile(p, el) {
|
||||
if (st.dirty && !confirm("Discard unsaved changes?")) return;
|
||||
clearInterval(st.watch);
|
||||
setActive(el);
|
||||
const d = await api("/api/files/read?path=" + encodeURIComponent(p));
|
||||
if (!d.ok) { $("viewer").innerHTML = `<div id="empty">${esc(d.error || "unreadable")}</div>`; return; }
|
||||
Object.assign(st, { file:p, kind:d.kind, mtime:d.mtime, dirty:false, mode:"preview" });
|
||||
$("crumb").textContent = p;
|
||||
$("saveBtn").style.display = (d.kind === "markdown" || d.kind === "text") ? "" : "none";
|
||||
$("rawBtn").style.display = (d.kind === "markdown" || d.kind === "html") ? "" : "none";
|
||||
$("rawBtn").classList.remove("on");
|
||||
render(d);
|
||||
// agent co-editing: watch mtime; refresh view when someone else writes
|
||||
st.watch = setInterval(async () => {
|
||||
if (st.dirty) return; // human's unsaved edit wins the screen
|
||||
const c = await api("/api/files/read?path=" + encodeURIComponent(st.file));
|
||||
if (c.ok && c.mtime !== st.mtime) { st.mtime = c.mtime; render(c); flash("updated by agent"); }
|
||||
}, 2000);
|
||||
try { fetch("/api/focus", { method:"POST", headers:{ "content-type":"application/json" },
|
||||
body: JSON.stringify({ surface:"files", selection:[p], view:st.root, ts:Date.now() }) }).catch(()=>{}); } catch {}
|
||||
}
|
||||
function render(d) {
|
||||
const v = $("viewer");
|
||||
if (d.kind === "markdown") {
|
||||
v.innerHTML = st.mode === "raw"
|
||||
? `<textarea id="ed" spellcheck="false"></textarea>`
|
||||
: `<div id="md"></div>`;
|
||||
if (st.mode === "raw") { $("ed").value = d.content; wireEditor(); }
|
||||
else $("md").innerHTML = marked.parse(d.content);
|
||||
st.content = d.content;
|
||||
} else if (d.kind === "text") {
|
||||
v.innerHTML = `<textarea id="ed" spellcheck="false"></textarea>`;
|
||||
$("ed").value = d.content; st.content = d.content; wireEditor();
|
||||
} else if (d.kind === "image") {
|
||||
v.innerHTML = `<img class="media" src="${esc(d.raw)}" alt="">`;
|
||||
} else if (d.kind === "video" || d.kind === "audio") {
|
||||
v.innerHTML = d.kind === "video" ? `<video controls src="${esc(d.raw)}"></video>`
|
||||
: `<div style="padding:40px"><audio controls src="${esc(d.raw)}" style="width:100%"></audio></div>`;
|
||||
} else if (d.kind === "html") {
|
||||
v.innerHTML = st.mode === "raw"
|
||||
? `<textarea id="ed" spellcheck="false"></textarea>`
|
||||
: `<iframe class="twin" sandbox="allow-scripts" src="${esc(d.raw || ("/api/files/raw?path=" + encodeURIComponent(st.file)))}"></iframe>`;
|
||||
if (st.mode === "raw") {
|
||||
api("/api/files/read?path=" + encodeURIComponent(st.file)).then((c) => {
|
||||
if ($("ed")) { $("ed").value = c.content ?? ""; st.content = c.content; wireEditor(); }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
v.innerHTML = `<div id="empty">no viewer for this type yet</div>`;
|
||||
}
|
||||
}
|
||||
function wireEditor() {
|
||||
$("ed").addEventListener("input", () => { st.dirty = true; status("unsaved", "dirty"); });
|
||||
$("ed").addEventListener("keydown", (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); save(); }
|
||||
});
|
||||
}
|
||||
const status = (t, cls) => { const s = $("status"); s.textContent = t; s.className = cls || ""; };
|
||||
const flash = (t) => { status(t, "saved"); setTimeout(() => !st.dirty && status(""), 2500); };
|
||||
|
||||
async function save() {
|
||||
const ed = $("ed"); if (!ed) return;
|
||||
const r = await api("/api/files/write", { method:"POST",
|
||||
headers:{ "content-type":"application/json" },
|
||||
body: JSON.stringify({ path: st.file, content: ed.value, baseMtime: st.mtime }) });
|
||||
if (r.status === 409) {
|
||||
if (confirm("An agent (or someone) changed this file while you edited.\nOK = load THEIR version (your text goes to clipboard). Cancel = keep editing yours.")) {
|
||||
try { await navigator.clipboard.writeText(ed.value); } catch {}
|
||||
st.dirty = false; st.mtime = r.mtime;
|
||||
render({ kind: st.kind, content: r.content, mtime: r.mtime });
|
||||
flash("reloaded their version");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { status(r.error || "save failed", "dirty"); return; }
|
||||
st.mtime = r.mtime; st.dirty = false; st.content = ed.value;
|
||||
flash("saved");
|
||||
}
|
||||
$("saveBtn").onclick = save;
|
||||
$("rawBtn").onclick = () => {
|
||||
st.mode = st.mode === "raw" ? "preview" : "raw";
|
||||
$("rawBtn").classList.toggle("on", st.mode === "raw");
|
||||
api("/api/files/read?path=" + encodeURIComponent(st.file)).then(render);
|
||||
};
|
||||
$("lensTree").onclick = () => { st.lens = "tree"; $("lensTree").classList.add("on"); $("lensTags").classList.remove("on"); renderRoots(); };
|
||||
$("lensTags").onclick = () => { st.lens = "tags"; $("lensTags").classList.add("on"); $("lensTree").classList.remove("on"); renderTags(); };
|
||||
|
||||
// search box: /path + Enter jumps straight there; anything else searches names
|
||||
let qTimer = null;
|
||||
$("q").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && $("q").value.trim().startsWith("/")) openFile($("q").value.trim(), null);
|
||||
});
|
||||
$("q").addEventListener("input", () => {
|
||||
clearTimeout(qTimer);
|
||||
const q = $("q").value.trim();
|
||||
if (q.startsWith("/") || q.length < 2) { if (!q) (st.lens === "tags" ? renderTags : renderRoots)(); return; }
|
||||
qTimer = setTimeout(async () => {
|
||||
const d = await api("/api/files/search?q=" + encodeURIComponent(q));
|
||||
$("tree").innerHTML = `<h3>results · ${d.hits.length}</h3>` + d.hits.map((f) => `
|
||||
<div class="node file" data-path="${esc(f.path)}" title="${esc(f.path)}"><span class="tri"></span><span class="ic">${ICONS[f.kind] || "·"}</span>${esc(f.name)}</div>`).join("")
|
||||
|| '<div class="node" style="color:var(--faint)">nothing matched</div>';
|
||||
for (const el of document.querySelectorAll("#tree .node.file")) el.onclick = () => openFile(el.dataset.path, el);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// agent-driven navigation: an agent POSTs /api/files/goto and this view follows
|
||||
let lastGoto = Date.now();
|
||||
setInterval(async () => {
|
||||
const d = await api("/api/files/goto").catch(() => null);
|
||||
if (d && d.goto && d.goto.ts > lastGoto) { lastGoto = d.goto.ts; openFile(d.goto.path, null); flash("opened by agent"); }
|
||||
}, 3000);
|
||||
|
||||
// keyboard tree navigation: up/down walk visible nodes, right expands or
|
||||
// opens, left collapses or jumps to parent, Enter activates
|
||||
const visNodes = () => [...document.querySelectorAll("#tree .node")].filter((n) => n.offsetParent !== null && !n.id);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (["TEXTAREA", "INPUT"].includes(e.target.tagName)) return;
|
||||
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Enter"].includes(e.key)) return;
|
||||
const nodes = visNodes(); if (!nodes.length) return;
|
||||
e.preventDefault();
|
||||
let i = nodes.findIndex((n) => n.classList.contains("kbd"));
|
||||
const setK = (j) => { nodes.forEach((n) => n.classList.remove("kbd"));
|
||||
i = Math.max(0, Math.min(nodes.length - 1, j));
|
||||
nodes[i].classList.add("kbd"); nodes[i].scrollIntoView({ block: "nearest" }); };
|
||||
if (i === -1) { setK(0); return; }
|
||||
const cur = nodes[i];
|
||||
const isDir = cur.classList.contains("dir") || cur.dataset.tag !== undefined;
|
||||
const expanded = cur.querySelector(".tri") && cur.querySelector(".tri").textContent === "▾";
|
||||
if (e.key === "ArrowDown") setK(i + 1);
|
||||
else if (e.key === "ArrowUp") setK(i - 1);
|
||||
else if (e.key === "Enter") { cur.click(); if (!isDir) return; setTimeout(() => setK(i), 150); }
|
||||
else if (e.key === "ArrowRight") {
|
||||
if (isDir && !expanded) { cur.click(); setTimeout(() => setK(i), 150); }
|
||||
else if (isDir && expanded) setK(i + 1);
|
||||
else cur.click();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
if (isDir && expanded) { cur.click(); setTimeout(() => setK(i), 150); }
|
||||
else {
|
||||
const depth = (n) => { let d = 0, p = n.parentElement; while (p && p.id !== "tree") { if (p.classList.contains("kids")) d++; p = p.parentElement; } return d; };
|
||||
const myD = depth(cur);
|
||||
for (let j = i - 1; j >= 0; j--) if (depth(nodes[j]) < myD) { setK(j); break; }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
renderRoots();
|
||||
window.__files = { openFile, save, st };
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
marked v12.0.2 (MIT) — pinned prebuilt from npm via jsdelivr, 2026-07-26.
|
||||
Used by FILES for markdown preview. Rebuild: fetch marked@12.0.2/marked.min.js.
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* marked v12.0.2 - a markdown parser
|
||||
* Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed)
|
||||
* https://github.com/markedjs/marked
|
||||
*/
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length<t;)n.push("");for(;s<n.length;s++)n[s]=n[s].trim().replace(/\\\|/g,"|");return n}function x(e,t,n){const s=e.length;if(0===s)return"";let r=0;for(;r<s;){const i=e.charAt(s-r-1);if(i!==t||n){if(i===t||!n)break;r++}else r++}return e.slice(0,s-r)}function b(e,t,n,s){const r=t.href,i=t.title?c(t.title):null,l=e[1].replace(/\\([\[\]])/g,"$1");if("!"!==e[0].charAt(0)){s.state.inLink=!0;const e={type:"link",raw:n,href:r,title:i,text:l,tokens:s.inlineTokens(l)};return s.state.inLink=!1,e}return{type:"image",raw:n,href:r,title:i,text:c(l)}}class w{options;rules;lexer;constructor(t){this.options=t||e.defaults}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))Line truncated
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "media-manager",
|
||||
"name": "MEDIA",
|
||||
"summary": "Tag, curate and route your footage library.",
|
||||
"category": "create",
|
||||
"maker": {
|
||||
"name": "OpenRig",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"surface": {
|
||||
"entry": "app/media-manager.html",
|
||||
"path": "/surfaces/media-manager.html",
|
||||
"glyph": "\u25a6",
|
||||
"hint": "browse, tag and route your footage library"
|
||||
},
|
||||
"provider": {
|
||||
"package": "@openrig/studio-video",
|
||||
"required": true,
|
||||
"_note": "This app is a CLIENT of that provider. Installing the surface alone gets you an HTML file that 502s \u2014 the field rigs-to-apps v1 had no slot for, which is why media-manager was listed there and was not installable.",
|
||||
"run": {
|
||||
"entry": "export-server.mjs",
|
||||
"args": [
|
||||
"--port",
|
||||
"{{port}}",
|
||||
"--slice-root",
|
||||
"{{root:project}}",
|
||||
"--canvas-root",
|
||||
"{{root:canvas}}",
|
||||
"--media-root",
|
||||
"{{root:media}}"
|
||||
],
|
||||
"env": {
|
||||
"CUTDOWN_API": "http://127.0.0.1:{{port:@openrig/studio-cutdown}}"
|
||||
},
|
||||
"_note": "Placeholders the box resolves: {{port}} = the port assigned to THIS provider, {{root:<kind>}} = the binding for that root kind (an array repeats its preceding flag), {{port:<pkg>}} = another provider's assigned port, which is how inter-provider wiring stops being a hardcoded default."
|
||||
},
|
||||
"supplies": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"_supplies_note": "Binaries this provider SHIPS (ffmpeg-static/ffprobe-static), so the host does not need them. requires.binaries still declares the NEED \u2014 that stays legible and true; this says who satisfies it. Without the distinction the installer refuses a working install on any box without a system ffmpeg, which is most of them."
|
||||
},
|
||||
"roots": {
|
||||
"media": {
|
||||
"required": true
|
||||
},
|
||||
"project": {
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"verbs": [
|
||||
"/api/library",
|
||||
"/api/library/media",
|
||||
"/api/library/route",
|
||||
"/api/project-assets",
|
||||
"/api/tags",
|
||||
"/api/selections",
|
||||
"/api/media",
|
||||
"/api/thumb",
|
||||
"/api/curation",
|
||||
"/api/exports",
|
||||
"/api/focus"
|
||||
],
|
||||
"vendor": [],
|
||||
"requires": {
|
||||
"binaries": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"ffmpeg_filters": [
|
||||
"scale",
|
||||
"concat"
|
||||
],
|
||||
"_filters_note": "REQUIRED vs PREFERRED, and the split is the point. scale/concat are load-bearing: no cut renders without them. drawtext only writes the LABEL on a placeholder slate, and the assembler now renders an unlabelled slate and reports the degradation instead of failing the export. So drawtext must NOT block an install \u2014 the Linux ffmpeg-static build (7.0.2) lacks it while the macOS one (6.0) has it, and losing a caption on an unshot beat is not worth refusing a working studio.",
|
||||
"ffmpeg_filters_preferred": [
|
||||
"drawtext"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OpenRig Media Manager</title>
|
||||
<style>
|
||||
/* dark value ladder . Semantic
|
||||
token names kept from the Obsidian kit so selectors are untouched:
|
||||
--amber is now the ACTIVE accent (white, per the ladder's invert
|
||||
convention); coral is reserved for needs-human (unused here yet).
|
||||
Bridge-shaped: explorer rail (smart groups + TAGS), grid, one
|
||||
contextual verb bar, modal player. License info stays DISPLAY-ONLY. */
|
||||
:root {
|
||||
--bg: #121214; --panel: #1b1b1e; --well: rgba(255,255,255,.05);
|
||||
--line: rgba(255,255,255,.14); --hairline: rgba(255,255,255,.08);
|
||||
--ink: rgba(255,255,255,.95); --dim: rgba(255,255,255,.52);
|
||||
--amber: rgba(255,255,255,.95);
|
||||
--mono: Helvetica, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
body::before { content: ""; position: fixed; inset: -10%; z-index: 0; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(42% 55% at 18% 22%, rgba(139,147,255,.08), transparent 70%),
|
||||
radial-gradient(48% 60% at 82% 18%, rgba(255,107,90,.05), transparent 70%),
|
||||
radial-gradient(55% 65% at 60% 85%, rgba(120,160,255,.06), transparent 70%);
|
||||
filter: blur(56px) saturate(1.2) brightness(.9); transform: scale(1.1); }
|
||||
body > * { position: relative; z-index: 1; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--ink); font: 13px/1.45 var(--mono); }
|
||||
header { display: flex; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid var(--line); }
|
||||
header h1 { font: 600 13px var(--mono); letter-spacing: 0.08em; color: var(--amber); margin: 0; text-transform: uppercase; }
|
||||
header .sub { color: var(--dim); font-size: 11px; }
|
||||
.tools { display: flex; align-items: center; gap: 8px; padding: 8px 14px; flex-wrap: wrap; border-bottom: 1px solid var(--hairline); }
|
||||
input[type="search"], select, button, input[type="text"] {
|
||||
background: var(--well); color: var(--ink); border: 1px solid var(--line);
|
||||
border-radius: 2px; font: 11.5px var(--mono); padding: 4px 8px;
|
||||
}
|
||||
button { background: var(--panel); cursor: pointer; }
|
||||
button:hover { border-color: var(--amber); color: var(--amber); }
|
||||
button[aria-pressed="true"] { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
.body { display: grid; grid-template-columns: 200px minmax(0, 1fr); height: calc(100vh - 118px); }
|
||||
|
||||
/* ---- explorer rail: smart groups + tags ---- */
|
||||
#rail { border-right: 1px solid var(--line); overflow-y: auto; padding: 10px 0; }
|
||||
#rail h3 { margin: 8px 12px 4px; font: 600 9.5px var(--mono); letter-spacing: 0.1em;
|
||||
color: var(--dim); text-transform: uppercase; display: flex; align-items: center; }
|
||||
#rail h3 .add { margin-left: auto; cursor: pointer; color: var(--dim); border: 0; background: none; padding: 0 4px; font-size: 13px; }
|
||||
#rail h3 .add:hover { color: var(--amber); }
|
||||
.railrow { display: flex; align-items: baseline; gap: 7px; padding: 4px 12px; cursor: pointer;
|
||||
color: var(--dim); font-size: 11.5px; border-left: 2px solid transparent; }
|
||||
.railrow:hover { color: var(--ink); }
|
||||
.railrow.on { color: var(--ink); border-left-color: var(--ink); background: rgba(255,255,255,.07); }
|
||||
.railrow .g { width: 13px; text-align: center; }
|
||||
.railrow .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.railrow .c { margin-left: auto; font-size: 10px; color: var(--dim); font-variant-numeric: tabular-nums; }
|
||||
.railrow .sp { font-size: 9px; color: var(--amber); opacity: .8; }
|
||||
|
||||
/* ---- the contextual verb bar (selection OR active tag) ---- */
|
||||
#ctxbar { display: none; align-items: center; gap: 8px; padding: 6px 14px;
|
||||
border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
#ctxbar.on { display: flex; flex-wrap: wrap; }
|
||||
#ctxbar .n { color: var(--amber); font-size: 11px; letter-spacing: .06em; }
|
||||
#ctxbar button { padding: 4px 10px; }
|
||||
#ctxbar .spacer { flex: 1; }
|
||||
.tagmenu { position: relative; display: inline-block; }
|
||||
.tagmenu .drop { display: none; position: absolute; top: calc(100% + 4px); left: 0; z-index: 30;
|
||||
flex-direction: column; gap: 2px; background: rgba(24,24,27,.92); border: 1px solid var(--line);
|
||||
border-radius: 2px; padding: 6px; min-width: 160px; box-shadow: 0 8px 22px rgba(0,0,0,.55);
|
||||
backdrop-filter: blur(10px); }
|
||||
.tagmenu .drop.open { display: flex; }
|
||||
.tagmenu .drop button { border: 0; text-align: left; }
|
||||
.tagmenu .drop input { margin-top: 4px; }
|
||||
|
||||
/* ---- grid ---- */
|
||||
#main { display: flex; flex-direction: column; min-width: 0; }
|
||||
.grid { flex: 1; overflow-y: auto; padding: 10px 14px; display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; align-content: start; }
|
||||
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 2px; overflow: hidden; cursor: pointer; position: relative; }
|
||||
.tile.sel { border-color: var(--amber); box-shadow: 0 0 0 1px var(--amber); }
|
||||
.tile .selmark { position: absolute; top: 5px; left: 5px; z-index: 2; width: 16px; height: 16px;
|
||||
border: 1px solid var(--line); border-radius: 2px; background: rgba(0,0,0,.55);
|
||||
color: var(--amber); font-size: 11px; line-height: 15px; text-align: center; }
|
||||
.tile.sel .selmark { border-color: var(--amber); }
|
||||
.tile video, .tile img { width: 100%; height: 108px; object-fit: contain; display: block; background: #000; }
|
||||
.tile .thumb { background: #000; height: 108px; display: grid; place-items: center; }
|
||||
.tile .cap { padding: 5px 7px; font-size: 10.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tile .cap b { color: var(--ink); font-weight: 500; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tile .badges { display: flex; gap: 4px; padding: 0 7px 6px; flex-wrap: wrap; }
|
||||
.badge { font-size: 9px; border: 1px solid var(--line); border-radius: 2px; padding: 0 4px; color: var(--dim); }
|
||||
.badge.lk { border-color: var(--amber); color: var(--amber); }
|
||||
.badge.ver { color: var(--ink); }
|
||||
.badge.tag { cursor: pointer; }
|
||||
.badge.tag:hover { color: var(--amber); border-color: var(--amber); }
|
||||
.empty { color: var(--dim); padding: 40px; text-align: center; grid-column: 1 / -1; }
|
||||
#status { padding: 5px 14px; color: var(--dim); font-size: 11px; border-top: 1px solid var(--hairline); }
|
||||
|
||||
/* ---- the modal player (replaces the inspector drawer) ---- */
|
||||
#modalwrap { display: none; position: fixed; inset: 0; z-index: 50;
|
||||
background: rgba(0,0,0,.65); align-items: center; justify-content: center; }
|
||||
#modalwrap.open { display: flex; }
|
||||
#modal { width: min(680px, 92vw); max-height: 90vh; overflow-y: auto;
|
||||
background: var(--panel); border: 1px solid var(--line); border-radius: 2px; padding: 14px; }
|
||||
#modal h2 { margin: 0 34px 8px 0; font: 600 12px var(--mono); color: var(--amber); letter-spacing: .06em; word-break: break-all; }
|
||||
#modal .close { float: right; }
|
||||
#modal video, #modal img { width: 100%; max-height: 46vh; object-fit: contain; background: #000;
|
||||
border: 1px solid var(--line); border-radius: 2px; }
|
||||
#modal .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||||
#modal .k { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; min-width: 64px; }
|
||||
#modal .chips { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
|
||||
/* ---- the ask modal: our prompt(), in the house style ---- */
|
||||
#askwrap { display: none; position: fixed; inset: 0; z-index: 60;
|
||||
background: rgba(0,0,0,.65); align-items: center; justify-content: center; }
|
||||
#askwrap.open { display: flex; }
|
||||
#ask { width: min(420px, 90vw); background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 2px; padding: 16px; }
|
||||
#ask h2 { margin: 0 0 12px; font: 600 11.5px var(--mono); color: var(--amber);
|
||||
letter-spacing: .05em; line-height: 1.5; }
|
||||
#ask input { width: 100%; padding: 8px 10px; font-size: 12px; }
|
||||
#ask .row { display: flex; gap: 8px; margin-top: 12px; }
|
||||
#ask .spacer { flex: 1; }
|
||||
#ask button.primary { border-color: var(--amber); color: var(--amber); }
|
||||
</style>
|
||||
<style>
|
||||
/* dark-scheme scrollbars without color-scheme the
|
||||
browser paints LIGHT scrollbars on our dark surfaces */
|
||||
html { color-scheme: dark; }
|
||||
* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.22) transparent; }
|
||||
::-webkit-scrollbar { width: 9px; height: 9px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.18); border-radius: 4px; border: 2px solid transparent; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,.32); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Media Manager</h1>
|
||||
<span class="sub" id="projectName">loading...</span>
|
||||
</header>
|
||||
<div class="tools">
|
||||
<input id="search" type="search" placeholder="filter by name, note, tag, path">
|
||||
<button id="viewLocked" aria-pressed="true" title="each cut collapsed to its locked version (locked versions)">Locked</button>
|
||||
<button id="viewAll" aria-pressed="false" title="every version on disk">All versions</button>
|
||||
<button id="refresh" title="Re-list">↻</button>
|
||||
<span class="sub" id="count"></span>
|
||||
</div>
|
||||
<div id="ctxbar"></div>
|
||||
<div class="body">
|
||||
<nav id="rail"></nav>
|
||||
<div id="main"><div class="grid" id="grid"><div class="empty">Loading...</div></div></div>
|
||||
</div>
|
||||
<div id="status">Ready.</div>
|
||||
|
||||
<div id="modalwrap"><div id="modal"></div></div>
|
||||
|
||||
<div id="askwrap"><div id="ask">
|
||||
<h2 id="askTitle"></h2>
|
||||
<input type="text" id="askInput">
|
||||
<div class="row"><span class="spacer"></span>
|
||||
<button id="askCancel">cancel</button><button id="askOk" class="primary">OK</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (t) => String(t ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const VER_LABEL = { original: "ORIGINAL", light: "LIGHT", medium: "MEDIUM",
|
||||
strong: "STRONG", locked: "TRIPOD", smoothed: "SMOOTHED" };
|
||||
const GROUPS = [
|
||||
{ id: "all", name: "All", g: "◎" },
|
||||
{ id: "video", name: "Video", g: "▤" },
|
||||
{ id: "audio", name: "Audio", g: "♪" },
|
||||
{ id: "image", name: "Image", g: "▣" },
|
||||
{ id: "locked", name: "Locked", g: "◉" },
|
||||
{ id: "exports", name: "Exports", g: "⇥" },
|
||||
{ id: "library", name: "Library", g: "☁" },
|
||||
];
|
||||
const state = { view: "locked", group: "all", tag: null,
|
||||
assets: [], libAssets: null, exportAssets: null, curation: {}, tags: {}, byAsset: {}, sel: new Set() };
|
||||
|
||||
// our prompt(): house-styled, promise-shaped. resolve(null) on cancel.
|
||||
function ask(title, placeholder = "", value = "") {
|
||||
return new Promise((resolve) => {
|
||||
$("askTitle").textContent = title;
|
||||
const inp = $("askInput");
|
||||
inp.placeholder = placeholder; inp.value = value;
|
||||
$("askwrap").classList.add("open"); inp.focus();
|
||||
const done = (v) => { $("askwrap").classList.remove("open");
|
||||
inp.onkeydown = $("askOk").onclick = $("askCancel").onclick = null; resolve(v); };
|
||||
$("askOk").onclick = () => done(inp.value.trim());
|
||||
$("askCancel").onclick = () => done(null);
|
||||
inp.onkeydown = (e) => {
|
||||
if (e.key === "Enter") done(inp.value.trim());
|
||||
if (e.key === "Escape") { e.stopPropagation(); done(null); }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function api(pathname, opts) {
|
||||
const res = await fetch(pathname, opts);
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(payload.error || `HTTP ${res.status}`);
|
||||
return payload;
|
||||
}
|
||||
const mediaUrl = (a) => a.lib ? `/api/library/media?path=${encodeURIComponent(a.path)}` : `/api/media?path=${encodeURIComponent(a.path)}`;
|
||||
|
||||
async function loadCuration() {
|
||||
state.curation = {};
|
||||
try {
|
||||
const r = await api("/api/curation");
|
||||
for (const c of r.cuts || []) {
|
||||
const pick = c.pick || "original";
|
||||
for (const v of c.versions || []) {
|
||||
if (!v.file) continue;
|
||||
state.curation[v.file] = { label: VER_LABEL[v.key] || v.key.toUpperCase(),
|
||||
locked: v.key === pick, note: c.note || "", cut: c.file };
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
async function loadTags() {
|
||||
try { const r = await api("/api/tags"); state.tags = r.tags || {}; state.byAsset = r.byAsset || {}; }
|
||||
catch { state.tags = {}; state.byAsset = {}; }
|
||||
}
|
||||
|
||||
function mapAssets(payload) {
|
||||
return (payload.assets || [])
|
||||
.filter((a) => a.exists !== false && ["video", "image", "audio"].includes(a.media_type))
|
||||
.map((a) => ({ name: a.ref.split("/").pop(), path: a.path, kind: a.media_type,
|
||||
title: a.title || a.ref, role: a.role, slot_refs: a.slot_refs || [], meta: null,
|
||||
cur: state.curation[a.ref.split("/").pop()] || null }));
|
||||
}
|
||||
const dataSig = () => JSON.stringify([state.tags, state.byAsset,
|
||||
Object.keys(state.curation).length, state.assets.map((a) => a.name + (a.cur?.locked ? "L" : "") + (a.slot_refs || []).join(""))]);
|
||||
let pollSig = "";
|
||||
|
||||
async function load() {
|
||||
$("grid").innerHTML = '<div class="empty">Loading...</div>';
|
||||
try {
|
||||
await Promise.all([loadCuration(), loadTags()]);
|
||||
state.assets = mapAssets(await api("/api/project-assets"));
|
||||
pollSig = dataSig();
|
||||
render();
|
||||
} catch (e) { $("grid").innerHTML = `<div class="empty">${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
// live refresh: agents tag/lock/route things from the sidebar — the grid
|
||||
// must follow without a manual reload. Re-render ONLY when the data
|
||||
// actually changed, and never under an open modal/menu (or mid-hover
|
||||
// would also be rude, but hover previews survive a no-op cycle).
|
||||
setInterval(async () => {
|
||||
if ($("modalwrap").classList.contains("open") || document.querySelector(".drop.open")) return;
|
||||
try {
|
||||
await Promise.all([loadCuration(), loadTags()]);
|
||||
state.assets = mapAssets(await api("/api/project-assets"));
|
||||
const sig = dataSig();
|
||||
if (sig !== pollSig) { pollSig = sig; render(); $("status").textContent = "Updated."; }
|
||||
} catch {}
|
||||
}, 7000);
|
||||
async function loadLibrary() {
|
||||
try {
|
||||
const payload = await api("/api/library");
|
||||
state.libAssets = (payload.assets || []).map((a) => ({ ...a, lib: true, title: a.name, cur: null, slot_refs: [] }));
|
||||
} catch { state.libAssets = []; }
|
||||
}
|
||||
async function loadExports() {
|
||||
// the exports shelf: agents render, humans watch here — never hunt disk
|
||||
try {
|
||||
const payload = await api("/api/exports");
|
||||
state.exportAssets = (payload.exports || []).map((e) => ({ name: e.name, path: e.path,
|
||||
kind: "video", title: e.rel, role: `${(e.size / 1e6).toFixed(0)}MB · ${e.mtime.slice(0, 16).replace("T", " ")}`,
|
||||
cur: null, slot_refs: [], meta: null }));
|
||||
} catch { state.exportAssets = []; }
|
||||
}
|
||||
|
||||
function pool() {
|
||||
if (state.group === "library") return state.libAssets || [];
|
||||
if (state.group === "exports") return state.exportAssets || [];
|
||||
return state.assets;
|
||||
}
|
||||
|
||||
function visible() {
|
||||
const q = $("search").value.toLowerCase();
|
||||
return pool().filter((a) => {
|
||||
if (state.group !== "library" && state.group !== "exports") {
|
||||
if (state.view === "locked" && a.cur && !a.cur.locked) return false;
|
||||
if (state.group === "locked" && !(a.cur && a.cur.locked)) return false;
|
||||
if (["video", "audio", "image"].includes(state.group) && a.kind !== state.group) return false;
|
||||
if (state.tag && !(state.byAsset[a.name] || []).includes(state.tag)) return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
return [a.name, a.path, a.title, a.role, a.cur?.note, a.cur?.label,
|
||||
...(state.byAsset[a.name] || []), ...(a.slot_refs || [])].join(" ").toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- rail ---- */
|
||||
function renderRail() {
|
||||
const rail = $("rail"); rail.innerHTML = "";
|
||||
const h1 = document.createElement("h3"); h1.textContent = "LIBRARY"; rail.appendChild(h1);
|
||||
for (const g of GROUPS) {
|
||||
const counts = { all: state.assets.length,
|
||||
video: state.assets.filter((a) => a.kind === "video").length,
|
||||
audio: state.assets.filter((a) => a.kind === "audio").length,
|
||||
image: state.assets.filter((a) => a.kind === "image").length,
|
||||
locked: state.assets.filter((a) => a.cur && a.cur.locked).length,
|
||||
exports: state.exportAssets ? state.exportAssets.length : "",
|
||||
library: state.libAssets ? state.libAssets.length : "" };
|
||||
const d = document.createElement("div");
|
||||
d.className = "railrow" + (state.group === g.id && !state.tag ? " on" : "");
|
||||
d.innerHTML = `<span class="g">${g.g}</span><span class="n">${g.name}</span><span class="c">${counts[g.id]}</span>`;
|
||||
d.onclick = async () => {
|
||||
state.tag = null; state.group = g.id;
|
||||
if (g.id === "library" && state.libAssets == null) { await loadLibrary(); }
|
||||
if (g.id === "exports") { await loadExports(); }
|
||||
render();
|
||||
};
|
||||
rail.appendChild(d);
|
||||
}
|
||||
const h2 = document.createElement("h3");
|
||||
h2.innerHTML = `TAGS <button class="add" title="new tag (or just tell the agent)">+</button>`;
|
||||
h2.querySelector(".add").onclick = async () => {
|
||||
const t = await ask("New tag name (tip: tell the agent 'ceremony is scene 2' to bind it to the storyboard)", "ceremony");
|
||||
if (t) postTags([{ op: "add", tag: t, asset: "__placeholder__" }])
|
||||
.then(() => postTags([{ op: "remove", tag: t, asset: "__placeholder__" }]))
|
||||
.then(refreshTags);
|
||||
};
|
||||
rail.appendChild(h2);
|
||||
const names = Object.keys(state.tags).sort();
|
||||
if (!names.length) {
|
||||
const d = document.createElement("div"); d.className = "railrow";
|
||||
d.innerHTML = `<span class="n" style="color:var(--dim)">none yet — select tiles → TAG, or ask the agent</span>`;
|
||||
rail.appendChild(d);
|
||||
}
|
||||
for (const t of names) {
|
||||
const v = state.tags[t];
|
||||
const d = document.createElement("div");
|
||||
d.className = "railrow" + (state.tag === t ? " on" : "");
|
||||
d.innerHTML = `<span class="g">▸</span><span class="n">${esc(t)}</span>` +
|
||||
(v.spine ? `<span class="sp" title="bound to storyboard scene ${esc(v.spine)}">→${esc(v.spine)}</span>` : "") +
|
||||
`<span class="c">${v.count}</span>`;
|
||||
d.onclick = () => { state.tag = state.tag === t ? null : t; state.group = "all"; render(); };
|
||||
rail.appendChild(d);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- the contextual verb bar: selection wins, else the active tag ---- */
|
||||
function renderCtx() {
|
||||
const bar = $("ctxbar");
|
||||
const selN = state.sel.size;
|
||||
const tagActive = state.tag && state.tags[state.tag];
|
||||
bar.classList.toggle("on", selN > 0 || !!tagActive);
|
||||
if (!selN && !tagActive) { bar.innerHTML = ""; return; }
|
||||
const ctxAssets = selN ? [...state.sel]
|
||||
: visible().map((a) => a.path);
|
||||
const label = selN ? `▸ ${selN} selected`
|
||||
: `▸ ${esc(state.tag)} · ${ctxAssets.length} item(s)${state.tags[state.tag].spine ? ` · bound → scene ${esc(state.tags[state.tag].spine)}` : ""}`;
|
||||
bar.innerHTML = `<span class="n">${label}</span>
|
||||
<span class="tagmenu"><button id="tagBtn">TAG ▾</button><span class="drop" id="tagDrop"></span></span>
|
||||
<button id="pinBtn" title="save this as a named, durable selection and hand it to the agent">PIN → AGENT</button>
|
||||
<button id="dlM" title="download full-res masters (big files)">⇓ MASTERS</button>
|
||||
<button id="dlP" title="download lightweight previews">⇓ PREVIEWS</button>
|
||||
<span class="spacer"></span>` +
|
||||
(selN ? `<button id="clearSel">✕ clear</button>` : "");
|
||||
// TAG ▾ dropdown: existing tags + new
|
||||
const drop = $("tagDrop");
|
||||
for (const t of Object.keys(state.tags).sort()) {
|
||||
const b = document.createElement("button"); b.textContent = t;
|
||||
b.onclick = () => applyTag(t, ctxAssets);
|
||||
drop.appendChild(b);
|
||||
}
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "text"; inp.placeholder = "new tag… ⏎";
|
||||
inp.onkeydown = (e) => { if (e.key === "Enter" && inp.value.trim()) applyTag(inp.value.trim(), ctxAssets); };
|
||||
drop.appendChild(inp);
|
||||
$("tagBtn").onclick = (e) => { e.stopPropagation(); drop.classList.toggle("open"); if (drop.classList.contains("open")) inp.focus(); };
|
||||
$("pinBtn").onclick = async () => {
|
||||
const name = await ask("Name this selection (optional — e.g. 'ceremony picks')", "ceremony picks");
|
||||
if (name === null) return;
|
||||
await api("/api/selections", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name, assets: ctxAssets, surface: "media-manager" }) });
|
||||
try { parent.postMessage({ t: "open-agent" }, "*"); } catch {}
|
||||
$("status").textContent = `Pinned ${ctxAssets.length} item(s)${name ? ` as "${name}"` : ""} — tell the agent what to do with them.`;
|
||||
};
|
||||
$("dlM").onclick = () => bulkDownload(ctxAssets, false);
|
||||
$("dlP").onclick = () => bulkDownload(ctxAssets, true);
|
||||
if (selN) $("clearSel").onclick = () => { state.sel.clear(); render(); reportFocus(); };
|
||||
}
|
||||
addEventListener("click", () => document.querySelectorAll(".drop.open").forEach((d) => d.classList.remove("open")));
|
||||
|
||||
async function postTags(ops) {
|
||||
return api("/api/tags", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ops }) });
|
||||
}
|
||||
async function refreshTags() { await loadTags(); render(); }
|
||||
async function applyTag(tag, paths) {
|
||||
await postTags(paths.map((p) => ({ op: "add", tag, asset: p })));
|
||||
$("status").textContent = `Tagged ${paths.length} item(s) → ${tag}.`;
|
||||
state.sel.clear();
|
||||
refreshTags();
|
||||
}
|
||||
|
||||
function bulkDownload(paths, preview) {
|
||||
let i = 0;
|
||||
const next = () => {
|
||||
if (i >= paths.length) { $("status").textContent = `Triggered ${paths.length} download(s).`; return; }
|
||||
let p = paths[i++];
|
||||
if (preview) p = p.replace(/\/renders\//, "/renders/previews/");
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/media?path=${encodeURIComponent(p)}&dl=1`;
|
||||
a.download = ""; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(next, 600);
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
/* ---- focus: ANY selection is focus ---- */
|
||||
let focusTimer = null;
|
||||
function reportFocus() {
|
||||
clearTimeout(focusTimer);
|
||||
focusTimer = setTimeout(() => {
|
||||
fetch("/api/focus", { method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ surface: "media-manager", view: state.view + (state.tag ? `/tag:${state.tag}` : ""),
|
||||
selection: [...state.sel] }) }).catch(() => {});
|
||||
}, 700);
|
||||
}
|
||||
|
||||
/* ---- grid ---- */
|
||||
function render() {
|
||||
renderRail(); renderCtx();
|
||||
const items = visible();
|
||||
$("count").textContent = `${items.length} asset(s)`;
|
||||
$("grid").innerHTML = items.length ? "" :
|
||||
'<div class="empty">Nothing here. Select tiles → TAG to organize, or ask the agent to.</div>';
|
||||
for (const a of items) {
|
||||
const tile = document.createElement("div");
|
||||
const inSel = state.sel.has(a.path);
|
||||
tile.className = "tile" + (inSel ? " sel" : "");
|
||||
const media = a.kind === "video"
|
||||
? `<video src="${esc(mediaUrl(a))}" preload="metadata" muted onmouseover="this.play().catch(()=>{})" onmouseout="this.pause();this.currentTime=0"></video>`
|
||||
: a.kind === "image" ? `<img loading="lazy" src="${esc(mediaUrl(a))}" alt="">`
|
||||
: `<div class="thumb">♪</div>`;
|
||||
const curB = a.cur ? `<span class="badge ver">${esc(a.cur.label)}</span>${a.cur.locked ? '<span class="badge lk" title="the locked version">◉</span>' : ""}` : "";
|
||||
const tagB = (state.byAsset[a.name] || []).map((t) => `<span class="badge tag" data-t="${esc(t)}">${esc(t)}</span>`).join("");
|
||||
tile.innerHTML = `<span class="selmark">${inSel ? "✓" : ""}</span>${media}
|
||||
<div class="cap"><b>${esc(a.cur?.note || a.title || a.name)}</b>${esc(a.kind)}${a.role ? " - " + esc(a.role) : ""}</div>
|
||||
<div class="badges">${curB}${tagB}${(a.slot_refs || []).map((s) => `<span class="badge">${esc(s)}</span>`).join("")}</div>`;
|
||||
tile.querySelectorAll(".badge.tag").forEach((b) => b.onclick = (e) => {
|
||||
e.stopPropagation(); state.tag = b.dataset.t; state.group = "all"; render();
|
||||
});
|
||||
tile.addEventListener("click", () => {
|
||||
if (state.sel.has(a.path)) state.sel.delete(a.path); else state.sel.add(a.path);
|
||||
render(); reportFocus();
|
||||
});
|
||||
tile.addEventListener("dblclick", () => openModal(a));
|
||||
$("grid").appendChild(tile);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- modal player ---- */
|
||||
function openModal(a) {
|
||||
const m = $("modal");
|
||||
const preview = a.kind === "video" ? `<video src="${esc(mediaUrl(a))}" controls autoplay preload="metadata"></video>`
|
||||
: a.kind === "image" ? `<img src="${esc(mediaUrl(a))}" alt="">` : `<div class="thumb" style="height:80px">♪ audio</div>`;
|
||||
const tags = state.byAsset[a.name] || [];
|
||||
m.innerHTML = `<button class="close" id="mClose">✕</button>
|
||||
<h2>${esc(a.cur?.note || a.title || a.name)}</h2>
|
||||
${preview}
|
||||
${a.cur ? `<div class="row"><span class="k">version</span><span class="badge ver">${esc(a.cur.label)}</span>${a.cur.locked ? '<span class="badge lk">◉ locked</span>' : ""}</div>` : ""}
|
||||
<div class="row"><span class="k">tags</span><span class="chips" id="mChips"></span></div>
|
||||
<div class="row"><span class="k">file</span><span style="font-size:10.5px;color:var(--dim);word-break:break-all">${esc(a.path)}</span></div>
|
||||
<div class="row">
|
||||
<button id="mDlM">⇓ master</button><button id="mDlP">⇓ preview</button>
|
||||
<button id="mCopy">copy path</button><button id="mSlot">send to slot…</button>
|
||||
</div>`;
|
||||
const chips = m.querySelector("#mChips");
|
||||
for (const t of tags) {
|
||||
const c = document.createElement("span");
|
||||
c.className = "badge tag"; c.innerHTML = `${esc(t)} ✕`;
|
||||
c.title = "remove this tag";
|
||||
c.onclick = async () => { await postTags([{ op: "remove", tag: t, asset: a.path }]); await loadTags(); openModal(a); render(); };
|
||||
chips.appendChild(c);
|
||||
}
|
||||
const addT = document.createElement("input");
|
||||
addT.type = "text"; addT.placeholder = "+ tag ⏎"; addT.style.width = "90px";
|
||||
addT.onkeydown = async (e) => {
|
||||
if (e.key === "Enter" && addT.value.trim()) {
|
||||
await postTags([{ op: "add", tag: addT.value.trim(), asset: a.path }]);
|
||||
await loadTags(); openModal(a); render();
|
||||
}
|
||||
};
|
||||
chips.appendChild(addT);
|
||||
m.querySelector("#mClose").onclick = closeModal;
|
||||
m.querySelector("#mDlM").onclick = () => location.href = `/api/media?path=${encodeURIComponent(a.path)}&dl=1`;
|
||||
m.querySelector("#mDlP").onclick = () => location.href = `/api/media?path=${encodeURIComponent(a.path.replace(/\/renders\//, "/renders/previews/"))}&dl=1`;
|
||||
m.querySelector("#mCopy").onclick = () => { navigator.clipboard.writeText(a.path); $("status").textContent = "Path copied."; };
|
||||
m.querySelector("#mSlot").onclick = async () => {
|
||||
const slot = await ask("Target slot (dot id)", "2.1");
|
||||
if (!slot) return;
|
||||
try {
|
||||
const r = await api("/api/library/route", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ path: a.path, slot }) });
|
||||
$("status").textContent = `Routed → ${r.dest} (take candidate for ${slot}).`;
|
||||
} catch (e2) { $("status").textContent = e2.message; }
|
||||
};
|
||||
$("modalwrap").classList.add("open");
|
||||
}
|
||||
function closeModal() {
|
||||
const v = $("modal").querySelector("video"); if (v) v.pause();
|
||||
$("modalwrap").classList.remove("open");
|
||||
}
|
||||
$("modalwrap").addEventListener("click", (e) => { if (e.target === $("modalwrap")) closeModal(); });
|
||||
addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); });
|
||||
|
||||
/* ---- toolbar ---- */
|
||||
$("viewLocked").onclick = () => { state.view = "locked"; syncView(); render(); };
|
||||
$("viewAll").onclick = () => { state.view = "all"; syncView(); render(); };
|
||||
function syncView() {
|
||||
$("viewLocked").setAttribute("aria-pressed", String(state.view === "locked"));
|
||||
$("viewAll").setAttribute("aria-pressed", String(state.view === "all"));
|
||||
}
|
||||
$("search").addEventListener("input", render);
|
||||
$("refresh").addEventListener("click", load);
|
||||
fetch("/api/patch").then((r) => r.json()).then((p) => {
|
||||
$("projectName").textContent = (p.timelinePath || "").split("/").slice(-2).join("/");
|
||||
}).catch(() => { $("projectName").textContent = "(server offline?)"; });
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "mini-nle",
|
||||
"name": "MINI-NLE",
|
||||
"summary": "Cut and arrange a timeline against a storyboard. The storyboard is the spine \u2014 scripted or unscripted.",
|
||||
"category": "create",
|
||||
"maker": {
|
||||
"name": "OpenRig",
|
||||
"url": "https://github.com/mvschwarz"
|
||||
},
|
||||
"surface": {
|
||||
"entry": "app/mini-nle.html",
|
||||
"path": "/surfaces/mini-nle.html",
|
||||
"glyph": "\u29c9",
|
||||
"hint": "cut and arrange the timeline"
|
||||
},
|
||||
"provider": {
|
||||
"package": "@openrig/studio-video",
|
||||
"required": true,
|
||||
"_note": "This app is a CLIENT of that provider. Installing the surface alone gets you an HTML file that 502s \u2014 the field rigs-to-apps v1 had no slot for, which is why media-manager was listed there and was not installable.",
|
||||
"run": {
|
||||
"entry": "export-server.mjs",
|
||||
"args": [
|
||||
"--port",
|
||||
"{{port}}",
|
||||
"--slice-root",
|
||||
"{{root:project}}",
|
||||
"--canvas-root",
|
||||
"{{root:canvas}}",
|
||||
"--media-root",
|
||||
"{{root:media}}"
|
||||
],
|
||||
"env": {
|
||||
"CUTDOWN_API": "http://127.0.0.1:{{port:@openrig/studio-cutdown}}"
|
||||
},
|
||||
"_note": "Placeholders the box resolves: {{port}} = the port assigned to THIS provider, {{root:<kind>}} = the binding for that root kind (an array repeats its preceding flag), {{port:<pkg>}} = another provider's assigned port, which is how inter-provider wiring stops being a hardcoded default."
|
||||
},
|
||||
"supplies": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"_supplies_note": "Binaries this provider SHIPS (ffmpeg-static/ffprobe-static), so the host does not need them. requires.binaries still declares the NEED \u2014 that stays legible and true; this says who satisfies it. Without the distinction the installer refuses a working install on any box without a system ffmpeg, which is most of them."
|
||||
},
|
||||
"roots": {
|
||||
"project": {
|
||||
"required": true
|
||||
},
|
||||
"media": {
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"verbs": [
|
||||
"/api/timelines",
|
||||
"/api/timeline/select",
|
||||
"/api/patch",
|
||||
"/api/patch/validate",
|
||||
"/api/patch/apply",
|
||||
"/api/patch/discard",
|
||||
"/api/slot-history",
|
||||
"/api/history-since",
|
||||
"/api/project-assets",
|
||||
"/api/probe-duration",
|
||||
"/api/annotation",
|
||||
"/api/focus"
|
||||
],
|
||||
"vendor": [],
|
||||
"requires": {
|
||||
"binaries": [
|
||||
"ffmpeg",
|
||||
"ffprobe"
|
||||
],
|
||||
"ffmpeg_filters": [
|
||||
"scale",
|
||||
"concat"
|
||||
],
|
||||
"_filters_note": "REQUIRED vs PREFERRED, and the split is the point. scale/concat are load-bearing: no cut renders without them. drawtext only writes the LABEL on a placeholder slate, and the assembler now renders an unlabelled slate and reports the degradation instead of failing the export. So drawtext must NOT block an install \u2014 the Linux ffmpeg-static build (7.0.2) lacks it while the macOS one (6.0) has it, and losing a caption on an unshot beat is not worth refusing a working studio.",
|
||||
"ffmpeg_filters_preferred": [
|
||||
"drawtext"
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,23 @@
|
||||
// WHERE THIS PROVIDER'S BINARIES COME FROM.
|
||||
//
|
||||
// cut-clips and stabilize-clip called execFileSync("ffmpeg", …) with a BARE
|
||||
// NAME, which resolves against whatever PATH happens to hold. On a box without
|
||||
// ffmpeg that is an ENOENT at the moment a user punches a cut — invisible at
|
||||
// install, fatal in use. a fresh Linux VPS typically has node and git and NO ffmpeg.
|
||||
//
|
||||
// ffmpeg/ffprobe now ship WITH this provider (ffmpeg-static / ffprobe-static),
|
||||
// so `npm ci` is sufficient on any of the three platforms they build for.
|
||||
// PATH stays as the fallback: a box with a real ffmpeg — extra codecs, hardware
|
||||
// encoders — should be able to use it, and an operator override stays possible.
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const packaged = (name) => {
|
||||
try {
|
||||
return name === "ffmpeg" ? require("ffmpeg-static") : require("ffprobe-static").path;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
export const FFMPEG = packaged("ffmpeg") || "ffmpeg";
|
||||
export const FFPROBE = packaged("ffprobe") || "ffprobe";
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
// The cut lane: consumes markers.jsonl, cuts each marked range FROM THE
|
||||
// ORIGINALS at full quality (frame-accurate re-encode CRF 18), lands clips
|
||||
// in the project bundle's media/renders as take material, and reindexes.
|
||||
// Idempotent + self-healing: safe to run every watcher sweep.
|
||||
// - each marker is isolated: one ffmpeg failure never blocks the rest
|
||||
// - encodes are atomic: temp name -> rename, so partial files never count
|
||||
// as done and a delete during encode can't corrupt the lane
|
||||
// - a cut deleted via the UI (cut-deletions.jsonl) RETIRES its marker:
|
||||
// the lane will not resurrect it (re-punching the range works — only
|
||||
// deletions newer than the marker retire it)
|
||||
// - persistent failures leave a .cutfail.json sidecar (max 3 attempts)
|
||||
// that the server surfaces as FAILED instead of eternal CUTTING
|
||||
// Usage: node cut-clips.mjs [--footage dir] [--bundle dir]
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { FFMPEG, FFPROBE } from "./bin.mjs";
|
||||
|
||||
const arg = (k, d) => { const i = process.argv.indexOf("--" + k); return i > -1 ? process.argv[i + 1] : d; };
|
||||
// DE-BESPOKED (rig-studio port): --footage is the input folder, any project.
|
||||
const FOOTAGE = arg("footage", path.join(os.homedir(), "openrig-videos", "footage"));
|
||||
// was: a single hardcoded project. Clips land beside their footage by default.
|
||||
const BUNDLE = arg("bundle", FOOTAGE);
|
||||
const OUT = path.join(BUNDLE, "media", "renders");
|
||||
const MAX_ATTEMPTS = 3;
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const readJsonl = (p) => fs.existsSync(p)
|
||||
? fs.readFileSync(p, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l))
|
||||
: [];
|
||||
|
||||
const markersPath = path.join(FOOTAGE, "markers.jsonl");
|
||||
if (!fs.existsSync(markersPath)) { console.log("no markers yet"); process.exit(0); }
|
||||
const all = readJsonl(markersPath);
|
||||
const deleted = new Set(all.filter((m) => m.deleted).map((m) => m.ref));
|
||||
const marks = all.filter((m) => !m.deleted && !deleted.has(m.id) && m.out_s != null);
|
||||
// note_edit records overlay the referenced marker's note
|
||||
const noteEdits = {};
|
||||
for (const m of all) if (m.note_edit && m.ref) noteEdits[m.ref] = m.note;
|
||||
|
||||
// UI cut-deletions retire markers older than the deletion (see header)
|
||||
const cutDeletions = readJsonl(path.join(FOOTAGE, "cut-deletions.jsonl"));
|
||||
const latestDeletion = {};
|
||||
for (const d of cutDeletions) {
|
||||
if (!latestDeletion[d.clip] || d.ts > latestDeletion[d.clip]) latestDeletion[d.clip] = d.ts;
|
||||
}
|
||||
|
||||
const tagOf = (m) => {
|
||||
const base = m.clip.replace(/\.(mp4|MP4)$/, "");
|
||||
return `${base}-${Number(m.in_s).toFixed(1)}-${Number(m.out_s).toFixed(1)}`.replace(/\./g, "_");
|
||||
};
|
||||
|
||||
let made = 0, skipped = 0, retired = 0, failed = 0;
|
||||
for (const m of marks) {
|
||||
const base = m.clip.replace(/\.(mp4|MP4)$/, "");
|
||||
const tag = tagOf(m);
|
||||
const outName = `cut-${tag}.mp4`;
|
||||
const out = path.join(OUT, outName);
|
||||
const failSide = path.join(OUT, `cut-${tag}.cutfail.json`);
|
||||
|
||||
if (fs.existsSync(out)) { skipped++; continue; }
|
||||
const delTs = latestDeletion[outName];
|
||||
if (delTs && delTs > (m.ts || "")) { retired++; continue; }
|
||||
|
||||
let prevFail = null;
|
||||
try { prevFail = JSON.parse(fs.readFileSync(failSide, "utf8")); } catch {}
|
||||
if (prevFail && prevFail.attempts >= MAX_ATTEMPTS) { failed++; continue; }
|
||||
|
||||
// DE-BESPOKED: this looked ONLY in <footage>/originals, which assumed the
|
||||
// proxies/originals split the original project used. Still preferred when it
|
||||
// exists — cutting from the full-res original rather than a review proxy is
|
||||
// the point — but a plain folder of video now works, which is what "point it
|
||||
// at a folder and cut it down" means.
|
||||
// Case may differ between proxy and original (CLIP01.MP4 vs clip01.mp4).
|
||||
const exts = [".MP4", ".mp4", ".MOV", ".mov", ".webm"];
|
||||
const cand = [
|
||||
...exts.map((e) => path.join(FOOTAGE, "originals", base + e)),
|
||||
...exts.map((e) => path.join(FOOTAGE, base + e)),
|
||||
path.join(FOOTAGE, m.clip),
|
||||
];
|
||||
const src = cand.find((p) => fs.existsSync(p));
|
||||
if (!src) { console.log(`SKIP ${m.id}: source for ${m.clip} not found`); continue; }
|
||||
|
||||
const tmp = path.join(OUT, `.tmp-${outName}`);
|
||||
try {
|
||||
execFileSync(FFMPEG, ["-hide_banner", "-loglevel", "error", "-y",
|
||||
"-ss", String(m.in_s), "-to", String(m.out_s), "-i", src,
|
||||
"-c:v", "libx264", "-crf", "18", "-preset", "medium",
|
||||
"-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", tmp],
|
||||
{ stdio: ["ignore", "inherit", "pipe"] });
|
||||
fs.renameSync(tmp, out);
|
||||
fs.rmSync(failSide, { force: true });
|
||||
} catch (e) {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
const attempts = (prevFail?.attempts || 0) + 1;
|
||||
fs.writeFileSync(failSide, JSON.stringify({
|
||||
marker: m.id, clip: m.clip, in_s: m.in_s, out_s: m.out_s, by: m.by,
|
||||
attempts, last_error: String(e.stderr || e.message || e).slice(0, 500),
|
||||
ts: new Date().toISOString(),
|
||||
}, null, 1));
|
||||
failed++;
|
||||
console.log(`FAIL ${outName} (attempt ${attempts}/${MAX_ATTEMPTS}): ${String(e.message || e).slice(0, 200)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// preview proxy for browser playback (full file stays for the NLE);
|
||||
// preview failure is non-fatal — /cutprev/ falls back to the full file
|
||||
try {
|
||||
const prevDir = path.join(OUT, "previews");
|
||||
fs.mkdirSync(prevDir, { recursive: true });
|
||||
const ptmp = path.join(prevDir, `.tmp-${outName}`);
|
||||
execFileSync(FFMPEG, ["-hide_banner", "-loglevel", "error", "-y", "-i", out,
|
||||
"-vf", "scale=960:-2", "-c:v", "libx264", "-preset", "veryfast", "-crf", "28",
|
||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", ptmp], { stdio: "inherit" });
|
||||
fs.renameSync(ptmp, path.join(prevDir, outName));
|
||||
} catch (e) {
|
||||
console.log(`preview failed for ${outName} (non-fatal): ${String(e.message || e).slice(0, 120)}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(out.replace(/\.mp4$/, ".marker.json"),
|
||||
JSON.stringify({ marker: m.id, by: m.by, note: noteEdits[m.id] != null ? noteEdits[m.id] : m.note, src: path.basename(src), in_s: m.in_s, out_s: m.out_s, cut_at: new Date().toISOString() }, null, 1));
|
||||
made++;
|
||||
console.log(`cut ${outName} (${m.by}${m.note ? " — " + m.note : ""})`);
|
||||
}
|
||||
console.log(`done: ${made} cut, ${skipped} already existed, ${retired} retired by deletion, ${failed} failed, ${marks.length} markers total`);
|
||||
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env node
|
||||
// CUTDOWN server — the punch-in/out review surface's backend.
|
||||
// Serves proxies with Range support, persists attributed markers (JSONL,
|
||||
// durable-first), lists clips. Attribution: Cf-Access-Authenticated-User-Email
|
||||
// (the Access gateway injects it) -> "<user>-ui"; falls back to "local-ui".
|
||||
// Usage: node cutdown-server.mjs [--port 8795] [--footage ~/studio/footage]
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const arg = (k, d) => { const i = process.argv.indexOf("--" + k); return i > -1 ? process.argv[i + 1] : d; };
|
||||
const PORT = Number(arg("port", 8795));
|
||||
// DE-BESPOKED (rig-studio port): point this at ANY folder of video files.
|
||||
// --footage is the input; everything else derives from it so the tool is
|
||||
// self-contained on any project.
|
||||
const FOOTAGE = arg("footage", path.join(os.homedir(), "openrig-videos", "footage")).replace(/^~/, os.homedir());
|
||||
const PROXIES = path.join(FOOTAGE, "proxies");
|
||||
const MARKERS = path.join(FOOTAGE, "markers.jsonl");
|
||||
// was: a single hardcoded project — the single project this was built for.
|
||||
// Renders now land beside the footage being cut unless a bundle is named.
|
||||
const BUNDLE = arg("bundle", FOOTAGE);
|
||||
const RENDERS = path.join(BUNDLE, "media", "renders");
|
||||
const STABJOBS = path.join(FOOTAGE, "stab-jobs.jsonl");
|
||||
const PICKS = path.join(FOOTAGE, "picks.jsonl");
|
||||
|
||||
const send = (res, code, body, type = "application/json") => {
|
||||
res.writeHead(code, { "content-type": type, "cache-control": "no-store" });
|
||||
res.end(type === "application/json" ? JSON.stringify(body) : body);
|
||||
};
|
||||
|
||||
function attribution(req) {
|
||||
const email = req.headers["cf-access-authenticated-user-email"];
|
||||
return email ? `${String(email).split("@")[0].replace(/[^a-z0-9.]/gi, "")}-ui` : "local-ui";
|
||||
}
|
||||
|
||||
function readMarkers() {
|
||||
if (!fs.existsSync(MARKERS)) return [];
|
||||
return fs.readFileSync(MARKERS, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const u = new URL(req.url, "http://x");
|
||||
const p = u.pathname;
|
||||
|
||||
// THE PROVIDER NO LONGER SERVES THE SURFACE, AND MUST NOT CRASH SAYING SO.
|
||||
//
|
||||
// In studio-box this file sat next to cutdown.html and served it. After the
|
||||
// split the surface lives in the APP (apps/cutdown/app/cutdown.html) and the
|
||||
// studio shell serves it; this path was left reaching for a sibling that is
|
||||
// no longer there. A synchronous readFileSync on a missing file inside the
|
||||
// request handler threw an UNCAUGHT ENOENT, which killed the listener and
|
||||
// exited the process — so ONE GET to the provider's own landing route took
|
||||
// the whole cutdown backend down. Reproduced twice.
|
||||
//
|
||||
// Answering honestly is the fix; a try/catch that returned 500 would leave a
|
||||
// route pretending it still serves an app it does not own.
|
||||
if (p === "/" || p === "/cutdown.html") {
|
||||
return send(res, 200, {
|
||||
ok: true,
|
||||
provider: "@openrig/studio-cutdown",
|
||||
note: "This is the cutdown PROVIDER, not the surface. The studio shell serves the surface; this process serves /api/*, /media/, /cutprev/ and /cuts/.",
|
||||
footage: FOOTAGE,
|
||||
});
|
||||
}
|
||||
|
||||
if (p === "/api/clips") {
|
||||
// DE-BESPOKED: clips used to come ONLY from <footage>/proxies, which
|
||||
// assumed the proxy-generation workflow the original project had. Point
|
||||
// --footage at any folder of video and it works; a proxies/ dir is still
|
||||
// preferred when present, so existing setups behave exactly as before.
|
||||
let names = [];
|
||||
const readDir = (d) => {
|
||||
try { return fs.readdirSync(d).filter((f) => /\.(mp4|MP4|mov|MOV|webm)$/.test(f)).sort(); }
|
||||
catch { return []; }
|
||||
};
|
||||
names = readDir(PROXIES);
|
||||
if (!names.length) names = readDir(FOOTAGE);
|
||||
const marks = readMarkers();
|
||||
const perClip = {};
|
||||
for (const m of marks) if (!m.deleted) perClip[m.clip] = (perClip[m.clip] || 0) + 1;
|
||||
const deleted = new Set(marks.filter((m) => m.deleted).map((m) => m.ref));
|
||||
for (const d of deleted) { const m = marks.find((x) => x.id === d); if (m) perClip[m.clip] = Math.max(0, (perClip[m.clip] || 1) - 1); }
|
||||
return send(res, 200, { clips: names.map((n) => ({ name: n, markers: perClip[n] || 0 })) });
|
||||
}
|
||||
|
||||
if (p === "/api/markers" && req.method === "GET") {
|
||||
const all = readMarkers();
|
||||
const deleted = new Set(all.filter((m) => m.deleted).map((m) => m.ref));
|
||||
// note_edit records overlay the referenced marker's note (append-only rails)
|
||||
const notes = {};
|
||||
for (const m of all) if (m.note_edit && m.ref) notes[m.ref] = m.note;
|
||||
return send(res, 200, { markers: all.filter((m) => !m.deleted && !m.note_edit && !deleted.has(m.id))
|
||||
.map((m) => notes[m.id] != null ? { ...m, note: notes[m.id] } : m) });
|
||||
}
|
||||
|
||||
if (p === "/api/markers" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const m = JSON.parse(body);
|
||||
// guard: a live marker needs a real range; only deletion records skip it
|
||||
if (!m.deleted && (m.in_s == null || m.out_s == null || Number(m.out_s) <= Number(m.in_s))) {
|
||||
return send(res, 400, { ok: false, error: "marker needs in_s < out_s" });
|
||||
}
|
||||
const rec = {
|
||||
id: `mk-${Date.now()}-${Math.floor(Math.random() * 1e4)}`,
|
||||
ts: new Date().toISOString(),
|
||||
by: attribution(req),
|
||||
clip: String(m.clip || ""),
|
||||
in_s: m.in_s == null ? null : Number(m.in_s),
|
||||
out_s: m.out_s == null ? null : Number(m.out_s),
|
||||
note: String(m.note || "").slice(0, 2000),
|
||||
deleted: m.deleted ? true : undefined,
|
||||
ref: m.ref || undefined, // for deletion records: id being deleted
|
||||
};
|
||||
fs.appendFileSync(MARKERS, JSON.stringify(rec) + "\n");
|
||||
send(res, 200, { ok: true, marker: rec });
|
||||
} catch (e) { send(res, 400, { ok: false, error: String(e) }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === "/api/cuts") {
|
||||
// The version model (the curation design language): each cut has
|
||||
// up to N VERSIONS living side by side on disk — ORIGINAL plus one per
|
||||
// smoothing level (-stab-<level>.mp4; bare -stab.mp4 = legacy single-
|
||||
// version era). Three separate concepts the UI renders per cut:
|
||||
// which versions EXIST, which one is WATCHED, which one is LOCKED (the
|
||||
// curation pick — picks.jsonl, latest wins).
|
||||
let allFiles = [];
|
||||
try { allFiles = fs.readdirSync(RENDERS).filter((f) => /^cut-.*\.mp4$/.test(f)); } catch {}
|
||||
const files = allFiles.filter((f) => !/-stab(-\w+)?\.mp4$/.test(f));
|
||||
const jobs = fs.existsSync(STABJOBS) ? fs.readFileSync(STABJOBS, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
|
||||
const picks = fs.existsSync(PICKS) ? fs.readFileSync(PICKS, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : [];
|
||||
const pickFor = {};
|
||||
for (const pk of picks) pickFor[pk.clip] = pk.choice === "stabilized" ? "smoothed" : pk.choice;
|
||||
const readMeta = (fname) => {
|
||||
try { return JSON.parse(fs.readFileSync(path.join(RENDERS, fname.replace(/\.mp4$/, ".json")), "utf8")); } catch { return null; }
|
||||
};
|
||||
const _all = readMarkers();
|
||||
const _dead = new Set(_all.filter((m) => m.deleted).map((m) => m.ref));
|
||||
const marks = _all.filter((m) => !m.deleted && !_dead.has(m.id) && m.out_s != null);
|
||||
// ONE note source: the marker log with note_edits applied — the cut
|
||||
// sidecar's copy is fallback only (copies drift; the log doesn't)
|
||||
const noteOf = {};
|
||||
for (const m of marks) noteOf[m.id] = m.note || "";
|
||||
for (const m of _all) if (m.note_edit && m.ref) noteOf[m.ref] = m.note || "";
|
||||
const cutTags = new Set(files.map((f) => f));
|
||||
const LEVEL_KEYS = ["light", "medium", "strong", "locked"];
|
||||
const cuts = files.sort().map((f) => {
|
||||
let side = {};
|
||||
try { side = JSON.parse(fs.readFileSync(path.join(RENDERS, f.replace(/\.mp4$/, ".marker.json")), "utf8")); } catch {}
|
||||
const base = f.replace(/\.mp4$/, "");
|
||||
const versions = [{ key: "original", file: f }];
|
||||
let latestStabAt = null;
|
||||
const legacy = `${base}-stab.mp4`;
|
||||
if (allFiles.includes(legacy)) {
|
||||
const meta = readMeta(legacy);
|
||||
let at = null;
|
||||
try { at = fs.statSync(path.join(RENDERS, legacy)).mtime.toISOString(); } catch {}
|
||||
versions.push({ key: "smoothed", file: legacy, meta, at });
|
||||
latestStabAt = at;
|
||||
}
|
||||
for (const lvl of LEVEL_KEYS) {
|
||||
const vf = `${base}-stab-${lvl}.mp4`;
|
||||
if (allFiles.includes(vf)) {
|
||||
let at = null;
|
||||
try { at = fs.statSync(path.join(RENDERS, vf)).mtime.toISOString(); } catch {}
|
||||
versions.push({ key: lvl, file: vf, meta: readMeta(vf), at });
|
||||
if (at && (!latestStabAt || at > latestStabAt)) latestStabAt = at;
|
||||
} else {
|
||||
// queued/in-flight for this level? (latest job per clip+level)
|
||||
const queued = jobs.filter((j) => j.clip === f && (j.level || "medium") === lvl).pop();
|
||||
if (queued) {
|
||||
let prog = null;
|
||||
try { prog = Number(fs.readFileSync(path.join(RENDERS, `${base}.stabprogress-${lvl}`), "utf8").trim()); } catch {}
|
||||
if (prog != null && !Number.isNaN(prog)) versions.push({ key: lvl, queued: true, progress: prog });
|
||||
else if (Date.now() - Date.parse(queued.ts || 0) < 30 * 60 * 1000) versions.push({ key: lvl, queued: true, progress: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { file: f, note: (side.marker && noteOf[side.marker] != null ? noteOf[side.marker] : side.note) || "",
|
||||
by: side.by || "", src: side.src || "",
|
||||
in_s: side.in_s, out_s: side.out_s, cut_at: side.cut_at || null,
|
||||
versions, stab_at: latestStabAt,
|
||||
stabilized: versions.length > 1, pick: pickFor[f] || null };
|
||||
});
|
||||
// markers whose cut hasn't landed yet = cutting, unless the lane marked
|
||||
// them FAILED (.cutfail.json sidecar) or a UI deletion retired them
|
||||
// (cut-deletions.jsonl newer than the marker — mirrors cut-clips.mjs)
|
||||
let cutDels = [];
|
||||
try { cutDels = fs.readFileSync(path.join(FOOTAGE, "cut-deletions.jsonl"), "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)); } catch {}
|
||||
const latestDel = {};
|
||||
for (const d of cutDels) if (!latestDel[d.clip] || d.ts > latestDel[d.clip]) latestDel[d.clip] = d.ts;
|
||||
const cutting = [], failedCuts = [];
|
||||
for (const m of marks) {
|
||||
const base = m.clip.replace(/\.(mp4|MP4)$/, "");
|
||||
const tag = `${base}-${Number(m.in_s).toFixed(1)}-${Number(m.out_s).toFixed(1)}`.replace(/\./g, "_");
|
||||
if (cutTags.has(`cut-${tag}.mp4`)) continue;
|
||||
const delTs = latestDel[`cut-${tag}.mp4`];
|
||||
if (delTs && delTs > (m.ts || "")) continue; // retired by deletion
|
||||
let fail = null;
|
||||
try { fail = JSON.parse(fs.readFileSync(path.join(RENDERS, `cut-${tag}.cutfail.json`), "utf8")); } catch {}
|
||||
const item = { marker: m.id, clip: m.clip, in_s: m.in_s, out_s: m.out_s, by: m.by };
|
||||
if (fail && fail.attempts >= 3) failedCuts.push({ ...item, tag, attempts: fail.attempts, error: fail.last_error || "" });
|
||||
else cutting.push(item);
|
||||
}
|
||||
return send(res, 200, { cuts, cutting, failed: failedCuts });
|
||||
}
|
||||
|
||||
if (p === "/api/stabilize" && req.method === "POST") {
|
||||
// level -> the user-facing smoothing dial (the user experiments, we don't
|
||||
// fine-tune): light/medium/strong = trajectory window; locked = tripod.
|
||||
// redo:true archives the existing smooth so the watcher regenerates it
|
||||
// at the new level.
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
const clip = path.basename(String(j.clip));
|
||||
const LEVELS = { light: { smoothing: 6 }, medium: { smoothing: 12 },
|
||||
strong: { smoothing: 24 }, locked: { smoothing: 12, tripod: true } };
|
||||
const level = LEVELS[j.level] ? String(j.level) : "medium";
|
||||
const s = LEVELS[level];
|
||||
// versions live side by side; re-running an existing level archives
|
||||
// just that level's files and regenerates it
|
||||
const verName = clip.replace(/\.mp4$/, `-stab-${level}.mp4`);
|
||||
if (fs.existsSync(path.join(RENDERS, verName))) {
|
||||
const trash = path.join(BUNDLE, "archive", "cuts");
|
||||
fs.mkdirSync(trash, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
for (const [dir, name] of [[RENDERS, verName],
|
||||
[path.join(RENDERS, "previews"), verName],
|
||||
[RENDERS, verName.replace(/\.mp4$/, ".json")]]) {
|
||||
const f = path.join(dir, name);
|
||||
if (fs.existsSync(f)) fs.renameSync(f, path.join(trash, `${stamp}-${path.basename(dir) === "previews" ? "preview-" : ""}${name}`));
|
||||
}
|
||||
}
|
||||
fs.appendFileSync(STABJOBS, JSON.stringify({ clip, level, smoothing: s.smoothing,
|
||||
tripod: !!s.tripod, ts: new Date().toISOString(), by: attribution(req) }) + "\n");
|
||||
send(res, 200, { ok: true, level });
|
||||
} catch (e) { send(res, 400, { ok: false, error: String(e) }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === "/api/pick" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
// choice = version key: original | light | medium | strong | locked |
|
||||
// smoothed (legacy) — the 🔒 lock act of curation
|
||||
const OK = new Set(["original", "light", "medium", "strong", "locked", "smoothed", "stabilized"]);
|
||||
const choice = OK.has(j.choice) ? (j.choice === "stabilized" ? "smoothed" : j.choice) : "original";
|
||||
fs.appendFileSync(PICKS, JSON.stringify({ clip: path.basename(String(j.clip)), choice, ts: new Date().toISOString(), by: attribution(req) }) + "\n");
|
||||
send(res, 200, { ok: true });
|
||||
} catch (e) { send(res, 400, { ok: false, error: String(e) }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === "/api/cut-note" && req.method === "POST") {
|
||||
// edit/add a note AFTER the cut exists (the primary flow: punch fast,
|
||||
// name on review). Updates the cut's sidecar (display source) and
|
||||
// appends an attributed note_edit record to markers.jsonl (durable
|
||||
// provenance; also nudges the watcher to refresh the asset index).
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
const clip = path.basename(String(j.clip));
|
||||
const note = String(j.note || "").slice(0, 2000);
|
||||
const sidePath = path.join(RENDERS, clip.replace(/\.mp4$/, ".marker.json"));
|
||||
let side = {};
|
||||
try { side = JSON.parse(fs.readFileSync(sidePath, "utf8")); } catch {}
|
||||
side.note = note;
|
||||
fs.writeFileSync(sidePath, JSON.stringify(side, null, 1));
|
||||
fs.appendFileSync(MARKERS, JSON.stringify({
|
||||
id: `mk-${Date.now()}-${Math.floor(Math.random() * 1e4)}`,
|
||||
ts: new Date().toISOString(), by: attribution(req),
|
||||
note_edit: true, ref: side.marker || null, clip: side.src || null,
|
||||
in_s: null, out_s: null, note,
|
||||
}) + "\n");
|
||||
send(res, 200, { ok: true });
|
||||
} catch (e) { send(res, 400, { ok: false, error: String(e) }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === "/api/cut-retry" && req.method === "POST") {
|
||||
// clear a FAILED cut's sidecar so the watcher's next sweep retries it
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
const tag = path.basename(String(j.tag)).replace(/[^A-Za-z0-9_\-]/g, "");
|
||||
fs.rmSync(path.join(RENDERS, `cut-${tag}.cutfail.json`), { force: true });
|
||||
send(res, 200, { ok: true });
|
||||
} catch (e) { send(res, 400, { ok: false, error: String(e) }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (p === "/api/cut-delete" && req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const j = JSON.parse(body);
|
||||
const name = path.basename(String(j.clip));
|
||||
const trash = path.join(BUNDLE, "archive", "cuts");
|
||||
fs.mkdirSync(trash, { recursive: true });
|
||||
let moved = [];
|
||||
for (const suffix of [".mp4", "-stab.mp4", "-stab.json", ".marker.json", ".stabprogress"]) {
|
||||
const f = path.join(RENDERS, name.replace(/\.mp4$/, "") + suffix);
|
||||
if (fs.existsSync(f)) { fs.renameSync(f, path.join(trash, path.basename(f))); moved.push(path.basename(f)); }
|
||||
}
|
||||
fs.appendFileSync(path.join(FOOTAGE, "cut-deletions.jsonl"),
|
||||
JSON.stringify({ clip: name, moved, ts: new Date().toISOString(), by: attribution(req) }) + "\n");
|
||||
send(res, 200, { ok: true, moved });
|
||||
} catch (e) { send(res, 400, { ok: false, error: String(e) }); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.startsWith("/master/")) {
|
||||
// deliberate master movement (doctrine: masters move on purpose, never
|
||||
// by default) — full-res download of a cut for external editors
|
||||
const name = path.basename(decodeURIComponent(p.slice(8)));
|
||||
if (!/^cut-[\w\-]+\.mp4$/.test(name)) return send(res, 400, { error: "bad name" });
|
||||
const f = path.join(RENDERS, name);
|
||||
if (!fs.existsSync(f)) return send(res, 404, { error: "no such master" });
|
||||
res.writeHead(200, { "content-type": "video/mp4",
|
||||
"content-length": fs.statSync(f).size,
|
||||
"content-disposition": `attachment; filename="${name}"` });
|
||||
fs.createReadStream(f).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.startsWith("/cutprev/")) {
|
||||
const name = path.basename(decodeURIComponent(p.slice(9)));
|
||||
const prev = path.join(RENDERS, "previews", name);
|
||||
const file = fs.existsSync(prev) ? prev : path.join(RENDERS, name); // fallback: full file
|
||||
if (!file.startsWith(RENDERS) || !fs.existsSync(file)) return send(res, 404, { error: "not found" });
|
||||
if (u.searchParams.get("dl")) {
|
||||
// lightweight download lane (the compressed sibling of /master/)
|
||||
res.writeHead(200, { "content-type": "video/mp4", "content-length": fs.statSync(file).size,
|
||||
"content-disposition": `attachment; filename="${name.replace(/\.mp4$/, "-preview.mp4")}"` });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
return;
|
||||
}
|
||||
const stat = fs.statSync(file);
|
||||
const range = req.headers.range;
|
||||
if (range) {
|
||||
const m2 = range.match(/bytes=(\d+)-(\d*)/);
|
||||
const start = Number(m2[1]);
|
||||
const end = m2[2] ? Number(m2[2]) : Math.min(start + 4 * 1024 * 1024, stat.size - 1);
|
||||
res.writeHead(206, { "content-range": `bytes ${start}-${end}/${stat.size}`, "accept-ranges": "bytes",
|
||||
"content-length": end - start + 1, "content-type": "video/mp4" });
|
||||
fs.createReadStream(file, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, { "content-length": stat.size, "content-type": "video/mp4", "accept-ranges": "bytes" });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.startsWith("/cuts/")) {
|
||||
const name = path.basename(decodeURIComponent(p.slice(6)));
|
||||
const file = path.join(RENDERS, name);
|
||||
if (!file.startsWith(RENDERS) || !fs.existsSync(file)) return send(res, 404, { error: "not found" });
|
||||
const stat = fs.statSync(file);
|
||||
const range = req.headers.range;
|
||||
if (range) {
|
||||
const m2 = range.match(/bytes=(\d+)-(\d*)/);
|
||||
const start = Number(m2[1]);
|
||||
const end = m2[2] ? Number(m2[2]) : Math.min(start + 4 * 1024 * 1024, stat.size - 1);
|
||||
res.writeHead(206, { "content-range": `bytes ${start}-${end}/${stat.size}`, "accept-ranges": "bytes",
|
||||
"content-length": end - start + 1, "content-type": "video/mp4" });
|
||||
fs.createReadStream(file, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, { "content-length": stat.size, "content-type": "video/mp4", "accept-ranges": "bytes" });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (p.startsWith("/media/")) {
|
||||
const name = path.basename(decodeURIComponent(p.slice(7)));
|
||||
// DE-BESPOKED: served ONLY from <footage>/proxies, so footage sitting
|
||||
// directly in the root was LISTED by /api/clips (which was de-bespoked
|
||||
// earlier) and then 404'd on playback — listed and unplayable. Same
|
||||
// proxies-only assumption, one layer down, and it survived the first pass
|
||||
// because the two halves were fixed in different files.
|
||||
const candidates = [path.join(PROXIES, name), path.join(FOOTAGE, name)];
|
||||
const file = candidates.find((c) =>
|
||||
(c.startsWith(PROXIES) || c.startsWith(FOOTAGE)) && fs.existsSync(c));
|
||||
if (!file) return send(res, 404, { error: "not found" });
|
||||
const stat = fs.statSync(file);
|
||||
const range = req.headers.range;
|
||||
if (range) {
|
||||
const m = range.match(/bytes=(\d+)-(\d*)/);
|
||||
const start = Number(m[1]);
|
||||
const end = m[2] ? Number(m[2]) : Math.min(start + 4 * 1024 * 1024, stat.size - 1);
|
||||
res.writeHead(206, {
|
||||
"content-range": `bytes ${start}-${end}/${stat.size}`,
|
||||
"accept-ranges": "bytes",
|
||||
"content-length": end - start + 1,
|
||||
"content-type": "video/mp4",
|
||||
});
|
||||
fs.createReadStream(file, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, { "content-length": stat.size, "content-type": "video/mp4", "accept-ranges": "bytes" });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
send(res, 404, { error: "not found" });
|
||||
}).listen(PORT, "127.0.0.1", () => console.log(`cutdown: http://127.0.0.1:${PORT}/ footage=${FOOTAGE}`));
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"name": "@openrig/studio-cutdown",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openrig/studio-cutdown",
|
||||
"version": "0.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"ffprobe-static": "^3.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"openrig-studio-cutdown": "cutdown-server.mjs",
|
||||
"openrig-studio-cutlane": "watch-markers.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/ffprobe-static": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz",
|
||||
"integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"name": "@openrig/studio-cutdown",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "The cutdown provider: punch in/out on a folder of footage, and the lane that renders the cuts. Point --footage at any folder of video.",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"bin": {
|
||||
"openrig-studio-cutdown": "./cutdown-server.mjs",
|
||||
"openrig-studio-cutlane": "./watch-markers.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": "./cutdown-server.mjs",
|
||||
"./lane": "./watch-markers.mjs"
|
||||
},
|
||||
"openrig": {
|
||||
"_note": "DECLARED, not discovered \u2014 same principle as the video provider. Every hardcoded path removed from this tree existed because there was nowhere to state it.",
|
||||
"kind": "provider",
|
||||
"surfaces_served": [
|
||||
"cutdown"
|
||||
],
|
||||
"verbs": [
|
||||
"/api/clips",
|
||||
"/api/cuts",
|
||||
"/api/markers",
|
||||
"/api/pick",
|
||||
"/api/cut-note",
|
||||
"/api/cut-delete",
|
||||
"/api/cut-retry",
|
||||
"/api/stabilize"
|
||||
],
|
||||
"processes": {
|
||||
"_note": "TWO processes, and the second is not optional. The server ACCEPTS cuts; the lane PERFORMS them. Run the server alone and every write verb answers ok:true while nothing ever renders \u2014 accepted-but-unperformed, which passes both a verbs-answer check and a real-data check because the job file genuinely gains a line.",
|
||||
"server": {
|
||||
"entry": "cutdown-server.mjs",
|
||||
"args": {
|
||||
"--port": "required; binds loopback only",
|
||||
"--footage": "required; the folder of video being cut down",
|
||||
"--bundle": "optional; where renders land, defaults to <footage>"
|
||||
}
|
||||
},
|
||||
"lane": {
|
||||
"entry": "watch-markers.mjs",
|
||||
"args": {
|
||||
"--footage": "required; must match the server",
|
||||
"--bundle": "required; must match the server",
|
||||
"--interval": "optional; sweep period in ms, default 10000"
|
||||
},
|
||||
"history": "Rewritten from watch-markers.sh, which used `stat -c %Y` \u2014 GNU syntax. On macOS that returned nothing, the guard fell through, and the cut lane never ran at all. It was Linux-only by accident."
|
||||
}
|
||||
},
|
||||
"roots": {
|
||||
"_note": "KINDS, not paths.",
|
||||
"footage": {
|
||||
"required": true,
|
||||
"description": "any folder of video files. A proxies/ subdir is used when present (review proxies) and originals/ is preferred as the cut source when present, but a plain folder works \u2014 that is the whole setup."
|
||||
},
|
||||
"bundle": {
|
||||
"required": false,
|
||||
"description": "where cut clips are written; defaults to the footage root so the tool is self-contained"
|
||||
}
|
||||
},
|
||||
"binaries": {
|
||||
"ffmpeg": {
|
||||
"required": true,
|
||||
"used_for": "cutting clips to in/out, stabilization passes"
|
||||
},
|
||||
"ffprobe": {
|
||||
"required": true,
|
||||
"used_for": "clip duration for the stabilize progress readout"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"_note": "Append-only and attributed, written into the footage root. Durable-first: the UI gesture lands on disk before anything renders.",
|
||||
"markers.jsonl": "punch in/out marks, attributed by UI user",
|
||||
"picks.jsonl": "selects",
|
||||
"stab-jobs.jsonl": "stabilization requests the lane consumes",
|
||||
"cutlane.log": "what the lane did"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"ffprobe-static": "^3.1.0"
|
||||
},
|
||||
"_deps_note": "The cut lane shells out to ffmpeg/ffprobe. Shipped with the provider so a box needs only node \u2014 see studio-video/timeline-export-probe.mjs resolveSystemBin for the packaged-first, PATH-fallback order."
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env node
|
||||
// vid.stab two-pass stabilization for a cut clip. Deterministic lane.
|
||||
// Produces <cut>-stab.mp4 beside the original cut. Tuned for handheld
|
||||
// footage with no gyro stabilization.
|
||||
// Usage: node stabilize-clip.mjs <cut.mp4>
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { FFMPEG, FFPROBE } from "./bin.mjs";
|
||||
|
||||
const src = process.argv[2];
|
||||
if (!src || !fs.existsSync(src)) { console.error("no such clip"); process.exit(1); }
|
||||
const argIdx = process.argv.indexOf("--smoothing");
|
||||
const SMOOTHING = argIdx > -1 ? Number(process.argv[argIdx + 1]) : 12;
|
||||
const TRIPOD = process.argv.includes("--tripod");
|
||||
const lvlIdx = process.argv.indexOf("--level");
|
||||
const LEVEL = lvlIdx > -1 ? process.argv[lvlIdx + 1]
|
||||
: (TRIPOD ? "locked" : SMOOTHING <= 8 ? "light" : SMOOTHING <= 16 ? "medium" : "strong");
|
||||
const out = src.replace(/\.mp4$/, `-stab-${LEVEL}.mp4`);
|
||||
if (fs.existsSync(out)) { console.log("exists:", path.basename(out)); process.exit(0); }
|
||||
const trf = path.join(os.tmpdir(), path.basename(src) + ".trf");
|
||||
const progFile = src.replace(/\.mp4$/, `.stabprogress-${LEVEL}`);
|
||||
const durS = Number(execFileSync(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", src]).toString().trim());
|
||||
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
function runPass(args, frac0, frac1) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const progPipe = path.join(os.tmpdir(), path.basename(src) + ".prog");
|
||||
fs.rmSync(progPipe, { force: true });
|
||||
const child = spawn(FFMPEG, ["-hide_banner", "-loglevel", "error", "-y", "-progress", progPipe, ...args], { stdio: "ignore" });
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
const txt = fs.readFileSync(progPipe, "utf8");
|
||||
const m = [...txt.matchAll(/out_time_us=(\d+)/g)].pop();
|
||||
if (m && durS > 0) {
|
||||
const frac = Math.min(1, Number(m[1]) / 1e6 / durS);
|
||||
fs.writeFileSync(progFile, String(Math.round((frac0 + frac * (frac1 - frac0)) * 100)));
|
||||
}
|
||||
} catch {}
|
||||
}, 3000);
|
||||
child.on("exit", (code) => { clearInterval(timer); code === 0 ? resolve() : reject(new Error("ffmpeg exit " + code)); });
|
||||
});
|
||||
}
|
||||
const tp = TRIPOD ? ":tripod=1" : "";
|
||||
await runPass(["-i", src, "-vf", `vidstabdetect=shakiness=6:accuracy=15${tp}:result=${trf}`, "-f", "null", "-"], 0, 0.5);
|
||||
await runPass(["-i", src, "-vf", `vidstabtransform=input=${trf}:smoothing=${SMOOTHING}${tp}:optzoom=1:interpol=bicubic,unsharp=5:5:0.5:3:3:0.3`,
|
||||
"-c:v", "libx264", "-crf", "18", "-preset", "medium", "-c:a", "copy", "-movflags", "+faststart", out], 0.5, 1);
|
||||
fs.writeFileSync(out.replace(/\.mp4$/, ".json"),
|
||||
JSON.stringify({ level: LEVEL, smoothing: SMOOTHING, tripod: TRIPOD, engine: "local", at: new Date().toISOString() }));
|
||||
fs.rmSync(trf, { force: true });
|
||||
const prevDir = path.join(path.dirname(out), "previews");
|
||||
fs.mkdirSync(prevDir, { recursive: true });
|
||||
execFileSync(FFMPEG, ["-hide_banner", "-loglevel", "error", "-y", "-i", out,
|
||||
"-vf", "scale=960:-2", "-c:v", "libx264", "-preset", "veryfast", "-crf", "28",
|
||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart",
|
||||
path.join(prevDir, path.basename(out))], { stdio: "inherit" });
|
||||
fs.rmSync(progFile, { force: true });
|
||||
console.log("stabilized:", path.basename(out));
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
// The cut-lane watcher — the process cutdown's write verbs enqueue for.
|
||||
//
|
||||
// REWRITTEN FROM watch-markers.sh rather than ported. The original was a bash
|
||||
// loop with the bespoke era baked in: a hardcoded app dir, a single-project
|
||||
// default, logs into $HOME, an optional Modal GPU offload keyed on files in the
|
||||
// machine-local files — and `stat -c %Y`, which is GNU syntax. On macOS
|
||||
// that returns nothing, the guard falls through to "none", and THE CUT LANE
|
||||
// NEVER RUNS AT ALL. It was Linux-only by accident, not by choice.
|
||||
//
|
||||
// Same lane, same idempotence, paths from arguments, runs anywhere Node does.
|
||||
//
|
||||
// The lane sweeps every cycle because it is cheap when there is nothing to do,
|
||||
// so a crashed run or a deleted-then-repunched cut self-heals within one sweep.
|
||||
// The heavier index rebuild only runs when the lane actually did work or the
|
||||
// markers file changed.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
const arg = (k, d) => { const i = process.argv.indexOf(`--${k}`); return i > -1 ? process.argv[i + 1] : d; };
|
||||
const FOOTAGE = path.resolve(arg("footage", ""));
|
||||
const BUNDLE = path.resolve(arg("bundle", FOOTAGE));
|
||||
const INTERVAL = Number(arg("interval", 10000));
|
||||
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
||||
const EXPORT_TOOLS = path.resolve(HERE, "..", "studio-video");
|
||||
const LOG = path.join(FOOTAGE, "cutlane.log");
|
||||
|
||||
if (!FOOTAGE || !fs.existsSync(FOOTAGE)) {
|
||||
console.error(`watch-markers: --footage must be an existing directory (got ${FOOTAGE || "nothing"})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const log = (m) => {
|
||||
const line = `${new Date().toISOString()} ${m}\n`;
|
||||
try { fs.appendFileSync(LOG, line); } catch { /* logging must never take the lane down */ }
|
||||
};
|
||||
|
||||
const node = (file, args, cwd) =>
|
||||
new Promise((resolve) => {
|
||||
execFile(process.execPath, [file, ...args], { cwd, timeout: 15 * 60 * 1000, maxBuffer: 16 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => resolve({ err, out: String(stdout || ""), errOut: String(stderr || "") }));
|
||||
});
|
||||
|
||||
const mtime = (p) => { try { return fs.statSync(p).mtimeMs; } catch { return null; } };
|
||||
|
||||
async function rebuildIndexes() {
|
||||
for (const [tool, args] of [
|
||||
["project-assets.mjs", ["generate", "--slice-root", "."]],
|
||||
["timeline-history.mjs", ["rebuild", "--slice-root", "."]],
|
||||
]) {
|
||||
const r = await node(path.join(EXPORT_TOOLS, tool), args, BUNDLE);
|
||||
if (r.err) log(`index rebuild failed (${tool}): ${r.err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Latest job per clip+level wins. Entries without a level are legacy and are
|
||||
// already satisfied by the bare -stab output, so they are skipped.
|
||||
function pendingStabJobs() {
|
||||
const p = path.join(FOOTAGE, "stab-jobs.jsonl");
|
||||
if (!fs.existsSync(p)) return [];
|
||||
const latest = new Map();
|
||||
for (const line of fs.readFileSync(p, "utf8").split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const j = JSON.parse(line);
|
||||
if (!j.level) continue;
|
||||
latest.set(`${j.clip}|${j.level}`, j);
|
||||
} catch { /* a malformed line is skipped, not fatal */ }
|
||||
}
|
||||
return [...latest.values()];
|
||||
}
|
||||
|
||||
let lastMarkers = null;
|
||||
|
||||
async function sweep() {
|
||||
const markers = path.join(FOOTAGE, "markers.jsonl");
|
||||
const cur = mtime(markers);
|
||||
|
||||
if (cur !== null) {
|
||||
const lane = await node(path.join(HERE, "cut-clips.mjs"), ["--footage", FOOTAGE, "--bundle", BUNDLE]);
|
||||
const didWork = (lane.out.match(/^cut /gm) || []).length;
|
||||
if (didWork) log(`lane cut ${didWork} clip(s)`);
|
||||
if (lane.err && !lane.out) log(`lane error: ${lane.err.message}`);
|
||||
if (cur !== lastMarkers || didWork > 0) {
|
||||
lastMarkers = cur;
|
||||
await rebuildIndexes();
|
||||
}
|
||||
}
|
||||
|
||||
for (const job of pendingStabJobs()) {
|
||||
const src = path.join(BUNDLE, "media", "renders", job.clip);
|
||||
const out = src.replace(/\.mp4$/, `-stab-${job.level}.mp4`);
|
||||
if (!fs.existsSync(src) || fs.existsSync(out)) continue;
|
||||
log(`stabilizing ${job.clip} (${job.level})`);
|
||||
const args = [src, "--level", job.level, "--smoothing", String(job.smoothing ?? 12)];
|
||||
if (job.tripod) args.push("--tripod");
|
||||
// The original had a Modal GPU fast lane gated on machine-local files
|
||||
// home directory, falling back to this local pass. Dropped: it is host
|
||||
// setup, not product, and the local lane is what actually shipped.
|
||||
const r = await node(path.join(HERE, "stabilize-clip.mjs"), args);
|
||||
if (r.err) log(`stabilize failed for ${job.clip}: ${r.err.message}`);
|
||||
else await rebuildIndexes();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`watch-markers: ${FOOTAGE}/markers.jsonl -> ${BUNDLE} (every ${INTERVAL}ms)`);
|
||||
const tick = async () => {
|
||||
try { await sweep(); } catch (e) { log(`sweep threw: ${e.message}`); }
|
||||
setTimeout(tick, INTERVAL);
|
||||
};
|
||||
tick();
|
||||
@@ -0,0 +1,538 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
buildAudioMuxArgs,
|
||||
DEFAULT_EXPORT,
|
||||
planExport,
|
||||
readTimelineFile,
|
||||
roundSeconds,
|
||||
videoFadeFilters,
|
||||
writeCurrentExport,
|
||||
} from "./timeline-export-core.mjs";
|
||||
import {
|
||||
resolveSystemBin,
|
||||
validatePlanWithProbes,
|
||||
} from "./timeline-export-probe.mjs";
|
||||
|
||||
const DEFAULT_SLICE_ROOT = path.resolve(new URL("../../", import.meta.url).pathname);
|
||||
// DE-BESPOKED (rig-studio port): this used to be
|
||||
// "/Users/wrandom/openrig-videos/content-piece-1/assembly"
|
||||
// a specific project on a specific machine, from when this was a bespoke tool.
|
||||
// Exports belong IN the bundle so they travel with it, which the code below
|
||||
// already did for every project EXCEPT the original one. Removing the special
|
||||
// case makes that the rule instead of the exception.
|
||||
const DEFAULT_SLUG = "timeline-export";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
timeline: path.join(DEFAULT_SLICE_ROOT, "timeline.json"),
|
||||
sliceRoot: DEFAULT_SLICE_ROOT,
|
||||
mode: "review",
|
||||
outDir: "",
|
||||
assemblyRoot: "", // resolved after flag parsing: explicit > <slice-root>/assembly > legacy default
|
||||
slug: DEFAULT_SLUG,
|
||||
width: DEFAULT_EXPORT.width,
|
||||
height: DEFAULT_EXPORT.height,
|
||||
fps: DEFAULT_EXPORT.fps,
|
||||
maxCards: 0,
|
||||
limitSeconds: 0,
|
||||
json: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--timeline") args.timeline = argv[++i];
|
||||
else if (arg === "--slice-root") args.sliceRoot = argv[++i];
|
||||
else if (arg === "--mode") args.mode = argv[++i];
|
||||
else if (arg === "--out-dir") args.outDir = argv[++i];
|
||||
else if (arg === "--assembly-root") args.assemblyRoot = argv[++i];
|
||||
else if (arg === "--slug") args.slug = argv[++i];
|
||||
else if (arg === "--width") args.width = Number(argv[++i]);
|
||||
else if (arg === "--height") args.height = Number(argv[++i]);
|
||||
else if (arg === "--fps") args.fps = Number(argv[++i]);
|
||||
else if (arg === "--max-cards") args.maxCards = Number(argv[++i]);
|
||||
else if (arg === "--limit-seconds") args.limitSeconds = Number(argv[++i]);
|
||||
else if (arg === "--json") args.json = true;
|
||||
else if (arg === "-h" || arg === "--help") {
|
||||
console.log("Usage: node assemble-timeline.mjs [--timeline timeline.json] [--mode review|final] [--out-dir dir] [--assembly-root dir] [--slug name] [--max-cards n] [--limit-seconds s] [--json]");
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!args.assemblyRoot) {
|
||||
// Exports live IN the bundle so they travel with it. No legacy branch: the
|
||||
// project this tool was born for is not special any more.
|
||||
args.assemblyRoot = path.join(path.resolve(args.sliceRoot), "assembly");
|
||||
}
|
||||
if (args.slug === DEFAULT_SLUG) {
|
||||
args.slug = path.basename(path.resolve(args.sliceRoot)) || DEFAULT_SLUG;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function safeName(value, fallback = "segment") {
|
||||
const cleaned = String(value || fallback).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function nextVersionDir(baseDir, slug, mode) {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
for (let i = 1; i < 1000; i++) {
|
||||
const candidate = path.join(baseDir, `${safeName(slug, "timeline-export")}-v${String(i).padStart(3, "0")}-${mode}`);
|
||||
if (!fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`Could not allocate version directory in ${baseDir}`);
|
||||
}
|
||||
|
||||
function run(bin, args, { cwd, logFile, label, timeoutMs = 20 * 60 * 1000 }) {
|
||||
fs.appendFileSync(logFile, `\n\n# ${label}\n${bin} ${args.join(" ")}\n`);
|
||||
const result = spawnSync(bin, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
fs.appendFileSync(logFile, `\n## stdout\n${result.stdout || ""}\n## stderr\n${result.stderr || ""}\n## status\n${result.status}\n`);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${label} failed with status ${result.status}. See ${logFile}`);
|
||||
}
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function vfScale({ width, height, fps }) {
|
||||
return [
|
||||
`scale=${width}:${height}:force_original_aspect_ratio=decrease:flags=lanczos`,
|
||||
`pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black`,
|
||||
"setsar=1",
|
||||
`fps=${fps}`,
|
||||
"format=yuv420p",
|
||||
].join(",");
|
||||
}
|
||||
|
||||
function hasLoopHoldPolicy(segment) {
|
||||
const text = [
|
||||
segment?.hold_policy,
|
||||
segment?.holdPolicy,
|
||||
segment?.note,
|
||||
segment?.status,
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return /\b(loop|repeat|seamless)\b/.test(text);
|
||||
}
|
||||
|
||||
function renderVideoSegment({ ffmpeg, segment, outPath, logFile, width, height, fps }) {
|
||||
const duration = Number(segment.duration_s);
|
||||
const sourceIn = Number.isFinite(segment.source_in_s) ? Math.max(0, segment.source_in_s) : 0;
|
||||
const sourceOut = Number.isFinite(segment.source_out_s) && segment.source_out_s > sourceIn ? segment.source_out_s : 0;
|
||||
const shouldLoop = hasLoopHoldPolicy(segment);
|
||||
const trimPart = !shouldLoop && sourceOut > sourceIn ? `trim=start=${sourceIn}:end=${sourceOut}` : `trim=start=${sourceIn}`;
|
||||
const speed = Number.isFinite(Number(segment.speed_factor)) && Number(segment.speed_factor) > 0 ? Number(segment.speed_factor) : 1;
|
||||
const filter = [
|
||||
trimPart,
|
||||
`setpts=(PTS-STARTPTS)/${speed}`,
|
||||
vfScale({ width, height, fps }),
|
||||
`tpad=stop_mode=clone:stop_duration=${duration}`,
|
||||
`trim=duration=${duration}`,
|
||||
"setpts=PTS-STARTPTS",
|
||||
...videoFadeFilters(segment),
|
||||
].join(",");
|
||||
run(ffmpeg, [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
...(shouldLoop ? ["-stream_loop", "-1"] : []),
|
||||
"-i", segment.assetPath,
|
||||
"-an", "-sn", "-dn",
|
||||
"-vf", filter,
|
||||
"-t", String(duration),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "18",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-r", String(fps),
|
||||
"-movflags", "+faststart",
|
||||
outPath,
|
||||
], { logFile, label: `render ${segment.id}` });
|
||||
}
|
||||
|
||||
function renderImageSegment({ ffmpeg, segment, outPath, logFile, width, height, fps }) {
|
||||
const duration = Number(segment.duration_s);
|
||||
run(ffmpeg, [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-loop", "1",
|
||||
"-framerate", String(fps),
|
||||
"-t", String(duration),
|
||||
"-i", segment.assetPath,
|
||||
"-vf", [vfScale({ width, height, fps }), ...videoFadeFilters(segment)].join(","),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "18",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-r", String(fps),
|
||||
"-movflags", "+faststart",
|
||||
outPath,
|
||||
], { logFile, label: `render image ${segment.id}` });
|
||||
}
|
||||
|
||||
function drawTextFilter(text, y, size = 52, color = "white") {
|
||||
const safe = String(text || "")
|
||||
.replace(/[\\:']/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.slice(0, 96);
|
||||
return `drawtext=text='${safe}':x=(w-text_w)/2:y=${y}:fontsize=${size}:fontcolor=${color}:box=1:[email protected]:boxborderw=18`;
|
||||
}
|
||||
|
||||
// Some system ffmpeg builds have no text filters (drawtext/subtitles absent - same
|
||||
// gap make-video-contact-sheet works around). Labeled slates come from Cocoa
|
||||
// text drawing via osascript JXA (slate-png.jxa, pure macOS - retina hosts
|
||||
// return an exact 2x image, which vfScale normalizes); drawtext is attempted
|
||||
// first so text-capable ffmpeg builds skip the detour.
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let ffmpegHasDrawtext = null;
|
||||
|
||||
// Beats whose slate rendered WITHOUT its label, so the export can report the
|
||||
// degradation instead of the user discovering a blank card in the cut.
|
||||
const degradedSlates = [];
|
||||
|
||||
function renderSlatePng({ segment, outDir, width, height, logFile }) {
|
||||
const pngPath = path.join(outDir, `slate-${safeName(String(segment.id), "slot")}.png`);
|
||||
const spec = {
|
||||
out: pngPath,
|
||||
width,
|
||||
height,
|
||||
background: "#17140f",
|
||||
lines: [
|
||||
{ text: "EMPTY SLOT", size: 58, fill: "#b9822f", dy: -120 },
|
||||
{ text: String(segment.id), size: 42, fill: "#ffffff", dy: -35 },
|
||||
{ text: String(segment.title || "Placeholder").slice(0, 96), size: 36, fill: "#ffffff", dy: 35 },
|
||||
...(segment.template ? [{ text: String(segment.template).slice(0, 96), size: 28, fill: "#8f8676", dy: 95 }] : []),
|
||||
],
|
||||
};
|
||||
const result = spawnSync("/usr/bin/osascript",
|
||||
["-l", "JavaScript", path.join(__dirname, "slate-png.jxa"), JSON.stringify(spec)], { encoding: "utf8" });
|
||||
if (result.status !== 0 || !fs.existsSync(pngPath)) {
|
||||
fs.appendFileSync(logFile, `\n## slate-png ${segment.id} failed\n${result.stderr || ""}\n`);
|
||||
return null;
|
||||
}
|
||||
return pngPath;
|
||||
}
|
||||
|
||||
function renderPlaceholderSegment(opts) {
|
||||
const { ffmpeg, segment, outPath, logFile, width, height, fps } = opts;
|
||||
const duration = Number(segment.duration_s);
|
||||
if (ffmpegHasDrawtext !== false) {
|
||||
const filter = [
|
||||
drawTextFilter("EMPTY SLOT", "(h/2)-120", 58, "0xb9822f"),
|
||||
drawTextFilter(segment.id, "(h/2)-35", 42, "white"),
|
||||
drawTextFilter(segment.title || "Placeholder", "(h/2)+35", 36, "white"),
|
||||
...videoFadeFilters(segment),
|
||||
].join(",");
|
||||
const attempt = spawnSync(ffmpeg, [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi",
|
||||
"-i", `color=c=0x17140f:s=${width}x${height}:r=${fps}:d=${duration}`,
|
||||
"-vf", filter,
|
||||
"-t", String(duration),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "18",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-r", String(fps),
|
||||
"-movflags", "+faststart",
|
||||
outPath,
|
||||
], { encoding: "utf8" });
|
||||
if (attempt.status === 0) { ffmpegHasDrawtext = true; return; }
|
||||
if (!/No such filter: 'drawtext'|Filter not found/i.test(attempt.stderr || "")) {
|
||||
fs.appendFileSync(logFile, `\n## render placeholder ${segment.id}\n${attempt.stderr || ""}\n`);
|
||||
throw new Error(`render placeholder ${segment.id} failed with status ${attempt.status}. See ${logFile}`);
|
||||
}
|
||||
ffmpegHasDrawtext = false;
|
||||
fs.appendFileSync(logFile, `\n## drawtext unavailable; using JXA slates for placeholders\n`);
|
||||
}
|
||||
const slatePng = renderSlatePng({ segment, outDir: path.dirname(outPath), width, height, logFile });
|
||||
if (slatePng) {
|
||||
renderImageSegment({ ...opts, segment: { ...segment, assetPath: slatePng } });
|
||||
return;
|
||||
}
|
||||
|
||||
// THIRD TIER: AN UNLABELLED SLATE. A missing text filter must not fail an
|
||||
// export.
|
||||
//
|
||||
// Both labelled paths are platform-bound and neither is available
|
||||
// everywhere: `drawtext` is absent from the LINUX ffmpeg-static build (7.0.2)
|
||||
// though present in the macOS one (6.0), and the JXA fallback is macOS-only.
|
||||
// So on a Linux box with no system ffmpeg there was no way to label a slate,
|
||||
// and the assembler THREW — losing the whole export over a caption on a shot
|
||||
// that has not been filmed yet.
|
||||
//
|
||||
// The beat still occupies its exact duration, so the cut's timing, ordering
|
||||
// and every real shot around it are unaffected. What is lost is the words on
|
||||
// the card. That is a degraded label, not a broken render, and it is recorded
|
||||
// as a warning that reaches the UI rather than swallowed here — the point is
|
||||
// that the user is TOLD, not that it quietly looks fine.
|
||||
fs.appendFileSync(logFile,
|
||||
`\n## ${segment.id}: no text filter and no JXA slate — rendering an UNLABELLED slate\n`);
|
||||
degradedSlates.push(String(segment.id));
|
||||
|
||||
const plain = spawnSync(ffmpeg, [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "lavfi",
|
||||
"-i", `color=c=0x17140f:s=${width}x${height}:r=${fps}:d=${duration}`,
|
||||
...(videoFadeFilters(segment).length ? ["-vf", videoFadeFilters(segment).join(",")] : []),
|
||||
"-t", String(duration),
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
||||
"-pix_fmt", "yuv420p", "-r", String(fps), "-movflags", "+faststart",
|
||||
outPath,
|
||||
], { encoding: "utf8" });
|
||||
|
||||
if (plain.status !== 0) {
|
||||
fs.appendFileSync(logFile, `\n## unlabelled slate ${segment.id} failed\n${plain.stderr || ""}\n`);
|
||||
throw new Error(`placeholder ${segment.id}: even an unlabelled slate failed. See ${logFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSegment(opts) {
|
||||
const { segment } = opts;
|
||||
if (segment.placeholder) return renderPlaceholderSegment(opts);
|
||||
if (segment.assetKind === "image") return renderImageSegment(opts);
|
||||
return renderVideoSegment(opts);
|
||||
}
|
||||
|
||||
function concatSegments({ ffmpeg, segmentPaths, outPath, listPath, logFile }) {
|
||||
fs.writeFileSync(listPath, segmentPaths.map((file) => `file '${file.replaceAll("'", "'\\''")}'`).join("\n"));
|
||||
run(ffmpeg, [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", listPath,
|
||||
"-c", "copy",
|
||||
outPath,
|
||||
], { logFile, label: "concat normalized segments" });
|
||||
}
|
||||
|
||||
function mixAudio({ ffmpeg, silentVideoPath, audioMasterPath, audioBeds, syncSegments, outPath, totalDuration, audioLoudnessTarget, logFile, fps }) {
|
||||
const args = buildAudioMuxArgs({
|
||||
silentVideoPath,
|
||||
audioMasterPath,
|
||||
audioBeds,
|
||||
syncSegments,
|
||||
outPath,
|
||||
totalDuration,
|
||||
audioLoudnessTarget,
|
||||
fps,
|
||||
});
|
||||
run(ffmpeg, args, { logFile, label: "mux final audio/video" });
|
||||
}
|
||||
|
||||
function makeContactSheet({ ffmpeg, videoPath, outPath, logFile }) {
|
||||
run(ffmpeg, [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", videoPath,
|
||||
"-vf", "select='not(mod(n\\,900))',scale=320:180:force_original_aspect_ratio=decrease,pad=320:180:(ow-iw)/2:(oh-ih)/2:black,tile=5x4,format=yuvj420p",
|
||||
"-frames:v", "1",
|
||||
"-update", "1",
|
||||
outPath,
|
||||
], { logFile, label: "make contact sheet", timeoutMs: 5 * 60 * 1000 });
|
||||
if (!fs.existsSync(outPath)) {
|
||||
throw new Error(`contact sheet was not written: ${outPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function filteredPlan(plan, { maxCards, limitSeconds }) {
|
||||
if (!maxCards && !limitSeconds) return plan;
|
||||
const segments = [];
|
||||
let consumed = 0;
|
||||
for (const segment of plan.segments) {
|
||||
if (maxCards && segments.length >= maxCards) break;
|
||||
if (limitSeconds && consumed >= limitSeconds) break;
|
||||
const next = { ...segment };
|
||||
if (limitSeconds && consumed + next.duration_s > limitSeconds) {
|
||||
next.duration_s = roundSeconds(limitSeconds - consumed);
|
||||
}
|
||||
if (next.duration_s > 0) {
|
||||
segments.push(next);
|
||||
consumed += next.duration_s;
|
||||
}
|
||||
}
|
||||
const totalDurationS = limitSeconds ? Math.min(plan.totalDurationS, consumed) : consumed;
|
||||
return {
|
||||
...plan,
|
||||
segments,
|
||||
audioBeds: plan.audioBeds
|
||||
.filter((bed) => Number(bed.start_s) < totalDurationS)
|
||||
.map((bed) => ({ ...bed, duration_s: Math.min(Number(bed.duration_s), totalDurationS - Number(bed.start_s)) })),
|
||||
totalDurationS: roundSeconds(totalDurationS),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!["review", "final"].includes(args.mode)) throw new Error("--mode must be review or final");
|
||||
const timelinePath = path.resolve(args.timeline);
|
||||
const sliceRoot = path.resolve(args.sliceRoot);
|
||||
const timeline = readTimelineFile(timelinePath);
|
||||
const ffmpeg = resolveSystemBin("ffmpeg");
|
||||
const ffprobe = resolveSystemBin("ffprobe");
|
||||
const originalPlan = validatePlanWithProbes(planExport(timeline, {
|
||||
sliceRoot,
|
||||
mode: args.mode,
|
||||
checkFiles: true,
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
fps: args.fps,
|
||||
}), {
|
||||
ffprobe,
|
||||
mode: args.mode,
|
||||
frameDurationS: 1 / args.fps,
|
||||
});
|
||||
if (!originalPlan.validation.ok) {
|
||||
throw new Error(`Timeline is not exportable:\n${originalPlan.validation.errors.join("\n")}`);
|
||||
}
|
||||
|
||||
const plan = filteredPlan(originalPlan, args);
|
||||
const baseOut = args.outDir
|
||||
? path.resolve(args.outDir)
|
||||
: nextVersionDir(path.resolve(args.assemblyRoot), args.slug, args.mode);
|
||||
const workDir = path.join(baseOut, "work");
|
||||
const segDir = path.join(baseOut, "clips");
|
||||
const logsDir = path.join(baseOut, "logs");
|
||||
fs.mkdirSync(segDir, { recursive: true });
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
const logFile = path.join(logsDir, "ffmpeg.log");
|
||||
fs.writeFileSync(logFile, `Timeline export started ${new Date().toISOString()}\n`);
|
||||
|
||||
const rendered = [];
|
||||
for (const segment of plan.segments) {
|
||||
const outPath = path.join(segDir, `${String(segment.index).padStart(3, "0")}-${safeName(segment.id)}.mp4`);
|
||||
renderSegment({ ffmpeg, segment, outPath, logFile, width: args.width, height: args.height, fps: args.fps });
|
||||
rendered.push({ path: outPath, duration: Number(segment.duration_s), xfadeOut: Math.max(0, Number(segment.xfade_out_s) || 0) });
|
||||
}
|
||||
|
||||
// crossfade chains — consecutive segments joined by xfade_out_s
|
||||
// merge into ONE file via ffmpeg xfade (segments are already normalized to
|
||||
// the same size/fps, so xfade is safe); everything else concats as before.
|
||||
const segmentPaths = [];
|
||||
let chainIndex = 0;
|
||||
for (let i = 0; i < rendered.length; i++) {
|
||||
let current = rendered[i];
|
||||
while (current.xfadeOut > 0 && i + 1 < rendered.length) {
|
||||
const next = rendered[i + 1];
|
||||
const fade = Math.min(current.xfadeOut, Math.max(0.05, current.duration - 0.05), Math.max(0.05, next.duration - 0.05));
|
||||
const joined = path.join(segDir, `xfade-${String(chainIndex).padStart(3, "0")}-${i}.mp4`);
|
||||
run(ffmpeg, [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", current.path, "-i", next.path,
|
||||
"-filter_complex", `[0:v][1:v]xfade=transition=fade:duration=${fade}:offset=${Math.max(0, +(current.duration - fade).toFixed(3))}[v]`,
|
||||
"-map", "[v]",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
||||
"-r", String(args.fps), "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart", joined,
|
||||
], { logFile, label: `xfade join ${i}` });
|
||||
current = { path: joined, duration: +(current.duration + next.duration - fade).toFixed(3), xfadeOut: next.xfadeOut };
|
||||
i += 1;
|
||||
}
|
||||
segmentPaths.push(current.path);
|
||||
chainIndex += 1;
|
||||
}
|
||||
|
||||
const visualOnlyPath = path.join(baseOut, "visual-only.mp4");
|
||||
const concatListPath = path.join(baseOut, "concat-list.txt");
|
||||
concatSegments({ ffmpeg, segmentPaths, outPath: visualOnlyPath, listPath: concatListPath, logFile });
|
||||
|
||||
const outputPath = path.join(baseOut, `${safeName(args.slug, "timeline-export")}-${args.mode}${args.limitSeconds ? "-smoke" : ""}.mp4`);
|
||||
// sync-sound clips carry their own audio into the mix
|
||||
const syncSegments = plan.segments.filter((segment) =>
|
||||
segment.useSourceAudio && !segment.placeholder && segment.assetKind === "video" && segment.assetExists);
|
||||
if (plan.audioMaster.path || syncSegments.length) {
|
||||
mixAudio({
|
||||
ffmpeg,
|
||||
silentVideoPath: visualOnlyPath,
|
||||
audioMasterPath: plan.audioMaster.path || null,
|
||||
audioBeds: plan.audioBeds.filter((bed) => bed.assetExists),
|
||||
syncSegments,
|
||||
outPath: outputPath,
|
||||
totalDuration: plan.totalDurationS,
|
||||
audioLoudnessTarget: plan.audioLoudnessTarget,
|
||||
logFile,
|
||||
fps: args.fps,
|
||||
});
|
||||
} else {
|
||||
// pre-audio board : the visual concat IS the review export
|
||||
fs.copyFileSync(visualOnlyPath, outputPath);
|
||||
}
|
||||
|
||||
const contactSheetPath = path.join(baseOut, "contact-sheet.jpg");
|
||||
makeContactSheet({ ffmpeg, videoPath: outputPath, outPath: contactSheetPath, logFile });
|
||||
|
||||
const manifest = {
|
||||
createdAt: new Date().toISOString(),
|
||||
mode: args.mode,
|
||||
smoke: Boolean(args.maxCards || args.limitSeconds),
|
||||
timeline: timelinePath,
|
||||
output: outputPath,
|
||||
visualOnly: visualOnlyPath,
|
||||
contactSheet: contactSheetPath,
|
||||
ffmpegLog: logFile,
|
||||
concatList: concatListPath,
|
||||
width: args.width,
|
||||
height: args.height,
|
||||
fps: args.fps,
|
||||
totalDurationS: plan.totalDurationS,
|
||||
validation: plan.validation,
|
||||
segments: plan.segments,
|
||||
audioMaster: plan.audioMaster,
|
||||
audioBeds: plan.audioBeds,
|
||||
audioPolicy: `Card source audio is opt-in. The final mix is normalized to ${plan.audioLoudnessTarget} LUFS and limited to -1.5 dBTP.`,
|
||||
...(degradedSlates.length ? { degraded: { unlabelledSlates: [...degradedSlates] } } : {}),
|
||||
};
|
||||
const manifestPath = path.join(baseOut, "assembly-manifest.json");
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
writeCurrentExport(sliceRoot, args.mode + (args.maxCards || args.limitSeconds ? "-smoke" : ""), {
|
||||
output: outputPath,
|
||||
manifest: manifestPath,
|
||||
contactSheet: contactSheetPath,
|
||||
totalDurationS: plan.totalDurationS,
|
||||
segmentCount: plan.segments.length,
|
||||
timeline: timelinePath,
|
||||
});
|
||||
|
||||
const result = {
|
||||
ok: true,
|
||||
output: outputPath,
|
||||
outDir: baseOut,
|
||||
manifest: manifestPath,
|
||||
visualOnly: visualOnlyPath,
|
||||
contactSheet: contactSheetPath,
|
||||
ffmpegLog: logFile,
|
||||
concatList: concatListPath,
|
||||
totalDurationS: plan.totalDurationS,
|
||||
segmentCount: plan.segments.length,
|
||||
// Reported, not buried in a log. The export SUCCEEDED and some placeholder
|
||||
// cards have no words on them, and the person watching the cut is entitled
|
||||
// to know which rather than wondering why a card is blank.
|
||||
...(degradedSlates.length ? {
|
||||
warnings: [
|
||||
`${degradedSlates.length} placeholder slate(s) rendered without labels — this ffmpeg build has no drawtext filter and no macOS text fallback is available: ${degradedSlates.join(", ")}`,
|
||||
],
|
||||
} : {}),
|
||||
};
|
||||
if (args.json) console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
console.log(`Export complete: ${outputPath}`);
|
||||
console.log(`manifest: ${manifestPath}`);
|
||||
console.log(`contact sheet: ${contactSheetPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
if (process.argv.includes("--json")) {
|
||||
console.log(JSON.stringify({ ok: false, error: error.message }, null, 2));
|
||||
} else {
|
||||
console.error(error.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// CANVAS rails — validate → backup → apply → history for <bundle>/canvas.json.
|
||||
// The doc is a thin wrapper over the NATIVE tldraw snapshot (pinned bundle
|
||||
// app/vendor/tldraw-v5.2.5): { rev, updated_at, updated_by, document:{schema,store} }.
|
||||
// Agents READ canvas.json directly; agents and the UI WRITE through
|
||||
// /api/canvas/apply with baseRev discipline (409 + current doc on mismatch).
|
||||
// Design: . No staged-draft
|
||||
// step by design — undo depth = coalesced backups + history.
|
||||
|
||||
// The provider carries its OWN copy of the tldraw vector codec. It used to
|
||||
// reach ../../vendor across a shared repo -- an implicit dependency that only
|
||||
// worked while the app and the provider lived in one tree. Splitting the repos
|
||||
// turned it into a MODULE_NOT_FOUND, which is the honest form of it: the
|
||||
// browser BUNDLE is an app asset, this codec is a provider dependency, and
|
||||
// they are two different things that happened to sit in one folder.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { b64Vecs } from "./vendor/b64Vecs.mjs";
|
||||
|
||||
const MAX_DOC_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_RECORDS = 5000;
|
||||
const BACKUP_COALESCE_MS = 10 * 60 * 1000;
|
||||
|
||||
export function canvasPaths(sliceRoot) {
|
||||
return {
|
||||
docPath: path.join(sliceRoot, "canvas.json"),
|
||||
backupsDir: path.join(sliceRoot, "canvas-backups"),
|
||||
historyPath: path.join(sliceRoot, "canvas-history.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
export function readCanvas(sliceRoot) {
|
||||
const { docPath } = canvasPaths(sliceRoot);
|
||||
if (!fs.existsSync(docPath)) return { rev: 0, updated_at: null, updated_by: null, document: null };
|
||||
return JSON.parse(fs.readFileSync(docPath, "utf8"));
|
||||
}
|
||||
|
||||
// Media src law: shapes reference media ONLY through the same-origin rails so
|
||||
// the asset allowlist keeps holding on the canvas. No http(s), no base64 blobs.
|
||||
function validateSrc(src, { isAllowed }) {
|
||||
const value = String(src || "");
|
||||
if (!value) return null;
|
||||
if (value.startsWith("data:") || /^https?:\/\//.test(value)) {
|
||||
return `asset src must use the media rails (/api/media?path=... or /api/library/media?path=...), got: ${value.slice(0, 80)}`;
|
||||
}
|
||||
const match = /^\/api\/(media|library\/media|thumb)\?/.exec(value);
|
||||
if (!match) return `asset src is not a recognized media rail: ${value.slice(0, 80)}`;
|
||||
const wanted = new URL(value, "http://x").searchParams.get(match[1] === "thumb" ? "abs" : "path")
|
||||
|| new URL(value, "http://x").searchParams.get("src") || "";
|
||||
if (wanted && !isAllowed(wanted, match[1])) {
|
||||
return `asset src path is not an indexed project asset or under a media root: ${wanted.slice(0, 120)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCanvasDoc(document, { isAllowed }) {
|
||||
const errors = [];
|
||||
if (!document || typeof document !== "object") return { ok: false, errors: ["document is required"] };
|
||||
if (!document.schema || typeof document.schema !== "object") errors.push("document.schema missing (native tldraw snapshot expected)");
|
||||
const store = document.store;
|
||||
if (!store || typeof store !== "object") errors.push("document.store missing");
|
||||
const records = store ? Object.values(store) : [];
|
||||
if (records.length > MAX_RECORDS) errors.push(`record count ${records.length} exceeds clamp ${MAX_RECORDS}`);
|
||||
for (const [key, record] of Object.entries(store || {})) {
|
||||
if (!record || typeof record !== "object" || !record.typeName || record.id !== key) {
|
||||
errors.push(`malformed record at key ${key}`);
|
||||
continue;
|
||||
}
|
||||
if (record.typeName === "asset") {
|
||||
const srcError = validateSrc(record.props?.src, { isAllowed });
|
||||
if (srcError) errors.push(`${key}: ${srcError}`);
|
||||
}
|
||||
}
|
||||
const bytes = Buffer.byteLength(JSON.stringify(document));
|
||||
if (bytes > MAX_DOC_BYTES) errors.push(`document is ${bytes} bytes, clamp is ${MAX_DOC_BYTES}`);
|
||||
return { ok: errors.length === 0, errors, recordCount: records.length, bytes };
|
||||
}
|
||||
|
||||
// Coalesced backups: a sketch surface autosaving every ~1.5s must not mint a
|
||||
// backup per stroke. One backup per actor per burst window; a NEW actor always
|
||||
// backs up (that boundary is exactly what a restore wants to reach).
|
||||
function maybeBackup({ backupsDir, current, actor }) {
|
||||
if (!current.document) return null;
|
||||
fs.mkdirSync(backupsDir, { recursive: true });
|
||||
const latest = fs.readdirSync(backupsDir).filter((name) => name.endsWith(".json")).sort().pop();
|
||||
if (latest) {
|
||||
const stat = fs.statSync(path.join(backupsDir, latest));
|
||||
const sameActor = latest.includes(`-by-${String(actor).replace(/[^a-zA-Z0-9.@-]+/g, "_")}`);
|
||||
if (sameActor && Date.now() - stat.mtimeMs < BACKUP_COALESCE_MS) return null;
|
||||
}
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const safeActor = String(actor).replace(/[^a-zA-Z0-9.@-]+/g, "_");
|
||||
const backupPath = path.join(backupsDir, `canvas-${stamp}-by-${safeActor}.json`);
|
||||
fs.writeFileSync(backupPath, JSON.stringify(current, null, 2));
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
function diffCounts(before, after) {
|
||||
const a = before?.store || {};
|
||||
const b = after?.store || {};
|
||||
let added = 0, removed = 0, updated = 0;
|
||||
for (const key of Object.keys(b)) {
|
||||
if (!(key in a)) added++;
|
||||
else if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) updated++;
|
||||
}
|
||||
for (const key of Object.keys(a)) if (!(key in b)) removed++;
|
||||
return { added, removed, updated };
|
||||
}
|
||||
|
||||
// Collaboration rails : agents get capability PARITY
|
||||
// through plain data — the rail does the format lifting.
|
||||
// 1. Ink normalization: agents write draw segments as honest
|
||||
// {type, points:[{x,y,z?}]} — the rail encodes to tldraw 5.x's packed
|
||||
// {type, path} storage form (vendored codec, dim 3, z = pressure 0.5).
|
||||
// 2. Authorship: every record ADDED by an apply gets meta.author = actor
|
||||
// (unless the author already set it) — on-canvas provenance is stamped
|
||||
// by code, never left to caller diligence.
|
||||
function normalizeForCollab(document, currentDocument, actor) {
|
||||
const before = currentDocument?.store || {};
|
||||
for (const record of Object.values(document.store || {})) {
|
||||
if (record?.typeName === "shape" && record.type === "draw" && Array.isArray(record.props?.segments)) {
|
||||
record.props.segments = record.props.segments.map((segment) => {
|
||||
if (!segment || segment.path !== undefined || !Array.isArray(segment.points)) return segment;
|
||||
const points = segment.points.map((p) => ({ x: Number(p.x) || 0, y: Number(p.y) || 0, z: p.z == null ? 0.5 : Number(p.z) }));
|
||||
return { type: segment.type || "free", path: b64Vecs.encodePoints(points, 3) };
|
||||
});
|
||||
}
|
||||
if (record && typeof record === "object" && !(record.id in before)) {
|
||||
record.meta = { ...(record.meta || {}) };
|
||||
if (!record.meta.author) record.meta.author = String(actor || "unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyCanvas({ sliceRoot, baseRev, document, actor, reason, isAllowed }) {
|
||||
const paths = canvasPaths(sliceRoot);
|
||||
const current = readCanvas(sliceRoot);
|
||||
if (Number(baseRev) !== current.rev) {
|
||||
return { ok: false, status: 409, error: `rev mismatch: base ${baseRev}, current ${current.rev} — re-read and merge`, rev: current.rev, document: current.document };
|
||||
}
|
||||
if (document && typeof document === "object") normalizeForCollab(document, current.document, actor);
|
||||
const validation = validateCanvasDoc(document, { isAllowed });
|
||||
if (!validation.ok) return { ok: false, status: 400, error: "validation failed", errors: validation.errors, rev: current.rev };
|
||||
const backupPath = maybeBackup({ backupsDir: paths.backupsDir, current, actor });
|
||||
const next = {
|
||||
rev: current.rev + 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
updated_by: String(actor || "unknown"),
|
||||
document,
|
||||
};
|
||||
const tmp = `${paths.docPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(next, null, 1));
|
||||
fs.renameSync(tmp, paths.docPath);
|
||||
const counts = diffCounts(current.document, document);
|
||||
fs.appendFileSync(paths.historyPath, JSON.stringify({
|
||||
ts: next.updated_at, actor: next.updated_by, rev: next.rev,
|
||||
reason: String(reason || "").slice(0, 200), ...counts,
|
||||
records: validation.recordCount,
|
||||
}) + "\n");
|
||||
return { ok: true, rev: next.rev, backup: backupPath ? path.relative(sliceRoot, backupPath) : null, ...counts };
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
// the cut health strip's data - one CHEAP aggregation (fs checks
|
||||
// only, no ffprobe) answering "what does this video need next". Phase is
|
||||
// DERIVED from counts, never stored . Locked = approved in
|
||||
// the slots ledger; done = no placeholders + all slots locked + a master.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isPlaceholderCard, materializeCards, planExport, readTimelineFile, resolveRefPath } from "./timeline-export-core.mjs";
|
||||
import { analyzeTimelineAudio, maybeReadText, readJsonFile } from "./timeline-audio-validation.mjs";
|
||||
import { checkBundle } from "./studio-bundle.mjs";
|
||||
|
||||
// v1 doctrine thresholds : storyboard while placeholders
|
||||
// hold at least half the seats; finishing once locks reach half.
|
||||
export function healthPhase({ cardCount, placeholders, locked, hasMaster, masterIsScratch = false }) {
|
||||
if (!cardCount) return "concept";
|
||||
// scratch VO caps at finishing: done means the REAL audio spine
|
||||
if (placeholders === 0 && locked >= cardCount && hasMaster && !masterIsScratch) return "done";
|
||||
if (locked >= cardCount / 2) return "finishing";
|
||||
if (placeholders >= cardCount / 2) return "storyboard";
|
||||
return "assembly";
|
||||
}
|
||||
|
||||
export function computeHealth({ sliceRoot, timelinePath }) {
|
||||
const timeline = readTimelineFile(timelinePath);
|
||||
const cards = materializeCards(timeline);
|
||||
const sequence = Array.isArray(timeline.sequence) ? timeline.sequence : [];
|
||||
const gaps = sequence.filter((entry) => entry.type === "gap");
|
||||
|
||||
// one cheap plan (fs checks, NO probes - validatePlanWithProbes is never
|
||||
// called here) feeds placeholders, missing media, and the audio segments
|
||||
const plan = planExport(timeline, { sliceRoot, mode: "review", checkFiles: true });
|
||||
const placeholderCards = cards.filter((card) => isPlaceholderCard(card));
|
||||
const placeholderIds = new Set(placeholderCards.map((card) => String(card.id)));
|
||||
const missing = (plan.assets || []).filter((asset) =>
|
||||
asset.exists === false && !placeholderIds.has(String(asset.cardId)));
|
||||
|
||||
// slots ledger: locked = approved ; candidates = takes waiting
|
||||
let slots = [];
|
||||
try { slots = JSON.parse(fs.readFileSync(path.join(sliceRoot, "timeline-slots.json"), "utf8")).slots || []; } catch {}
|
||||
const slotByDot = new Map(slots.map((slot) => [String(slot.dot_id), slot]));
|
||||
const lockedCards = cards.filter((card) => slotByDot.get(String(card.id))?.approved === true);
|
||||
const firstUnlocked = cards.find((card) => slotByDot.get(String(card.id))?.approved !== true);
|
||||
const takesWaiting = cards.filter((card) => Number(slotByDot.get(String(card.id))?.candidate_count) > 0);
|
||||
|
||||
// audio anomalies: cheap words/captions analysis; a pre-audio board is a
|
||||
// legitimate state, not a wall of parity errors
|
||||
let audioAnomalies = [];
|
||||
if (timeline.audio_master) {
|
||||
try {
|
||||
const wordsPayload = timeline.words_json
|
||||
? readJsonFile(resolveRefPath(timeline.words_json, { sliceRoot }))
|
||||
: null;
|
||||
const captionsText = timeline.captions_vtt
|
||||
? maybeReadText(resolveRefPath(timeline.captions_vtt, { sliceRoot }))
|
||||
: "";
|
||||
const report = analyzeTimelineAudio(timeline, { wordsPayload, captionsText, sliceRoot, segments: plan.segments || [] });
|
||||
audioAnomalies = (report.anomalies || []).filter((anomaly) => anomaly.severity !== "info");
|
||||
} catch (error) {
|
||||
audioAnomalies = [{ severity: "error", message: `audio analysis failed: ${error?.message || error}` }];
|
||||
}
|
||||
}
|
||||
|
||||
// filing: null for grandfathered (no PLACEMENT.md) projects, same contract
|
||||
// as /api/project-info
|
||||
let strays = null;
|
||||
if (fs.existsSync(path.join(sliceRoot, "PLACEMENT.md"))) {
|
||||
try { strays = checkBundle(sliceRoot).strays; } catch { strays = null; }
|
||||
}
|
||||
|
||||
const counts = {
|
||||
cardCount: cards.length,
|
||||
placeholders: placeholderCards.length,
|
||||
locked: lockedCards.length,
|
||||
hasMaster: Boolean(timeline.audio_master),
|
||||
masterIsScratch: String(timeline.audio_master_status || "") === "scratch",
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
computed_at: new Date().toISOString(),
|
||||
phase: healthPhase(counts),
|
||||
audio_status: timeline.audio_master ? String(timeline.audio_master_status || "unknown") : null,
|
||||
slots_total: cards.length,
|
||||
locked: lockedCards.length,
|
||||
first_unlocked: firstUnlocked ? String(firstUnlocked.id) : null,
|
||||
placeholders: placeholderCards.length,
|
||||
first_placeholder: placeholderCards.length ? String(placeholderCards[0].id) : null,
|
||||
gaps: gaps.length,
|
||||
first_gap_at_s: gaps.length ? Number(gaps[0].start_s ?? 0) : null,
|
||||
takes_waiting: takesWaiting.length,
|
||||
first_takes_slot: takesWaiting.length ? String(takesWaiting[0].id) : null,
|
||||
audio_anomalies: audioAnomalies.length,
|
||||
first_audio_anomaly: audioAnomalies.length ? (audioAnomalies[0].cardId || audioAnomalies[0].card_id || null) : null,
|
||||
missing_media: missing.length,
|
||||
first_missing: missing.length ? String(missing[0].cardId) : null,
|
||||
strays: strays === null ? null : strays.length,
|
||||
};
|
||||
}
|
||||
Generated
+234
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"name": "@openrig/studio-video",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openrig/studio-video",
|
||||
"version": "0.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"ffprobe-static": "^3.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"openrig-studio-video": "export-server.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/ffprobe-static": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz",
|
||||
"integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"name": "@openrig/studio-video",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "The video/timeline provider for OpenRig Studio surfaces. Serves the verbs mini-nle, media-manager and canvas call. Ported from studio-box; not extracted, not rewritten.",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"bin": {
|
||||
"openrig-studio-video": "./export-server.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": "./export-server.mjs",
|
||||
"./scaffold": "./video-new.mjs",
|
||||
"./timeline-patch": "./timeline-patch.mjs",
|
||||
"./project-assets": "./project-assets.mjs"
|
||||
},
|
||||
"openrig": {
|
||||
"_note": "DECLARED, not discovered. Everything below is something an installer would otherwise have to infer from the code \u2014 and inferring it is how you get a hardcoded path. The empty-slot principle: if there is a field for it, nobody inlines it.",
|
||||
"kind": "provider",
|
||||
"surfaces_served": [
|
||||
"mini-nle",
|
||||
"media-manager",
|
||||
"canvas"
|
||||
],
|
||||
"start": {
|
||||
"entry": "export-server.mjs",
|
||||
"args": {
|
||||
"--port": "required; the provider binds loopback only",
|
||||
"--slice-root": "required; the project bundle (holds timeline.json)",
|
||||
"--canvas-root": "optional; defaults to <slice-root>/../canvases",
|
||||
"--media-root": "repeatable; the user's media libraries",
|
||||
"--timeline": "optional; defaults to <slice-root>/timeline.json"
|
||||
}
|
||||
},
|
||||
"roots": {
|
||||
"_note": "KINDS, not paths. The install binds these to real directories on the user's machine. Every hardcoded path removed from this tree existed because there was nowhere to say this.",
|
||||
"project": {
|
||||
"required": true,
|
||||
"description": "the bundle being edited; timeline.json, history, exports live here"
|
||||
},
|
||||
"media": {
|
||||
"required": true,
|
||||
"repeatable": true,
|
||||
"description": "source footage and stills; never inside the app"
|
||||
},
|
||||
"canvas": {
|
||||
"required": false,
|
||||
"description": "freeform canvas documents, shared across projects"
|
||||
}
|
||||
},
|
||||
"binaries": {
|
||||
"ffmpeg": {
|
||||
"required": true,
|
||||
"used_for": "thumbnails, cut rendering, assembly"
|
||||
},
|
||||
"ffprobe": {
|
||||
"required": true,
|
||||
"used_for": "duration and codec probing of indexed assets"
|
||||
},
|
||||
"rig": {
|
||||
"required": false,
|
||||
"used_for": "the note verbs file work into the owner's own rig queue"
|
||||
},
|
||||
"open": {
|
||||
"required": false,
|
||||
"platform": "darwin",
|
||||
"used_for": "reveal-in-Finder; not served in this studio"
|
||||
},
|
||||
"_resolution": "ffmpeg/ffprobe are resolved by resolveSystemBin from TRUSTED BIN DIRS only (/opt/homebrew/bin, /usr/local/bin, /usr/bin) and it throws a named error otherwise. Worth knowing on a distro that installs elsewhere."
|
||||
},
|
||||
"first_run": {
|
||||
"_note": "Without this the timeline verbs answer ENOENT on a new project \u2014 honest, but not a usable app.",
|
||||
"scaffold": "video-new.mjs :: scaffoldBundle(name, { parent })",
|
||||
"creates": "timeline.json, history, slots, doctrine, media/ and exports/ skeleton"
|
||||
},
|
||||
"peers": {
|
||||
"cutdown": {
|
||||
"_note": "/api/curation proxies to the cutdown backend over loopback. It defaults to 127.0.0.1:8795 and MUST be bound explicitly \u2014 left to the default it will happily read whatever else is on that port and answer ok:true.",
|
||||
"env": "CUTDOWN_API",
|
||||
"degrades": "absent cutdown -> {ok:false, cuts:[]} -> media-manager falls back to a plain grid"
|
||||
}
|
||||
},
|
||||
"vendored": {
|
||||
"vendor/b64Vecs.mjs": "tldraw vector codec, used server-side by canvas-rails. The browser bundle is NOT here - it is an app asset and ships with apps/canvas."
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"ffprobe-static": "^3.1.0"
|
||||
},
|
||||
"_deps_note": "ffmpeg/ffprobe ship WITH this provider rather than being assumed on PATH. Verified: ffmpeg-static 5.3.0 is ffmpeg 6.0 and carries drawtext/scale/xfade/concat. A stock Linux box commonly has node+npm+git and neither binary \u2014 this is what makes npm ci sufficient there."
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env node
|
||||
// Derived active-asset index (project-assets.json) for the mini-NLE Assets tab
|
||||
// and the server reveal/stream allowlist. Authority stays with timeline.json,
|
||||
// timeline-history.jsonl, and _CANON; this index is rebuildable at any time.
|
||||
//
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
fileExists,
|
||||
materializeCards,
|
||||
mediaKind,
|
||||
readTimelineFile,
|
||||
resolveRefPath,
|
||||
} from "./timeline-export-core.mjs";
|
||||
import { probeMedia, resolveSystemBin } from "./timeline-export-probe.mjs";
|
||||
import { readHistory } from "./timeline-history.mjs";
|
||||
|
||||
const TEXT_ROLES = new Set(["captions", "word-timings", "phrase-timings", "manifest"]);
|
||||
|
||||
function assetIdFor(role, ref) {
|
||||
const base = String(ref).split("/").filter(Boolean).slice(-2).join("-").replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
return `${role}:${base}`;
|
||||
}
|
||||
|
||||
function mediaTypeFor(ref, role) {
|
||||
if (TEXT_ROLES.has(role)) return "text";
|
||||
const kind = mediaKind(ref);
|
||||
return kind === "unknown" ? "text" : kind;
|
||||
}
|
||||
|
||||
// a media file whose name LEADS with a slot token registers as a
|
||||
// take candidate for that slot ("4.3-take2.mp4", "04-3-v2.mp4" -> 4.3).
|
||||
// Current boards can also use one-part ids ("01-cold-open.png" -> 1), but
|
||||
// only bind those with timeline context so loose filenames do not overmatch.
|
||||
// The filename IS the registration - no new storage.
|
||||
export function slotRefFromFileName(name, validSlotRefs = null) {
|
||||
const value = String(name);
|
||||
const splitMatch = value.match(/^0?(\d+)[-._]0?(\d+)(?=[-._\s])/);
|
||||
const splitRef = splitMatch ? `${Number(splitMatch[1])}.${Number(splitMatch[2])}` : null;
|
||||
if (!validSlotRefs) return splitRef;
|
||||
|
||||
const valid = validSlotRefs instanceof Set ? validSlotRefs : new Set([...validSlotRefs].map(String));
|
||||
if (splitRef && valid.has(splitRef)) return splitRef;
|
||||
|
||||
const singleMatch = value.match(/^0?(\d+)(?=[-._\s])/);
|
||||
const singleRef = singleMatch ? `${Number(singleMatch[1])}` : null;
|
||||
if (singleRef && valid.has(singleRef)) return singleRef;
|
||||
|
||||
return splitRef;
|
||||
}
|
||||
|
||||
export function collectAssetEntries(timeline, { sliceRoot, historyPath }) {
|
||||
const entries = new Map();
|
||||
const cards = materializeCards(timeline);
|
||||
const validSlotRefs = new Set(cards.map((card) => String(card.id)));
|
||||
|
||||
function add(ref, { role, slotRef = null, title = "", status = "active", historyEventId = null, note = "" }) {
|
||||
const value = String(ref || "").trim();
|
||||
if (!value) return;
|
||||
const resolved = resolveRefPath(value, { sliceRoot });
|
||||
const key = resolved;
|
||||
const existing = entries.get(key);
|
||||
if (existing) {
|
||||
if (slotRef && !existing.slot_refs.includes(slotRef)) existing.slot_refs.push(slotRef);
|
||||
if (historyEventId && !existing.history_event_ids.includes(historyEventId)) existing.history_event_ids.push(historyEventId);
|
||||
// current-slot-occupant beats historical/canon roles when the same file shows up twice
|
||||
if (role === "current-slot-occupant") existing.role = role;
|
||||
if (status === "active") existing.status = "active";
|
||||
return;
|
||||
}
|
||||
entries.set(key, {
|
||||
asset_id: assetIdFor(role, value),
|
||||
role,
|
||||
media_type: mediaTypeFor(value, role),
|
||||
status,
|
||||
slot_refs: slotRef ? [slotRef] : [],
|
||||
title,
|
||||
ref: value,
|
||||
path: resolved,
|
||||
exists: fileExists(resolved),
|
||||
history_event_ids: historyEventId ? [historyEventId] : [],
|
||||
note,
|
||||
});
|
||||
}
|
||||
|
||||
function addBundleMedia(relDir, { role, titlePrefix }) {
|
||||
const dir = path.join(sliceRoot, relDir);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const ref = path.join(relDir, name);
|
||||
if (mediaKind(ref) === "unknown") continue;
|
||||
const resolved = resolveRefPath(ref, { sliceRoot });
|
||||
if (entries.has(resolved)) continue;
|
||||
const slotRef = slotRefFromFileName(name, validSlotRefs);
|
||||
add(ref, { role: slotRef ? "candidate" : role, slotRef, status: "candidate", title: `${titlePrefix}/${name}` });
|
||||
}
|
||||
}
|
||||
|
||||
add(timeline.audio_master, { role: "audio-master", title: "Narration master" });
|
||||
add(timeline.captions_vtt, { role: "captions", title: "Captions VTT" });
|
||||
add(timeline.words_json, { role: "word-timings", title: "Word timings" });
|
||||
add(timeline.subtitles_srt, { role: "captions", title: "Release subtitles (SRT)" });
|
||||
add(timeline.loudness_report, { role: "manifest", title: "Loudness report" });
|
||||
add(timeline.audio_master_previous, { role: "audio-master", status: "historical", title: "Previous narration master" });
|
||||
|
||||
// index the active audio plan's package artifacts when present
|
||||
const currentAudioPath = path.join(sliceRoot, "current-audio.json");
|
||||
if (fs.existsSync(currentAudioPath)) {
|
||||
try {
|
||||
const plan = JSON.parse(fs.readFileSync(currentAudioPath, "utf8"));
|
||||
for (const [key, role] of [["srt", "captions"], ["loudness_report", "manifest"]]) {
|
||||
if (plan?.active?.[key]) add(plan.active[key], { role, title: `Audio package ${key}` });
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const bed of [...(timeline.audio_beds || []), ...(timeline.music_beds || [])]) {
|
||||
add(bed.asset, { role: "audio-bed", slotRef: String(bed.id || ""), title: String(bed.title || "Audio bed") });
|
||||
}
|
||||
|
||||
for (const card of cards) {
|
||||
const slotRef = String(card.id);
|
||||
add(card.asset, { role: "current-slot-occupant", slotRef, title: String(card.title || "") });
|
||||
add(card.asset_source, { role: "source", slotRef, title: `${card.title || slotRef} (source)` });
|
||||
add(card.audio_asset, { role: "card-audio-source", slotRef, title: `${slotRef} audio segment` });
|
||||
add(card.audio_words_json, { role: "word-timings", slotRef, title: `${slotRef} words` });
|
||||
add(card.audio_phrase_timings_json, { role: "phrase-timings", slotRef, title: `${slotRef} phrase timings` });
|
||||
add(card.captions_vtt, { role: "captions", slotRef, title: `${slotRef} captions` });
|
||||
}
|
||||
|
||||
if (historyPath && fs.existsSync(historyPath)) {
|
||||
for (const event of readHistory(historyPath)) {
|
||||
const slotRef = String(event?.slot?.dot_id_after || event?.slot?.dot_id_before || "");
|
||||
for (const side of ["before", "after"]) {
|
||||
const asset = event?.[side]?.asset;
|
||||
if (!asset) continue;
|
||||
add(asset, {
|
||||
role: "historical-occupant",
|
||||
slotRef,
|
||||
status: "historical",
|
||||
historyEventId: event.event_id,
|
||||
title: `${slotRef} ${side} (${event.event_id})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const spec of [
|
||||
["media/renders", { role: "bundle-media", titlePrefix: "renders" }],
|
||||
["media/captures", { role: "bundle-media", titlePrefix: "captures" }],
|
||||
["media/audio", { role: "bundle-media", titlePrefix: "audio" }],
|
||||
["media/images", { role: "bundle-media", titlePrefix: "images" }],
|
||||
["exports", { role: "distribution-export", titlePrefix: "exports" }],
|
||||
]) {
|
||||
addBundleMedia(...spec);
|
||||
}
|
||||
|
||||
// _CANON selects: list media files, mark as active when the timeline references them.
|
||||
const canonDir = path.join(sliceRoot, "_CANON");
|
||||
if (fs.existsSync(canonDir)) {
|
||||
for (const group of ["slides", "clips"]) {
|
||||
const groupDir = path.join(canonDir, group);
|
||||
if (!fs.existsSync(groupDir)) continue;
|
||||
for (const name of fs.readdirSync(groupDir)) {
|
||||
const ref = path.join("_CANON", group, name);
|
||||
if (mediaKind(ref) === "unknown") continue;
|
||||
const resolved = resolveRefPath(ref, { sliceRoot });
|
||||
if (!entries.has(resolved)) {
|
||||
const slotRef = slotRefFromFileName(name);
|
||||
add(ref, { role: slotRef ? "candidate" : "canon-select", slotRef, status: "candidate", title: `${group}/${name}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries.values()];
|
||||
}
|
||||
|
||||
export function probeEntries(entries, { probeLimitRoles = new Set(["current-slot-occupant", "audio-master", "audio-bed", "source", "historical-occupant"]) } = {}) {
|
||||
let ffprobe;
|
||||
try {
|
||||
ffprobe = resolveSystemBin("ffprobe");
|
||||
} catch {
|
||||
return entries; // no ffprobe available; index still useful without metadata
|
||||
}
|
||||
return entries.map((entry) => {
|
||||
if (!entry.exists || !probeLimitRoles.has(entry.role)) return entry;
|
||||
if (entry.media_type !== "video" && entry.media_type !== "audio") return entry;
|
||||
try {
|
||||
const probe = probeMedia(ffprobe, entry.path);
|
||||
const video = (probe.streams || []).find((stream) => stream.codec_type === "video");
|
||||
return {
|
||||
...entry,
|
||||
duration_s: probe.duration_s,
|
||||
width: video?.width,
|
||||
height: video?.height,
|
||||
codec: video?.codec_name || (probe.streams || [])[0]?.codec_name,
|
||||
};
|
||||
} catch {
|
||||
return entry;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function generateProjectAssets({ sliceRoot, timelinePath, historyPath, assetsPath, probe = true }) {
|
||||
const timeline = readTimelineFile(timelinePath);
|
||||
let assets = collectAssetEntries(timeline, { sliceRoot, historyPath });
|
||||
if (probe) assets = probeEntries(assets);
|
||||
const payload = {
|
||||
_note: "DERIVED active-asset index for the mini-NLE Assets tab and the reveal/stream allowlist. Rebuild with project-assets.mjs generate. Not a source of truth.",
|
||||
generated_at: new Date().toISOString(),
|
||||
slice_root: sliceRoot,
|
||||
asset_count: assets.length,
|
||||
assets,
|
||||
};
|
||||
fs.writeFileSync(assetsPath, JSON.stringify(payload, null, 2));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function loadAllowlist(assetsPath) {
|
||||
const resolvedPaths = new Set();
|
||||
if (!fs.existsSync(assetsPath)) return resolvedPaths;
|
||||
const payload = JSON.parse(fs.readFileSync(assetsPath, "utf8"));
|
||||
for (const asset of payload.assets || []) {
|
||||
for (const key of ["path", "source_path", "proxy_path"]) {
|
||||
const value = asset[key];
|
||||
if (!value) continue;
|
||||
try {
|
||||
resolvedPaths.add(fs.realpathSync(value));
|
||||
} catch {
|
||||
resolvedPaths.add(path.resolve(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolvedPaths;
|
||||
}
|
||||
|
||||
export function resolveRoots(roots) {
|
||||
return (roots || []).map((root) => {
|
||||
try {
|
||||
return fs.realpathSync(root);
|
||||
} catch {
|
||||
return path.resolve(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Membership in the derived index AND residence under a configured root
|
||||
// (slice root / media roots). Both gates required ().
|
||||
export function isAllowlisted(candidatePath, allowlist, roots = null) {
|
||||
if (!candidatePath || typeof candidatePath !== "string") return false;
|
||||
let resolved;
|
||||
try {
|
||||
resolved = fs.realpathSync(candidatePath);
|
||||
} catch {
|
||||
return false; // must exist to be revealed/streamed
|
||||
}
|
||||
if (!allowlist.has(resolved)) return false;
|
||||
if (roots === null) return true;
|
||||
return resolveRoots(roots).some((root) => resolved === root || resolved.startsWith(root + path.sep));
|
||||
}
|
||||
|
||||
export function defaultAssetPaths(sliceRoot) {
|
||||
return {
|
||||
timelinePath: path.join(sliceRoot, "timeline.json"),
|
||||
historyPath: path.join(sliceRoot, "timeline-history.jsonl"),
|
||||
assetsPath: path.join(sliceRoot, "project-assets.json"),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const command = argv[0];
|
||||
const options = { sliceRoot: path.resolve(new URL("../../", import.meta.url).pathname), json: false, probe: true };
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--slice-root") options.sliceRoot = path.resolve(argv[++i]);
|
||||
else if (arg === "--no-probe") options.probe = false;
|
||||
else if (arg === "--json") options.json = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (command === "generate") {
|
||||
const paths = defaultAssetPaths(options.sliceRoot);
|
||||
const payload = generateProjectAssets({ sliceRoot: options.sliceRoot, ...paths, probe: options.probe });
|
||||
console.log(options.json
|
||||
? JSON.stringify({ ok: true, assetCount: payload.asset_count, assetsPath: paths.assetsPath }, null, 2)
|
||||
: `generated ${payload.asset_count} assets at ${paths.assetsPath}`);
|
||||
return;
|
||||
}
|
||||
console.log("Usage: node project-assets.mjs generate [--slice-root dir] [--no-probe] [--json]");
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
// The Studio Workspace Contract : ONE bundle spec shared by the
|
||||
// scaffolder (video-new) and the checker (bundle-check), so the convention
|
||||
// and its enforcement can never disagree.
|
||||
// star ().
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { mediaKind } from "./timeline-export-core.mjs";
|
||||
|
||||
// dirs created by video-new; `what` lines become PLACEMENT.md verbatim
|
||||
export const BUNDLE_DIRS = [
|
||||
{ dir: "intake", what: "UNSURE WHERE A FILE GOES? Put it here. bundle-check --file moves intake items to their canonical homes and indexes them. Wrong-but-in-intake beats confidently-misplaced." },
|
||||
{ dir: "media/renders", what: "installed visual renders in (or heading into) the timeline. installed visual renders land here (or stay external and get indexed)." },
|
||||
{ dir: "media/captures", what: "demo/terminal/browser captures, normalized. Raw capture trees may stay external; what the cut uses lives or is proxied here." },
|
||||
{ dir: "media/audio", what: "loose audio files in timeline use (beds, sfx). Narration master PACKAGES go to audio/packages/." },
|
||||
{ dir: "media/images", what: "stills and image cards used by the timeline." },
|
||||
{ dir: "audio/packages", what: "narration master packages: one dir per master (wav/mp3 + words json + vtt + srt + loudness). The re-conform wizard consumes these." },
|
||||
{ dir: "recordings", what: "your VO takes, raw. The audio pipeline turns these into packages." },
|
||||
{ dir: "_CANON/slides", what: "approved slide renders. Approval is what moves a file here - never park candidates in canon." },
|
||||
{ dir: "_CANON/clips", what: "approved demo clips." },
|
||||
{ dir: "_CANON/audio", what: "approved audio selects." },
|
||||
{ dir: "_CANON/stills", what: "approved stills." },
|
||||
{ dir: "review", what: "generated review surfaces (proxy boards, comparisons). Generated - safe to regenerate." },
|
||||
{ dir: "exports", what: "export output dirs (review/smoke/final). Heavy exports may live external; current-export.json always points at the latest." },
|
||||
{ dir: "reports/notes", what: "review notes (durable-first) + receivers.json. Written by the mini-NLE note flow." },
|
||||
{ dir: "reports/annotations", what: "annotated frames (PNG) attached to review notes." },
|
||||
{ dir: "reports/reconform", what: "re-conform movement reports + A/B listening pages." },
|
||||
{ dir: "reports/preflight", what: "media preflight results (preflight.mjs)." },
|
||||
{ dir: "reports/validation", what: "validator output kept for the record." },
|
||||
{ dir: "doctrine", what: "current-rules.md - the live rule card (privacy, approval gates, source-of-truth ownership). Rendered in the mini-NLE Info panel. UPDATE THIS when rules change; stale rules leak back." },
|
||||
{ dir: "archive", what: "retired surfaces (old boards, superseded docs). EXCLUDED from active indexes and bundle-check. Move things here instead of deleting or letting them haunt the root." },
|
||||
{ dir: "timeline-backups", what: "automatic pre-apply backups + applied patch archive. Written by the patch layer." },
|
||||
];
|
||||
|
||||
// files allowed at the bundle ROOT; everything else at root is a stray
|
||||
export const ROOT_ALLOW = new Set([
|
||||
"README.md", "PLACEMENT.md", "brief.md", "script.md", "aesthetic-direction.md",
|
||||
"timeline.json", "timeline-edits.patch.json", "timeline-history.jsonl",
|
||||
"timeline-slots.json", "timeline-board-proposal.json", "timeline-patched-preview.json",
|
||||
"project-assets.json", "current-audio.json",
|
||||
"current-export.json", "templates.json", "manifest.json", "mutation-owner.json",
|
||||
".DS_Store",
|
||||
]);
|
||||
|
||||
// the seat<->surface duality table (north star). The UI's lanes,
|
||||
// the seeded receivers, and the ownership default are projections of the
|
||||
// same topology. Static by design - the topology lives in the RigSpec; sync
|
||||
// from a live roster only if the roster starts churning.
|
||||
export const SEAT_LANES = {
|
||||
audio: "",
|
||||
visual: "",
|
||||
capture: "",
|
||||
assembly: "",
|
||||
review: "",
|
||||
};
|
||||
|
||||
export function seededReceivers() {
|
||||
// review/producer first: it stays the note form's fallback default
|
||||
return [SEAT_LANES.review, SEAT_LANES.audio, SEAT_LANES.visual, SEAT_LANES.capture, SEAT_LANES.assembly];
|
||||
}
|
||||
|
||||
export function mutationOwnerSeed() {
|
||||
return {
|
||||
_note: "Session that owns the NEXT canonical timeline apply (one-writer rule, soft-enforced: mismatched actors WARN and are recorded, never blocked - graduation to a hard gate is a doctrine decision). Move ownership by editing this file; reference the handoff in handoff_ref.",
|
||||
owner: SEAT_LANES.assembly,
|
||||
set_by: "video-new",
|
||||
set_at: new Date().toISOString(),
|
||||
handoff_ref: null,
|
||||
};
|
||||
}
|
||||
|
||||
// dirs whose CONTENTS are never classified (external-ish or self-managed)
|
||||
const SKIP_DIRS = new Set(["archive", "timeline-backups", "exports", "node_modules", ".git", "tools"]);
|
||||
|
||||
// template registry. Bundle-local templates.json wins; the
|
||||
// canonical copy beside the tools is the fallback, so every bundle speaks
|
||||
// the vocabulary without carrying it.
|
||||
const CANONICAL_TEMPLATES = path.join(path.dirname(new URL(import.meta.url).pathname), "templates.json");
|
||||
|
||||
export function loadTemplates(root) {
|
||||
for (const candidate of [root && path.join(root, "templates.json"), CANONICAL_TEMPLATES]) {
|
||||
if (!candidate || !fs.existsSync(candidate)) continue;
|
||||
try { return JSON.parse(fs.readFileSync(candidate, "utf8")).templates || []; } catch { /* fall through */ }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Resolve a card.template value (semantic id OR legacy free text like
|
||||
// "rig send T1") to a registry entry; null when nothing matches.
|
||||
export function resolveTemplate(templates, value) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return null;
|
||||
const exact = templates.find((entry) => entry.id === text);
|
||||
if (exact) return exact;
|
||||
const aliasHit = templates.find((entry) =>
|
||||
new RegExp(`(^|[^0-9A-Za-z])${entry.legacy_alias}([^0-9A-Za-z]|$)`).test(text));
|
||||
return aliasHit || null;
|
||||
}
|
||||
|
||||
export function templatesSeedPath() {
|
||||
return CANONICAL_TEMPLATES;
|
||||
}
|
||||
|
||||
export function proposeDestination(fileName) {
|
||||
const kind = mediaKind(fileName);
|
||||
if (kind === "video") return "media/renders/";
|
||||
if (kind === "image") return "media/images/";
|
||||
if (kind === "audio") return "media/audio/";
|
||||
if (/\.(vtt|srt)$/i.test(fileName)) return "media/audio/";
|
||||
if (/words.*\.json$/i.test(fileName)) return "media/audio/";
|
||||
if (/\.md$/i.test(fileName)) return "reports/";
|
||||
return "intake/";
|
||||
}
|
||||
|
||||
// walk the bundle and classify: returns { strays, intakeItems, scanned }
|
||||
export function checkBundle(root) {
|
||||
const strays = [];
|
||||
const intakeItems = [];
|
||||
let scanned = 0;
|
||||
const knownDirs = new Set(BUNDLE_DIRS.map((entry) => entry.dir.split("/")[0]));
|
||||
|
||||
function walk(dir, relBase) {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const full = path.join(dir, name);
|
||||
const rel = relBase ? `${relBase}/${name}` : name;
|
||||
const top = rel.split("/")[0];
|
||||
let stat;
|
||||
try { stat = fs.statSync(full); } catch { continue; }
|
||||
if (stat.isDirectory()) {
|
||||
if (SKIP_DIRS.has(top) || name.startsWith(".")) continue;
|
||||
walk(full, rel);
|
||||
continue;
|
||||
}
|
||||
scanned++;
|
||||
// outline/board variants (timeline-*.json) are tool outputs
|
||||
if (relBase === "" && !ROOT_ALLOW.has(name) && !/^timeline-.*\.json$/.test(name)) {
|
||||
strays.push({ path: rel, proposed: proposeDestination(name), reason: "unexpected file at bundle root" });
|
||||
continue;
|
||||
}
|
||||
if (top === "intake") {
|
||||
intakeItems.push({ path: rel, proposed: proposeDestination(name) });
|
||||
continue;
|
||||
}
|
||||
// media-typed files inside non-media dirs (reports/doctrine) are strays
|
||||
if ((top === "reports" || top === "doctrine") && mediaKind(name) !== "unknown" && !/annotations|preflight/.test(rel)) {
|
||||
strays.push({ path: rel, proposed: proposeDestination(name), reason: `media file inside ${top}/` });
|
||||
}
|
||||
if (relBase !== "" && !knownDirs.has(top) && !name.startsWith(".")) {
|
||||
strays.push({ path: rel, proposed: proposeDestination(name), reason: `unknown top-level dir ${top}/` });
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root, "");
|
||||
return { strays, intakeItems, scanned };
|
||||
}
|
||||
|
||||
export function fileIntake(root) {
|
||||
const { intakeItems } = checkBundle(root);
|
||||
const moved = [];
|
||||
for (const item of intakeItems) {
|
||||
const destDir = path.join(root, item.proposed);
|
||||
if (item.proposed === "intake/") continue; // no better home known; stays for a human
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const target = path.join(destDir, path.basename(item.path));
|
||||
if (fs.existsSync(target)) {
|
||||
moved.push({ ...item, skipped: "destination exists" });
|
||||
continue;
|
||||
}
|
||||
fs.renameSync(path.join(root, item.path), target);
|
||||
moved.push({ ...item, to: path.relative(root, target) });
|
||||
}
|
||||
return moved;
|
||||
}
|
||||
|
||||
export function placementText() {
|
||||
return [
|
||||
"# PLACEMENT - the filing map (written for agents)",
|
||||
"",
|
||||
"One rule: every file you create has exactly one home below. If you cannot",
|
||||
"decide, put it in intake/ - never invent a location. Run",
|
||||
"",
|
||||
"`--file` moves intake items home. The mini-NLE Info panel shows the stray",
|
||||
"count continuously - misplaced files are VISIBLE.",
|
||||
"",
|
||||
"Root files: " + [...ROOT_ALLOW].filter((name) => name !== ".DS_Store").join(", "),
|
||||
"",
|
||||
...BUNDLE_DIRS.map((entry) => `- \`${entry.dir}/\` - ${entry.what}`),
|
||||
"",
|
||||
"Heavy source trees (render render roots, raw capture trees,",
|
||||
"~/openrig-videos) may stay external - but everything the cut USES must be",
|
||||
"indexed in project-assets.json so it is findable from the tool.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function doctrineSeed() {
|
||||
return [
|
||||
"# Current rules (live card - rendered in the mini-NLE Info panel)",
|
||||
"",
|
||||
"UPDATE THIS FILE when a rule changes. Stale rules leaking back after",
|
||||
"resets was a named failure mode of the first production.",
|
||||
"",
|
||||
"- SOURCE OF TRUTH: timeline.json is canonical; ONLY the patch layer",
|
||||
" mutates it (validate -> backup -> apply -> history). Derived indexes",
|
||||
" (slots, assets, pointers) are rebuildable, never authored.",
|
||||
"- ONE WRITER: the assembly/timeline tool applies patches; other seats",
|
||||
" publish candidates and notes, never freehand JSON edits.",
|
||||
"- APPROVAL: approval events live in the slot ledger. Only",
|
||||
" approved/installed assets should reach final assembly (final-mode",
|
||||
" validation WARNS on unapproved cards; hard gate is a doctrine decision).",
|
||||
"- CANON: _CANON/ holds approved selects only.",
|
||||
"- EXPORTS: review exports may carry placeholders; FINAL must not. Final",
|
||||
" output comes from FFmpeg + manifests, never browser recording.",
|
||||
"- FILING: see PLACEMENT.md. Unsure -> intake/.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function timelineSkeleton(name) {
|
||||
return {
|
||||
_note: `${name}: fresh Studio bundle. Add the first audio master package (audio/packages/) and re-conform, or seed placeholders from a locked script. sequence[] is the source of truth.`,
|
||||
audio_master: "",
|
||||
captions_vtt: "",
|
||||
words_json: "",
|
||||
total_duration_s: 0,
|
||||
schema_version: "timeline-sequence-v1",
|
||||
sequence: [],
|
||||
cards: [],
|
||||
audio_beds: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"_note": "Studio template registry . Semantic ids fix the backwards T1/T2 naming; legacy_alias keeps existing free-text card.template values (e.g. 'rig send T1') resolvable. Canonical copy lives with the tools; video-new copies it into new bundles. Gallery ground truth: ",
|
||||
"templates": [
|
||||
{
|
||||
"id": "terminal-duo",
|
||||
"legacy_alias": "T1",
|
||||
"summary": "Two terminals side by side, framed roles + arrow between them",
|
||||
"hyperframes_template": "T1 two-terminal ",
|
||||
"default_theme": "dark",
|
||||
"good_for": "agent-to-agent command demos (rig send, rig queue)"
|
||||
},
|
||||
{
|
||||
"id": "terminal-solo",
|
||||
"legacy_alias": "T2",
|
||||
"summary": "Single centered terminal with centered label",
|
||||
"hyperframes_template": "T2 single-terminal ",
|
||||
"default_theme": "dark",
|
||||
"good_for": "single-command demos (rig capture, rig transcript)"
|
||||
},
|
||||
{
|
||||
"id": "ui-demo-overlay",
|
||||
"legacy_alias": "T3",
|
||||
"summary": "Product UI demo with the claim as text-over-UI (caption at top)",
|
||||
"hyperframes_template": "T3 UI-demo + text-over-UI",
|
||||
"default_theme": "dark",
|
||||
"good_for": "UI walkthroughs that need a claim overlaid"
|
||||
},
|
||||
{
|
||||
"id": "sequence-cut",
|
||||
"legacy_alias": "T4",
|
||||
"summary": "Sequential cut of steps, caption at top",
|
||||
"hyperframes_template": "T4 sequential cut",
|
||||
"default_theme": "dark",
|
||||
"good_for": "multi-step flows compressed into one beat"
|
||||
},
|
||||
{
|
||||
"id": "explainer-slide",
|
||||
"legacy_alias": "T5",
|
||||
"summary": "Explainer slide, title left (the most used template)",
|
||||
"hyperframes_template": "T5 explainer-slide title-left",
|
||||
"default_theme": "light",
|
||||
"good_for": "concept beats, definitions, framing"
|
||||
},
|
||||
{
|
||||
"id": "full-proof-montage",
|
||||
"legacy_alias": "T6",
|
||||
"summary": "Full-screen UI proof montage",
|
||||
"hyperframes_template": "T6 full-proof montage",
|
||||
"default_theme": "dark",
|
||||
"good_for": "credibility beats - show the real thing running"
|
||||
},
|
||||
{
|
||||
"id": "centered-idea",
|
||||
"legacy_alias": "T7",
|
||||
"summary": "One centered idea, nothing else",
|
||||
"hyperframes_template": "T7 centered idea",
|
||||
"default_theme": "light",
|
||||
"good_for": "payoffs, taglines, the one-sentence takeaway"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
chooseCardAsset,
|
||||
normalizedTimeline,
|
||||
resolveRefPath,
|
||||
roundSeconds,
|
||||
sortedCards,
|
||||
} from "./timeline-export-core.mjs";
|
||||
|
||||
const VERSION_RE = /\bv(\d{3,})\b/i;
|
||||
|
||||
export function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
export function maybeReadText(filePath) {
|
||||
if (!filePath) return "";
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function versionToken(ref) {
|
||||
const match = String(ref || "").match(VERSION_RE);
|
||||
return match ? `v${match[1]}` : "";
|
||||
}
|
||||
|
||||
function timeToSeconds(value) {
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/^(\d{2}):(\d{2}):(\d{2})[.,](\d{3})$/);
|
||||
if (!match) return NaN;
|
||||
return Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]) + Number(match[4]) / 1000;
|
||||
}
|
||||
|
||||
export function parseVtt(text) {
|
||||
const notes = [];
|
||||
const cues = [];
|
||||
const lines = String(text || "").replace(/\r/g, "").split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("NOTE")) {
|
||||
const noteLines = [line.replace(/^NOTE\s*/, "").trim()];
|
||||
while (i + 1 < lines.length && lines[i + 1].trim()) {
|
||||
noteLines.push(lines[++i].trim());
|
||||
}
|
||||
notes.push(noteLines.join(" ").trim());
|
||||
continue;
|
||||
}
|
||||
const timing = line.match(/^(\d{2}:\d{2}:\d{2}[.,]\d{3})\s+-->\s+(\d{2}:\d{2}:\d{2}[.,]\d{3})/);
|
||||
if (!timing) continue;
|
||||
const cueLines = [];
|
||||
while (i + 1 < lines.length && lines[i + 1].trim()) {
|
||||
cueLines.push(lines[++i].trim());
|
||||
}
|
||||
cues.push({
|
||||
start_s: timeToSeconds(timing[1]),
|
||||
end_s: timeToSeconds(timing[2]),
|
||||
text: cueLines.join(" ").replace(/<[^>]+>/g, "").trim(),
|
||||
});
|
||||
}
|
||||
return { notes, cues };
|
||||
}
|
||||
|
||||
function wordStart(word) {
|
||||
return Number(word?.masterStart ?? word?.start ?? word?.start_s ?? word?.timestamp?.start);
|
||||
}
|
||||
|
||||
function wordEnd(word) {
|
||||
return Number(word?.masterEnd ?? word?.end ?? word?.end_s ?? word?.timestamp?.end);
|
||||
}
|
||||
|
||||
function wordText(word) {
|
||||
return String(word?.text ?? word?.word ?? "").trim();
|
||||
}
|
||||
|
||||
function overlaps(startA, endA, startB, endB, tolerance = 0) {
|
||||
return Number(endA) > Number(startB) + tolerance && Number(startA) < Number(endB) - tolerance;
|
||||
}
|
||||
|
||||
function cardEnd(card) {
|
||||
return roundSeconds(Number(card.start_s) + Number(card.duration_s));
|
||||
}
|
||||
|
||||
function normalizeBeatNumber(value) {
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/^0*(\d+)(?:\.(\d+))?/);
|
||||
if (!match) return NaN;
|
||||
const major = Number(match[1]);
|
||||
const minor = match[2] == null ? 0 : Number(`0.${match[2]}`);
|
||||
return major + minor;
|
||||
}
|
||||
|
||||
function looksOlderBeatAfterSplice(beatReport, cardId, spliceInside) {
|
||||
const beat = String(beatReport?.beat || "");
|
||||
const cardText = String(cardId || "");
|
||||
const beatNum = normalizeBeatNumber(beat);
|
||||
const cardNum = normalizeBeatNumber(cardText);
|
||||
if (!Number.isFinite(beatNum) || !Number.isFinite(cardNum)) return false;
|
||||
if (Math.floor(beatNum) < Math.floor(cardNum)) return true;
|
||||
if (/^\d{2,}$/.test(beat) && /^\d+\.\d+/.test(cardText)) {
|
||||
return spliceInside.some((point) => beatReport.start_s >= point.end_s - 0.05);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function groupContiguousWords(words) {
|
||||
const groups = [];
|
||||
for (const word of words) {
|
||||
const beat = String(word.beat || word.section || "unknown");
|
||||
const start = wordStart(word);
|
||||
const end = wordEnd(word);
|
||||
const current = groups[groups.length - 1];
|
||||
if (current && current.beat === beat && start - current.end_s < 0.75) {
|
||||
current.end_s = Math.max(current.end_s, end);
|
||||
current.wordCount += 1;
|
||||
if (current.sample.length < 18) current.sample.push(wordText(word));
|
||||
} else {
|
||||
groups.push({
|
||||
beat,
|
||||
section: String(word.section || ""),
|
||||
start_s: start,
|
||||
end_s: end,
|
||||
wordCount: 1,
|
||||
sample: wordText(word) ? [wordText(word)] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups.map((group) => ({
|
||||
...group,
|
||||
start_s: roundSeconds(group.start_s),
|
||||
end_s: roundSeconds(group.end_s),
|
||||
sampleText: group.sample.join(" "),
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeBeats(words) {
|
||||
const byBeat = new Map();
|
||||
for (const word of words) {
|
||||
const beat = String(word.beat || word.section || "unknown");
|
||||
const start = wordStart(word);
|
||||
const end = wordEnd(word);
|
||||
const existing = byBeat.get(beat) || {
|
||||
beat,
|
||||
section: String(word.section || ""),
|
||||
start_s: start,
|
||||
end_s: end,
|
||||
wordCount: 0,
|
||||
sample: [],
|
||||
};
|
||||
existing.start_s = Math.min(existing.start_s, start);
|
||||
existing.end_s = Math.max(existing.end_s, end);
|
||||
existing.wordCount += 1;
|
||||
if (existing.sample.length < 14) existing.sample.push(wordText(word));
|
||||
byBeat.set(beat, existing);
|
||||
}
|
||||
return [...byBeat.values()]
|
||||
.sort((a, b) => a.start_s - b.start_s)
|
||||
.map((beat) => ({
|
||||
...beat,
|
||||
start_s: roundSeconds(beat.start_s),
|
||||
end_s: roundSeconds(beat.end_s),
|
||||
sampleText: beat.sample.join(" "),
|
||||
}));
|
||||
}
|
||||
|
||||
function spliceEntries(splice) {
|
||||
const entries = [];
|
||||
for (const [key, value] of Object.entries(splice || {})) {
|
||||
if (!/EndSeconds$/.test(key)) continue;
|
||||
if (/SpokenEndSeconds$/.test(key)) continue;
|
||||
if (/^old[A-Z]/.test(key)) continue;
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds)) continue;
|
||||
const prefix = key.replace(/EndSeconds$/, "");
|
||||
entries.push({
|
||||
key,
|
||||
prefix,
|
||||
end_s: seconds,
|
||||
start_s: Number(splice[`${prefix}StartSeconds`]),
|
||||
oldStart_s: Number(splice[`old${prefix[0].toUpperCase()}${prefix.slice(1)}StartSeconds`]),
|
||||
oldEnd_s: Number(splice[`old${prefix[0].toUpperCase()}${prefix.slice(1)}EndSeconds`]),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function findCompositeManifestRefs(timeline, wordsPayload = {}) {
|
||||
const refs = new Set();
|
||||
for (const card of sortedCards(normalizedTimeline(timeline))) {
|
||||
if (card.audio_composite_manifest) refs.add(card.audio_composite_manifest);
|
||||
}
|
||||
for (const [key, value] of Object.entries(wordsPayload?.splice || {})) {
|
||||
if (/CompositeManifestPath$/i.test(key) && value) refs.add(value);
|
||||
}
|
||||
return [...refs];
|
||||
}
|
||||
|
||||
function maybeReadCompositeManifestTexts(refs, { sliceRoot }) {
|
||||
return refs.map((ref) => {
|
||||
const filePath = path.isAbsolute(ref) ? ref : resolveRefPath(ref, { sliceRoot });
|
||||
return {
|
||||
ref,
|
||||
path: filePath,
|
||||
text: maybeReadText(filePath),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function detectVersionCoherence(timeline, wordsPayload, vtt) {
|
||||
const audioVersion = versionToken(timeline.audio_master);
|
||||
const wordsVersion = versionToken(timeline.words_json);
|
||||
const captionsVersion = versionToken(timeline.captions_vtt);
|
||||
const notes = [
|
||||
String(wordsPayload?.v010Note || ""),
|
||||
String(wordsPayload?.v009Note || ""),
|
||||
String(wordsPayload?.v008Note || ""),
|
||||
...(vtt?.notes || []),
|
||||
].join("\n").toLowerCase();
|
||||
const timingEquivalent = /\btimings unchanged\b|\btiming[- ]equivalent\b|\bno timing shifts\b/.test(notes);
|
||||
const warnings = [];
|
||||
if (audioVersion && wordsVersion && audioVersion !== wordsVersion && !timingEquivalent) {
|
||||
warnings.push(`audio_master ${audioVersion} but words_json ${wordsVersion}; add timing-equivalence note or regenerate words`);
|
||||
}
|
||||
if (audioVersion && captionsVersion && audioVersion !== captionsVersion && !timingEquivalent) {
|
||||
warnings.push(`audio_master ${audioVersion} but captions_vtt ${captionsVersion}; add timing-equivalence note or regenerate captions`);
|
||||
}
|
||||
return {
|
||||
audioVersion,
|
||||
wordsVersion,
|
||||
captionsVersion,
|
||||
timingEquivalent,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function segmentForCard(card, segments = []) {
|
||||
return segments.find((segment) => String(segment.id) === String(card.id));
|
||||
}
|
||||
|
||||
function mediaSummary(card, segment) {
|
||||
if (!segment) return null;
|
||||
const streams = segment.probe?.streams || [];
|
||||
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
||||
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
||||
const sourceDuration = Number(segment.probe?.duration_s);
|
||||
const slotDuration = Number(card.duration_s);
|
||||
const tolerance = 1 / 30 + 0.02;
|
||||
let category = "duration-unknown";
|
||||
if (Number.isFinite(sourceDuration)) {
|
||||
if (sourceDuration < slotDuration - tolerance) category = "source-shorter-than-slot";
|
||||
else if (sourceDuration > slotDuration + tolerance) category = "source-longer-than-slot";
|
||||
else category = "frame-rounded-match";
|
||||
}
|
||||
let previewAction = "unknown";
|
||||
if (!segment.assetExists) previewAction = "missing asset";
|
||||
else if (!hasAudio) previewAction = "video-only; preview/export rely on top-level audio_master";
|
||||
else previewAction = "embedded audio ignored; preview/export use top-level audio_master";
|
||||
if (category === "source-shorter-than-slot") previewAction += "; exporter holds/pads unless final policy rejects";
|
||||
if (category === "source-longer-than-slot") previewAction += "; exporter trims to slot/source window";
|
||||
return {
|
||||
sourceDuration_s: Number.isFinite(sourceDuration) ? roundSeconds(sourceDuration) : null,
|
||||
slotDuration_s: roundSeconds(slotDuration),
|
||||
category,
|
||||
hasVideo,
|
||||
hasAudio,
|
||||
streams: streams.map((stream) => ({
|
||||
type: stream.codec_type,
|
||||
codec: stream.codec_name,
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
duration: stream.duration,
|
||||
})),
|
||||
previewAction,
|
||||
};
|
||||
}
|
||||
|
||||
export function analyzeTimelineAudio(timeline, {
|
||||
wordsPayload = null,
|
||||
captionsText = "",
|
||||
sliceRoot = process.cwd(),
|
||||
segments = [],
|
||||
compositeManifestTexts = null,
|
||||
toleranceS = 0.02,
|
||||
} = {}) {
|
||||
const effectiveTimeline = normalizedTimeline(timeline);
|
||||
const cards = sortedCards(effectiveTimeline);
|
||||
const words = Array.isArray(wordsPayload?.words) ? wordsPayload.words : [];
|
||||
const vtt = parseVtt(captionsText);
|
||||
const splice = wordsPayload?.splice || {};
|
||||
const splicePoints = spliceEntries(splice);
|
||||
const manifestTexts = compositeManifestTexts ?? maybeReadCompositeManifestTexts(
|
||||
findCompositeManifestRefs(effectiveTimeline, wordsPayload),
|
||||
{ sliceRoot },
|
||||
);
|
||||
const manifestCombinedText = manifestTexts.map((entry) => entry.text).join("\n");
|
||||
const versionCoherence = detectVersionCoherence(effectiveTimeline, wordsPayload, vtt);
|
||||
const topLevelAnomalies = versionCoherence.warnings.map((message) => ({
|
||||
code: "version-skew",
|
||||
severity: "warn",
|
||||
message,
|
||||
}));
|
||||
|
||||
const cardReports = cards.map((card) => {
|
||||
const start = Number(card.start_s);
|
||||
const end = cardEnd(card);
|
||||
const windowWords = words
|
||||
.filter((word) => Number.isFinite(wordStart(word)) && Number.isFinite(wordEnd(word)))
|
||||
.filter((word) => overlaps(wordStart(word), wordEnd(word), start, end, toleranceS))
|
||||
.sort((a, b) => wordStart(a) - wordStart(b));
|
||||
const windowCues = vtt.cues
|
||||
.filter((cue) => overlaps(cue.start_s, cue.end_s, start, end, toleranceS));
|
||||
const beats = summarizeBeats(windowWords);
|
||||
const contiguousBeats = groupContiguousWords(windowWords);
|
||||
const anomalies = [];
|
||||
|
||||
if (windowWords.length === 0) {
|
||||
anomalies.push({
|
||||
code: "no-words-in-card-window",
|
||||
severity: "warn",
|
||||
message: `${card.id}: no word-level audio found in ${start}-${end}`,
|
||||
});
|
||||
}
|
||||
if (beats.length > 1) {
|
||||
anomalies.push({
|
||||
code: "multiple-audio-beats",
|
||||
severity: "warn",
|
||||
message: `${card.id}: audio window spans beats ${beats.map((beat) => beat.beat).join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const spliceInside = splicePoints.filter((point) => point.end_s > start + toleranceS && point.end_s < end - toleranceS);
|
||||
const olderBeats = beats.filter((beat) => looksOlderBeatAfterSplice(beat, card.id, spliceInside));
|
||||
if (olderBeats.length) {
|
||||
anomalies.push({
|
||||
code: "older-audio-beat-inside-card",
|
||||
severity: "error",
|
||||
message: `${card.id}: older audio beat(s) ${olderBeats.map((beat) => beat.beat).join(", ")} present inside visual card`,
|
||||
beats: olderBeats.map((beat) => beat.beat),
|
||||
});
|
||||
}
|
||||
|
||||
for (const point of spliceInside) {
|
||||
anomalies.push({
|
||||
code: "splice-end-inside-card",
|
||||
severity: "error",
|
||||
message: `${card.id}: ${point.key}=${point.end_s} lands inside visual window ${start}-${end}; composite audio may resume old source before card ends`,
|
||||
spliceKey: point.key,
|
||||
spliceEnd_s: point.end_s,
|
||||
});
|
||||
}
|
||||
|
||||
if (card.audio_asset) {
|
||||
const audioAsset = String(card.audio_asset);
|
||||
const represented = manifestCombinedText.includes(audioAsset) || manifestCombinedText.includes(path.basename(audioAsset));
|
||||
if (!represented) {
|
||||
anomalies.push({
|
||||
code: "card-audio-asset-not-represented",
|
||||
severity: "warn",
|
||||
message: `${card.id}: card audio_asset is metadata only unless represented in the active composite manifest/master`,
|
||||
audio_asset: audioAsset,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const segment = segmentForCard(card, segments);
|
||||
const media = mediaSummary(card, segment);
|
||||
if (media?.hasAudio === false) {
|
||||
anomalies.push({
|
||||
code: "video-only-card-relies-on-audio-master",
|
||||
severity: "info",
|
||||
message: `${card.id}: visual media has no embedded audio; preview/export rely on top-level audio_master`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(card.id),
|
||||
title: String(card.title || ""),
|
||||
start_s: roundSeconds(start),
|
||||
end_s: roundSeconds(end),
|
||||
duration_s: roundSeconds(Number(card.duration_s)),
|
||||
transcript: String(card.transcript || ""),
|
||||
wordCount: windowWords.length,
|
||||
wordRange_s: windowWords.length
|
||||
? { start_s: roundSeconds(wordStart(windowWords[0])), end_s: roundSeconds(wordEnd(windowWords[windowWords.length - 1])) }
|
||||
: null,
|
||||
captionCount: windowCues.length,
|
||||
captionRange_s: windowCues.length
|
||||
? { start_s: roundSeconds(windowCues[0].start_s), end_s: roundSeconds(windowCues[windowCues.length - 1].end_s) }
|
||||
: null,
|
||||
beats,
|
||||
contiguousBeats,
|
||||
media,
|
||||
anomalies,
|
||||
fixHint: anomalies.some((anomaly) => anomaly.code === "splice-end-inside-card" || anomaly.code === "older-audio-beat-inside-card")
|
||||
? "Rebuild the composite audio/words/captions through this visual card boundary, or shorten/realign the visual window to the active composite splice."
|
||||
: "",
|
||||
};
|
||||
});
|
||||
|
||||
const allAnomalies = [
|
||||
...topLevelAnomalies,
|
||||
...cardReports.flatMap((card) => card.anomalies.map((anomaly) => ({ cardId: card.id, ...anomaly }))),
|
||||
];
|
||||
const errorCount = allAnomalies.filter((anomaly) => anomaly.severity === "error").length;
|
||||
const warningCount = allAnomalies.filter((anomaly) => anomaly.severity === "warn").length;
|
||||
const infoCount = allAnomalies.filter((anomaly) => anomaly.severity === "info").length;
|
||||
const summaryLines = [
|
||||
`Timeline/audio validation: ${errorCount ? "FAILED" : "OK"} (${errorCount} error, ${warningCount} warn, ${infoCount} info)`,
|
||||
`audio_master=${effectiveTimeline.audio_master || "(missing)"}`,
|
||||
`words_json=${effectiveTimeline.words_json || "(missing)"}`,
|
||||
`captions_vtt=${effectiveTimeline.captions_vtt || "(missing)"}`,
|
||||
...allAnomalies.slice(0, 12).map((anomaly) => {
|
||||
const prefix = anomaly.cardId ? `${anomaly.cardId}: ` : "";
|
||||
return `${anomaly.severity.toUpperCase()}: ${prefix}${anomaly.message}`;
|
||||
}),
|
||||
allAnomalies.length > 12 ? `...${allAnomalies.length - 12} more anomaly/anomalies` : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
ok: errorCount === 0,
|
||||
timeline: {
|
||||
audio_master: effectiveTimeline.audio_master || "",
|
||||
words_json: effectiveTimeline.words_json || "",
|
||||
captions_vtt: effectiveTimeline.captions_vtt || "",
|
||||
totalDuration_s: Number(effectiveTimeline.total_duration_s || 0),
|
||||
},
|
||||
versionCoherence,
|
||||
splice,
|
||||
cards: cardReports,
|
||||
anomalies: allAnomalies,
|
||||
counts: {
|
||||
cards: cardReports.length,
|
||||
errors: errorCount,
|
||||
warnings: warningCount,
|
||||
info: infoCount,
|
||||
},
|
||||
summaryText: summaryLines.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function markdownAudioReport(report) {
|
||||
const lines = [
|
||||
"# Timeline Audio Validation",
|
||||
"",
|
||||
"```text",
|
||||
report.summaryText,
|
||||
"```",
|
||||
"",
|
||||
"## Cards",
|
||||
"",
|
||||
];
|
||||
for (const card of report.cards) {
|
||||
lines.push(`### ${card.id} — ${card.title}`);
|
||||
lines.push("");
|
||||
lines.push(`- Window: ${card.start_s} -> ${card.end_s} (${card.duration_s}s)`);
|
||||
lines.push(`- Words: ${card.wordCount}${card.wordRange_s ? ` (${card.wordRange_s.start_s} -> ${card.wordRange_s.end_s})` : ""}`);
|
||||
lines.push(`- Captions: ${card.captionCount}${card.captionRange_s ? ` (${card.captionRange_s.start_s} -> ${card.captionRange_s.end_s})` : ""}`);
|
||||
if (card.media) {
|
||||
lines.push(`- Visual source: ${card.media.category}; ${card.media.previewAction}`);
|
||||
lines.push(`- Streams: ${card.media.streams.map((stream) => `${stream.type}:${stream.codec}${stream.width ? ` ${stream.width}x${stream.height}` : ""}`).join(", ") || "none"}`);
|
||||
}
|
||||
if (card.beats.length) {
|
||||
lines.push("- Audio beats:");
|
||||
for (const beat of card.beats) {
|
||||
lines.push(` - ${beat.beat}: ${beat.start_s} -> ${beat.end_s}, ${beat.wordCount} word(s), "${beat.sampleText}"`);
|
||||
}
|
||||
}
|
||||
if (card.anomalies.length) {
|
||||
lines.push("- Anomalies:");
|
||||
for (const anomaly of card.anomalies) {
|
||||
lines.push(` - ${anomaly.severity.toUpperCase()} ${anomaly.code}: ${anomaly.message}`);
|
||||
}
|
||||
}
|
||||
if (card.fixHint) lines.push(`- Likely fix: ${card.fixHint}`);
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const MEDIA_EXTS = new Set([
|
||||
".mp4",
|
||||
".mov",
|
||||
".m4v",
|
||||
".webm",
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".mp3",
|
||||
".m4a",
|
||||
".wav",
|
||||
".aif",
|
||||
".aiff",
|
||||
]);
|
||||
|
||||
const VIDEO_EXTS = new Set([".mp4", ".mov", ".m4v", ".webm"]);
|
||||
const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||
const AUDIO_EXTS = new Set([".mp3", ".m4a", ".wav", ".aif", ".aiff"]);
|
||||
|
||||
export const DEFAULT_EXPORT = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
fps: 30,
|
||||
audioSampleRate: 48000,
|
||||
timelineFile: "timeline.json",
|
||||
};
|
||||
|
||||
export function roundSeconds(value, places = 6) {
|
||||
const factor = 10 ** places;
|
||||
return Math.round((Number(value) || 0) * factor) / factor;
|
||||
}
|
||||
|
||||
export function fileExists(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isUrl(ref) {
|
||||
return /^[a-z]+:\/\//i.test(String(ref || ""));
|
||||
}
|
||||
|
||||
export function extensionOf(ref) {
|
||||
const pathname = String(ref || "").split("?")[0].split("#")[0];
|
||||
return path.extname(pathname).toLowerCase();
|
||||
}
|
||||
|
||||
export function hasMediaExtension(ref) {
|
||||
return MEDIA_EXTS.has(extensionOf(ref));
|
||||
}
|
||||
|
||||
export function mediaKind(ref) {
|
||||
const ext = extensionOf(ref);
|
||||
if (VIDEO_EXTS.has(ext)) return "video";
|
||||
if (IMAGE_EXTS.has(ext)) return "image";
|
||||
if (AUDIO_EXTS.has(ext)) return "audio";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function resolveRefPath(ref, { sliceRoot }) {
|
||||
const value = String(ref || "").trim();
|
||||
if (!value) return "";
|
||||
if (isUrl(value)) return value;
|
||||
if (path.isAbsolute(value)) return value;
|
||||
return path.resolve(sliceRoot, value);
|
||||
}
|
||||
|
||||
export function chooseCardAsset(card, { sliceRoot }) {
|
||||
const candidates = [card?.asset_source, card?.source, card?.asset].filter(Boolean);
|
||||
for (const ref of candidates) {
|
||||
if (!hasMediaExtension(ref)) continue;
|
||||
const resolved = resolveRefPath(ref, { sliceRoot });
|
||||
return {
|
||||
ref: String(ref),
|
||||
path: resolved,
|
||||
kind: mediaKind(ref),
|
||||
exists: !isUrl(resolved) && fileExists(resolved),
|
||||
isUrl: isUrl(resolved),
|
||||
};
|
||||
}
|
||||
const fallback = String(card?.asset || "");
|
||||
const resolved = resolveRefPath(fallback, { sliceRoot });
|
||||
return {
|
||||
ref: fallback,
|
||||
path: resolved,
|
||||
kind: mediaKind(fallback),
|
||||
exists: Boolean(fallback) && !isUrl(resolved) && fileExists(resolved),
|
||||
isUrl: isUrl(resolved),
|
||||
};
|
||||
}
|
||||
|
||||
export function chooseAudioAsset(ref, { sliceRoot }) {
|
||||
const value = String(ref || "").trim();
|
||||
const resolved = resolveRefPath(value, { sliceRoot });
|
||||
return {
|
||||
ref: value,
|
||||
path: resolved,
|
||||
kind: mediaKind(value),
|
||||
exists: Boolean(value) && !isUrl(resolved) && fileExists(resolved),
|
||||
isUrl: isUrl(resolved),
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeCards(timeline) {
|
||||
if (!Array.isArray(timeline?.sequence) || timeline.sequence.length === 0) {
|
||||
return [...(Array.isArray(timeline?.cards) ? timeline.cards : [])].map((card) => ({ ...card }));
|
||||
}
|
||||
|
||||
const sourceCards = new Map((Array.isArray(timeline?.cards) ? timeline.cards : []).map((card) => [String(card.id), card]));
|
||||
const materialized = [];
|
||||
let cursor = 0;
|
||||
for (const entry of timeline.sequence) {
|
||||
const entryType = entry?.type || "card";
|
||||
const duration = Number(entry?.duration_s);
|
||||
if (entryType === "gap") {
|
||||
if (Number.isFinite(duration) && duration > 0) cursor = roundSeconds(cursor + duration);
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = String(entry?.id || entry?.card?.id || "");
|
||||
const source = entry?.card || sourceCards.get(id);
|
||||
if (!source) continue;
|
||||
const cardDuration = Number.isFinite(duration) && duration > 0 ? duration : Number(source.duration_s);
|
||||
const start = cursor;
|
||||
const card = {
|
||||
...source,
|
||||
id,
|
||||
start_s: roundSeconds(start),
|
||||
audio_start_s: roundSeconds(start),
|
||||
audio_end_s: roundSeconds(start + cardDuration),
|
||||
duration_s: roundSeconds(cardDuration),
|
||||
};
|
||||
if (card.target_clip_length_s == null) card.target_clip_length_s = card.duration_s;
|
||||
materialized.push(card);
|
||||
cursor = roundSeconds(cursor + card.duration_s);
|
||||
}
|
||||
return applyCrossfadeOverlaps(materialized);
|
||||
}
|
||||
|
||||
// crossfade_to_next_s becomes REAL — card B starts fade seconds
|
||||
// before card A ends. Applied here in core so every consumer (preview,
|
||||
// viewer, export plan, audio graph, health) agrees on the shifted timing.
|
||||
// Only butt-joined neighbors crossfade (a gap between cards disables it);
|
||||
// the fade is clamped so neither card is consumed entirely.
|
||||
function applyCrossfadeOverlaps(cards) {
|
||||
let shift = 0;
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const card = cards[i];
|
||||
if (shift > 0) {
|
||||
card.start_s = roundSeconds(card.start_s - shift);
|
||||
card.audio_start_s = roundSeconds(card.audio_start_s - shift);
|
||||
card.audio_end_s = roundSeconds(card.audio_end_s - shift);
|
||||
}
|
||||
const next = cards[i + 1];
|
||||
let fade = Math.max(0, Number(card.crossfade_to_next_s) || 0);
|
||||
if (fade > 0 && next) {
|
||||
const buttJoined = Math.abs((next.start_s) - (card.start_s + card.duration_s)) < 0.01;
|
||||
if (!buttJoined) { fade = 0; }
|
||||
else {
|
||||
fade = roundSeconds(Math.min(fade,
|
||||
Math.max(0, card.duration_s - 0.1), Math.max(0, next.duration_s - 0.1)));
|
||||
}
|
||||
if (fade > 0) {
|
||||
card.xfade_out_s = fade;
|
||||
next.xfade_in_s = fade;
|
||||
shift = roundSeconds(shift + fade);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
export function normalizedTimeline(timeline) {
|
||||
if (!timeline || typeof timeline !== "object") return timeline;
|
||||
return {
|
||||
...timeline,
|
||||
cards: materializeCards(timeline),
|
||||
};
|
||||
}
|
||||
|
||||
export function sortedCards(timeline) {
|
||||
return materializeCards(timeline)
|
||||
.map((card) => ({ ...card }))
|
||||
.sort((a, b) => Number(a.start_s) - Number(b.start_s) || String(a.id).localeCompare(String(b.id)));
|
||||
}
|
||||
|
||||
export function isPlaceholderCard(card) {
|
||||
return card?.type === "placeholder" || card?.status === "placeholder" || !String(card?.asset || "").trim();
|
||||
}
|
||||
|
||||
export function cardEnd(card) {
|
||||
return roundSeconds(Number(card.start_s) + Number(card.duration_s));
|
||||
}
|
||||
|
||||
export function collectAudioBeds(timeline) {
|
||||
return [
|
||||
...(Array.isArray(timeline?.music_beds) ? timeline.music_beds : []),
|
||||
...(Array.isArray(timeline?.audio_beds) ? timeline.audio_beds : []),
|
||||
].map((bed) => ({ ...bed }))
|
||||
.filter((bed) => bed.asset || bed.src || bed.source || bed.audio)
|
||||
.map((bed, index) => {
|
||||
const asset = String(bed.asset || bed.src || bed.source || bed.audio || "");
|
||||
const start = Number(bed.start_s ?? bed.timeline_start_s ?? 0);
|
||||
const duration = Number(bed.duration_s ?? bed.length_s ?? bed.window_s ?? 0);
|
||||
const sourceIn = Number(bed.source_in_s ?? bed.asset_in_s ?? bed.clip_in_s ?? 0);
|
||||
return {
|
||||
id: String(bed.id || `audio-bed-${index + 1}`),
|
||||
title: String(bed.title || bed.label || "Audio bed"),
|
||||
asset,
|
||||
start_s: start,
|
||||
duration_s: duration,
|
||||
end_s: start + duration,
|
||||
source_in_s: Number.isFinite(sourceIn) ? sourceIn : 0,
|
||||
volume: Number.isFinite(Number(bed.volume)) ? Number(bed.volume) : 1,
|
||||
fade_in_s: Math.max(0, Number(bed.fade_in_s) || 0),
|
||||
fade_out_s: Math.max(0, Number(bed.fade_out_s) || 0),
|
||||
status: String(bed.status || ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// fade in/out filters for a visual segment, relative to its slot duration.
|
||||
// crossfade_to_next_s is validated metadata only for now.
|
||||
// Deferred: true cross-dissolve needs xfade + overlapping segment renders,
|
||||
// which breaks the concat model; build it when a cut actually needs one.
|
||||
export function videoFadeFilters(segment) {
|
||||
const duration = Number(segment?.duration_s) || 0;
|
||||
const fadeIn = Math.max(0, Number(segment?.fade_in_s) || 0);
|
||||
const fadeOut = Math.max(0, Number(segment?.fade_out_s) || 0);
|
||||
const filters = [];
|
||||
if (fadeIn > 0) filters.push(`fade=t=in:st=0:d=${roundSeconds(fadeIn)}`);
|
||||
if (fadeOut > 0) filters.push(`fade=t=out:st=${roundSeconds(Math.max(0, duration - fadeOut))}:d=${roundSeconds(fadeOut)}`);
|
||||
return filters;
|
||||
}
|
||||
|
||||
export function buildAudioMuxArgs({
|
||||
silentVideoPath,
|
||||
audioMasterPath,
|
||||
audioBeds = [],
|
||||
syncSegments = [],
|
||||
outPath,
|
||||
totalDuration,
|
||||
audioLoudnessTarget = -16,
|
||||
}) {
|
||||
const duration = roundSeconds(totalDuration);
|
||||
const requestedTarget = Number(audioLoudnessTarget);
|
||||
const loudnessTarget = Number.isFinite(requestedTarget) && requestedTarget >= -70 && requestedTarget <= -5
|
||||
? requestedTarget
|
||||
: -16;
|
||||
const finalAudioFilters = `loudnorm=I=${loudnessTarget}:TP=-1.5:LRA=11,alimiter=limit=0.841395:attack=5:release=50:level=false:latency=true`;
|
||||
const inputs = ["-i", silentVideoPath];
|
||||
if (audioMasterPath) inputs.push("-i", audioMasterPath);
|
||||
for (const bed of audioBeds) inputs.push("-i", bed.assetPath);
|
||||
for (const segment of syncSegments) inputs.push("-i", segment.assetPath);
|
||||
|
||||
const chains = [];
|
||||
const labels = [];
|
||||
if (audioMasterPath) {
|
||||
chains.push(`[1:a]atrim=0:${duration},asetpts=PTS-STARTPTS,apad=whole_dur=${duration}[a0]`);
|
||||
labels.push("[a0]");
|
||||
}
|
||||
const bedInputBase = audioMasterPath ? 2 : 1;
|
||||
// sync-sound clips - trim the SOURCE window, retime for speed,
|
||||
// place at the timeline position
|
||||
syncSegments.forEach((segment, index) => {
|
||||
const inputIndex = bedInputBase + audioBeds.length + index;
|
||||
const label = `sync${index}`;
|
||||
const speed = Number(segment.speed_factor) > 0 ? Number(segment.speed_factor) : 1;
|
||||
const sourceIn = Math.max(0, Number(segment.source_in_s) || 0);
|
||||
const sourceSpan = roundSeconds(Number(segment.duration_s) * speed);
|
||||
const delayMs = Math.max(0, Math.round(Number(segment.start_s) * 1000));
|
||||
const gain = Number.isFinite(Number(segment.sourceAudioGain)) ? Number(segment.sourceAudioGain) : 1;
|
||||
const parts = [`atrim=start=${sourceIn}:duration=${sourceSpan}`, "asetpts=PTS-STARTPTS"];
|
||||
// atempo supports 0.5-2 per stage; typical demo speedups fit one stage
|
||||
if (speed !== 1 && speed >= 0.5 && speed <= 2) parts.push(`atempo=${speed}`);
|
||||
// crossfading neighbors overlap — fade this clip's own audio
|
||||
// at the shared edges so the mix crossfades with the picture
|
||||
const xin = Math.max(0, Number(segment.xfade_in_s) || 0);
|
||||
const xout = Math.max(0, Number(segment.xfade_out_s) || 0);
|
||||
if (xin > 0) parts.push(`afade=t=in:st=0:d=${roundSeconds(xin)}`);
|
||||
if (xout > 0) parts.push(`afade=t=out:st=${roundSeconds(Math.max(0, Number(segment.duration_s) - xout))}:d=${roundSeconds(xout)}`);
|
||||
parts.push(`adelay=${delayMs}:all=1`, `volume=${gain}`);
|
||||
chains.push(`[${inputIndex}:a]${parts.join(",")}[${label}]`);
|
||||
labels.push(`[${label}]`);
|
||||
});
|
||||
audioBeds.forEach((bed, index) => {
|
||||
const inputIndex = bedInputBase + index;
|
||||
const label = `a${index + 1}`;
|
||||
const delayMs = Math.max(0, Math.round(Number(bed.start_s) * 1000));
|
||||
const sourceIn = Math.max(0, Number(bed.source_in_s) || 0);
|
||||
const bedDuration = Math.max(0, Number(bed.duration_s) || 0);
|
||||
const volume = Number.isFinite(Number(bed.volume)) ? Number(bed.volume) : 1;
|
||||
const fadeIn = Math.max(0, Number(bed.fade_in_s) || 0);
|
||||
const fadeOut = Math.max(0, Number(bed.fade_out_s) || 0);
|
||||
const parts = [`atrim=start=${sourceIn}:duration=${bedDuration}`, "asetpts=PTS-STARTPTS"];
|
||||
if (fadeIn > 0) parts.push(`afade=t=in:st=0:d=${roundSeconds(fadeIn)}`);
|
||||
if (fadeOut > 0) parts.push(`afade=t=out:st=${roundSeconds(Math.max(0, bedDuration - fadeOut))}:d=${roundSeconds(fadeOut)}`);
|
||||
parts.push(`adelay=${delayMs}:all=1`, `volume=${volume}`);
|
||||
chains.push(`[${inputIndex}:a]${parts.join(",")}[${label}]`);
|
||||
labels.push(`[${label}]`);
|
||||
});
|
||||
// pad to full duration so the audio stream never ends before the video
|
||||
if (labels.length === 1) {
|
||||
chains.push(`${labels[0]}apad=whole_dur=${duration},atrim=0:${duration},asetpts=PTS-STARTPTS,${finalAudioFilters}[aout]`);
|
||||
} else {
|
||||
chains.push(`${labels.join("")}amix=inputs=${labels.length}:duration=longest:normalize=0,apad=whole_dur=${duration},atrim=0:${duration},asetpts=PTS-STARTPTS,${finalAudioFilters}[aout]`);
|
||||
}
|
||||
|
||||
return [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
...inputs,
|
||||
"-filter_complex", chains.join(";"),
|
||||
"-map", "0:v:0",
|
||||
"-map", "[aout]",
|
||||
"-t", String(duration),
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-ar", "48000",
|
||||
"-movflags", "+faststart",
|
||||
outPath,
|
||||
];
|
||||
}
|
||||
|
||||
export function timelineTotalDuration(timeline) {
|
||||
const cardsEnd = sortedCards(timeline).reduce((max, card) => Math.max(max, cardEnd(card)), 0);
|
||||
const bedsEnd = collectAudioBeds(timeline).reduce((max, bed) => Math.max(max, Number(bed.end_s) || 0), 0);
|
||||
const declared = Number(timeline?.timeline_duration_s);
|
||||
const spine = Number(timeline?.total_duration_s);
|
||||
const extension = Number(timeline?.post_spine_extension_duration_s);
|
||||
return roundSeconds(Math.max(
|
||||
Number.isFinite(declared) ? declared : 0,
|
||||
Number.isFinite(spine) ? spine : 0,
|
||||
Number.isFinite(spine) && Number.isFinite(extension) ? spine + extension : 0,
|
||||
cardsEnd,
|
||||
bedsEnd,
|
||||
));
|
||||
}
|
||||
|
||||
export function validateTimelineForExport(timeline, {
|
||||
sliceRoot,
|
||||
mode = "review",
|
||||
checkFiles = true,
|
||||
toleranceS = 0.04,
|
||||
} = {}) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const placeholders = [];
|
||||
const assets = [];
|
||||
const audioBeds = [];
|
||||
|
||||
if (!timeline || typeof timeline !== "object") {
|
||||
return { ok: false, mode, errors: ["timeline must be an object"], warnings, placeholders, assets, audioBeds };
|
||||
}
|
||||
|
||||
const effectiveTimeline = normalizedTimeline(timeline);
|
||||
const approvalBySlot = new Map();
|
||||
if (mode === "final" && sliceRoot) {
|
||||
try {
|
||||
const slots = JSON.parse(fs.readFileSync(path.join(sliceRoot, "timeline-slots.json"), "utf8"));
|
||||
for (const slot of slots.slots || []) approvalBySlot.set(String(slot.dot_id), slot);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (String(effectiveTimeline.audio_master_status || "") === "scratch") {
|
||||
// scratch VO is a build tool, never the product
|
||||
(mode === "final" ? errors : warnings).push(mode === "final"
|
||||
? "audio master is SCRATCH VO - final export refuses; install the real master via re-conform"
|
||||
: "audio master is scratch VO (fine for review; final will refuse)");
|
||||
}
|
||||
// The guard's real intent is "never ship a SILENT master" — originally written
|
||||
// when a narration master was the only audio source. A scored film with music
|
||||
// beds + per-card sync audio has no narration master and correctly should not
|
||||
// fake one (faking audio_master=score would SUM a 2nd untrimmed unity copy under
|
||||
// amix normalize=0 and wreck the mix). So accept audible audio from ANY source.
|
||||
const _beds = [...(effectiveTimeline.music_beds || []), ...(effectiveTimeline.audio_beds || [])];
|
||||
const _hasAudibleBed = _beds.some((b) => Number(b && b.volume) > 0);
|
||||
const _hasSourceAudio = (effectiveTimeline.cards || []).some((c) => c && c.use_source_audio === true)
|
||||
|| (effectiveTimeline.sequence || []).some((e) => e && e.card && e.card.use_source_audio === true);
|
||||
const _hasAudibleAudio = Boolean(effectiveTimeline.audio_master) || _hasAudibleBed || _hasSourceAudio;
|
||||
if (!_hasAudibleAudio) {
|
||||
// no audio from ANY source (master, bed volume>0, or clip sync) -> genuinely silent
|
||||
(mode === "final" ? errors : warnings).push(mode === "final"
|
||||
? "no audible audio: set an audio_master, a music/audio bed (volume>0), or enable card source audio"
|
||||
: "audio is empty (pre-audio board): review export will be silent");
|
||||
}
|
||||
if (!Number.isFinite(Number(effectiveTimeline.total_duration_s))) warnings.push("total_duration_s is not numeric; exporter will use card/music end time");
|
||||
|
||||
const cards = sortedCards(effectiveTimeline);
|
||||
if (cards.length === 0) errors.push("cards[] or sequence[] cards are required");
|
||||
let expectedStart = null;
|
||||
for (const [index, card] of cards.entries()) {
|
||||
const prefix = `cards[${index}] ${card.id || "(missing id)"}`;
|
||||
for (const field of ["id", "type", "title", "transcript", "status"]) {
|
||||
if (!String(card[field] ?? "").trim()) errors.push(`${prefix}: ${field} is required`);
|
||||
}
|
||||
if (!["slide", "clip", "placeholder"].includes(String(card.type))) {
|
||||
errors.push(`${prefix}: type must be slide, clip, or placeholder`);
|
||||
}
|
||||
if (!Number.isFinite(Number(card.start_s))) errors.push(`${prefix}: start_s must be numeric`);
|
||||
if (!Number.isFinite(Number(card.duration_s)) || Number(card.duration_s) <= 0) {
|
||||
errors.push(`${prefix}: duration_s must be > 0`);
|
||||
}
|
||||
|
||||
const placeholder = isPlaceholderCard(card);
|
||||
if (placeholder) placeholders.push({ id: card.id, title: card.title, start_s: card.start_s, duration_s: card.duration_s });
|
||||
if (mode === "final" && placeholder) {
|
||||
errors.push(`${prefix}: placeholder/missing asset is not allowed in final export`);
|
||||
}
|
||||
if (mode === "final" && !placeholder) {
|
||||
const approval = approvalBySlot.get(String(card.id));
|
||||
if (!approval) warnings.push(`${card.id}: installed card has no approval ledger row`);
|
||||
else if (approval.approved !== true) warnings.push(`${card.id}: installed card is not approved`);
|
||||
}
|
||||
|
||||
const chosen = chooseCardAsset(card, { sliceRoot });
|
||||
assets.push({ cardId: card.id, ...chosen });
|
||||
if (!placeholder) {
|
||||
if (!chosen.ref) errors.push(`${prefix}: asset is required`);
|
||||
if (chosen.isUrl) errors.push(`${prefix}: remote URLs are not supported by the local exporter`);
|
||||
if (checkFiles && chosen.ref && !chosen.exists) {
|
||||
const message = `${prefix}: asset file not found: ${chosen.path}`;
|
||||
if (mode === "review") warnings.push(message);
|
||||
else errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
const start = Number(card.start_s);
|
||||
const duration = Number(card.duration_s);
|
||||
if (Number.isFinite(start) && Number.isFinite(duration)) {
|
||||
if (expectedStart == null) {
|
||||
if (Math.abs(start) > toleranceS) errors.push(`${prefix}: first card starts at ${start}, expected 0`);
|
||||
} else {
|
||||
const delta = start - expectedStart;
|
||||
// a crossfade overlap is INTENTIONAL — the applied
|
||||
// xfade_in_s on this card licenses exactly that much overlap
|
||||
const licensedOverlap = Math.max(0, Number(card.xfade_in_s) || 0);
|
||||
if (delta > toleranceS) errors.push(`${prefix}: timeline gap ${roundSeconds(delta)}s before this card`);
|
||||
if (delta < -(licensedOverlap + toleranceS)) errors.push(`${prefix}: timeline overlap ${roundSeconds(-delta)}s before this card (crossfade licenses only ${licensedOverlap}s)`);
|
||||
}
|
||||
expectedStart = start + duration;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, bed] of collectAudioBeds(effectiveTimeline).entries()) {
|
||||
const asset = chooseAudioAsset(bed.asset, { sliceRoot });
|
||||
audioBeds.push({ ...bed, assetPath: asset.path, exists: asset.exists });
|
||||
if (!Number.isFinite(Number(bed.start_s))) errors.push(`audio_beds[${index}]: start_s must be numeric`);
|
||||
if (!Number.isFinite(Number(bed.duration_s)) || Number(bed.duration_s) <= 0) errors.push(`audio_beds[${index}]: duration_s must be > 0`);
|
||||
if (!asset.ref) errors.push(`audio_beds[${index}]: asset is required`);
|
||||
if (asset.isUrl) errors.push(`audio_beds[${index}]: remote URLs are not supported by the local exporter`);
|
||||
if (checkFiles && asset.ref && !asset.exists) {
|
||||
const message = `audio_beds[${index}]: file not found: ${asset.path}`;
|
||||
if (mode === "review") warnings.push(message);
|
||||
else errors.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
const audioAsset = chooseAudioAsset(effectiveTimeline.audio_master, { sliceRoot });
|
||||
if (effectiveTimeline.audio_master && audioAsset.isUrl) errors.push("audio_master remote URLs are not supported by the local exporter");
|
||||
if (checkFiles && effectiveTimeline.audio_master && !audioAsset.exists) errors.push(`audio_master file not found: ${audioAsset.path}`);
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
mode,
|
||||
errors,
|
||||
warnings,
|
||||
placeholders,
|
||||
assets,
|
||||
audioMaster: audioAsset,
|
||||
audioBeds,
|
||||
totalDurationS: timelineTotalDuration(effectiveTimeline),
|
||||
cardCount: cards.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function planExport(timeline, {
|
||||
sliceRoot,
|
||||
mode = "review",
|
||||
checkFiles = true,
|
||||
width = DEFAULT_EXPORT.width,
|
||||
height = DEFAULT_EXPORT.height,
|
||||
fps = DEFAULT_EXPORT.fps,
|
||||
} = {}) {
|
||||
const effectiveTimeline = normalizedTimeline(timeline);
|
||||
const validation = validateTimelineForExport(effectiveTimeline, { sliceRoot, mode, checkFiles });
|
||||
const segments = sortedCards(effectiveTimeline).map((card, index) => {
|
||||
const chosen = chooseCardAsset(card, { sliceRoot });
|
||||
const sourceOutRaw = card.source_out_s ?? card.clip_out_s ?? card.asset_out_s;
|
||||
const sourceInRaw = card.source_in_s ?? card.clip_in_s ?? card.asset_in_s ?? 0;
|
||||
const speedFactor = Number(card.speed_factor ?? card.playback_rate ?? 1);
|
||||
return {
|
||||
index,
|
||||
id: String(card.id),
|
||||
type: String(card.type),
|
||||
title: String(card.title || card.id || `Card ${index + 1}`),
|
||||
transcript: String(card.transcript || ""),
|
||||
status: String(card.status || ""),
|
||||
start_s: Number(card.start_s),
|
||||
duration_s: Number(card.duration_s),
|
||||
source_in_s: Number(sourceInRaw),
|
||||
source_out_s: Number(sourceOutRaw ?? 0),
|
||||
sourceOutExplicit: sourceOutRaw !== undefined && sourceOutRaw !== null && sourceOutRaw !== "",
|
||||
speed_factor: Number.isFinite(speedFactor) && speedFactor > 0 ? speedFactor : 1,
|
||||
placeholder: isPlaceholderCard(card) || !chosen.exists,
|
||||
assetRef: chosen.ref,
|
||||
assetPath: chosen.path,
|
||||
assetKind: chosen.kind,
|
||||
assetExists: chosen.exists,
|
||||
note: String(card.note || ""),
|
||||
hold_policy: String(card.hold_policy || card.holdPolicy || ""),
|
||||
fade_in_s: Math.max(0, Number(card.fade_in_s) || 0),
|
||||
fade_out_s: Math.max(0, Number(card.fade_out_s) || 0),
|
||||
crossfade_to_next_s: Math.max(0, Number(card.crossfade_to_next_s) || 0),
|
||||
// the APPLIED overlap fades (clamped, adjacency-checked in
|
||||
// applyCrossfadeOverlaps) — the assembler joins on xfade_out_s
|
||||
xfade_in_s: Math.max(0, Number(card.xfade_in_s) || 0),
|
||||
xfade_out_s: Math.max(0, Number(card.xfade_out_s) || 0),
|
||||
// sync-sound - the clip's OWN audio ships in its window;
|
||||
// trims move picture and sound together because they are one file
|
||||
useSourceAudio: card.use_source_audio === true,
|
||||
sourceAudioGain: Number.isFinite(Number(card.source_audio_gain)) ? Number(card.source_audio_gain) : 1,
|
||||
};
|
||||
});
|
||||
|
||||
const audioBeds = collectAudioBeds(effectiveTimeline).map((bed) => {
|
||||
const asset = chooseAudioAsset(bed.asset, { sliceRoot });
|
||||
return { ...bed, assetPath: asset.path, assetExists: asset.exists, assetKind: asset.kind };
|
||||
});
|
||||
const requestedLoudnessTarget = Number(effectiveTimeline.audio_loudness_target);
|
||||
const audioLoudnessTarget = Number.isFinite(requestedLoudnessTarget)
|
||||
&& requestedLoudnessTarget >= -70
|
||||
&& requestedLoudnessTarget <= -5
|
||||
? requestedLoudnessTarget
|
||||
: -16;
|
||||
|
||||
return {
|
||||
mode,
|
||||
validation,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
totalDurationS: timelineTotalDuration(effectiveTimeline),
|
||||
audioLoudnessTarget,
|
||||
audioMaster: validation.audioMaster,
|
||||
audioBeds,
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
export function readTimelineFile(timelinePath) {
|
||||
return JSON.parse(fs.readFileSync(timelinePath, "utf8"));
|
||||
}
|
||||
|
||||
// "you should never have to ask which cut to watch" -
|
||||
// every successful export updates the per-mode pointer at the bundle root.
|
||||
export function writeCurrentExport(sliceRoot, mode, result) {
|
||||
const pointerPath = path.join(sliceRoot, "current-export.json");
|
||||
let current = {};
|
||||
try {
|
||||
current = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
|
||||
} catch {}
|
||||
current._note = "DERIVED pointer to the latest export per mode. Written by assemble-timeline on success.";
|
||||
current[mode] = {
|
||||
created_at: new Date().toISOString(),
|
||||
output: result.output,
|
||||
manifest: result.manifest,
|
||||
contact_sheet: result.contactSheet,
|
||||
total_duration_s: result.totalDurationS,
|
||||
segment_count: result.segmentCount,
|
||||
timeline: result.timeline || null,
|
||||
};
|
||||
fs.writeFileSync(pointerPath, JSON.stringify(current, null, 2));
|
||||
return pointerPath;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const TRUSTED_BIN_DIRS = new Set(["/usr/bin", "/usr/local/bin", "/opt/homebrew/bin", "/opt/local/bin"]);
|
||||
|
||||
// PREFER THE BINARY WE SHIPPED, then fall back to the host's.
|
||||
//
|
||||
// ffmpeg and ffprobe used to be pure SYSTEM dependencies: the box either had
|
||||
// them on PATH or the video verbs died at the moment of use. That is the worst
|
||||
// shape a dependency can have — invisible at install, fatal in use, and
|
||||
// different on every target. A fresh Linux VPS typically has node and git and NEITHER of
|
||||
// these, which is exactly the case this resolves.
|
||||
//
|
||||
// `ffmpeg-static` and `ffprobe-static` ship prebuilt binaries for macOS, Linux
|
||||
// and Windows as ordinary npm packages, so `npm ci` installs them and there is
|
||||
// nothing platform-specific left to provision. Verified before adopting:
|
||||
// ffmpeg-static 5.3.0 is ffmpeg 6.0 and carries drawtext, scale, xfade and
|
||||
// concat — including the text filters some system ffmpeg builds lack, which is
|
||||
// what forced the macOS-only `qlmanage` slate fallback in the assembler.
|
||||
//
|
||||
// PATH is kept as the fallback rather than removed: a box that has a real
|
||||
// ffmpeg (codecs a static build may omit, hardware encoders) should be able to
|
||||
// use it, and an operator override stays possible.
|
||||
const PACKAGED = {
|
||||
ffmpeg: () => createRequire(import.meta.url)("ffmpeg-static"),
|
||||
ffprobe: () => createRequire(import.meta.url)("ffprobe-static").path,
|
||||
};
|
||||
|
||||
export function resolveSystemBin(name) {
|
||||
const packaged = PACKAGED[name];
|
||||
if (packaged) {
|
||||
try {
|
||||
const bin = packaged();
|
||||
if (bin) { fs.accessSync(bin, fs.constants.X_OK); return bin; }
|
||||
} catch { /* not installed, or not executable — fall through to PATH */ }
|
||||
}
|
||||
|
||||
const pathEntries = String(process.env.PATH || "").split(path.delimiter);
|
||||
for (const dir of pathEntries) {
|
||||
if (!TRUSTED_BIN_DIRS.has(dir)) continue;
|
||||
const candidate = path.join(dir, name);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
return candidate;
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(
|
||||
`${name} not found. It ships with this provider — run \`npm ci\` in its directory. ` +
|
||||
`Failing that, install it on PATH under /opt/homebrew/bin, /usr/local/bin, or /usr/bin.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function probeMedia(ffprobe, mediaPath) {
|
||||
const result = spawnSync(ffprobe, [
|
||||
"-hide_banner",
|
||||
"-loglevel", "error",
|
||||
"-show_entries", "format=duration:stream=index,codec_type,codec_name,width,height,duration",
|
||||
"-of", "json",
|
||||
mediaPath,
|
||||
], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 30 * 1000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ffprobe failed for ${mediaPath}: ${result.stderr || result.stdout || `status ${result.status}`}`);
|
||||
}
|
||||
const payload = JSON.parse(result.stdout || "{}");
|
||||
const duration = Number(payload?.format?.duration);
|
||||
const streamDuration = Math.max(0, ...(payload?.streams || []).map((stream) => Number(stream.duration) || 0));
|
||||
return {
|
||||
path: mediaPath,
|
||||
duration_s: Number.isFinite(duration) && duration > 0 ? duration : streamDuration,
|
||||
streams: payload?.streams || [],
|
||||
};
|
||||
}
|
||||
|
||||
export function hasIntentionalHoldPolicy(segment) {
|
||||
const text = [
|
||||
segment?.hold_policy,
|
||||
segment?.holdPolicy,
|
||||
segment?.note,
|
||||
segment?.status,
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return /\b(hold|last-frame|freeze|pad|tail|loop|repeat|seamless)\b/.test(text);
|
||||
}
|
||||
|
||||
function pushByMode({ mode, errors, warnings }, message) {
|
||||
if (mode === "final") errors.push(message);
|
||||
else warnings.push(message);
|
||||
}
|
||||
|
||||
export function evaluateSegmentProbe(segment, probe, {
|
||||
mode = "review",
|
||||
frameDurationS = 1 / 30,
|
||||
toleranceS = 0.02,
|
||||
} = {}) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const duration = Number(probe?.duration_s);
|
||||
const sourceIn = Math.max(0, Number(segment?.source_in_s) || 0);
|
||||
const explicitSourceOut = Boolean(segment?.sourceOutExplicit);
|
||||
const requestedSourceOut = Number(segment?.source_out_s);
|
||||
const sourceOut = explicitSourceOut && Number.isFinite(requestedSourceOut) && requestedSourceOut > sourceIn
|
||||
? requestedSourceOut
|
||||
: duration;
|
||||
const speed = Number.isFinite(Number(segment?.speed_factor)) && Number(segment.speed_factor) > 0
|
||||
? Number(segment.speed_factor)
|
||||
: 1;
|
||||
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
pushByMode({ mode, errors, warnings }, `${segment.id}: could not probe media duration`);
|
||||
return { errors, warnings, sourceIn, sourceOut, activeDurationS: 0, padDurationS: 0 };
|
||||
}
|
||||
|
||||
if (sourceIn > duration + toleranceS) {
|
||||
pushByMode({ mode, errors, warnings }, `${segment.id}: source_in_s ${sourceIn} exceeds probed media duration ${duration}`);
|
||||
}
|
||||
if (explicitSourceOut && sourceOut > duration + toleranceS) {
|
||||
pushByMode({ mode, errors, warnings }, `${segment.id}: source_out_s ${sourceOut} exceeds probed media duration ${duration}`);
|
||||
}
|
||||
if (explicitSourceOut && sourceOut <= sourceIn + toleranceS) {
|
||||
pushByMode({ mode, errors, warnings }, `${segment.id}: source_out_s ${sourceOut} must be greater than source_in_s ${sourceIn}`);
|
||||
}
|
||||
|
||||
const clampedSourceOut = Math.min(sourceOut, duration);
|
||||
const sourceSpan = Math.max(0, clampedSourceOut - Math.min(sourceIn, duration));
|
||||
const activeDurationS = sourceSpan / speed;
|
||||
const padDurationS = Math.max(0, Number(segment?.duration_s || 0) - activeDurationS);
|
||||
if (padDurationS > frameDurationS && !hasIntentionalHoldPolicy(segment)) {
|
||||
warnings.push(`${segment.id}: active source span ${activeDurationS.toFixed(3)}s is shorter than slot ${Number(segment.duration_s).toFixed(3)}s; exporter will hold last frame unless final policy changes`);
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
warnings,
|
||||
sourceIn,
|
||||
sourceOut,
|
||||
clampedSourceOut,
|
||||
speed,
|
||||
activeDurationS,
|
||||
padDurationS,
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePlanWithProbes(plan, {
|
||||
ffprobe = resolveSystemBin("ffprobe"),
|
||||
mode = plan?.mode || "review",
|
||||
frameDurationS = 1 / Number(plan?.fps || 30),
|
||||
} = {}) {
|
||||
const errors = [...(plan.validation?.errors || [])];
|
||||
const warnings = [...(plan.validation?.warnings || [])];
|
||||
const segments = plan.segments.map((segment) => {
|
||||
if (segment.placeholder || segment.assetKind !== "video" || !segment.assetExists) return segment;
|
||||
const probe = probeMedia(ffprobe, segment.assetPath);
|
||||
const probeValidation = evaluateSegmentProbe(segment, probe, { mode, frameDurationS });
|
||||
errors.push(...probeValidation.errors);
|
||||
warnings.push(...probeValidation.warnings);
|
||||
return {
|
||||
...segment,
|
||||
probe,
|
||||
probeValidation,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...plan,
|
||||
validation: {
|
||||
...plan.validation,
|
||||
errors,
|
||||
warnings,
|
||||
ok: errors.length === 0,
|
||||
},
|
||||
segments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export function parseByteRange(rangeHeader, fileSize) {
|
||||
if (!rangeHeader) return null;
|
||||
const size = Number(fileSize);
|
||||
if (!Number.isFinite(size) || size <= 0) return { invalid: true };
|
||||
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim());
|
||||
if (!match) return { invalid: true };
|
||||
|
||||
const [, startText, endText] = match;
|
||||
if (!startText && !endText) return { invalid: true };
|
||||
|
||||
let start;
|
||||
let end;
|
||||
if (!startText) {
|
||||
const suffixLength = Number(endText);
|
||||
if (!Number.isInteger(suffixLength) || suffixLength <= 0) return { invalid: true };
|
||||
start = Math.max(0, size - suffixLength);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(startText);
|
||||
end = endText ? Number(endText) : size - 1;
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end)) return { invalid: true };
|
||||
if (start < 0 || end < start || start >= size) return { invalid: true };
|
||||
end = Math.min(end, size - 1);
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function staticResponseHeaders({ fileSize, rangeHeader, contentType }) {
|
||||
const size = Number(fileSize);
|
||||
const baseHeaders = {
|
||||
"content-type": contentType || "application/octet-stream",
|
||||
"access-control-allow-origin": "*",
|
||||
"accept-ranges": "bytes",
|
||||
};
|
||||
|
||||
const parsedRange = parseByteRange(rangeHeader, size);
|
||||
if (parsedRange?.invalid) {
|
||||
return {
|
||||
status: 416,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
"content-range": `bytes */${size}`,
|
||||
"content-length": "0",
|
||||
},
|
||||
range: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsedRange) {
|
||||
return {
|
||||
status: 206,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
"content-range": `bytes ${parsedRange.start}-${parsedRange.end}/${size}`,
|
||||
"content-length": String(parsedRange.end - parsedRange.start + 1),
|
||||
},
|
||||
range: parsedRange,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
"content-length": String(size),
|
||||
},
|
||||
range: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env node
|
||||
// Slot revision history: append-only timeline-history.jsonl + derived timeline-slots.json.
|
||||
//
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { materializeCards, readTimelineFile } from "./timeline-export-core.mjs";
|
||||
|
||||
// Deferred: slot_uid = normalized dot id (slot_04_03). Split/merge lineage with
|
||||
// generated uids is deferred until a real renumber/split happens.
|
||||
export function slotUidFor(dotId) {
|
||||
const parts = String(dotId).split(".").map((part) => part.replace(/[^a-zA-Z0-9]+/g, ""));
|
||||
return `slot_${parts.map((part) => (/^\d+$/.test(part) ? part.padStart(2, "0") : part)).join("_")}`;
|
||||
}
|
||||
|
||||
export function eventTimestamp(date = new Date()) {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function eventIdStamp(date = new Date()) {
|
||||
return date.toISOString().replace(/[-:T]/g, "").slice(0, 15).replace(/^(\d{8})(\d{6}).*$/, "$1_$2");
|
||||
}
|
||||
|
||||
export function makeEventId(dotId, action, date = new Date(), suffix = "") {
|
||||
const dotNorm = String(dotId).replace(/[^a-zA-Z0-9]+/g, "_");
|
||||
return `tlh_${eventIdStamp(date)}_${dotNorm}_${action}${suffix ? `_${suffix}` : ""}`;
|
||||
}
|
||||
|
||||
export function cardSnapshot(card) {
|
||||
if (!card) return null;
|
||||
const snapshot = {
|
||||
start_s: Number(card.start_s),
|
||||
duration_s: Number(card.duration_s),
|
||||
asset: String(card.asset || ""),
|
||||
source_in_s: card.source_in_s != null ? Number(card.source_in_s) : 0,
|
||||
source_out_s: card.source_out_s != null ? Number(card.source_out_s) : null,
|
||||
speed_factor: card.speed_factor != null ? Number(card.speed_factor) : 1,
|
||||
transcript: String(card.transcript || ""),
|
||||
};
|
||||
if (card.title) snapshot.title = String(card.title);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function readHistory(historyPath) {
|
||||
if (!fs.existsSync(historyPath)) return [];
|
||||
return fs.readFileSync(historyPath, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line, index) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
throw new Error(`timeline-history.jsonl line ${index + 1} is not valid JSON`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function appendEvents(historyPath, events) {
|
||||
if (!events.length) return;
|
||||
const lines = events.map((event) => JSON.stringify(event)).join("\n") + "\n";
|
||||
fs.appendFileSync(historyPath, lines);
|
||||
}
|
||||
|
||||
export function bootstrapHistory({ timelinePath, historyPath, actor = "timeline-history.mjs" }) {
|
||||
if (fs.existsSync(historyPath) && readHistory(historyPath).length > 0) {
|
||||
return { bootstrapped: false, events: [] };
|
||||
}
|
||||
const timeline = readTimelineFile(timelinePath);
|
||||
const now = new Date();
|
||||
const events = materializeCards(timeline).map((card, index) => ({
|
||||
event_id: makeEventId(card.id, "bootstrap", now, String(index)),
|
||||
changed_at: eventTimestamp(now),
|
||||
actor,
|
||||
source: "bootstrap-current-timeline",
|
||||
patch_id: null,
|
||||
slot: {
|
||||
slot_uid: slotUidFor(card.id),
|
||||
dot_id_before: null,
|
||||
dot_id_after: String(card.id),
|
||||
section: String(card.section || ""),
|
||||
},
|
||||
before: null,
|
||||
after: cardSnapshot(card),
|
||||
reason: "History tracking enabled; snapshot of current timeline state.",
|
||||
validation: null,
|
||||
}));
|
||||
appendEvents(historyPath, events);
|
||||
return { bootstrapped: true, events };
|
||||
}
|
||||
|
||||
export function rebuildSlots({ timelinePath, historyPath, slotsPath, assetsPath = null }) {
|
||||
const timeline = readTimelineFile(timelinePath);
|
||||
const history = readHistory(historyPath);
|
||||
const eventsBySlotUid = new Map();
|
||||
for (const event of history) {
|
||||
const uid = event?.slot?.slot_uid;
|
||||
if (!uid) continue;
|
||||
if (!eventsBySlotUid.has(uid)) eventsBySlotUid.set(uid, []);
|
||||
eventsBySlotUid.get(uid).push(event);
|
||||
}
|
||||
|
||||
// the postmortem's "card ledger" is a DERIVED JOIN over existing
|
||||
// authorities - candidates come from the asset index, approval from review
|
||||
// events. Never a new source of truth.
|
||||
const candidatesBySlot = new Map();
|
||||
const resolvedAssets = assetsPath || path.join(path.dirname(timelinePath), "project-assets.json");
|
||||
if (fs.existsSync(resolvedAssets)) {
|
||||
try {
|
||||
for (const asset of JSON.parse(fs.readFileSync(resolvedAssets, "utf8")).assets || []) {
|
||||
if (asset.role !== "candidate" && asset.status !== "candidate") continue;
|
||||
for (const slotRef of asset.slot_refs || []) {
|
||||
if (!candidatesBySlot.has(slotRef)) candidatesBySlot.set(slotRef, []);
|
||||
candidatesBySlot.get(slotRef).push(asset.path);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const currentCards = materializeCards(timeline);
|
||||
const currentUids = new Set();
|
||||
const slots = currentCards.map((card) => {
|
||||
const uid = slotUidFor(card.id);
|
||||
currentUids.add(uid);
|
||||
const events = eventsBySlotUid.get(uid) || [];
|
||||
const last = events.at(-1) || null;
|
||||
// the LAST review event's action decides - unlock clears the
|
||||
// lock; events without an action are historical approves (back-compat)
|
||||
const lastReview = [...events].reverse().find((event) => event.source === "review");
|
||||
const lastContentChange = [...events].reverse().find((event) => event.source === "patch-apply");
|
||||
const approvedAt = lastReview && lastReview.action !== "unlock" ? lastReview.changed_at : null;
|
||||
const approved = Boolean(approvedAt) &&
|
||||
(!lastContentChange || new Date(approvedAt) >= new Date(lastContentChange.changed_at));
|
||||
return {
|
||||
approved,
|
||||
approved_at: approvedAt,
|
||||
candidate_count: (candidatesBySlot.get(String(card.id)) || []).length,
|
||||
candidates: candidatesBySlot.get(String(card.id)) || [],
|
||||
dot_id: String(card.id),
|
||||
slot_uid: uid,
|
||||
status: "current",
|
||||
current: cardSnapshot(card),
|
||||
section: String(card.section || ""),
|
||||
title: String(card.title || ""),
|
||||
type: String(card.type || ""),
|
||||
event_count: events.length,
|
||||
event_ids: events.map((event) => event.event_id),
|
||||
last_event_id: last?.event_id || null,
|
||||
last_changed_at: last?.changed_at || null,
|
||||
last_source: last?.source || null, // review lens: "review" clears the badge
|
||||
};
|
||||
});
|
||||
|
||||
for (const [uid, events] of eventsBySlotUid.entries()) {
|
||||
if (currentUids.has(uid)) continue;
|
||||
const last = events.at(-1);
|
||||
slots.push({
|
||||
dot_id: String(last?.slot?.dot_id_after || last?.slot?.dot_id_before || uid),
|
||||
slot_uid: uid,
|
||||
status: "retired",
|
||||
current: null,
|
||||
event_count: events.length,
|
||||
event_ids: events.map((event) => event.event_id),
|
||||
last_event_id: last?.event_id || null,
|
||||
last_changed_at: last?.changed_at || null,
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
_note: "DERIVED index for the mini-NLE slot history tab. Rebuild with timeline-history.mjs rebuild; timeline-history.jsonl is the audit source of truth.",
|
||||
generated_at: eventTimestamp(),
|
||||
timeline: path.basename(timelinePath),
|
||||
slots,
|
||||
};
|
||||
fs.writeFileSync(slotsPath, JSON.stringify(payload, null, 2));
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function defaultPaths(sliceRoot) {
|
||||
return {
|
||||
timelinePath: path.join(sliceRoot, "timeline.json"),
|
||||
historyPath: path.join(sliceRoot, "timeline-history.jsonl"),
|
||||
slotsPath: path.join(sliceRoot, "timeline-slots.json"),
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const command = argv[0];
|
||||
const options = { sliceRoot: path.resolve(new URL("../../", import.meta.url).pathname), json: false };
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--slice-root") options.sliceRoot = path.resolve(argv[++i]);
|
||||
else if (arg === "--json") options.json = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
const paths = defaultPaths(options.sliceRoot);
|
||||
if (command === "bootstrap") {
|
||||
const result = bootstrapHistory(paths);
|
||||
rebuildSlots(paths);
|
||||
const summary = { ok: true, bootstrapped: result.bootstrapped, eventCount: result.events.length, historyPath: paths.historyPath, slotsPath: paths.slotsPath };
|
||||
console.log(options.json ? JSON.stringify(summary, null, 2) : `bootstrap: ${result.bootstrapped ? `${result.events.length} events written` : "history already exists, skipped"}; slots rebuilt at ${paths.slotsPath}`);
|
||||
return;
|
||||
}
|
||||
if (command === "rebuild") {
|
||||
const payload = rebuildSlots(paths);
|
||||
console.log(options.json ? JSON.stringify({ ok: true, slotCount: payload.slots.length, slotsPath: paths.slotsPath }, null, 2) : `rebuilt ${payload.slots.length} slots at ${paths.slotsPath}`);
|
||||
return;
|
||||
}
|
||||
console.log("Usage: node timeline-history.mjs <bootstrap|rebuild> [--slice-root dir] [--json]");
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,751 @@
|
||||
#!/usr/bin/env node
|
||||
// Patch layer for the mini-NLE: edits land in timeline-edits.patch.json first,
|
||||
// and timeline.json is only mutated by applyPatch after validation passes.
|
||||
// Validation reuses the exporter's own planExport + validatePlanWithProbes.
|
||||
//
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
materializeCards,
|
||||
mediaKind,
|
||||
planExport,
|
||||
readTimelineFile,
|
||||
roundSeconds,
|
||||
timelineTotalDuration,
|
||||
} from "./timeline-export-core.mjs";
|
||||
import { resolveSystemBin, validatePlanWithProbes } from "./timeline-export-probe.mjs";
|
||||
import {
|
||||
appendEvents,
|
||||
bootstrapHistory,
|
||||
cardSnapshot,
|
||||
defaultPaths,
|
||||
eventTimestamp,
|
||||
makeEventId,
|
||||
rebuildSlots,
|
||||
slotUidFor,
|
||||
} from "./timeline-history.mjs";
|
||||
import { defaultAssetPaths, generateProjectAssets } from "./project-assets.mjs";
|
||||
|
||||
const MIN_DURATION_S = 0.2;
|
||||
|
||||
export function makePatchId(date = new Date()) {
|
||||
return `patch_${date.toISOString().replace(/[-:T]/g, "").slice(0, 14)}_${Math.random().toString(16).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
export function emptyPatch({ actor = "mini-nle", reason = "" } = {}) {
|
||||
return {
|
||||
patch_id: makePatchId(),
|
||||
created_at: eventTimestamp(),
|
||||
actor,
|
||||
reason,
|
||||
base_timeline: "timeline.json",
|
||||
ops: [],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneJson(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function ensureSequenceBackbone(timeline) {
|
||||
if (Array.isArray(timeline.sequence) && timeline.sequence.length > 0) {
|
||||
// REFERENCED-style entries ({id} with the card in cards[])
|
||||
// get their card embedded — agent-authored boards use this style; the
|
||||
// patch layer previously refused them ("has no embedded card")
|
||||
const byId = new Map((Array.isArray(timeline.cards) ? timeline.cards : []).map((card) => [String(card.id), card]));
|
||||
timeline.sequence = timeline.sequence.map((entry) => {
|
||||
if ((entry?.type || "card") === "card" && !entry.card) {
|
||||
const src = byId.get(String(entry.id));
|
||||
if (src) {
|
||||
return { ...entry,
|
||||
duration_s: entry.duration_s != null ? entry.duration_s : roundSeconds(Number(src.duration_s)),
|
||||
card: cloneJson(src) };
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(timeline.cards) || timeline.cards.length === 0) return;
|
||||
timeline.sequence = [...timeline.cards]
|
||||
.sort((a, b) => Number(a.start_s) - Number(b.start_s) || String(a.id).localeCompare(String(b.id)))
|
||||
.map((card) => ({
|
||||
id: String(card.id),
|
||||
type: "card",
|
||||
duration_s: roundSeconds(Number(card.duration_s)),
|
||||
card: cloneJson(card),
|
||||
}));
|
||||
}
|
||||
|
||||
function findSequenceEntry(timeline, id) {
|
||||
const entry = (timeline.sequence || []).find((item) => String(item.id) === String(id) && (item.type || "card") === "card");
|
||||
if (!entry) throw new Error(`op target not found in sequence[]: ${id}`);
|
||||
if (!entry.card) throw new Error(`sequence entry ${id} has no embedded card; unsupported by patch layer`);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function setEntryDuration(entry, duration) {
|
||||
const value = roundSeconds(duration);
|
||||
if (!Number.isFinite(value) || value < MIN_DURATION_S) {
|
||||
throw new Error(`${entry.id}: duration_s must be >= ${MIN_DURATION_S}, got ${duration}`);
|
||||
}
|
||||
entry.duration_s = value;
|
||||
entry.card.duration_s = value;
|
||||
if (entry.card.target_clip_length_s != null) entry.card.target_clip_length_s = value;
|
||||
}
|
||||
|
||||
function sourceSpanOf(card) {
|
||||
const sourceIn = Math.max(0, Number(card.source_in_s ?? 0));
|
||||
const sourceOut = Number(card.source_out_s);
|
||||
if (!Number.isFinite(sourceOut) || sourceOut <= sourceIn) return null;
|
||||
return { sourceIn, sourceOut, span: sourceOut - sourceIn };
|
||||
}
|
||||
|
||||
function sectionFromCardId(id) {
|
||||
const match = String(id || "").match(/^(\d+)/);
|
||||
return match ? match[1] : "outline";
|
||||
}
|
||||
|
||||
// Apply one op to the working timeline (deep-cloned by applyOps). Each op
|
||||
// returns a change record used for history events.
|
||||
const OP_HANDLERS = {
|
||||
set_duration(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
setEntryDuration(entry, op.duration_s);
|
||||
return { id: String(op.id), action: "retime", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
trim_source(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
if (op.source_in_s != null) entry.card.source_in_s = roundSeconds(Math.max(0, Number(op.source_in_s)));
|
||||
if (op.source_out_s != null) entry.card.source_out_s = roundSeconds(Number(op.source_out_s));
|
||||
if (op.duration_s != null) setEntryDuration(entry, op.duration_s);
|
||||
const span = sourceSpanOf(entry.card);
|
||||
if (entry.card.source_out_s != null && !span) {
|
||||
throw new Error(`${op.id}: source_out_s must be greater than source_in_s`);
|
||||
}
|
||||
return { id: String(op.id), action: "trim", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
split_at(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
const offset = roundSeconds(Number(op.offset_s));
|
||||
const total = Number(entry.duration_s ?? entry.card.duration_s);
|
||||
if (!Number.isFinite(offset) || offset < MIN_DURATION_S || offset > total - MIN_DURATION_S) {
|
||||
throw new Error(`${op.id}: split offset_s ${op.offset_s} must be inside (${MIN_DURATION_S}, ${roundSeconds(total - MIN_DURATION_S)})`);
|
||||
}
|
||||
const newId = String(op.new_id || `${op.id}b`);
|
||||
if ((timeline.sequence || []).some((item) => String(item.id) === newId)) {
|
||||
throw new Error(`split new_id already exists in sequence: ${newId}`);
|
||||
}
|
||||
const speed = Number(entry.card.speed_factor) > 0 ? Number(entry.card.speed_factor) : 1;
|
||||
const secondCard = cloneJson(entry.card);
|
||||
secondCard.id = newId;
|
||||
secondCard.title = `${entry.card.title || op.id} (split)`;
|
||||
const sourceIn = Math.max(0, Number(entry.card.source_in_s ?? 0));
|
||||
secondCard.source_in_s = roundSeconds(sourceIn + offset * speed);
|
||||
const secondEntry = {
|
||||
id: newId,
|
||||
type: "card",
|
||||
duration_s: roundSeconds(total - offset),
|
||||
card: secondCard,
|
||||
};
|
||||
setEntryDuration(entry, offset);
|
||||
// first half now ends where the split lands in the source
|
||||
if (sourceSpanOf(entry.card) || entry.card.source_out_s == null) {
|
||||
entry.card.source_out_s = roundSeconds(sourceIn + offset * speed);
|
||||
}
|
||||
setEntryDuration(secondEntry, secondEntry.duration_s);
|
||||
const index = timeline.sequence.indexOf(entry);
|
||||
timeline.sequence.splice(index + 1, 0, secondEntry);
|
||||
return [
|
||||
{ id: String(op.id), action: "split", before, after: cloneJson(entry.card) },
|
||||
{ id: newId, action: "split-create", before: null, after: cloneJson(secondCard), parentSlotUid: slotUidFor(op.id) },
|
||||
];
|
||||
},
|
||||
|
||||
stretch_to(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
const target = Number(op.duration_s);
|
||||
const span = sourceSpanOf(entry.card);
|
||||
const previousSpeed = Number(entry.card.speed_factor) > 0 ? Number(entry.card.speed_factor) : 1;
|
||||
const activeSpan = span ? span.span : Number(entry.duration_s ?? entry.card.duration_s) * previousSpeed;
|
||||
if (!Number.isFinite(target) || target < MIN_DURATION_S) {
|
||||
throw new Error(`${op.id}: stretch_to duration_s must be >= ${MIN_DURATION_S}`);
|
||||
}
|
||||
entry.card.speed_factor = roundSeconds(activeSpan / target);
|
||||
setEntryDuration(entry, target);
|
||||
return { id: String(op.id), action: "stretch", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
upsert_bed(timeline, op) {
|
||||
const source = { ...(op.bed || op) };
|
||||
const id = String(source.id || source.bed_id || "").trim();
|
||||
if (!id) throw new Error("upsert_bed requires id");
|
||||
const asset = String(source.asset || "").trim();
|
||||
if (!asset) throw new Error(`${id}: upsert_bed requires asset`);
|
||||
const start = roundSeconds(Math.max(0, Number(source.start_s ?? 0)));
|
||||
const duration = roundSeconds(Number(source.duration_s));
|
||||
if (!Number.isFinite(duration) || duration <= 0) throw new Error(`${id}: bed duration_s must be > 0`);
|
||||
const fadeIn = roundSeconds(Math.max(0, Number(source.fade_in_s || 0)));
|
||||
const fadeOut = roundSeconds(Math.max(0, Number(source.fade_out_s || 0)));
|
||||
if (fadeIn + fadeOut > duration) throw new Error(`${id}: fade_in_s + fade_out_s exceeds bed duration ${duration}`);
|
||||
const volume = Number(source.volume ?? 1);
|
||||
if (!Number.isFinite(volume) || volume < 0) throw new Error(`${id}: volume must be >= 0`);
|
||||
const collectionName = source.collection === "music_beds" || source.kind === "music" ? "music_beds" : "audio_beds";
|
||||
if (!Array.isArray(timeline[collectionName])) timeline[collectionName] = [];
|
||||
const index = timeline[collectionName].findIndex((item) => String(item.id) === id);
|
||||
const before = index >= 0 ? cloneJson(timeline[collectionName][index]) : null;
|
||||
const bed = {
|
||||
id,
|
||||
title: String(source.title || id),
|
||||
asset,
|
||||
start_s: start,
|
||||
duration_s: duration,
|
||||
source_in_s: roundSeconds(Math.max(0, Number(source.source_in_s || 0))),
|
||||
volume: roundSeconds(volume),
|
||||
fade_in_s: fadeIn,
|
||||
fade_out_s: fadeOut,
|
||||
...(source.status ? { status: String(source.status) } : {}),
|
||||
};
|
||||
if (index >= 0) timeline[collectionName][index] = bed;
|
||||
else timeline[collectionName].push(bed);
|
||||
return { id, action: index >= 0 ? "bed-upsert" : "bed-create", before, after: cloneJson(bed), isBed: true };
|
||||
},
|
||||
|
||||
set_bed_window(timeline, op) {
|
||||
const beds = [...(timeline.audio_beds || []), ...(timeline.music_beds || [])];
|
||||
const bed = beds.find((item) => String(item.id) === String(op.bed_id));
|
||||
if (!bed) throw new Error(`op target bed not found: ${op.bed_id}`);
|
||||
const before = cloneJson(bed);
|
||||
if (op.start_s != null) bed.start_s = roundSeconds(Math.max(0, Number(op.start_s)));
|
||||
if (op.duration_s != null) {
|
||||
const value = roundSeconds(Number(op.duration_s));
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error(`${op.bed_id}: bed duration_s must be > 0`);
|
||||
bed.duration_s = value;
|
||||
}
|
||||
for (const field of ["fade_in_s", "fade_out_s"]) {
|
||||
if (op[field] === undefined) continue;
|
||||
const value = Number(op[field]);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error(`${op.bed_id}: ${field} must be >= 0`);
|
||||
bed[field] = roundSeconds(value);
|
||||
}
|
||||
if (op.volume !== undefined) {
|
||||
const value = Number(op.volume);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error(`${op.bed_id}: volume must be >= 0`);
|
||||
bed.volume = roundSeconds(value);
|
||||
}
|
||||
if (Number(bed.fade_in_s || 0) + Number(bed.fade_out_s || 0) > Number(bed.duration_s)) {
|
||||
throw new Error(`${op.bed_id}: fade_in_s + fade_out_s exceeds bed duration ${bed.duration_s}`);
|
||||
}
|
||||
return { id: String(op.bed_id), action: "bed-retime", before, after: cloneJson(bed), isBed: true };
|
||||
},
|
||||
|
||||
// fade/crossfade METADATA on cards (). The exporter does not
|
||||
// render fades yet in v0; these fields ride the card for the assembler.
|
||||
set_fades(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
for (const field of ["fade_in_s", "fade_out_s", "crossfade_to_next_s"]) {
|
||||
if (op[field] === undefined) continue;
|
||||
if (op[field] === null) { delete entry.card[field]; continue; }
|
||||
const value = Number(op[field]);
|
||||
if (!Number.isFinite(value) || value < 0) throw new Error(`${op.id}: ${field} must be >= 0`);
|
||||
entry.card[field] = roundSeconds(value);
|
||||
}
|
||||
const duration = Number(entry.duration_s ?? entry.card.duration_s);
|
||||
const fadeIn = Number(entry.card.fade_in_s || 0);
|
||||
const fadeOut = Number(entry.card.fade_out_s || 0);
|
||||
if (fadeIn + fadeOut > duration) {
|
||||
throw new Error(`${op.id}: fade_in_s + fade_out_s (${roundSeconds(fadeIn + fadeOut)}) exceeds card duration ${duration}`);
|
||||
}
|
||||
const crossfade = Number(entry.card.crossfade_to_next_s || 0);
|
||||
if (crossfade > 0) {
|
||||
const cardEntries = (timeline.sequence || []).filter((item) => (item.type || "card") === "card");
|
||||
const index = cardEntries.indexOf(entry);
|
||||
const next = cardEntries[index + 1];
|
||||
if (!next) throw new Error(`${op.id}: crossfade_to_next_s set on the last card`);
|
||||
const nextDuration = Number(next.duration_s ?? next.card?.duration_s);
|
||||
if (crossfade > duration || crossfade > nextDuration) {
|
||||
throw new Error(`${op.id}: crossfade_to_next_s ${crossfade} exceeds this card (${duration}s) or the next card (${nextDuration}s)`);
|
||||
}
|
||||
}
|
||||
return { id: String(op.id), action: "fades", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
// install a new audio master package . Swaps the master triple,
|
||||
// preserves the previous triple via the existing *_previous convention, and
|
||||
// resets the spoken-spine duration to the new master's probed length.
|
||||
set_master(timeline, op) {
|
||||
for (const field of ["audio_master", "words_json", "captions_vtt"]) {
|
||||
if (!String(op[field] || "").trim()) throw new Error(`set_master: ${field} is required`);
|
||||
}
|
||||
const total = Number(op.total_duration_s);
|
||||
if (!Number.isFinite(total) || total <= 0) throw new Error("set_master: total_duration_s must be > 0");
|
||||
const before = {
|
||||
audio_master: timeline.audio_master,
|
||||
words_json: timeline.words_json,
|
||||
captions_vtt: timeline.captions_vtt,
|
||||
total_duration_s: timeline.total_duration_s,
|
||||
};
|
||||
timeline.audio_master_previous = timeline.audio_master;
|
||||
timeline.words_json_previous = timeline.words_json;
|
||||
timeline.captions_vtt_previous = timeline.captions_vtt;
|
||||
timeline.audio_master = String(op.audio_master);
|
||||
timeline.words_json = String(op.words_json);
|
||||
timeline.captions_vtt = String(op.captions_vtt);
|
||||
if (op.srt) timeline.subtitles_srt = String(op.srt);
|
||||
if (op.loudness_report) timeline.loudness_report = String(op.loudness_report);
|
||||
timeline.audio_master_status = String(op.status || "review-candidate");
|
||||
timeline.total_duration_s = roundSeconds(total);
|
||||
const after = {
|
||||
audio_master: timeline.audio_master,
|
||||
words_json: timeline.words_json,
|
||||
captions_vtt: timeline.captions_vtt,
|
||||
total_duration_s: timeline.total_duration_s,
|
||||
};
|
||||
return { id: "audio-master", action: "install-master", before, after, isMaster: true };
|
||||
},
|
||||
|
||||
restore_occupant(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
const asset = String(op.asset || "").trim();
|
||||
if (!asset) throw new Error(`${op.id}: restore_occupant requires asset`);
|
||||
entry.card.asset = asset;
|
||||
const kind = mediaKind(asset);
|
||||
if (kind === "video") entry.card.type = "clip";
|
||||
else if (kind === "image") entry.card.type = "slide";
|
||||
if (op.type) entry.card.type = String(op.type);
|
||||
entry.card.status = String(op.status || "installed");
|
||||
if (op.source_in_s != null) entry.card.source_in_s = roundSeconds(Math.max(0, Number(op.source_in_s)));
|
||||
else delete entry.card.source_in_s;
|
||||
if (op.source_out_s != null) entry.card.source_out_s = roundSeconds(Number(op.source_out_s));
|
||||
else delete entry.card.source_out_s;
|
||||
if (op.hold_policy !== undefined || op.holdPolicy !== undefined) {
|
||||
const policy = String(op.hold_policy ?? op.holdPolicy ?? "").trim();
|
||||
if (policy) entry.card.hold_policy = policy;
|
||||
else delete entry.card.hold_policy;
|
||||
}
|
||||
if (op.duration_s != null) setEntryDuration(entry, op.duration_s);
|
||||
return { id: String(op.id), action: "replace-asset", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
// ---- board ops - the human's sweep and agent suggestions ride
|
||||
// the same rails (validate -> backup -> apply -> history) ----
|
||||
|
||||
// toggle a clip's own (sync) audio; gain optional
|
||||
set_source_audio(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
if (op.use_source_audio === true) entry.card.use_source_audio = true;
|
||||
else delete entry.card.use_source_audio;
|
||||
if (op.gain != null) {
|
||||
const gain = Number(op.gain);
|
||||
if (!Number.isFinite(gain) || gain < 0) throw new Error(`${op.id}: gain must be >= 0`);
|
||||
entry.card.source_audio_gain = roundSeconds(gain);
|
||||
}
|
||||
return { id: String(op.id), action: "sync-audio", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
set_audio_loudness(timeline, op) {
|
||||
const before = { audio_loudness_target: timeline.audio_loudness_target };
|
||||
if (op.target == null) delete timeline.audio_loudness_target;
|
||||
else {
|
||||
const target = Number(op.target);
|
||||
if (!Number.isFinite(target) || target < -70 || target > -5) {
|
||||
throw new Error("audio loudness target must be between -70 and -5 LUFS");
|
||||
}
|
||||
timeline.audio_loudness_target = roundSeconds(target);
|
||||
}
|
||||
return {
|
||||
id: "timeline.audio",
|
||||
action: "audio-loudness",
|
||||
before,
|
||||
after: { audio_loudness_target: timeline.audio_loudness_target },
|
||||
};
|
||||
},
|
||||
|
||||
set_template(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
if (!String(op.template || "").trim()) throw new Error(`${op.id}: set_template requires template`);
|
||||
entry.card.template = String(op.template);
|
||||
if (op.title != null) entry.card.title = String(op.title);
|
||||
if (op.note != null) entry.card.note = String(op.note);
|
||||
return { id: String(op.id), action: "retemplate", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
set_section(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
const section = String(op.section || "").trim();
|
||||
if (!section) throw new Error(`${op.id}: set_section requires section`);
|
||||
entry.card.section = section;
|
||||
return { id: String(op.id), action: "metadata", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
// basic CRUD (stage editor): rename a beat / edit its note without
|
||||
// touching anything else
|
||||
set_meta(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
if (op.title == null && op.note == null) throw new Error(`${op.id}: set_meta requires title and/or note`);
|
||||
if (op.title != null) {
|
||||
const title = String(op.title).trim();
|
||||
if (!title) throw new Error(`${op.id}: title cannot be empty`);
|
||||
entry.card.title = title;
|
||||
}
|
||||
if (op.note != null) {
|
||||
const note = String(op.note);
|
||||
if (note.trim()) entry.card.note = note;
|
||||
else delete entry.card.note;
|
||||
}
|
||||
return { id: String(op.id), action: "metadata", before, after: cloneJson(entry.card) };
|
||||
},
|
||||
|
||||
insert_beat(timeline, op) {
|
||||
const newId = String(op.id || "").trim();
|
||||
if (!newId) throw new Error("insert_beat requires id");
|
||||
if ((timeline.sequence || []).some((item) => String(item.id) === newId)) {
|
||||
throw new Error(`insert_beat id already exists in sequence: ${newId}`);
|
||||
}
|
||||
const duration = op.duration_s != null ? Number(op.duration_s) : 4;
|
||||
if (!Number.isFinite(duration) || duration < MIN_DURATION_S) {
|
||||
throw new Error(`${newId}: insert_beat duration_s must be >= ${MIN_DURATION_S}`);
|
||||
}
|
||||
const card = {
|
||||
id: newId,
|
||||
type: "placeholder",
|
||||
status: "placeholder",
|
||||
title: String(op.title || newId),
|
||||
section: String(op.section || sectionFromCardId(newId)),
|
||||
transcript: String(op.transcript || op.title || newId),
|
||||
duration_s: roundSeconds(duration),
|
||||
...(op.template ? { template: String(op.template) } : {}),
|
||||
...(op.note ? { note: String(op.note) } : {}),
|
||||
};
|
||||
const entry = { id: newId, type: "card", duration_s: roundSeconds(duration), card };
|
||||
timeline.sequence.splice(sequenceInsertIndex(timeline, op), 0, entry);
|
||||
return { id: newId, action: "seat-create", before: null, after: cloneJson(card) };
|
||||
},
|
||||
|
||||
remove_beat(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
timeline.sequence.splice(timeline.sequence.indexOf(entry), 1);
|
||||
return { id: String(op.id), action: "seat-retire", before, after: null };
|
||||
},
|
||||
|
||||
move_beat(timeline, op) {
|
||||
const entry = findSequenceEntry(timeline, op.id);
|
||||
const before = cloneJson(entry.card);
|
||||
const from = timeline.sequence.indexOf(entry);
|
||||
timeline.sequence.splice(from, 1);
|
||||
const to = sequenceInsertIndex(timeline, op);
|
||||
timeline.sequence.splice(to, 0, entry);
|
||||
return { id: String(op.id), action: "reorder", before, after: cloneJson(entry.card), reason: op.reason || `moved from index ${from} to ${to}` };
|
||||
},
|
||||
};
|
||||
|
||||
// Where insert_beat/move_beat land: after_id wins, then a numeric position,
|
||||
// else the end of the sequence.
|
||||
function sequenceInsertIndex(timeline, op) {
|
||||
const sequence = timeline.sequence || [];
|
||||
if (op.after_id != null) {
|
||||
const anchor = sequence.findIndex((item) => String(item.id) === String(op.after_id));
|
||||
if (anchor === -1) throw new Error(`after_id not found in sequence: ${op.after_id}`);
|
||||
return anchor + 1;
|
||||
}
|
||||
if (op.position != null) {
|
||||
const position = Number(op.position);
|
||||
if (!Number.isInteger(position) || position < 0 || position > sequence.length) {
|
||||
throw new Error(`position must be an integer in [0, ${sequence.length}], got ${op.position}`);
|
||||
}
|
||||
return position;
|
||||
}
|
||||
return sequence.length;
|
||||
}
|
||||
|
||||
export function applyOps(timeline, ops) {
|
||||
const working = cloneJson(timeline);
|
||||
ensureSequenceBackbone(working);
|
||||
const changes = [];
|
||||
for (const [index, op] of (ops || []).entries()) {
|
||||
const handler = OP_HANDLERS[op?.op];
|
||||
if (!handler) throw new Error(`ops[${index}]: unknown op '${op?.op}'`);
|
||||
const result = handler(working, op);
|
||||
for (const change of Array.isArray(result) ? result : [result]) {
|
||||
changes.push({ ...change, op: cloneJson(op), reason: op.reason || change.reason || "" });
|
||||
}
|
||||
}
|
||||
// recompute derived data from the edited sequence[] (the semantic backbone).
|
||||
// Drop the stale declared total first: timelineTotalDuration takes a max
|
||||
// that includes it, so shrinking edits would otherwise ratchet upward and
|
||||
// exports would carry a silent tail.
|
||||
working.cards = materializeCards(working);
|
||||
delete working.timeline_duration_s;
|
||||
working.timeline_duration_s = timelineTotalDuration(working);
|
||||
return { timeline: working, changes };
|
||||
}
|
||||
|
||||
// Warnings that must block an apply when they mention a touched card. Review
|
||||
// mode downgrades these to warnings, but a patch that introduces them is bad.
|
||||
const BLOCKING_WARNING_RE = /(exceeds probed media duration|must be greater than source_in_s|asset file not found|could not probe media duration|file not found)/;
|
||||
|
||||
export function validatePatchedTimeline(patchedTimeline, { sliceRoot, touchedIds = [], probe = true }) {
|
||||
const plan = planExport(patchedTimeline, { sliceRoot, mode: "review", checkFiles: true });
|
||||
const validated = probe
|
||||
? validatePlanWithProbes(plan, { ffprobe: resolveSystemBin("ffprobe"), mode: "review" })
|
||||
: plan;
|
||||
const errors = [...validated.validation.errors];
|
||||
const touched = new Set(touchedIds.map(String));
|
||||
for (const warning of validated.validation.warnings) {
|
||||
if (!BLOCKING_WARNING_RE.test(warning)) continue;
|
||||
const mentionsTouched = [...touched].some((id) => warning.startsWith(`${id}:`) || warning.includes(` ${id}:`) || warning.includes(`] ${id}`));
|
||||
if (mentionsTouched || touched.size === 0) errors.push(`blocking-warning: ${warning}`);
|
||||
}
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings: validated.validation.warnings,
|
||||
totalDurationS: validated.totalDurationS,
|
||||
cardCount: validated.validation.cardCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function validatePatch({ timelinePath, patch, sliceRoot, probe = true }) {
|
||||
const timeline = readTimelineFile(timelinePath);
|
||||
const { timeline: patched, changes } = applyOps(timeline, patch.ops);
|
||||
const touchedIds = changes.map((change) => change.id);
|
||||
const validation = validatePatchedTimeline(patched, { sliceRoot, touchedIds, probe });
|
||||
return { validation, patched, changes, touchedIds };
|
||||
}
|
||||
|
||||
function backupTimeline(timelinePath, sliceRoot) {
|
||||
const backupDir = path.join(sliceRoot, "timeline-backups");
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[:]/g, "-");
|
||||
const backupPath = path.join(backupDir, `timeline-${stamp}.json`);
|
||||
fs.copyFileSync(timelinePath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
// one-writer rule, soft-enforced. Reads mutation-owner.json when
|
||||
// present; a mismatched actor WARNS and is recorded, never blocked. The
|
||||
// human UI client always passes (the person at the surface outranks the ledger).
|
||||
const UI_ACTORS = new Set(["mini-nle-ui", "founder-ui@openrig-studio"]);
|
||||
|
||||
export function checkMutationOwner(sliceRoot, actor) {
|
||||
const ownerPath = path.join(sliceRoot, "mutation-owner.json");
|
||||
if (!fs.existsSync(ownerPath)) return { owner: null, mismatch: false, warning: null };
|
||||
let owner = null;
|
||||
try { owner = JSON.parse(fs.readFileSync(ownerPath, "utf8")).owner || null; } catch { return { owner: null, mismatch: false, warning: null }; }
|
||||
if (!owner || !actor || UI_ACTORS.has(actor) || actor === owner) return { owner, mismatch: false, warning: null };
|
||||
return {
|
||||
owner,
|
||||
mismatch: true,
|
||||
warning: `one-writer: ${actor} applied but mutation-owner.json names ${owner}. Applied anyway (soft enforcement); recorded in history. Move ownership via the owner file + a queue handoff.`,
|
||||
};
|
||||
}
|
||||
|
||||
// locked (approved) slots warn on edit - soft, like one-writer
|
||||
export function lockedSlotIds(sliceRoot) {
|
||||
try {
|
||||
const slots = JSON.parse(fs.readFileSync(path.join(sliceRoot, "timeline-slots.json"), "utf8")).slots || [];
|
||||
return new Set(slots.filter((slot) => slot.approved === true).map((slot) => String(slot.dot_id)));
|
||||
} catch { return new Set(); }
|
||||
}
|
||||
|
||||
export function historyEventsForChanges(changes, { patchId, actor, reason, ownerMismatch = false, lockedIds = new Set() }) {
|
||||
const now = new Date();
|
||||
return changes
|
||||
.filter((change) => !change.isBed) // Deferred: bed retimes audit via patch archive only; slot ledger tracks cards
|
||||
.map((change, index) => change.isMaster ? {
|
||||
event_id: makeEventId("audio-master", change.action, now, String(index)),
|
||||
changed_at: eventTimestamp(now),
|
||||
actor,
|
||||
source: "patch-apply",
|
||||
...(ownerMismatch ? { owner_mismatch: true } : {}),
|
||||
patch_id: patchId,
|
||||
slot: { slot_uid: "audio_master", dot_id_before: "A1", dot_id_after: "A1", section: "audio" },
|
||||
before: change.before,
|
||||
after: change.after,
|
||||
reason: change.reason || reason || "",
|
||||
validation: { schema: "pass", media: "pass", contiguity: "pass", export_review: "not-run" },
|
||||
} : ({
|
||||
event_id: makeEventId(change.id, change.action, now, String(index)),
|
||||
changed_at: eventTimestamp(now),
|
||||
actor,
|
||||
source: "patch-apply",
|
||||
...(ownerMismatch ? { owner_mismatch: true } : {}),
|
||||
...(lockedIds.has(String(change.id)) ? { locked_edit: true } : {}),
|
||||
patch_id: patchId,
|
||||
slot: {
|
||||
slot_uid: slotUidFor(change.id),
|
||||
dot_id_before: change.before ? String(change.id) : null,
|
||||
dot_id_after: String(change.id),
|
||||
section: String(change.after?.section || change.before?.section || ""),
|
||||
...(change.parentSlotUid ? { parent_slot_uid: change.parentSlotUid } : {}),
|
||||
},
|
||||
before: change.before ? cardSnapshot(change.before) : null,
|
||||
after: change.after ? cardSnapshot(change.after) : null,
|
||||
reason: change.reason || reason || "",
|
||||
validation: { schema: "pass", media: "pass", contiguity: "pass", export_review: "not-run" },
|
||||
}));
|
||||
}
|
||||
|
||||
export function applyPatch({
|
||||
sliceRoot,
|
||||
timelinePath,
|
||||
patchPath,
|
||||
patch = null,
|
||||
target = "canonical",
|
||||
actor = "timeline-patch.mjs",
|
||||
probe = true,
|
||||
}) {
|
||||
const resolvedPatch = patch || JSON.parse(fs.readFileSync(patchPath, "utf8"));
|
||||
if (!Array.isArray(resolvedPatch.ops) || resolvedPatch.ops.length === 0) {
|
||||
return { ok: false, errors: ["patch has no ops"] };
|
||||
}
|
||||
const { validation, patched, changes } = validatePatch({ timelinePath, patch: resolvedPatch, sliceRoot, probe });
|
||||
if (!validation.ok) {
|
||||
return { ok: false, errors: validation.errors, warnings: validation.warnings };
|
||||
}
|
||||
|
||||
const targetPath = target === "canonical" ? timelinePath : path.resolve(target);
|
||||
const writingCanonical = path.resolve(targetPath) === path.resolve(timelinePath);
|
||||
const ownership = writingCanonical ? checkMutationOwner(sliceRoot, actor) : { mismatch: false, warning: null };
|
||||
if (ownership.warning) validation.warnings.push(ownership.warning);
|
||||
const lockedIds = writingCanonical ? lockedSlotIds(sliceRoot) : new Set();
|
||||
for (const id of new Set(changes.map((change) => String(change.id)))) {
|
||||
if (lockedIds.has(id)) validation.warnings.push(`locked slot edited: ${id} is locked; applied anyway (soft enforcement) and recorded in history. Unlock it if this is deliberate rework.`);
|
||||
}
|
||||
let backupPath = null;
|
||||
if (writingCanonical) {
|
||||
backupPath = backupTimeline(timelinePath, sliceRoot);
|
||||
// bootstrap BEFORE the canonical write so the snapshot records pre-patch state
|
||||
bootstrapHistory(defaultPaths(sliceRoot)); // no-op when history already exists
|
||||
}
|
||||
fs.writeFileSync(targetPath, JSON.stringify(patched, null, 2));
|
||||
|
||||
let historyPath = null;
|
||||
let slotsPath = null;
|
||||
let assetsPath = null;
|
||||
if (writingCanonical) {
|
||||
const paths = defaultPaths(sliceRoot);
|
||||
historyPath = paths.historyPath;
|
||||
slotsPath = paths.slotsPath;
|
||||
appendEvents(historyPath, historyEventsForChanges(changes, {
|
||||
patchId: resolvedPatch.patch_id,
|
||||
actor,
|
||||
reason: resolvedPatch.reason,
|
||||
ownerMismatch: ownership.mismatch,
|
||||
lockedIds,
|
||||
}));
|
||||
rebuildSlots(paths);
|
||||
const assetPaths = defaultAssetPaths(sliceRoot);
|
||||
assetsPath = assetPaths.assetsPath;
|
||||
generateProjectAssets({ sliceRoot, ...assetPaths, probe: false });
|
||||
|
||||
// master installs update the current-audio pointer - nobody
|
||||
// never has to ask which audio plan is live.
|
||||
if (changes.some((change) => change.isMaster)) {
|
||||
fs.writeFileSync(path.join(sliceRoot, "current-audio.json"), JSON.stringify({
|
||||
_note: "DERIVED pointer to the active audio plan. Written on set_master apply.",
|
||||
installed_at: new Date().toISOString(),
|
||||
active: {
|
||||
master: patched.audio_master,
|
||||
words: patched.words_json,
|
||||
captions: patched.captions_vtt,
|
||||
srt: patched.subtitles_srt || null,
|
||||
loudness_report: patched.loudness_report || null,
|
||||
status: patched.audio_master_status || "review-candidate",
|
||||
total_duration_s: patched.total_duration_s,
|
||||
},
|
||||
previous: {
|
||||
master: patched.audio_master_previous || null,
|
||||
words: patched.words_json_previous || null,
|
||||
captions: patched.captions_vtt_previous || null,
|
||||
},
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
// archive the consumed draft next to the backups, then clear it
|
||||
if (patchPath && fs.existsSync(patchPath)) {
|
||||
const archivePath = path.join(sliceRoot, "timeline-backups", `applied-${resolvedPatch.patch_id}.json`);
|
||||
fs.copyFileSync(patchPath, archivePath);
|
||||
fs.rmSync(patchPath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
target: targetPath,
|
||||
canonical: writingCanonical,
|
||||
backupPath,
|
||||
historyPath,
|
||||
slotsPath,
|
||||
assetsPath,
|
||||
patchId: resolvedPatch.patch_id,
|
||||
changeCount: changes.length,
|
||||
changedIds: changes.map((change) => change.id),
|
||||
totalDurationS: validation.totalDurationS,
|
||||
warnings: validation.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const command = argv[0];
|
||||
const options = {
|
||||
sliceRoot: path.resolve(new URL("../../", import.meta.url).pathname),
|
||||
patchPath: "",
|
||||
target: "canonical",
|
||||
actor: "timeline-patch-cli",
|
||||
json: false,
|
||||
probe: true,
|
||||
};
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--slice-root") options.sliceRoot = path.resolve(argv[++i]);
|
||||
else if (arg === "--patch") options.patchPath = path.resolve(argv[++i]);
|
||||
else if (arg === "--target") options.target = argv[++i];
|
||||
else if (arg === "--actor") options.actor = argv[++i];
|
||||
else if (arg === "--no-probe") options.probe = false;
|
||||
else if (arg === "--json") options.json = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
const timelinePath = path.join(options.sliceRoot, "timeline.json");
|
||||
const patchPath = options.patchPath || path.join(options.sliceRoot, "timeline-edits.patch.json");
|
||||
|
||||
if (command === "validate") {
|
||||
const patch = JSON.parse(fs.readFileSync(patchPath, "utf8"));
|
||||
const { validation, touchedIds } = validatePatch({ timelinePath, patch, sliceRoot: options.sliceRoot, probe: options.probe });
|
||||
const payload = { ok: validation.ok, touchedIds, ...validation };
|
||||
console.log(options.json ? JSON.stringify(payload, null, 2) : `${validation.ok ? "VALID" : "INVALID"}\n${validation.errors.join("\n")}`);
|
||||
process.exit(validation.ok ? 0 : 1);
|
||||
}
|
||||
if (command === "apply") {
|
||||
const result = applyPatch({
|
||||
sliceRoot: options.sliceRoot,
|
||||
timelinePath,
|
||||
patchPath,
|
||||
target: options.target,
|
||||
actor: options.actor,
|
||||
probe: options.probe,
|
||||
});
|
||||
console.log(options.json ? JSON.stringify(result, null, 2) : (result.ok ? `applied ${result.changeCount} change(s) to ${result.target}` : `REJECTED:\n${result.errors.join("\n")}`));
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
}
|
||||
console.log("Usage: node timeline-patch.mjs <validate|apply> [--patch file] [--slice-root dir] [--target canonical|/path/copy.json] [--actor name] [--no-probe] [--json]");
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env node
|
||||
// Audio re-conform wizard : swap in a new audio master PACKAGE and
|
||||
// emit the complete retime as ONE reviewable draft patch. Alignment is
|
||||
// text-anchored word matching between the old and new words JSON; boundaries
|
||||
// map through matched anchor words; slots whose words fail to align are
|
||||
// REPORTED AND LEFT UNPATCHED - never guessed.
|
||||
//
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { materializeCards, readTimelineFile, roundSeconds } from "./timeline-export-core.mjs";
|
||||
import { probeMedia, resolveSystemBin } from "./timeline-export-probe.mjs";
|
||||
|
||||
const BOUNDARY_TOLERANCE_S = 0.02; // ignore sub-frame drift
|
||||
const FAILED_WINDOW_RATIO = 0.10; // >10% unmatched words in a slot = failed alignment
|
||||
|
||||
// ---- words ----
|
||||
|
||||
export function loadWordsFile(wordsPath) {
|
||||
const payload = JSON.parse(fs.readFileSync(wordsPath, "utf8"));
|
||||
const raw = Array.isArray(payload) ? payload : payload.words;
|
||||
if (!Array.isArray(raw)) throw new Error(`${wordsPath}: no words[] found`);
|
||||
return raw
|
||||
.map((word, index) => ({
|
||||
text: String(word.text ?? ""),
|
||||
start: Number(word.masterStart ?? word.start),
|
||||
end: Number(word.masterEnd ?? word.end),
|
||||
index,
|
||||
}))
|
||||
.filter((word) => word.text && Number.isFinite(word.start) && Number.isFinite(word.end))
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
}
|
||||
|
||||
function normToken(text) {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
// Classic LCS over normalized tokens. ~1600x1600 fits comfortably in two
|
||||
// Int32Array rows. Deferred: O(n*m) is fine at transcript scale; revisit only
|
||||
// if scripts grow past ~10k words.
|
||||
export function alignWords(oldWords, newWords) {
|
||||
const a = oldWords.map((word) => normToken(word.text));
|
||||
const b = newWords.map((word) => normToken(word.text));
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
// DP table as rows to reconstruct the path we keep a compact full table of
|
||||
// Uint16 lengths (n+1)*(m+1); at 1617*1617 that is ~5.2MB - acceptable.
|
||||
const width = m + 1;
|
||||
const table = new Uint16Array((n + 1) * width);
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const rowBase = i * width;
|
||||
const prevBase = rowBase - width;
|
||||
for (let j = 1; j <= m; j++) {
|
||||
table[rowBase + j] = a[i - 1] === b[j - 1]
|
||||
? table[prevBase + j - 1] + 1
|
||||
: Math.max(table[prevBase + j], table[rowBase + j - 1]);
|
||||
}
|
||||
}
|
||||
const matches = [];
|
||||
let i = n;
|
||||
let j = m;
|
||||
while (i > 0 && j > 0) {
|
||||
if (a[i - 1] === b[j - 1] && table[i * width + j] === table[(i - 1) * width + (j - 1)] + 1) {
|
||||
matches.push({ oi: i - 1, ni: j - 1 });
|
||||
i--; j--;
|
||||
} else if (table[(i - 1) * width + j] >= table[i * width + (j - 1)]) {
|
||||
i--;
|
||||
} else {
|
||||
j--;
|
||||
}
|
||||
}
|
||||
matches.reverse();
|
||||
const matchedOld = new Set(matches.map((match) => match.oi));
|
||||
const matchedNew = new Set(matches.map((match) => match.ni));
|
||||
return {
|
||||
matches,
|
||||
oldUnmatched: oldWords.map((_, index) => index).filter((index) => !matchedOld.has(index)),
|
||||
newUnmatched: newWords.map((_, index) => index).filter((index) => !matchedNew.has(index)),
|
||||
};
|
||||
}
|
||||
|
||||
// Map an OLD master-clock time to the NEW master clock: anchor on the first
|
||||
// matched word at/after t and preserve the lead-in offset (silence before the
|
||||
// first spoken word of a slot survives the swap). Past the last match,
|
||||
// preserve the tail offset from the last matched word's end.
|
||||
export function buildTimeMapper(oldWords, newWords, matches) {
|
||||
const anchors = matches.map(({ oi, ni }) => ({
|
||||
oldStart: oldWords[oi].start,
|
||||
newStart: newWords[ni].start,
|
||||
oldEnd: oldWords[oi].end,
|
||||
newEnd: newWords[ni].end,
|
||||
}));
|
||||
return function mapTime(t) {
|
||||
// binary search: first anchor with oldStart >= t
|
||||
let lo = 0;
|
||||
let hi = anchors.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (anchors[mid].oldStart >= t) hi = mid;
|
||||
else lo = mid + 1;
|
||||
}
|
||||
if (lo < anchors.length) {
|
||||
const anchor = anchors[lo];
|
||||
return roundSeconds(anchor.newStart - (anchor.oldStart - t));
|
||||
}
|
||||
const last = anchors[anchors.length - 1];
|
||||
if (!last) return t;
|
||||
return roundSeconds(last.newEnd + (t - last.oldEnd));
|
||||
};
|
||||
}
|
||||
|
||||
// ---- audio package intake ----
|
||||
|
||||
export function readAudioPackage(input, { requireExists = true } = {}) {
|
||||
const resolved = path.resolve(input);
|
||||
const pending = [];
|
||||
let files = {};
|
||||
let packageStatus = null;
|
||||
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
||||
// manifest.json (or package.json) may declare status "scratch"
|
||||
// (AI placeholder VO) vs "master" - threads through to audio_master_status
|
||||
for (const manifestName of ["manifest.json", "package.json"]) {
|
||||
const manifestPath = path.join(resolved, manifestName);
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
if (manifest.status) packageStatus = String(manifest.status);
|
||||
for (const key of ["master", "words", "captions", "srt", "loudness"]) {
|
||||
if (manifest[key]) files[key] = path.resolve(resolved, manifest[key]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!files.master) {
|
||||
const names = fs.readdirSync(resolved);
|
||||
const pick = (test) => {
|
||||
const name = names.find(test);
|
||||
return name ? path.join(resolved, name) : undefined;
|
||||
};
|
||||
files.master = pick((name) => name.endsWith(".wav"))
|
||||
|| pick((name) => name.endsWith(".mp3"));
|
||||
files.words = pick((name) => name.endsWith(".json") && /words/i.test(name));
|
||||
files.captions = pick((name) => name.endsWith(".vtt"));
|
||||
files.srt = pick((name) => name.endsWith(".srt"));
|
||||
files.loudness = pick((name) => /loudness/i.test(name));
|
||||
}
|
||||
} else {
|
||||
throw new Error(`audio package not found or not a directory: ${resolved}`);
|
||||
}
|
||||
for (const key of ["master", "words", "captions"]) {
|
||||
if (!files[key] || (requireExists && !fs.existsSync(files[key]))) {
|
||||
throw new Error(`audio package incomplete: missing required ${key} (${files[key] || "not found"})`);
|
||||
}
|
||||
}
|
||||
for (const key of ["srt", "loudness"]) {
|
||||
if (!files[key] || !fs.existsSync(files[key])) {
|
||||
pending.push(key);
|
||||
delete files[key];
|
||||
}
|
||||
}
|
||||
return { ...files, pending, root: resolved, status: packageStatus };
|
||||
}
|
||||
|
||||
// ---- re-conform ----
|
||||
|
||||
function relativizeRef(filePath, sliceRoot) {
|
||||
const resolved = path.resolve(filePath);
|
||||
const root = path.resolve(sliceRoot) + path.sep;
|
||||
return resolved.startsWith(root) ? resolved.slice(root.length) : resolved;
|
||||
}
|
||||
|
||||
export function reconformTimeline(timeline, {
|
||||
oldWords,
|
||||
newWords,
|
||||
newMasterDurationS,
|
||||
pkg,
|
||||
sliceRoot,
|
||||
actorNote = "",
|
||||
}) {
|
||||
const { matches } = alignWords(oldWords, newWords);
|
||||
const mapTime = buildTimeMapper(oldWords, newWords, matches);
|
||||
const matchedOld = new Set(matches.map((match) => match.oi));
|
||||
|
||||
const cards = materializeCards(timeline);
|
||||
const ops = [];
|
||||
const moves = [];
|
||||
const failed = [];
|
||||
|
||||
for (const card of cards) {
|
||||
if (card.extension) continue; // post-spine cards keep durations; starts ride contiguity
|
||||
const oldStart = Number(card.audio_start_s ?? card.start_s);
|
||||
const oldEnd = Number(card.audio_end_s ?? oldStart + Number(card.duration_s));
|
||||
const windowWords = oldWords
|
||||
.map((word, index) => ({ word, index }))
|
||||
.filter(({ word }) => word.start >= oldStart - 0.001 && word.start < oldEnd);
|
||||
const unmatched = windowWords.filter(({ index }) => !matchedOld.has(index));
|
||||
if (windowWords.length > 0 && unmatched.length > Math.max(2, windowWords.length * FAILED_WINDOW_RATIO)) {
|
||||
failed.push({
|
||||
id: String(card.id),
|
||||
title: String(card.title || ""),
|
||||
unmatchedWords: unmatched.length,
|
||||
windowWords: windowWords.length,
|
||||
sample: unmatched.slice(0, 6).map(({ word }) => word.text).join(" "),
|
||||
});
|
||||
continue; // report-and-stop for this slot: keep its old duration
|
||||
}
|
||||
const newStart = mapTime(oldStart);
|
||||
const newEnd = mapTime(oldEnd);
|
||||
const newDuration = roundSeconds(newEnd - newStart);
|
||||
const oldDuration = Number(card.duration_s);
|
||||
if (!Number.isFinite(newDuration) || newDuration <= 0) {
|
||||
failed.push({ id: String(card.id), title: String(card.title || ""), reason: `mapped duration ${newDuration}` });
|
||||
continue;
|
||||
}
|
||||
if (Math.abs(newDuration - oldDuration) > BOUNDARY_TOLERANCE_S) {
|
||||
ops.push({ op: "set_duration", id: String(card.id), duration_s: newDuration, reason: `reconform: ${oldDuration}s -> ${newDuration}s` });
|
||||
}
|
||||
moves.push({
|
||||
id: String(card.id),
|
||||
oldStart, newStart,
|
||||
oldDuration, newDuration,
|
||||
startDelta: roundSeconds(newStart - oldStart),
|
||||
durationDelta: roundSeconds(newDuration - oldDuration),
|
||||
});
|
||||
}
|
||||
|
||||
for (const bed of [...(timeline.audio_beds || []), ...(timeline.music_beds || [])]) {
|
||||
const oldStart = Number(bed.start_s);
|
||||
if (!Number.isFinite(oldStart)) continue;
|
||||
const newStart = mapTime(oldStart);
|
||||
if (Math.abs(newStart - oldStart) > BOUNDARY_TOLERANCE_S) {
|
||||
ops.push({ op: "set_bed_window", bed_id: String(bed.id), start_s: newStart, reason: `reconform: bed ${oldStart}s -> ${newStart}s` });
|
||||
moves.push({ id: String(bed.id), bed: true, oldStart, newStart, startDelta: roundSeconds(newStart - oldStart) });
|
||||
}
|
||||
}
|
||||
|
||||
ops.push({
|
||||
op: "set_master",
|
||||
audio_master: relativizeRef(pkg.master, sliceRoot),
|
||||
words_json: relativizeRef(pkg.words, sliceRoot),
|
||||
captions_vtt: relativizeRef(pkg.captions, sliceRoot),
|
||||
...(pkg.srt ? { srt: relativizeRef(pkg.srt, sliceRoot) } : {}),
|
||||
...(pkg.loudness ? { loudness_report: relativizeRef(pkg.loudness, sliceRoot) } : {}),
|
||||
total_duration_s: roundSeconds(newMasterDurationS),
|
||||
status: pkg.status || "review-candidate",
|
||||
reason: actorNote || "reconform master install",
|
||||
});
|
||||
|
||||
return { ops, moves, failed, matchCount: matches.length, oldWordCount: oldWords.length, newWordCount: newWords.length };
|
||||
}
|
||||
|
||||
export function movementReport(result, { pkg, timelinePath }) {
|
||||
const shifted = result.moves.filter((move) => !move.bed && (Math.abs(move.startDelta) > BOUNDARY_TOLERANCE_S || Math.abs(move.durationDelta) > BOUNDARY_TOLERANCE_S));
|
||||
const beds = result.moves.filter((move) => move.bed);
|
||||
const lines = [
|
||||
`# Re-conform movement report`,
|
||||
``,
|
||||
`- timeline: ${timelinePath}`,
|
||||
`- new master: ${pkg.master}`,
|
||||
`- words aligned: ${result.matchCount}/${result.oldWordCount} old, ${result.newWordCount} new`,
|
||||
`- package pending artifacts: ${pkg.pending?.length ? pkg.pending.join(", ") : "none"}`,
|
||||
``,
|
||||
`## Cards (${shifted.length} moved, of ${result.moves.filter((m) => !m.bed).length} spine cards)`,
|
||||
``,
|
||||
...shifted.map((move) =>
|
||||
`- ${move.id}: start ${move.oldStart}s -> ${move.newStart}s (${move.startDelta >= 0 ? "+" : ""}${move.startDelta}s)` +
|
||||
(Math.abs(move.durationDelta) > BOUNDARY_TOLERANCE_S ? `; duration ${move.oldDuration}s -> ${move.newDuration}s` : "")),
|
||||
``,
|
||||
`## Music beds`,
|
||||
``,
|
||||
...(beds.length ? beds.map((move) => `- ${move.id}: start ${move.oldStart}s -> ${move.newStart}s (${move.startDelta >= 0 ? "+" : ""}${move.startDelta}s)`) : ["- unchanged"]),
|
||||
``,
|
||||
`## FAILED ALIGNMENT - left unpatched, needs a human`,
|
||||
``,
|
||||
...(result.failed.length
|
||||
? result.failed.map((slot) => `- ${slot.id} "${slot.title}": ${slot.unmatchedWords ?? "?"}/${slot.windowWords ?? "?"} words unmatched${slot.sample ? ` (e.g. "${slot.sample}")` : ""}${slot.reason ? ` [${slot.reason}]` : ""}`)
|
||||
: ["- none"]),
|
||||
``,
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// A/B listening block: the biggest boundary shifts, old vs new master, using
|
||||
// media-fragment offsets so you can hear each seam side by side.
|
||||
export function abListeningHtml(result, { oldMasterRef, newMasterRef }) {
|
||||
const top = result.moves
|
||||
.filter((move) => !move.bed && Math.abs(move.startDelta) > BOUNDARY_TOLERANCE_S)
|
||||
.sort((a, b) => Math.abs(b.startDelta) - Math.abs(a.startDelta))
|
||||
.slice(0, 5);
|
||||
const rows = top.map((move) => {
|
||||
const oldFrom = Math.max(0, move.oldStart - 3).toFixed(2);
|
||||
const newFrom = Math.max(0, move.newStart - 3).toFixed(2);
|
||||
return `<div class="seam"><h3>${move.id} (shift ${move.startDelta >= 0 ? "+" : ""}${move.startDelta}s)</h3>
|
||||
<label>OLD</label><audio controls preload="none" src="../../${oldMasterRef}#t=${oldFrom},${(Number(oldFrom) + 8).toFixed(2)}"></audio>
|
||||
<label>NEW</label><audio controls preload="none" src="../../${newMasterRef}#t=${newFrom},${(Number(newFrom) + 8).toFixed(2)}"></audio></div>`;
|
||||
});
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>Re-conform A/B</title>
|
||||
<style>body{background:#1b1a17;color:#d2cfc7;font:13px Menlo,monospace;padding:20px;max-width:760px}
|
||||
.seam{border:1px solid #33302a;border-radius:4px;padding:10px 14px;margin-bottom:12px;background:#211f1c}
|
||||
h3{color:#c9a35a;margin:0 0 6px}label{display:block;color:#756f61;font-size:10px;margin-top:6px}audio{width:100%}</style></head>
|
||||
<body><h1>Re-conform A/B - biggest boundary shifts</h1>${rows.join("\n") || "<p>No boundary shifts above tolerance.</p>"}</body></html>`;
|
||||
}
|
||||
|
||||
export function runReconform({ sliceRoot, timelinePath, packagePath, actorNote }) {
|
||||
const timeline = readTimelineFile(timelinePath);
|
||||
const pkg = readAudioPackage(packagePath);
|
||||
const oldWordsPath = path.resolve(sliceRoot, String(timeline.words_json || ""));
|
||||
if (!fs.existsSync(oldWordsPath)) throw new Error(`current timeline words_json not found: ${oldWordsPath}`);
|
||||
const oldWords = loadWordsFile(oldWordsPath);
|
||||
const newWords = loadWordsFile(pkg.words);
|
||||
const newMasterDurationS = probeMedia(resolveSystemBin("ffprobe"), pkg.master).duration_s;
|
||||
const result = reconformTimeline(timeline, { oldWords, newWords, newMasterDurationS, pkg, sliceRoot, actorNote });
|
||||
const report = movementReport(result, { pkg, timelinePath });
|
||||
const ab = abListeningHtml(result, {
|
||||
oldMasterRef: String(timeline.audio_master),
|
||||
newMasterRef: relativizeRef(pkg.master, sliceRoot),
|
||||
});
|
||||
return { ...result, report, ab, pkg, newMasterDurationS };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const options = {
|
||||
sliceRoot: path.resolve(new URL("../../", import.meta.url).pathname),
|
||||
packagePath: "",
|
||||
writePatch: false,
|
||||
json: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--slice-root") options.sliceRoot = path.resolve(argv[++i]);
|
||||
else if (arg === "--package") options.packagePath = argv[++i];
|
||||
else if (arg === "--write-patch") options.writePatch = true;
|
||||
else if (arg === "--json") options.json = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (!options.packagePath) {
|
||||
console.log("Usage: node timeline-reconform.mjs --package <dir> [--slice-root dir] [--write-patch] [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
const timelinePath = path.join(options.sliceRoot, "timeline.json");
|
||||
const result = runReconform({ sliceRoot: options.sliceRoot, timelinePath, packagePath: options.packagePath });
|
||||
if (options.writePatch) {
|
||||
const patch = {
|
||||
patch_id: `patch_reconform_${Date.now().toString(36)}`,
|
||||
created_at: new Date().toISOString(),
|
||||
actor: "timeline-reconform-cli",
|
||||
reason: `reconform to ${path.basename(result.pkg.master)}`,
|
||||
base_timeline: "timeline.json",
|
||||
ops: result.ops,
|
||||
};
|
||||
fs.writeFileSync(path.join(options.sliceRoot, "timeline-edits.patch.json"), JSON.stringify(patch, null, 2));
|
||||
}
|
||||
console.log(options.json
|
||||
? JSON.stringify({ ok: true, opCount: result.ops.length, failed: result.failed, matchCount: result.matchCount }, null, 2)
|
||||
: result.report);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname)) {
|
||||
main();
|
||||
}
|
||||
+455
@@ -0,0 +1,455 @@
|
||||
const _POINT_B64_LENGTH = 8;
|
||||
const FIRST_POINT_B64_LENGTH = 16;
|
||||
const FIRST_POINT_2D_B64_LENGTH = 12;
|
||||
const DEFAULT_PRESSURE = 0.5;
|
||||
const DIM_2D = 2;
|
||||
const DIM_3D = 3;
|
||||
const BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const B64_LOOKUP = new Uint8Array(128);
|
||||
for (let i = 0; i < 64; i++) {
|
||||
B64_LOOKUP[BASE64_CHARS.charCodeAt(i)] = i;
|
||||
}
|
||||
const SIX_BIT_MASK = 63;
|
||||
const PADDING_CHAR_CODE = "=".charCodeAt(0);
|
||||
const POW2 = new Float64Array(31);
|
||||
for (let i = 0; i < 31; i++) {
|
||||
POW2[i] = Math.pow(2, i - 15);
|
||||
}
|
||||
const POW2_SUBNORMAL = Math.pow(2, -14) / 1024;
|
||||
const MANTISSA = new Float64Array(1024);
|
||||
for (let i = 0; i < 1024; i++) {
|
||||
MANTISSA[i] = 1 + i / 1024;
|
||||
}
|
||||
function nativeGetFloat16(dataView, offset) {
|
||||
return dataView.getFloat16(offset, true);
|
||||
}
|
||||
function fallbackGetFloat16(dataView, offset) {
|
||||
return float16BitsToNumber(dataView.getUint16(offset, true));
|
||||
}
|
||||
const getFloat16 = typeof DataView.prototype.getFloat16 === "function" ? nativeGetFloat16 : fallbackGetFloat16;
|
||||
function nativeSetFloat16(dataView, offset, value) {
|
||||
;
|
||||
dataView.setFloat16(offset, value, true);
|
||||
}
|
||||
function fallbackSetFloat16(dataView, offset, value) {
|
||||
dataView.setUint16(offset, numberToFloat16Bits(value), true);
|
||||
}
|
||||
const setFloat16 = typeof DataView.prototype.setFloat16 === "function" ? nativeSetFloat16 : fallbackSetFloat16;
|
||||
function nativeBase64ToUint8Array(base64) {
|
||||
return Uint8Array.fromBase64(base64);
|
||||
}
|
||||
function fallbackBase64ToUint8Array(base64) {
|
||||
const paddedLength = base64.length;
|
||||
let padding = 0;
|
||||
if (paddedLength > 0 && base64.charCodeAt(paddedLength - 1) === PADDING_CHAR_CODE) {
|
||||
padding++;
|
||||
if (paddedLength > 1 && base64.charCodeAt(paddedLength - 2) === PADDING_CHAR_CODE) {
|
||||
padding++;
|
||||
}
|
||||
}
|
||||
const numBytes = Math.floor(paddedLength * 3 / 4) - padding;
|
||||
const bytes = new Uint8Array(numBytes);
|
||||
let byteIndex = 0;
|
||||
const fullGroups = Math.floor((paddedLength - padding) / 4) * 4;
|
||||
for (let i = 0; i < fullGroups; i += 4) {
|
||||
const c0 = B64_LOOKUP[base64.charCodeAt(i)];
|
||||
const c1 = B64_LOOKUP[base64.charCodeAt(i + 1)];
|
||||
const c2 = B64_LOOKUP[base64.charCodeAt(i + 2)];
|
||||
const c3 = B64_LOOKUP[base64.charCodeAt(i + 3)];
|
||||
const bitmap = c0 << 18 | c1 << 12 | c2 << 6 | c3;
|
||||
bytes[byteIndex++] = bitmap >> 16 & 255;
|
||||
bytes[byteIndex++] = bitmap >> 8 & 255;
|
||||
bytes[byteIndex++] = bitmap & 255;
|
||||
}
|
||||
if (padding === 1) {
|
||||
const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)];
|
||||
const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)];
|
||||
const c2 = B64_LOOKUP[base64.charCodeAt(fullGroups + 2)];
|
||||
const bitmap = c0 << 18 | c1 << 12 | c2 << 6;
|
||||
bytes[byteIndex++] = bitmap >> 16 & 255;
|
||||
bytes[byteIndex++] = bitmap >> 8 & 255;
|
||||
} else if (padding === 2) {
|
||||
const c0 = B64_LOOKUP[base64.charCodeAt(fullGroups)];
|
||||
const c1 = B64_LOOKUP[base64.charCodeAt(fullGroups + 1)];
|
||||
const bitmap = c0 << 18 | c1 << 12;
|
||||
bytes[byteIndex++] = bitmap >> 16 & 255;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
function nativeUint8ArrayToBase64(uint8Array) {
|
||||
return uint8Array.toBase64();
|
||||
}
|
||||
function fallbackUint8ArrayToBase64(uint8Array) {
|
||||
const len = uint8Array.length;
|
||||
const fullGroups = Math.floor(len / 3) * 3;
|
||||
let result = "";
|
||||
for (let i = 0; i < fullGroups; i += 3) {
|
||||
const byte1 = uint8Array[i];
|
||||
const byte2 = uint8Array[i + 1];
|
||||
const byte3 = uint8Array[i + 2];
|
||||
const bitmap = byte1 << 16 | byte2 << 8 | byte3;
|
||||
result += BASE64_CHARS[bitmap >> 18 & SIX_BIT_MASK] + // bits 23–18 (top sextet)
|
||||
BASE64_CHARS[bitmap >> 12 & SIX_BIT_MASK] + // bits 17–12
|
||||
BASE64_CHARS[bitmap >> 6 & SIX_BIT_MASK] + // bits 11–6
|
||||
BASE64_CHARS[bitmap & SIX_BIT_MASK];
|
||||
}
|
||||
const remaining = len - fullGroups;
|
||||
if (remaining === 1) {
|
||||
const bitmap = uint8Array[fullGroups] << 16;
|
||||
result += BASE64_CHARS[bitmap >> 18 & SIX_BIT_MASK] + BASE64_CHARS[bitmap >> 12 & SIX_BIT_MASK] + "==";
|
||||
} else if (remaining === 2) {
|
||||
const bitmap = uint8Array[fullGroups] << 16 | uint8Array[fullGroups + 1] << 8;
|
||||
result += BASE64_CHARS[bitmap >> 18 & SIX_BIT_MASK] + BASE64_CHARS[bitmap >> 12 & SIX_BIT_MASK] + BASE64_CHARS[bitmap >> 6 & SIX_BIT_MASK] + "=";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const uint8ArrayToBase64 = typeof Uint8Array.prototype.toBase64 === "function" ? nativeUint8ArrayToBase64 : fallbackUint8ArrayToBase64;
|
||||
const base64ToUint8Array = typeof Uint8Array.fromBase64 === "function" ? nativeBase64ToUint8Array : fallbackBase64ToUint8Array;
|
||||
function float16BitsToNumber(bits) {
|
||||
const sign = bits >> 15;
|
||||
const exp = bits >> 10 & 31;
|
||||
const frac = bits & 1023;
|
||||
if (exp === 0) {
|
||||
return sign ? -frac * POW2_SUBNORMAL : frac * POW2_SUBNORMAL;
|
||||
}
|
||||
if (exp === 31) {
|
||||
return frac ? NaN : sign ? -Infinity : Infinity;
|
||||
}
|
||||
const magnitude = POW2[exp] * MANTISSA[frac];
|
||||
return sign ? -magnitude : magnitude;
|
||||
}
|
||||
function numberToFloat16Bits(value) {
|
||||
if (value === 0) return Object.is(value, -0) ? 32768 : 0;
|
||||
if (!Number.isFinite(value)) {
|
||||
if (Number.isNaN(value)) return 32256;
|
||||
return value > 0 ? 31744 : 64512;
|
||||
}
|
||||
const sign = value < 0 ? 1 : 0;
|
||||
value = Math.abs(value);
|
||||
const exp = Math.floor(Math.log2(value));
|
||||
let expBiased = exp + 15;
|
||||
if (expBiased >= 31) {
|
||||
return sign << 15 | 31744;
|
||||
}
|
||||
if (expBiased <= 0) {
|
||||
const frac2 = Math.round(value * Math.pow(2, 14) * 1024);
|
||||
return sign << 15 | frac2 & 1023;
|
||||
}
|
||||
const mantissa = value / Math.pow(2, exp) - 1;
|
||||
let frac = Math.round(mantissa * 1024);
|
||||
if (frac >= 1024) {
|
||||
frac = 0;
|
||||
expBiased++;
|
||||
if (expBiased >= 31) {
|
||||
return sign << 15 | 31744;
|
||||
}
|
||||
}
|
||||
return sign << 15 | expBiased << 10 | frac;
|
||||
}
|
||||
class b64Vecs {
|
||||
/**
|
||||
* Encode a single point (x, y, z) to 8 base64 characters using legacy Float16 encoding.
|
||||
* Each coordinate is encoded as a Float16 value, resulting in 6 bytes total.
|
||||
*
|
||||
* @param x - The x coordinate
|
||||
* @param y - The y coordinate
|
||||
* @param z - The z coordinate
|
||||
* @returns An 8-character base64 string representing the point
|
||||
* @internal
|
||||
*/
|
||||
static _legacyEncodePoint(x, y, z) {
|
||||
const buffer = new Uint8Array(6);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
setFloat16(dataView, 0, x);
|
||||
setFloat16(dataView, 2, y);
|
||||
setFloat16(dataView, 4, z);
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Convert an array of VecModels to a base64 string using legacy Float16 encoding.
|
||||
* Uses Float16 encoding for each coordinate (x, y, z). If a point's z value is
|
||||
* undefined, it defaults to 0.5.
|
||||
*
|
||||
* @param points - An array of VecModel objects to encode
|
||||
* @returns A base64-encoded string containing all points
|
||||
* @internal Used only for migrations from legacy format
|
||||
*/
|
||||
static _legacyEncodePoints(points) {
|
||||
if (points.length === 0) return "";
|
||||
const buffer = new Uint8Array(points.length * 6);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const offset = i * 6;
|
||||
setFloat16(dataView, offset, p.x);
|
||||
setFloat16(dataView, offset + 2, p.y);
|
||||
setFloat16(dataView, offset + 4, p.z ?? 0.5);
|
||||
}
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Convert a legacy base64 string back to an array of VecModels.
|
||||
* Decodes Float16-encoded coordinates (x, y, z) from the base64 string.
|
||||
*
|
||||
* @param base64 - The base64-encoded string containing point data
|
||||
* @returns An array of VecModel objects decoded from the string
|
||||
* @internal Used only for migrations from legacy format
|
||||
*/
|
||||
static _legacyDecodePoints(base64) {
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const result = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += 6) {
|
||||
result.push({
|
||||
x: getFloat16(dataView, offset),
|
||||
y: getFloat16(dataView, offset + 2),
|
||||
z: getFloat16(dataView, offset + 4)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Encode an array of VecModels using delta encoding for improved precision.
|
||||
* The first point is stored as Float32 (high precision for absolute position),
|
||||
* subsequent points are stored as Float16 deltas from the previous point.
|
||||
* This provides full precision for the starting position and excellent precision
|
||||
* for deltas between consecutive points (which are typically small values).
|
||||
*
|
||||
* Format:
|
||||
* - First point: 3 Float32 values = 12 bytes = 16 base64 chars
|
||||
* - Delta points: 3 Float16 values each = 6 bytes = 8 base64 chars each
|
||||
*
|
||||
* @param points - An array of VecModel objects to encode
|
||||
* @param dim - Encoding dimension; `2` routes through the 2D variant (drops z), `3` (default) keeps x, y, z
|
||||
* @returns A base64-encoded string containing delta-encoded points
|
||||
* @public
|
||||
*/
|
||||
static encodePoints(points, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.encodePoints2D(points);
|
||||
if (points.length === 0) return "";
|
||||
const firstPointBytes = 12;
|
||||
const deltaBytes = (points.length - 1) * 6;
|
||||
const totalBytes = firstPointBytes + deltaBytes;
|
||||
const buffer = new Uint8Array(totalBytes);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
const first = points[0];
|
||||
dataView.setFloat32(0, first.x, true);
|
||||
dataView.setFloat32(4, first.y, true);
|
||||
dataView.setFloat32(8, first.z ?? 0.5, true);
|
||||
let prevX = first.x;
|
||||
let prevY = first.y;
|
||||
let prevZ = first.z ?? 0.5;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const z = p.z ?? 0.5;
|
||||
const offset = firstPointBytes + (i - 1) * 6;
|
||||
setFloat16(dataView, offset, p.x - prevX);
|
||||
setFloat16(dataView, offset + 2, p.y - prevY);
|
||||
setFloat16(dataView, offset + 4, z - prevZ);
|
||||
prevX = p.x;
|
||||
prevY = p.y;
|
||||
prevZ = z;
|
||||
}
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Decode a delta-encoded base64 string back to an array of absolute VecModels.
|
||||
* The first point is stored as Float32 (high precision), subsequent points are
|
||||
* Float16 deltas that are accumulated to reconstruct absolute positions.
|
||||
*
|
||||
* @param base64 - The base64-encoded string containing delta-encoded point data
|
||||
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
|
||||
* @returns An array of VecModel objects with absolute coordinates
|
||||
* @public
|
||||
*/
|
||||
static decodePoints(base64, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.decodePoints2D(base64);
|
||||
if (base64.length === 0) return [];
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const result = [];
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
let z = dataView.getFloat32(8, true);
|
||||
result.push({ x, y, z });
|
||||
const firstPointBytes = 12;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 6) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
z += getFloat16(dataView, offset + 4);
|
||||
result.push({ x, y, z });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Get the first point from a delta-encoded base64 string.
|
||||
* The first point is stored as Float32 for full precision.
|
||||
*
|
||||
* @param b64Points - The delta-encoded base64 string
|
||||
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
|
||||
* @returns The first point as a VecModel, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeFirstPoint(b64Points, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.decodeFirstPoint2D(b64Points);
|
||||
if (b64Points.length < FIRST_POINT_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_B64_LENGTH));
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return {
|
||||
x: dataView.getFloat32(0, true),
|
||||
y: dataView.getFloat32(4, true),
|
||||
z: dataView.getFloat32(8, true)
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get the last point from a delta-encoded base64 string.
|
||||
* Requires decoding all points to accumulate deltas.
|
||||
*
|
||||
* @param b64Points - The delta-encoded base64 string
|
||||
* @param dim - Encoding dimension; `2` expects x/y only (z supplied as 0.5), `3` (default) expects x/y/z
|
||||
* @returns The last point as a VecModel, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeLastPoint(b64Points, dim) {
|
||||
if (dim === DIM_2D) return b64Vecs.decodeLastPoint2D(b64Points);
|
||||
if (b64Points.length < FIRST_POINT_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
let z = dataView.getFloat32(8, true);
|
||||
const firstPointBytes = 12;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 6) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
z += getFloat16(dataView, offset + 4);
|
||||
}
|
||||
return { x, y, z };
|
||||
}
|
||||
/**
|
||||
* Encode an array of VecModels as 2D delta-encoded points, dropping z entirely.
|
||||
* Use for draw shapes from devices that don't report pressure, where z is a
|
||||
* constant 0.5 and storing it wastes ~33% of per-point bytes.
|
||||
*
|
||||
* Format:
|
||||
* - First point: 2 Float32 values (x, y) = 8 bytes
|
||||
* - Delta points: 2 Float16 values (dx, dy) = 4 bytes each
|
||||
*
|
||||
* @param points - An array of VecModel objects to encode (z is discarded)
|
||||
* @returns A base64-encoded string containing 2D delta-encoded points
|
||||
* @public
|
||||
*/
|
||||
static encodePoints2D(points) {
|
||||
if (points.length === 0) return "";
|
||||
const firstPointBytes = 8;
|
||||
const deltaBytes = (points.length - 1) * 4;
|
||||
const buffer = new Uint8Array(firstPointBytes + deltaBytes);
|
||||
const dataView = new DataView(buffer.buffer);
|
||||
const first = points[0];
|
||||
dataView.setFloat32(0, first.x, true);
|
||||
dataView.setFloat32(4, first.y, true);
|
||||
let prevX = first.x;
|
||||
let prevY = first.y;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const p = points[i];
|
||||
const offset = firstPointBytes + (i - 1) * 4;
|
||||
setFloat16(dataView, offset, p.x - prevX);
|
||||
setFloat16(dataView, offset + 2, p.y - prevY);
|
||||
prevX = p.x;
|
||||
prevY = p.y;
|
||||
}
|
||||
return uint8ArrayToBase64(buffer);
|
||||
}
|
||||
/**
|
||||
* Decode a 2D delta-encoded base64 string back to an array of absolute VecModels.
|
||||
* The z coordinate is always set to 0.5 (the default pressure value) so downstream
|
||||
* consumers don't need a separate code path.
|
||||
*
|
||||
* @param base64 - The base64-encoded string containing 2D delta-encoded point data
|
||||
* @returns An array of VecModel objects with absolute (x, y) and z = 0.5
|
||||
* @public
|
||||
*/
|
||||
static decodePoints2D(base64) {
|
||||
if (base64.length === 0) return [];
|
||||
const bytes = base64ToUint8Array(base64);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const result = [];
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
result.push({ x, y, z: DEFAULT_PRESSURE });
|
||||
const firstPointBytes = 8;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
result.push({ x, y, z: DEFAULT_PRESSURE });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Get the first point from a 2D delta-encoded base64 string.
|
||||
*
|
||||
* @param b64Points - The 2D delta-encoded base64 string
|
||||
* @returns The first point with z = 0.5, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeFirstPoint2D(b64Points) {
|
||||
if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points.slice(0, FIRST_POINT_2D_B64_LENGTH));
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
return {
|
||||
x: dataView.getFloat32(0, true),
|
||||
y: dataView.getFloat32(4, true),
|
||||
z: DEFAULT_PRESSURE
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get the last point from a 2D delta-encoded base64 string.
|
||||
* Requires decoding all points to accumulate deltas.
|
||||
*
|
||||
* @param b64Points - The 2D delta-encoded base64 string
|
||||
* @returns The last point with z = 0.5, or null if the string is too short
|
||||
* @public
|
||||
*/
|
||||
static decodeLastPoint2D(b64Points) {
|
||||
if (b64Points.length < FIRST_POINT_2D_B64_LENGTH) return null;
|
||||
const bytes = base64ToUint8Array(b64Points);
|
||||
const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let x = dataView.getFloat32(0, true);
|
||||
let y = dataView.getFloat32(4, true);
|
||||
const firstPointBytes = 8;
|
||||
for (let offset = firstPointBytes; offset < bytes.length; offset += 4) {
|
||||
x += getFloat16(dataView, offset);
|
||||
y += getFloat16(dataView, offset + 2);
|
||||
}
|
||||
return { x, y, z: DEFAULT_PRESSURE };
|
||||
}
|
||||
/**
|
||||
* Whether an encoded path contains only a single point (a "dot"), inferred from
|
||||
* the encoded length without decoding — cheap enough for the render path.
|
||||
*
|
||||
* The single-point length depends on the encoding dimension, so this takes the
|
||||
* segment's `dim`: a one-point path is `FIRST_POINT_B64_LENGTH` chars (3D) or
|
||||
* `FIRST_POINT_2D_B64_LENGTH` chars (2D). Keeping this beside the layout constants
|
||||
* is deliberate — it is the single source of truth for "how long is one point", so
|
||||
* callers never hard-code a length threshold (which silently breaks when a new
|
||||
* encoding is added).
|
||||
*
|
||||
* @param b64Points - The encoded path string
|
||||
* @param dim - Encoding dimension; `2` for (x, y), `3` (default) for (x, y, z)
|
||||
* @returns true if the path encodes exactly one point
|
||||
* @public
|
||||
*/
|
||||
static isSinglePoint(b64Points, dim) {
|
||||
return b64Points.length <= (dim === DIM_2D ? FIRST_POINT_2D_B64_LENGTH : FIRST_POINT_B64_LENGTH);
|
||||
}
|
||||
}
|
||||
export {
|
||||
DIM_2D,
|
||||
DIM_3D,
|
||||
b64Vecs,
|
||||
fallbackBase64ToUint8Array,
|
||||
fallbackUint8ArrayToBase64,
|
||||
float16BitsToNumber,
|
||||
numberToFloat16Bits
|
||||
};
|
||||
//# sourceMappingURL=b64Vecs.mjs.map
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
// Scaffold a new Studio video bundle per the workspace contract .
|
||||
// Usage: node video-new.mjs <name> [--parent <dir>] [--port <n>]
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BUNDLE_DIRS, doctrineSeed, mutationOwnerSeed, placementText, seededReceivers, templatesSeedPath, timelineSkeleton } from "./studio-bundle.mjs";
|
||||
import { bootstrapHistory, defaultPaths, rebuildSlots } from "./timeline-history.mjs";
|
||||
|
||||
export function briefSeed(name, idea) {
|
||||
return [
|
||||
`# ${name} - brief`,
|
||||
"",
|
||||
"## THE IDEA (your words, verbatim - never rewrite this section)",
|
||||
"",
|
||||
idea ? idea.trim() : "(paste your rough idea here, verbatim)",
|
||||
"",
|
||||
"## Angle (propose competing one-liners as TAKES; you pick)",
|
||||
"",
|
||||
"## Audience + the one takeaway",
|
||||
"",
|
||||
"## Beats sketch (becomes the storyboard's first beats)",
|
||||
"",
|
||||
"## Demo plan (what you record; marker phrases for the cut)",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function scaffoldBundle(name, { parent = process.cwd(), idea = "", port = 8792 } = {}) {
|
||||
const root = path.join(parent, name);
|
||||
if (fs.existsSync(root)) throw new Error(`already exists: ${root}`);
|
||||
|
||||
for (const entry of BUNDLE_DIRS) fs.mkdirSync(path.join(root, entry.dir), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "PLACEMENT.md"), placementText());
|
||||
fs.writeFileSync(path.join(root, "doctrine", "current-rules.md"), doctrineSeed());
|
||||
fs.writeFileSync(path.join(root, "timeline.json"), JSON.stringify(timelineSkeleton(name), null, 2));
|
||||
fs.writeFileSync(path.join(root, "current-audio.json"), JSON.stringify({
|
||||
_note: "No active audio package installed yet. Install the first narration package via Re-conform.",
|
||||
active: null,
|
||||
previous: null,
|
||||
}, null, 2));
|
||||
fs.writeFileSync(path.join(root, "current-export.json"), JSON.stringify({
|
||||
_note: "No export has been written yet. Run assemble-timeline.mjs to populate review/final pointers.",
|
||||
}, null, 2));
|
||||
const historyPaths = defaultPaths(root);
|
||||
bootstrapHistory(historyPaths);
|
||||
if (!fs.existsSync(historyPaths.historyPath)) fs.writeFileSync(historyPaths.historyPath, "");
|
||||
rebuildSlots(historyPaths);
|
||||
// bundles are born knowing the topology (seat<->surface duality)
|
||||
fs.writeFileSync(path.join(root, "reports", "notes", "receivers.json"), JSON.stringify(seededReceivers(), null, 2));
|
||||
fs.writeFileSync(path.join(root, "mutation-owner.json"), JSON.stringify(mutationOwnerSeed(), null, 2));
|
||||
// bundles speak the template vocabulary from birth
|
||||
fs.copyFileSync(templatesSeedPath(), path.join(root, "templates.json"));
|
||||
fs.writeFileSync(path.join(root, "brief.md"), briefSeed(name, idea));
|
||||
fs.writeFileSync(path.join(root, "script.md"), `# ${name} - script\n\n(locked script beats become placeholder slots; see the template-board slice)\n`);
|
||||
fs.writeFileSync(path.join(root, "README.md"), [
|
||||
`# ${name}`,
|
||||
"",
|
||||
"Studio video bundle (workspace contract v1). Front doors:",
|
||||
"",
|
||||
"- filing map: PLACEMENT.md - unsure where a file goes? intake/",
|
||||
"- rules: doctrine/current-rules.md (rendered in the mini-NLE Info panel)",
|
||||
`- open the cockpit: open http://127.0.0.1:${port}/mini-nle.html (or run the studio-video provider directly with --port ${port} --slice-root .)`,
|
||||
"- check filing: node <tools>/bundle-check.mjs --root .",
|
||||
"- agent onboarding: the studio-instrument skill (openrig-work/skills/studio-instrument/)",
|
||||
"",
|
||||
"timeline.json starts empty: install the first audio master package via",
|
||||
"Re-conform, or seed placeholders from the locked script.",
|
||||
"",
|
||||
].join("\n"));
|
||||
return { ok: true, root, dirs: BUNDLE_DIRS.length };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const name = argv[0];
|
||||
if (!name || name.startsWith("-")) {
|
||||
console.log("Usage: node video-new.mjs <name> [--parent <dir>] [--port <n>] [--json]");
|
||||
process.exit(1);
|
||||
}
|
||||
let parent = process.cwd();
|
||||
let idea = "";
|
||||
let port = 8792;
|
||||
let json = false;
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
if (argv[i] === "--parent") parent = path.resolve(argv[++i]);
|
||||
else if (argv[i] === "--idea") idea = argv[++i];
|
||||
else if (argv[i] === "--port") port = Number(argv[++i]);
|
||||
else if (argv[i] === "--json") json = true;
|
||||
else throw new Error(`Unknown argument: ${argv[i]}`);
|
||||
}
|
||||
const result = scaffoldBundle(name, { parent, idea, port });
|
||||
console.log(json ? JSON.stringify(result, null, 2) : `bundle ready: ${result.root}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();
|
||||
@@ -0,0 +1,16 @@
|
||||
# Skills
|
||||
|
||||
## building-studio-apps — MOVED
|
||||
|
||||
Canonical home is the SDK repo:
|
||||
|
||||
openrig-studio/skills/building-studio-apps/
|
||||
|
||||
It was drafted here from the consumer side, then moved and extended in place by the
|
||||
SDK's authors. **Do not restore a copy in this repo.** Two copies of a skill diverge
|
||||
silently, and this one is the single source of truth for how anyone — human or agent —
|
||||
learns to build on the Studio SDK.
|
||||
|
||||
Edit it there. If you are changing it because of something you hit while building an
|
||||
app in *this* repo, say so in the change: consumer-observed failures are the part of
|
||||
that skill that cannot be written from the producer side.
|
||||
Reference in new issue
Block a user