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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fablecut",
3
- "version": "1.12.1",
3
+ "version": "1.14.0",
4
4
  "description": "Zero-dependency browser video editor that AI agents can drive — JSON timeline, MCP server, REST API, live-reloading UI.",
5
5
  "main": "server.js",
6
6
  "bin": {
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
- sendJSON(res, 200, exportJobs.request({ kind: "scopes", time, where: opts.where, revision, matte: m || null }));
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(820px, 94vw);
2942
- max-height: min(90vh, 640px);
2943
- overflow: auto;
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
- display: grid;
3015
- grid-template-columns: 1fr 1fr;
3016
- gap: 0 28px;
3017
- margin-bottom: 12px;
3018
- align-items: start;
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 6px;
3029
- border-bottom: 1px solid #26262c;
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: 40%;
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
- @media (max-width: 700px) {
3041
- .dialog.help .keys-cols {
3042
- grid-template-columns: 1fr;
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
- }