scenri 0.7.1 → 0.7.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.2](https://github.com/tonygorb/Scenri/compare/v0.7.1...v0.7.2) (2026-08-30)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * a reshape is planned at the engine budget, never upscaled ([a580adc](https://github.com/tonygorb/Scenri/commit/a580adce27c85ae31b9071d9ff5411cb9d06bdaa))
9
+ * a reshape is planned at the engine budget, never upscaled ([2325779](https://github.com/tonygorb/Scenri/commit/2325779b32bdb83687135a21758953a1e3465eb2))
10
+
3
11
  ## [0.7.1](https://github.com/tonygorb/Scenri/compare/v0.7.0...v0.7.1) (2026-08-30)
4
12
 
5
13
 
package/dist/serve.js CHANGED
@@ -3351,6 +3351,9 @@ function productFidelityDirective(attached) {
3351
3351
  }
3352
3352
  return "The attached product images all show the exact product to feature: preserve its label, shape and proportions faithfully, do not redesign it, and never treat an extra image as an additional product. The first product image is the authority for its color, finish and material. Where another image differs in color or finish, it shows the same product in another colorway \u2014 never blend colorways, and render the one the first image shows. Any face not visible in them is unknown \u2014 keep it plain and consistent with the materials the first image shows, and do not invent detail on it. If the direction above explicitly asks for more than one colorway, that explicit request wins.";
3353
3353
  }
3354
+ function extendPreservationDirective() {
3355
+ return "This grows the frame of a photograph that already exists; it does not restage it. The photograph in hand is the shot: the same person with the same face and the same clothing, the same product with the same label, geometry and colour, each at the same size, in the same place, under the same light. New area only continues the same scene past the original edges. Do not redesign the product, replace the person, change what anyone wears, or move the camera nearer or further away.";
3356
+ }
3354
3357
  function editPreservationDirective(scope, opts) {
3355
3358
  if (scope === "local") {
3356
3359
  const removal = opts?.removal ? " What is removed leaves nothing behind: the surface and the scene continue as if it had never been there, with no outline, silhouette, residue or ghost of it." : "";
@@ -3810,7 +3813,13 @@ function compileBrief(brief, ctx) {
3810
3813
  ] : [];
3811
3814
  const brandLines = brandRuleDirectives(ctx.brand);
3812
3815
  const preservation = ctx.mode === "edit" ? [
3813
- ...ctx.editReshape === "extend" ? [] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3816
+ // An extend gets its own preservation language: the global
3817
+ // directive's "same framing, same dimensions" lines contradict a
3818
+ // frame that is deliberately growing, but dropping preservation
3819
+ // altogether left the redrawn-frame arm with nothing protecting
3820
+ // the person, the product or the wardrobe. See
3821
+ // extendPreservationDirective.
3822
+ ...ctx.editReshape === "extend" ? [extendPreservationDirective()] : [editPreservationDirective(ctx.editScope ?? "global", { removal: ctx.editRemoval })],
3814
3823
  ...ctx.inheritedIdentity ? [inheritedIdentityDirective(ctx.inheritedIdentity === true ? void 0 : ctx.inheritedIdentity)] : [],
3815
3824
  ...ctx.inheritedDirectives ?? []
3816
3825
  ] : [];
@@ -7057,6 +7066,74 @@ function planCrop(source, targetRatio) {
7057
7066
  const height = Math.max(1, Math.min(source.height, Math.round(source.width / targetRatio)));
7058
7067
  return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height, axis: "height" };
7059
7068
  }
7069
+ function defaultReshapeOp(sourceRatio, targetRatio) {
7070
+ if (!(sourceRatio > 0 && targetRatio > 0)) return "extend";
7071
+ return Math.abs(Math.log(targetRatio)) < Math.abs(Math.log(sourceRatio)) - 0.01 ? "crop" : "extend";
7072
+ }
7073
+
7074
+ // src/outpaint/growth.ts
7075
+ var SINGLE_PASS_MAX = 1.5;
7076
+ var CROP_ASSIST_ABOVE = 2;
7077
+ var CROP_ASSIST_MAX = 0.15;
7078
+ var STAGE_MAX = 1.4;
7079
+ function planGrowth(source, targetRatio) {
7080
+ if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return null;
7081
+ const current = source.width / source.height;
7082
+ if (Math.abs(current - targetRatio) / targetRatio < 0.01) return null;
7083
+ const axis = targetRatio > current ? "width" : "height";
7084
+ const growth = axis === "width" ? targetRatio / current : current / targetRatio;
7085
+ let cropAssist = 0;
7086
+ if (growth > CROP_ASSIST_ABOVE) {
7087
+ const wanted = 1 - CROP_ASSIST_ABOVE / growth;
7088
+ cropAssist = Math.min(CROP_ASSIST_MAX, wanted);
7089
+ }
7090
+ const effective = growth * (1 - cropAssist);
7091
+ const stages = effective <= SINGLE_PASS_MAX ? 1 : Math.ceil(Math.log(effective) / Math.log(STAGE_MAX));
7092
+ return { growth, axis, stages, cropAssist, effective };
7093
+ }
7094
+ function cropAssistWindow(source, plan) {
7095
+ if (plan.cropAssist <= 0) return null;
7096
+ if (plan.axis === "width") {
7097
+ const height = Math.max(1, Math.round(source.height * (1 - plan.cropAssist)));
7098
+ return { left: 0, top: Math.floor((source.height - height) / 2), width: source.width, height };
7099
+ }
7100
+ const width = Math.max(1, Math.round(source.width * (1 - plan.cropAssist)));
7101
+ return { left: Math.floor((source.width - width) / 2), top: 0, width, height: source.height };
7102
+ }
7103
+
7104
+ // src/reshapeRules.ts
7105
+ var EXTEND_MAX = CROP_ASSIST_ABOVE;
7106
+ function classifyReshape(source, targetRatio, requested) {
7107
+ if (!(source.width > 0 && source.height > 0 && targetRatio > 0)) return { op: "none" };
7108
+ const growth = planGrowth(source, targetRatio);
7109
+ if (!growth) return { op: "none" };
7110
+ const op = requested ?? defaultReshapeOp(source.width / source.height, targetRatio);
7111
+ if (op === "crop") return { op: "crop", forced: false };
7112
+ if (growth.effective > EXTEND_MAX + 1e-9) return { op: "crop", forced: true, growth };
7113
+ return { op: "extend", growth, assist: cropAssistWindow(source, growth) };
7114
+ }
7115
+ function fitExpandToBudget(plan, source, pixelBudget) {
7116
+ if (!pixelBudget || plan.width * plan.height <= pixelBudget) return { plan, source, scale: 1 };
7117
+ const frame = budgetSize(plan.width, plan.height, pixelBudget);
7118
+ if (plan.axis === "width") {
7119
+ const scale2 = frame.height / plan.height;
7120
+ const width = Math.min(frame.width, Math.max(1, Math.round(source.width * scale2)));
7121
+ const left = Math.min(Math.max(0, Math.round(plan.left * scale2)), frame.width - width);
7122
+ return {
7123
+ plan: { width: frame.width, height: frame.height, left, top: 0, axis: "width" },
7124
+ source: { width, height: frame.height },
7125
+ scale: scale2
7126
+ };
7127
+ }
7128
+ const scale = frame.width / plan.width;
7129
+ const height = Math.min(frame.height, Math.max(1, Math.round(source.height * scale)));
7130
+ const top = Math.min(Math.max(0, Math.round(plan.top * scale)), frame.height - height);
7131
+ return {
7132
+ plan: { width: frame.width, height: frame.height, left: 0, top, axis: "height" },
7133
+ source: { width: frame.width, height },
7134
+ scale
7135
+ };
7136
+ }
7060
7137
  async function attentionCropOrigin(srcBuf, source, plan) {
7061
7138
  try {
7062
7139
  const { info } = await sharp20(srcBuf).resize(plan.width, plan.height, { fit: "cover", position: "attention" }).toBuffer({ resolveWithObject: true });
@@ -8649,6 +8726,21 @@ function registerImageRoutes(app, deps) {
8649
8726
 
8650
8727
  // src/release/notes.data.ts
8651
8728
  var RELEASES = [
8729
+ {
8730
+ version: "0.7.2",
8731
+ date: "2026-08-30",
8732
+ title: "Changing a shot to a new shape keeps the photograph.",
8733
+ sections: [
8734
+ {
8735
+ heading: "Create",
8736
+ body: "Refining a shot into a different aspect ratio no longer costs it quality. The frame is planned at the size the engine can genuinely draw, so nothing is enlarged afterwards to fill a canvas its pixels could not reach. The presenter, the product, the wardrobe, the light and the subject scale carry across the new shape, and the stored size is now the size that was really drawn."
8737
+ },
8738
+ {
8739
+ heading: "Fixes",
8740
+ body: "A target shape that is tighter than the shot now crops it, instantly and without a generation, instead of building out around it. A shape too far from the current one to reach in a single step crops as well, and says so, rather than attempting a stretch that could not work. The composer tells you which of the two will happen before you run it."
8741
+ }
8742
+ ]
8743
+ },
8652
8744
  {
8653
8745
  version: "0.7.1",
8654
8746
  date: "2026-08-30",
@@ -10067,33 +10159,15 @@ function buildServer(opts) {
10067
10159
  if (!project) return reply.status(404).send({ error: "project not found" });
10068
10160
  const rawReshape = req.body.reshape ?? req.body.brief?.reshape;
10069
10161
  const reshape = rawReshape === "crop" ? "crop" : rawReshape === "extend" ? "extend" : void 0;
10070
- if (kind === "edit" && reshape === "crop") {
10071
- const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
10072
- if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
10073
- const cropParentId = parentId ? String(parentId) : rootForCrop.id;
10074
- if (brief && Array.isArray(brief.tokens)) {
10075
- const briefErrors = validateBrief(brief);
10076
- if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
10077
- }
10078
- const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
10079
- (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
10080
- ) : void 0;
10081
- if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
10082
- const parent = core.store.getNode(cropParentId);
10083
- const srcHash = req.body.sourceImage ?? parent?.images[0];
10084
- if (!srcHash || !core.images.has(String(srcHash)))
10085
- return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
10086
- const srcBuf = core.images.read(String(srcHash));
10087
- const srcMeta = await sharp20(srcBuf).metadata();
10088
- if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
10089
- const plan2 = planCrop({ width: srcMeta.width, height: srcMeta.height }, Number(fmt.w) / Number(fmt.h));
10162
+ const runCropNode = async (args) => {
10163
+ const plan2 = planCrop(args.srcSize, Number(args.fmt.w) / Number(args.fmt.h));
10090
10164
  if (!plan2) return reply.status(400).send({ error: "the picture is already this shape" });
10091
- const origin = await attentionCropOrigin(srcBuf, { width: srcMeta.width, height: srcMeta.height }, plan2);
10165
+ const origin = await attentionCropOrigin(args.srcBuf, args.srcSize, plan2);
10092
10166
  const window = { left: origin.left, top: origin.top, width: plan2.width, height: plan2.height };
10093
- const label = FORMATS.find((f) => f.id === fmt.id)?.label ?? `${fmt.w}x${fmt.h}`;
10167
+ const label = FORMATS.find((f) => f.id === args.fmt.id)?.label ?? `${args.fmt.w}x${args.fmt.h}`;
10094
10168
  const node2 = core.store.addNode({
10095
10169
  projectId: project.id,
10096
- parentId: cropParentId,
10170
+ parentId: args.parentId,
10097
10171
  kind: "edit",
10098
10172
  prompt: `Cropped to ${label}`,
10099
10173
  // No provider was asked; recording the engine the client HAPPENED to
@@ -10102,18 +10176,45 @@ function buildServer(opts) {
10102
10176
  });
10103
10177
  core.store.setBrief(node2.id, {
10104
10178
  ...briefInputsOnly(brief ?? {}),
10105
- sourceImage: String(srcHash),
10179
+ sourceImage: args.srcHash,
10106
10180
  reshape: "crop",
10107
10181
  crop: window
10108
10182
  });
10109
10183
  const work2 = async () => ({
10110
- images: [core.images.save(await sharp20(srcBuf).extract(window).png().toBuffer())],
10184
+ images: [core.images.save(await sharp20(args.srcBuf).extract(window).png().toBuffer())],
10111
10185
  costUsd: 0
10112
10186
  });
10113
10187
  void runNode(node2.id, null, 0, work2, { width: plan2.width, height: plan2.height }).catch(
10114
10188
  (err) => app.log.error({ err }, "crop run failed")
10115
10189
  );
10116
- return reply.status(202).send(node2);
10190
+ return reply.status(202).send(args.note ? { ...node2, warnings: [args.note] } : node2);
10191
+ };
10192
+ if (kind === "edit" && reshape === "crop") {
10193
+ const rootForCrop = core.store.treeFor(project.id).find((n) => n.kind === "root");
10194
+ if (!rootForCrop) return reply.status(500).send({ error: "project has no root node" });
10195
+ const cropParentId = parentId ? String(parentId) : rootForCrop.id;
10196
+ if (brief && Array.isArray(brief.tokens)) {
10197
+ const briefErrors = validateBrief(brief);
10198
+ if (briefErrors.length) return reply.status(400).send({ error: `invalid brief: ${briefErrors.join("; ")}` });
10199
+ }
10200
+ const fmt = Array.isArray(brief?.tokens) ? brief.tokens.find(
10201
+ (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
10202
+ ) : void 0;
10203
+ if (!fmt) return reply.status(400).send({ error: "a crop needs a target format" });
10204
+ const parent = core.store.getNode(cropParentId);
10205
+ const srcHash = req.body.sourceImage ?? parent?.images[0];
10206
+ if (!srcHash || !core.images.has(String(srcHash)))
10207
+ return reply.status(400).send({ error: "edit needs a parent node with an image (sourceImage)" });
10208
+ const srcBuf = core.images.read(String(srcHash));
10209
+ const srcMeta = await sharp20(srcBuf).metadata();
10210
+ if (!srcMeta.width || !srcMeta.height) return reply.status(400).send({ error: "source image unreadable" });
10211
+ return runCropNode({
10212
+ parentId: cropParentId,
10213
+ fmt,
10214
+ srcHash: String(srcHash),
10215
+ srcBuf,
10216
+ srcSize: { width: srcMeta.width, height: srcMeta.height }
10217
+ });
10117
10218
  }
10118
10219
  const engine = engines.get(String(engineId));
10119
10220
  if (!engine) return reply.status(400).send({ error: `unknown engine ${engineId}` });
@@ -10134,6 +10235,9 @@ function buildServer(opts) {
10134
10235
  let sentSize;
10135
10236
  const extraWarnings = [];
10136
10237
  let expandPlan = null;
10238
+ let expandSent = null;
10239
+ let expandAssist = null;
10240
+ let expandWorkHash = null;
10137
10241
  let expandSourceHash = null;
10138
10242
  if (brief && Array.isArray(brief.tokens)) {
10139
10243
  const briefErrors = validateBrief(brief);
@@ -10288,14 +10392,41 @@ function buildServer(opts) {
10288
10392
  width: srcMeta.width,
10289
10393
  height: srcMeta.height
10290
10394
  });
10291
- if (reshapeIntended && srcMeta.width && srcMeta.height && compiled2?.width && compiled2?.height) {
10292
- expandPlan = planExpand({ width: srcMeta.width, height: srcMeta.height }, compiled2.width / compiled2.height);
10395
+ let workBuf = srcBuf;
10396
+ let workSize = srcMeta.width && srcMeta.height ? { width: srcMeta.width, height: srcMeta.height } : null;
10397
+ if (reshapeIntended && workSize && compiled2?.width && compiled2?.height) {
10398
+ const targetRatio = compiled2.width / compiled2.height;
10399
+ const decision = classifyReshape(workSize, targetRatio, reshape);
10400
+ if (decision.op === "crop") {
10401
+ if (reshape === "extend")
10402
+ return reply.status(400).send({
10403
+ error: `growing a ${workSize.width}x${workSize.height} frame to this shape would invent more of the photograph than it keeps; crop instead`
10404
+ });
10405
+ const fmt = (brief?.tokens ?? []).find(
10406
+ (t) => t.t === "format" && Number(t.w) > 0 && Number(t.h) > 0
10407
+ );
10408
+ if (fmt)
10409
+ return runCropNode({
10410
+ parentId: resolvedParentId,
10411
+ fmt,
10412
+ srcHash: String(srcHash),
10413
+ srcBuf,
10414
+ srcSize: workSize,
10415
+ note: decision.forced ? "That shape is further than one extend can reach, so the picture was cropped to it instead." : void 0
10416
+ });
10417
+ } else if (decision.op === "extend") {
10418
+ if (decision.assist) {
10419
+ expandAssist = { width: decision.assist.width, height: decision.assist.height };
10420
+ workBuf = await sharp20(srcBuf).extract(decision.assist).png().toBuffer();
10421
+ workSize = { width: decision.assist.width, height: decision.assist.height };
10422
+ }
10423
+ expandPlan = planExpand(workSize, targetRatio);
10424
+ }
10293
10425
  }
10294
10426
  if (reshape === "extend" && !expandPlan)
10295
10427
  return reply.status(400).send({ error: "the picture is already this shape" });
10296
- if (expandPlan && srcMeta.width && srcMeta.height) {
10297
- const size = { width: srcMeta.width, height: srcMeta.height };
10298
- expandPlan = placeExpand(expandPlan, size, await subjectFraction(srcBuf, size, expandPlan.axis));
10428
+ if (expandPlan && workSize) {
10429
+ expandPlan = placeExpand(expandPlan, workSize, await subjectFraction(workBuf, workSize, expandPlan.axis));
10299
10430
  }
10300
10431
  if (expandPlan) {
10301
10432
  const route = await resolveOutpaintRoute(engines.all(), engine);
@@ -10303,11 +10434,25 @@ function buildServer(opts) {
10303
10434
  expandMethod = route.method;
10304
10435
  }
10305
10436
  const canOutpaint2 = expandMethod === "outpaint";
10437
+ if (expandPlan && workSize) {
10438
+ const fit = fitExpandToBudget(expandPlan, workSize, runEngine.capabilities().editPixelBudget);
10439
+ if (fit.scale < 1) {
10440
+ expandPlan = fit.plan;
10441
+ workBuf = await sharp20(workBuf).resize(fit.source.width, fit.source.height, { fit: "fill", kernel: "lanczos3" }).png().toBuffer();
10442
+ workSize = fit.source;
10443
+ extraWarnings.push(
10444
+ `${runEngine.capabilities().displayName} draws about ${((runEngine.capabilities().editPixelBudget ?? 0) / 1e6).toFixed(1)} megapixels, so this shape continues as a ${fit.plan.width}x${fit.plan.height} frame with the photograph riding inside it at ${fit.source.width}x${fit.source.height}. Nothing is upscaled; the stored size is the size the engine truly drew.`
10445
+ );
10446
+ }
10447
+ expandSent = workSize;
10448
+ }
10306
10449
  let reframeSourceHash;
10307
10450
  if (expandPlan) {
10308
10451
  if (!canOutpaint2) {
10309
- expandSourceHash = core.images.save(await expandCanvas(srcBuf, expandPlan));
10310
- reframeSourceHash = core.images.save(await conditioningCanvas(srcBuf, expandPlan, "edge"));
10452
+ expandSourceHash = core.images.save(await expandCanvas(workBuf, expandPlan));
10453
+ reframeSourceHash = core.images.save(await conditioningCanvas(workBuf, expandPlan, "edge"));
10454
+ } else {
10455
+ expandWorkHash = core.images.save(workBuf);
10311
10456
  }
10312
10457
  expectShape = { width: expandPlan.width, height: expandPlan.height };
10313
10458
  }
@@ -10324,7 +10469,7 @@ function buildServer(opts) {
10324
10469
  }
10325
10470
  const editReq = {
10326
10471
  instruction: expandPlan ? expandInstruction(expandPlan, finalPrompt) : finalPrompt,
10327
- sourceImage: core.images.pathFor(String(expandSourceHash ?? budgetSourceHash ?? srcHash)),
10472
+ sourceImage: core.images.pathFor(String(expandSourceHash ?? expandWorkHash ?? budgetSourceHash ?? srcHash)),
10328
10473
  brand: ctx,
10329
10474
  ...editRefs.length ? { referenceImages: editRefs.map((r) => r.path) } : {},
10330
10475
  ...editRefs.length ? { referenceRoles: editRefs.map((r) => r.role ?? "reference") } : {},
@@ -10341,8 +10486,10 @@ function buildServer(opts) {
10341
10486
  expand: {
10342
10487
  left: expandPlan.left,
10343
10488
  top: expandPlan.top,
10344
- width: srcMeta.width ?? 0,
10345
- height: srcMeta.height ?? 0
10489
+ // The sent copy's own size: after crop assist and the budget
10490
+ // fit these are the pixels the offsets actually refer to.
10491
+ width: workSize?.width ?? srcMeta.width ?? 0,
10492
+ height: workSize?.height ?? srcMeta.height ?? 0
10346
10493
  },
10347
10494
  // Derived from the picture and the shape asked for, so the same
10348
10495
  // extend of the same shot is the same picture every time. Without
@@ -10354,8 +10501,8 @@ function buildServer(opts) {
10354
10501
  };
10355
10502
  estimate = await runEngine.costEstimate(editReq);
10356
10503
  const plan2 = expandPlan;
10357
- const srcSize = { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
10358
- const original2 = srcBuf;
10504
+ const srcSize = workSize ?? { width: srcMeta.width ?? 0, height: srcMeta.height ?? 0 };
10505
+ const original2 = workBuf;
10359
10506
  const reframeReq = plan2 && !canOutpaint2 && reframeSourceHash ? {
10360
10507
  ...editReq,
10361
10508
  instruction: reframeInstruction(plan2, srcSize, finalPrompt),
@@ -10425,7 +10572,13 @@ function buildServer(opts) {
10425
10572
  // Placement is no longer always centred, so a reader that
10426
10573
  // assumes it is would be looking in the wrong place.
10427
10574
  left: expandPlan.left,
10428
- top: expandPlan.top
10575
+ top: expandPlan.top,
10576
+ // The planned frame and the size the photograph was sent at,
10577
+ // so requested-versus-drawn is a readable fact — and the
10578
+ // assist window when a slice of the other axis was given up.
10579
+ frame: [expandPlan.width, expandPlan.height],
10580
+ ...expandSent ? { source: [expandSent.width, expandSent.height] } : {},
10581
+ ...expandAssist ? { assist: [expandAssist.width, expandAssist.height] } : {}
10429
10582
  }
10430
10583
  } : {},
10431
10584
  // What the refinement carried, recorded apart from what it asked for:
@@ -10498,7 +10651,8 @@ function buildServer(opts) {
10498
10651
  { nodeId: node.id, got: `${got.width}x${got.height}`, want: `${plan.width}x${plan.height}` },
10499
10652
  "expand: engine size differs from plan"
10500
10653
  );
10501
- const { image, aligned } = await compositeExpand(answer, original, plan);
10654
+ const pasted = expandWorkHash ? core.images.read(expandWorkHash) : original;
10655
+ const { image, aligned } = await compositeExpand(answer, pasted, plan);
10502
10656
  if (!aligned) app.log.warn({ nodeId: node.id }, "expand: engine frame did not align, kept the bed");
10503
10657
  out.push(core.images.save(image));
10504
10658
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scenri",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "author": "Tony Gorb <hello@scenri.co>",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",