fablecut 1.12.1 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +657 -11
- package/README.md +45 -10
- package/app.js +3352 -212
- package/color.js +47 -14
- package/comp.js +195 -0
- package/edit-ops.js +70 -17
- package/export-jobs.js +8 -5
- package/index.html +148 -62
- package/library/README.md +2 -0
- package/library/elements/logo-placeholder.svg +12 -0
- package/mask.js +467 -0
- package/mcp-server.js +639 -19
- package/motion.js +803 -0
- package/package.json +1 -1
- package/paths.js +25 -2
- package/server.js +42 -2
- package/style.css +188 -24
- package/templates.js +576 -0
- package/tracker.js +253 -0
package/package.json
CHANGED
package/paths.js
CHANGED
|
@@ -33,7 +33,7 @@ const EXPORTS_DIR = path.join(DATA_DIR, "exports");
|
|
|
33
33
|
const ANALYSIS_DIR = path.join(DATA_DIR, "analysis");
|
|
34
34
|
const LIBRARY_DIR = path.join(DATA_DIR, "library");
|
|
35
35
|
const PROJECT_FILE = path.join(DATA_DIR, "project.json");
|
|
36
|
-
const LIBRARY_SUBDIRS = ["sfx", "elements", "svg", "fonts"];
|
|
36
|
+
const LIBRARY_SUBDIRS = ["sfx", "elements", "svg", "fonts", "presets", "templates"];
|
|
37
37
|
|
|
38
38
|
/* The asset library ships with the repo but users also drop their own files in
|
|
39
39
|
(library/sfx is gitignored precisely for that). When the data dir is split
|
|
@@ -99,8 +99,31 @@ function ensureDirs() {
|
|
|
99
99
|
seedLibrary();
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/* Saved animation presets and templates: one JSON file each in library/presets/
|
|
103
|
+
and library/templates/ (templates.js validates them). Unreadable files are skipped. */
|
|
104
|
+
function readSaved(kind) {
|
|
105
|
+
const dir = path.join(LIBRARY_DIR, kind);
|
|
106
|
+
let names = [];
|
|
107
|
+
try { names = fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith(".json")).sort(); } catch { return []; }
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const f of names) {
|
|
110
|
+
try { out.push({ file: f, data: JSON.parse(fs.readFileSync(path.join(dir, f), "utf8").replace(new RegExp("^\\uFEFF"), "")) }); } catch { }
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
function writeSaved(kind, id, data) {
|
|
115
|
+
const dir = path.join(LIBRARY_DIR, kind);
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
117
|
+
const file = path.join(dir, String(id).replace(/[^a-z0-9-]+/gi, "-").slice(0, 60) + ".json");
|
|
118
|
+
// tmp + rename, like the project file: a crash mid-write never leaves half a preset
|
|
119
|
+
const tmp = `${file}.${crypto.randomUUID()}.tmp`; // unique: the editor and the MCP server may save at once
|
|
120
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
121
|
+
fs.renameSync(tmp, file);
|
|
122
|
+
return file;
|
|
123
|
+
}
|
|
124
|
+
|
|
102
125
|
module.exports = {
|
|
103
126
|
APP_DIR, DATA_DIR, SPLIT,
|
|
104
127
|
MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR, PROJECT_FILE,
|
|
105
|
-
LIBRARY_SUBDIRS, ensureDirs,
|
|
128
|
+
LIBRARY_SUBDIRS, ensureDirs, readSaved, writeSaved,
|
|
106
129
|
};
|
package/server.js
CHANGED
|
@@ -37,8 +37,9 @@ const { denoiseFile, AMOUNT_IDS: DENOISE_AMOUNTS } = require("./denoise");
|
|
|
37
37
|
|
|
38
38
|
const {
|
|
39
39
|
APP_DIR, DATA_DIR, MEDIA_DIR, EXPORTS_DIR, ANALYSIS_DIR, LIBRARY_DIR,
|
|
40
|
-
PROJECT_FILE, LIBRARY_SUBDIRS, ensureDirs,
|
|
40
|
+
PROJECT_FILE, LIBRARY_SUBDIRS, ensureDirs, readSaved, writeSaved,
|
|
41
41
|
} = require("./paths");
|
|
42
|
+
const Templates = require("./templates");
|
|
42
43
|
|
|
43
44
|
/* Static app files are served from the install dir; everything the user creates
|
|
44
45
|
lives under DATA_DIR. The two are the same unless FABLECUT_DATA_DIR is set. */
|
|
@@ -466,6 +467,24 @@ const server = http.createServer(async (req, res) => {
|
|
|
466
467
|
return;
|
|
467
468
|
}
|
|
468
469
|
|
|
470
|
+
/* API: saved animation presets and templates (library/presets, library/templates).
|
|
471
|
+
GET /api/presets | /api/templates → [ …validated items… ] (bad files skipped)
|
|
472
|
+
POST /api/presets {preset} | /api/templates {template} → saved as library/<kind>/<id>.json */
|
|
473
|
+
if ((p === "/api/presets" || p === "/api/templates") && (req.method === "GET" || req.method === "POST")) {
|
|
474
|
+
const kind = p.slice(5), norm = kind === "presets" ? Templates.normalizePreset : Templates.normalizeTemplate;
|
|
475
|
+
try {
|
|
476
|
+
if (req.method === "GET") {
|
|
477
|
+
sendJSON(res, 200, readSaved(kind).map((x) => norm(x.data)).filter(Boolean));
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
481
|
+
const item = Templates.asSaved(norm(kind === "presets" ? body.preset : body.template, true), kind);
|
|
482
|
+
const file = writeSaved(kind, item.id, item);
|
|
483
|
+
sendJSON(res, 200, { id: item.id, name: item.name, file: path.relative(DATA_DIR, file).split(path.sep).join("/") });
|
|
484
|
+
} catch (e) { sendJSON(res, 400, { error: String(e.message || e) }); }
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
469
488
|
/* API: upload → saved into ./media */
|
|
470
489
|
if (p === "/api/upload" && req.method === "POST") {
|
|
471
490
|
try {
|
|
@@ -541,7 +560,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
541
560
|
if (m != null && (typeof m !== "object" || typeof m.clip !== "string" || !Number.isInteger(m.layer) || m.layer < 0)) {
|
|
542
561
|
sendJSON(res, 400, { error: "matte must be {clip: \"<clip id>\", layer: <index ≥ 0>}" }); return;
|
|
543
562
|
}
|
|
544
|
-
|
|
563
|
+
const mk = opts.mask;
|
|
564
|
+
if (mk != null && (typeof mk !== "object" || typeof mk.clip !== "string")) {
|
|
565
|
+
sendJSON(res, 400, { error: "mask must be {clip: \"<clip id>\"}" }); return;
|
|
566
|
+
}
|
|
567
|
+
sendJSON(res, 200, exportJobs.request({ kind: "scopes", time, where: opts.where, revision, matte: m || null, mask: mk || null }));
|
|
568
|
+
} catch (e) {
|
|
569
|
+
sendJSON(res, e.code === 409 || e.code === 400 ? e.code : 500, { error: String(e.message || e) });
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
/* API: follow a region of a clip (fablecut_track) — a "track" job on the
|
|
574
|
+
same queue; the tab that runs it saves the track into the project. */
|
|
575
|
+
if (p === "/api/track/request" && req.method === "POST") {
|
|
576
|
+
try {
|
|
577
|
+
const opts = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
|
578
|
+
let revision = null;
|
|
579
|
+
try { revision = JSON.parse(fs.readFileSync(PROJECT_FILE, "utf8").replace(/^/, "")).revision ?? null; } catch {}
|
|
580
|
+
const t = opts.track;
|
|
581
|
+
if (!t || typeof t !== "object" || Array.isArray(t) || typeof t.clip !== "string") { sendJSON(res, 400, { error: "track must be {clip: \"<clip id>\", …}" }); return; }
|
|
582
|
+
const body = JSON.stringify(t);
|
|
583
|
+
if (body.length > 8192) { sendJSON(res, 400, { error: "track spec too large" }); return; }
|
|
584
|
+
sendJSON(res, 200, exportJobs.request({ kind: "track", where: opts.where, revision, track: JSON.parse(body) }));
|
|
545
585
|
} catch (e) {
|
|
546
586
|
sendJSON(res, e.code === 409 || e.code === 400 ? e.code : 500, { error: String(e.message || e) });
|
|
547
587
|
}
|
package/style.css
CHANGED
|
@@ -2296,6 +2296,17 @@ input[type=range] {
|
|
|
2296
2296
|
text-decoration: line-through;
|
|
2297
2297
|
}
|
|
2298
2298
|
|
|
2299
|
+
/* A track matte: it cuts the clip below instead of drawing. */
|
|
2300
|
+
.clip.matte-src .clip-body {
|
|
2301
|
+
opacity: .55;
|
|
2302
|
+
background-image: repeating-linear-gradient(135deg, transparent 0 6px, rgba(255, 255, 255, .08) 6px 12px);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
.clip.matte-src .clip-label::before {
|
|
2306
|
+
content: "Matte · ";
|
|
2307
|
+
opacity: .8;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2299
2310
|
.timeline-scroll {
|
|
2300
2311
|
flex: 1;
|
|
2301
2312
|
min-height: 0;
|
|
@@ -2702,6 +2713,98 @@ body.track-size-s .clip .fade-grip {
|
|
|
2702
2713
|
background: linear-gradient(180deg, #ffd16633, #ffd16611), #6b5420;
|
|
2703
2714
|
}
|
|
2704
2715
|
|
|
2716
|
+
.lib-item .bin-thumb.shape-thumb {
|
|
2717
|
+
display: grid;
|
|
2718
|
+
place-items: center;
|
|
2719
|
+
background: #1d4f63;
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
.lib-item .bin-thumb.shape-thumb svg {
|
|
2723
|
+
width: 70%;
|
|
2724
|
+
height: 70%;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
.lib-group {
|
|
2728
|
+
margin: 10px 4px 4px;
|
|
2729
|
+
font-size: 10.5px;
|
|
2730
|
+
font-weight: 600;
|
|
2731
|
+
letter-spacing: 0.06em;
|
|
2732
|
+
text-transform: uppercase;
|
|
2733
|
+
color: var(--dim);
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
.lib-group:first-child {
|
|
2737
|
+
margin-top: 2px;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
.lib-hint {
|
|
2741
|
+
margin: 10px 4px;
|
|
2742
|
+
font-size: 11px;
|
|
2743
|
+
line-height: 1.4;
|
|
2744
|
+
color: var(--dim);
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
.clip.c-comp .comp-open-hint {
|
|
2748
|
+
position: absolute;
|
|
2749
|
+
right: 6px;
|
|
2750
|
+
top: 50%;
|
|
2751
|
+
transform: translateY(-50%);
|
|
2752
|
+
padding: 2px 7px;
|
|
2753
|
+
border-radius: 999px;
|
|
2754
|
+
background: #00000073;
|
|
2755
|
+
color: #f3e8ff;
|
|
2756
|
+
font-size: 10.5px;
|
|
2757
|
+
white-space: nowrap;
|
|
2758
|
+
pointer-events: none;
|
|
2759
|
+
opacity: 0;
|
|
2760
|
+
transition: opacity 0.15s;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
.clip.c-comp:hover .comp-open-hint {
|
|
2764
|
+
opacity: 1;
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
body.track-size-s .clip.c-comp .comp-open-hint {
|
|
2768
|
+
display: none;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
.clip.c-comp .clip-body {
|
|
2772
|
+
background: linear-gradient(180deg, #c084fc33, #c084fc11), #4c2a73;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
.comp-crumb {
|
|
2776
|
+
display: inline-flex;
|
|
2777
|
+
align-items: center;
|
|
2778
|
+
gap: 6px;
|
|
2779
|
+
padding: 2px 8px 2px 2px;
|
|
2780
|
+
margin-right: 6px;
|
|
2781
|
+
border: 1px solid #c084fc66;
|
|
2782
|
+
border-radius: 6px;
|
|
2783
|
+
background: #c084fc1a;
|
|
2784
|
+
font-size: 12px;
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
.comp-crumb .crumb-sep {
|
|
2788
|
+
opacity: .6;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
.lib-item .bin-thumb.comp-thumb,
|
|
2792
|
+
.comp-item .bin-thumb.comp-thumb {
|
|
2793
|
+
display: grid;
|
|
2794
|
+
place-items: center;
|
|
2795
|
+
background: #4c2a73;
|
|
2796
|
+
color: #e9d5ff;
|
|
2797
|
+
font-size: 20px;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
.clip.c-shape .clip-body {
|
|
2801
|
+
background: linear-gradient(180deg, #5ad1ff33, #5ad1ff11), #1d4f63;
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
.clip.c-null .clip-body {
|
|
2805
|
+
background: repeating-linear-gradient(90deg, #ff6b6b22 0 6px, transparent 6px 12px), #4a2430;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2705
2808
|
.clip.c-adjust .clip-body {
|
|
2706
2809
|
background: repeating-linear-gradient(135deg, #38bdf822 0 10px, transparent 10px 20px), #155e75;
|
|
2707
2810
|
}
|
|
@@ -2938,9 +3041,43 @@ body.track-size-s .clip .fade-grip {
|
|
|
2938
3041
|
}
|
|
2939
3042
|
|
|
2940
3043
|
.dialog.help {
|
|
2941
|
-
width: min(
|
|
2942
|
-
max-height:
|
|
2943
|
-
|
|
3044
|
+
width: min(1080px, 94vw);
|
|
3045
|
+
max-height: 88vh;
|
|
3046
|
+
display: flex;
|
|
3047
|
+
flex-direction: column;
|
|
3048
|
+
padding-bottom: 14px;
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
.dialog.help .help-head {
|
|
3052
|
+
display: flex;
|
|
3053
|
+
align-items: center;
|
|
3054
|
+
gap: 16px;
|
|
3055
|
+
margin-bottom: 12px;
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
.dialog.help .help-head h2 {
|
|
3059
|
+
margin: 0;
|
|
3060
|
+
flex: 1;
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
.dialog.help .help-filter {
|
|
3064
|
+
width: min(280px, 50%);
|
|
3065
|
+
background: var(--panel-2);
|
|
3066
|
+
border: 1px solid var(--border);
|
|
3067
|
+
border-radius: 6px;
|
|
3068
|
+
color: var(--text);
|
|
3069
|
+
font: inherit;
|
|
3070
|
+
font-size: 12px;
|
|
3071
|
+
padding: 6px 10px;
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
.dialog.help .help-filter:focus {
|
|
3075
|
+
outline: none;
|
|
3076
|
+
border-color: var(--accent);
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
.dialog.help .dialog-actions {
|
|
3080
|
+
margin-top: 10px;
|
|
2944
3081
|
}
|
|
2945
3082
|
|
|
2946
3083
|
.dialog.settings {
|
|
@@ -3011,11 +3148,38 @@ body.track-size-s .clip .fade-grip {
|
|
|
3011
3148
|
}
|
|
3012
3149
|
|
|
3013
3150
|
.dialog.help .keys-cols {
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3151
|
+
flex: 1;
|
|
3152
|
+
min-height: 0;
|
|
3153
|
+
overflow: auto;
|
|
3154
|
+
columns: 2 380px;
|
|
3155
|
+
column-gap: 16px;
|
|
3156
|
+
padding-right: 4px;
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
.dialog.help .keys-sec {
|
|
3160
|
+
break-inside: avoid;
|
|
3161
|
+
margin: 0 0 14px;
|
|
3162
|
+
padding: 10px 12px 6px;
|
|
3163
|
+
background: var(--panel-2);
|
|
3164
|
+
border: 1px solid var(--border);
|
|
3165
|
+
border-radius: 8px;
|
|
3166
|
+
}
|
|
3167
|
+
|
|
3168
|
+
.dialog.help .keys-sec h3 {
|
|
3169
|
+
margin: 0 0 6px;
|
|
3170
|
+
font-size: 11px;
|
|
3171
|
+
font-weight: 600;
|
|
3172
|
+
letter-spacing: 0.06em;
|
|
3173
|
+
text-transform: uppercase;
|
|
3174
|
+
color: var(--text);
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
.dialog.help .keys-sec h3 span {
|
|
3178
|
+
margin-left: 6px;
|
|
3179
|
+
font-weight: 400;
|
|
3180
|
+
letter-spacing: 0;
|
|
3181
|
+
text-transform: none;
|
|
3182
|
+
color: var(--dim);
|
|
3019
3183
|
}
|
|
3020
3184
|
|
|
3021
3185
|
.dialog.help .keys {
|
|
@@ -3025,22 +3189,31 @@ body.track-size-s .clip .fade-grip {
|
|
|
3025
3189
|
}
|
|
3026
3190
|
|
|
3027
3191
|
.dialog.help .keys td {
|
|
3028
|
-
padding: 5px
|
|
3029
|
-
border-
|
|
3192
|
+
padding: 5px 0;
|
|
3193
|
+
border-top: 1px solid var(--border);
|
|
3030
3194
|
font-size: 12px;
|
|
3195
|
+
line-height: 1.45;
|
|
3031
3196
|
vertical-align: top;
|
|
3032
3197
|
}
|
|
3033
3198
|
|
|
3199
|
+
.dialog.help .keys tr:first-child td {
|
|
3200
|
+
border-top: 0;
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3034
3203
|
.dialog.help .keys td:first-child {
|
|
3035
|
-
width:
|
|
3204
|
+
width: 34%;
|
|
3205
|
+
padding-right: 12px;
|
|
3036
3206
|
color: var(--dim);
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
.dialog.help .keys td:first-child kbd {
|
|
3037
3210
|
white-space: nowrap;
|
|
3038
3211
|
}
|
|
3039
3212
|
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3213
|
+
.dialog.help .help-empty {
|
|
3214
|
+
color: var(--dim);
|
|
3215
|
+
font-size: 12px;
|
|
3216
|
+
margin: 8px 2px;
|
|
3044
3217
|
}
|
|
3045
3218
|
|
|
3046
3219
|
kbd {
|
|
@@ -3552,12 +3725,3 @@ input[type="range"].cq-hue {
|
|
|
3552
3725
|
border-radius: 3px;
|
|
3553
3726
|
height: 6px;
|
|
3554
3727
|
}
|
|
3555
|
-
.dialog.help .keys tr.keys-head td {
|
|
3556
|
-
padding-top: 12px;
|
|
3557
|
-
color: var(--text);
|
|
3558
|
-
font-weight: 600;
|
|
3559
|
-
font-size: 11px;
|
|
3560
|
-
letter-spacing: 0.04em;
|
|
3561
|
-
text-transform: uppercase;
|
|
3562
|
-
white-space: normal;
|
|
3563
|
-
}
|