@rig-c/parts 1.6.0 → 1.8.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/README.md +16 -11
- package/cli.ts +17 -6
- package/docs/AUTHORING.md +65 -16
- package/docs/STABILITY.md +1 -1
- package/package.json +2 -2
- package/src/assemble.ts +199 -16
- package/src/build.ts +172 -11
- package/src/check.ts +7 -4
- package/src/headframe.ts +207 -0
- package/src/motion.ts +20 -3
- package/src/parts.ts +2 -2
- package/src/propose.ts +687 -27
- package/src/rig.ts +322 -12
- package/src/scene.ts +14 -1
package/src/assemble.ts
CHANGED
|
@@ -73,6 +73,17 @@
|
|
|
73
73
|
* recorded with the same `<run>:<tag>` (its pixels came from that layer).
|
|
74
74
|
* The cut is made after step 4, so the new part takes the merged pixels and
|
|
75
75
|
* every later step treats it as a part of its own.
|
|
76
|
+
* - **The owner of a pixel follows the plan** (`pixelOwners`, issue #255):
|
|
77
|
+
* where two planned layers of one run are both opaque, the one the plan
|
|
78
|
+
* draws in front owns the pixel for projection; the reference went by
|
|
79
|
+
* See-through's depth alone. The proposer writes each run's parts in depth
|
|
80
|
+
* order, so the two differ only where an author reorders a plan; on the
|
|
81
|
+
* public examples no pixel changes owner, and they assemble to the bytes
|
|
82
|
+
* they did.
|
|
83
|
+
* - **The drift line** (`driftBodies`, issue #255) is this port's: a part
|
|
84
|
+
* whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw
|
|
85
|
+
* order is named, with the body and that part; the reference printed the
|
|
86
|
+
* refused count alone.
|
|
76
87
|
* - **What lies under a piece** (`cuts[].under`, `fillUnder`, issue #193) is
|
|
77
88
|
* this port's: an authored image the base takes where the cut removed its
|
|
78
89
|
* pixels, so a piece that swings away leaves no hole. The stage reads that
|
|
@@ -449,8 +460,8 @@ export interface ProjectionStats {
|
|
|
449
460
|
taken: number;
|
|
450
461
|
refused: number;
|
|
451
462
|
/**
|
|
452
|
-
* Where no
|
|
453
|
-
*
|
|
463
|
+
* Where no layer of the run in front of this one (`frontOf`) owns the
|
|
464
|
+
* pixel (`pixelOwners`): the owner is this layer, one behind it, or none. A
|
|
454
465
|
* pixel of this layer with alpha above 8 is VISIBLE exactly where this is
|
|
455
466
|
* set, and OCCLUDED everywhere else.
|
|
456
467
|
*/
|
|
@@ -466,12 +477,71 @@ function maxDiff(a: Uint8ClampedArray, i: number, b: Uint8ClampedArray, j: numbe
|
|
|
466
477
|
return Math.max(Math.abs(a[i] - b[j]), Math.abs(a[i + 1] - b[j + 1]), Math.abs(a[i + 2] - b[j + 2]));
|
|
467
478
|
}
|
|
468
479
|
|
|
480
|
+
/**
|
|
481
|
+
* The plan's rank of each layer of one run (issue #255): the index of the
|
|
482
|
+
* last plan entry that takes the layer from this run, or -1 for a layer no
|
|
483
|
+
* plan entry takes (an `extend_below_crop` layer included: its pixels are
|
|
484
|
+
* copied into another part, and it keeps See-through's depth).
|
|
485
|
+
*/
|
|
486
|
+
export function planRanks(tags: readonly string[], run: Run, plan: readonly PlanEntry[]): number[] {
|
|
487
|
+
return tags.map((tag) => {
|
|
488
|
+
let r = -1;
|
|
489
|
+
plan.forEach(([, ru, tg], i) => {
|
|
490
|
+
if (ru === run && tg === tag) r = i;
|
|
491
|
+
});
|
|
492
|
+
return r;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Whether layer `o` of a run is in front of layer `i` of the same run: by the
|
|
498
|
+
* plan when both are planned (`rank` >= 0 for both; the later plan entry is
|
|
499
|
+
* drawn in front), by See-through's depth (the run's own order) otherwise.
|
|
500
|
+
* `rank` null is depth throughout, the reference's rule.
|
|
501
|
+
*/
|
|
502
|
+
export function frontOf(o: number, i: number, rank: readonly number[] | null): boolean {
|
|
503
|
+
if (rank !== null && rank[o] >= 0 && rank[i] >= 0) return rank[o] > rank[i];
|
|
504
|
+
return o > i;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Who owns each rig pixel of one run, for projection (issue #255).
|
|
509
|
+
*
|
|
510
|
+
* ⚖️ Invariant: per pixel, the DEPTH owner is the run's top-most layer with
|
|
511
|
+
* alpha >= `CORE_ALPHA` there (-1: none), the reference's rule. When `rank`
|
|
512
|
+
* is given and the depth owner is a planned layer, the owner is instead the
|
|
513
|
+
* planned layer at alpha >= `CORE_ALPHA` there with the greatest rank: where
|
|
514
|
+
* two planned parts both hold a pixel, the one the plan draws in front takes
|
|
515
|
+
* it, whatever See-through's depth says. A depth owner no plan entry takes is
|
|
516
|
+
* kept — the painting there shows a layer the rig does not draw, and no
|
|
517
|
+
* planned part may take it. Where plan and depth agree the owner is the
|
|
518
|
+
* reference's, pixel for pixel.
|
|
519
|
+
*/
|
|
520
|
+
export function pixelOwners(rigLayers: readonly Raster[], rank: readonly number[] | null): Int32Array {
|
|
521
|
+
const n = rigLayers.length === 0 ? 0 : rigLayers[0].width * rigLayers[0].height;
|
|
522
|
+
const owner = new Int32Array(n).fill(-1);
|
|
523
|
+
for (let p = 0; p < n; p++) {
|
|
524
|
+
let depth = -1;
|
|
525
|
+
let best = -1;
|
|
526
|
+
for (let i = 0; i < rigLayers.length; i++) {
|
|
527
|
+
if (rigLayers[i].data[p * 4 + 3] < CORE_ALPHA) continue;
|
|
528
|
+
depth = i;
|
|
529
|
+
if (rank !== null && rank[i] >= 0 && (best < 0 || rank[i] > rank[best])) best = i;
|
|
530
|
+
}
|
|
531
|
+
owner[p] = rank !== null && depth >= 0 && rank[depth] >= 0 ? best : depth;
|
|
532
|
+
}
|
|
533
|
+
return owner;
|
|
534
|
+
}
|
|
535
|
+
|
|
469
536
|
/**
|
|
470
537
|
* Step 3, over one run's layers in draw order. Modifies each rig layer's
|
|
471
538
|
* colour in place and returns its counts.
|
|
472
539
|
*
|
|
473
|
-
* ⚖️ Invariant: a layer's CORE is where it is the
|
|
474
|
-
* layer of its own run,
|
|
540
|
+
* ⚖️ Invariant: a layer's CORE is where it is the pixel's owner
|
|
541
|
+
* (`pixelOwners`: the top-most `alpha >= 250` layer of its own run, or, with
|
|
542
|
+
* `rank`, the planned one the plan draws in front — issue #255, not in the
|
|
543
|
+
* reference, which is the same wherever plan and depth agree), eroded by a
|
|
544
|
+
* 5x5 square. Inside the core, the pixels
|
|
475
545
|
* that agree with the painting (max channel difference <= `DRIFT_LIMIT`),
|
|
476
546
|
* closed by 5x5 and cut back to the core — and, for the head run, kept
|
|
477
547
|
* `HEAD_WINDOW_INSET` pixels inside the head crop — are the ACCEPTED set. The
|
|
@@ -484,22 +554,20 @@ function maxDiff(a: Uint8ClampedArray, i: number, b: Uint8ClampedArray, j: numbe
|
|
|
484
554
|
*
|
|
485
555
|
* With `rule: 'visible'` (NOT in the reference) the core is the top-most
|
|
486
556
|
* `alpha >= 250` set less every pixel within the 5x5 square of a pixel where a
|
|
487
|
-
*
|
|
557
|
+
* layer in front of it (`frontOf`) is the owner — the erosion kept only along a rim with
|
|
488
558
|
* a neighbour in front, where the painting's pixel may be that neighbour's
|
|
489
559
|
* edge. Every other step is the same. Fringe pixels (alpha below 250) are not
|
|
490
560
|
* projected under either rule: the painting there is this layer blended with
|
|
491
561
|
* what is behind it.
|
|
492
562
|
*
|
|
493
|
-
* Both rules return `clear` (no
|
|
563
|
+
* Both rules return `clear` (no layer in front of this one, by `frontOf`, is the owner) and `takenMask`, the
|
|
494
564
|
* masks the part counts in `parts.json` are derived from; `takenMask` is a
|
|
495
565
|
* subset of the top-most set, and so of `clear`.
|
|
496
566
|
*/
|
|
497
|
-
export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE): ProjectionStats[] {
|
|
567
|
+
export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, run: Run, rule: ProjectRule = DEFAULT_PROJECT_RULE, rank: readonly number[] | null = null): ProjectionStats[] {
|
|
498
568
|
const { W, H } = frame;
|
|
499
|
-
const owner =
|
|
500
|
-
|
|
501
|
-
for (let p = 0; p < W * H; p++) if (r.data[p * 4 + 3] >= CORE_ALPHA) owner[p] = i;
|
|
502
|
-
});
|
|
569
|
+
const owner = pixelOwners(rigLayers, rank);
|
|
570
|
+
const inFront = (o: number, i: number): boolean => frontOf(o, i, rank);
|
|
503
571
|
let win: Mask | null = null;
|
|
504
572
|
if (run === 'head') {
|
|
505
573
|
win = newMask(W, H);
|
|
@@ -516,7 +584,7 @@ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, r
|
|
|
516
584
|
const takenMask = newMask(W, H);
|
|
517
585
|
let anyTop = false;
|
|
518
586
|
for (let p = 0; p < W * H; p++) {
|
|
519
|
-
if (owner[p]
|
|
587
|
+
if (owner[p] === -1 || owner[p] === i || !inFront(owner[p], i)) clear.data[p] = 1;
|
|
520
588
|
if (owner[p] === i && r.data[p * 4 + 3] >= CORE_ALPHA) {
|
|
521
589
|
top.data[p] = 1;
|
|
522
590
|
anyTop = true;
|
|
@@ -527,7 +595,7 @@ export function projectSource(rigLayers: Raster[], srcr: Raster, frame: Frame, r
|
|
|
527
595
|
if (rule === 'core') core = erode(top, CORE_KERNEL);
|
|
528
596
|
else {
|
|
529
597
|
const front = newMask(W, H);
|
|
530
|
-
for (let p = 0; p < W * H; p++) if (owner[p]
|
|
598
|
+
for (let p = 0; p < W * H; p++) if (owner[p] >= 0 && owner[p] !== i && inFront(owner[p], i)) front.data[p] = 1;
|
|
531
599
|
const rim = dilate(front, CORE_KERNEL);
|
|
532
600
|
core = newMask(W, H);
|
|
533
601
|
for (let p = 0; p < W * H; p++) if (top.data[p] === 1 && rim.data[p] === 0) core.data[p] = 1;
|
|
@@ -1602,6 +1670,8 @@ export interface AssembleResult {
|
|
|
1602
1670
|
cuts: CutCount[];
|
|
1603
1671
|
/** The full-run layers the plan does not take from the full run whose uncovered error holds a body (`uncoveredErrorLayers`); empty otherwise. */
|
|
1604
1672
|
uncovered: UncoveredErrorLayer[];
|
|
1673
|
+
/** The parts whose `DRIFT_LIMIT` refusals hold a body another part holds in the draw order (`driftBodies`), in draw order; empty otherwise. */
|
|
1674
|
+
drift: DriftBodies[];
|
|
1605
1675
|
seamRule: SeamRule;
|
|
1606
1676
|
projectRule: ProjectRule;
|
|
1607
1677
|
}
|
|
@@ -1664,7 +1734,7 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
1664
1734
|
const layers = runLayers(set, input.resolution);
|
|
1665
1735
|
if (run === 'full') fullTags.push(...layers.filter((l) => l.opaqueRunPx > 0).map((l) => l.tag));
|
|
1666
1736
|
const rigLayers = layers.map((l) => layerToRig(l.canvas, frame, run));
|
|
1667
|
-
const st = projectSource(rigLayers, srcr, frame, run, projectRule);
|
|
1737
|
+
const st = projectSource(rigLayers, srcr, frame, run, projectRule, planRanks(layers.map((l) => l.tag), run, plan));
|
|
1668
1738
|
layers.forEach((l, i) => {
|
|
1669
1739
|
rig.set(`${run}:${l.tag}`, rigLayers[i]);
|
|
1670
1740
|
stats.set(`${run}:${l.tag}`, st[i]);
|
|
@@ -1958,6 +2028,23 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
1958
2028
|
return { record: whole, image: b.r };
|
|
1959
2029
|
});
|
|
1960
2030
|
const pushedBy = pushBackFringe(stack, srcr, seamRule, seamRule === 'silhouette' ? silhouette : null);
|
|
2031
|
+
// Issue #255: a part's drift refusals in a body, and who holds the body in the draw order (the push-back clears
|
|
2032
|
+
// only fringe below CORE_ALPHA, and the seam override only recolours, so the holders read here are final).
|
|
2033
|
+
const holderAt = new Int32Array(W * H).fill(-1);
|
|
2034
|
+
stack.forEach(({ image }, i) => {
|
|
2035
|
+
for (let p = 0; p < W * H; p++) if (image.data[p * 4 + 3] >= CORE_ALPHA) holderAt[p] = i;
|
|
2036
|
+
});
|
|
2037
|
+
const stackNames = stack.map((q) => q.record.name);
|
|
2038
|
+
const drift: DriftBodies[] = [];
|
|
2039
|
+
order.forEach((d) => {
|
|
2040
|
+
if ('patch' in d) return;
|
|
2041
|
+
const { name, b } = pieceAt(d);
|
|
2042
|
+
if (b.st.refusedMask === null) return;
|
|
2043
|
+
const refused = newMask(W, H);
|
|
2044
|
+
for (let p = 0; p < W * H; p++) if (b.st.refusedMask.data[p] === 1 && (b.region === null || b.region.data[p] === 1)) refused.data[p] = 1;
|
|
2045
|
+
const found = driftBodies(name, b.key, refused, bodyMinPx(frame), holderAt, stackNames);
|
|
2046
|
+
if (found !== null) drift.push(found);
|
|
2047
|
+
});
|
|
1961
2048
|
const pushedOf: FringePushed[][] = plan.map(() => []);
|
|
1962
2049
|
const pushedOfCut: FringePushed[][] = cuts.map(() => []);
|
|
1963
2050
|
order.forEach((d, i) => {
|
|
@@ -2048,11 +2135,99 @@ export function assemble(input: AssembleInput): AssembleResult {
|
|
|
2048
2135
|
figures,
|
|
2049
2136
|
cuts: cutCounts,
|
|
2050
2137
|
uncovered,
|
|
2138
|
+
drift,
|
|
2051
2139
|
seamRule,
|
|
2052
2140
|
projectRule,
|
|
2053
2141
|
};
|
|
2054
2142
|
}
|
|
2055
2143
|
|
|
2144
|
+
// ---------------------------------------------------------------------------
|
|
2145
|
+
// refused bodies: a part whose layer is not what the painting shows (issue #255)
|
|
2146
|
+
// ---------------------------------------------------------------------------
|
|
2147
|
+
|
|
2148
|
+
/** A part whose `DRIFT_LIMIT` refusals hold a body, and which parts the draw order puts there. */
|
|
2149
|
+
export interface DriftBodies {
|
|
2150
|
+
/** The part, and its `<run>:<tag>`. */
|
|
2151
|
+
part: string;
|
|
2152
|
+
from: string;
|
|
2153
|
+
/** The part's `refused_drift_px`, in a body or not. */
|
|
2154
|
+
refusedPx: number;
|
|
2155
|
+
/** The bar the bodies were held to (`bodyMinPx`, the uncovered line's). */
|
|
2156
|
+
minPx: number;
|
|
2157
|
+
/** The bodies, largest first (`bodiesOf`). */
|
|
2158
|
+
bodies: UncoveredBody[];
|
|
2159
|
+
/**
|
|
2160
|
+
* Over every pixel of the bodies, the part that holds it in the draw order
|
|
2161
|
+
* — the top-most at alpha >= `CORE_ALPHA`, as the seam pass reads a holder
|
|
2162
|
+
* (`seamCandidates`) — with its pixel count, most first, then by name.
|
|
2163
|
+
*/
|
|
2164
|
+
holders: Array<{ part: string; px: number }>;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/**
|
|
2168
|
+
* A part's refused bodies that another part holds in the draw order (issue
|
|
2169
|
+
* #255).
|
|
2170
|
+
*
|
|
2171
|
+
* ⚖️ Invariant: `refused` is the part's projection core pixels whose layer
|
|
2172
|
+
* colour is more than `DRIFT_LIMIT` off the painting (`projectSource`'s
|
|
2173
|
+
* `refusedMask`, cut to the part's region); its bodies are `bodiesOf(refused,
|
|
2174
|
+
* minPx)` — the pixels that survive a `BODY_KERNEL` opening, in 8-connected
|
|
2175
|
+
* components of at least `minPx` — the same reading of "a body" as an
|
|
2176
|
+
* uncovered layer's, and the stage passes the same bar (`bodyMinPx`).
|
|
2177
|
+
* `holderAt` is, per rig pixel, the index into `names` of the top-most part
|
|
2178
|
+
* at alpha >= `CORE_ALPHA` in draw order (-1: none), as the seam pass reads a
|
|
2179
|
+
* holder. The result is non-null exactly when the bodies hold a pixel whose
|
|
2180
|
+
* holder is another part: the plan draws that part over the body, which is
|
|
2181
|
+
* the signature of "this layer is not the owner here".
|
|
2182
|
+
*
|
|
2183
|
+
* Not every body: a body the part holds itself, with nothing drawn over it,
|
|
2184
|
+
* is See-through's colour for the part's own pixels disagreeing with the
|
|
2185
|
+
* painting, and on four of the five public paintings it holds bodies of
|
|
2186
|
+
* hundreds to tens of thousands of pixels that no other part touches (the
|
|
2187
|
+
* pull request that added this has the figures); a line there would name no
|
|
2188
|
+
* owner, which is what the line exists to name.
|
|
2189
|
+
*/
|
|
2190
|
+
export function driftBodies(part: string, from: string, refused: Mask, minPx: number, holderAt: Int32Array, names: readonly string[]): DriftBodies | null {
|
|
2191
|
+
let refusedPx = 0;
|
|
2192
|
+
for (let p = 0; p < refused.data.length; p++) refusedPx += refused.data[p];
|
|
2193
|
+
if (refusedPx === 0) return null;
|
|
2194
|
+
const { bodies, pixels } = bodiesWithPixels(refused, minPx);
|
|
2195
|
+
if (bodies.length === 0) return null;
|
|
2196
|
+
const count = new Map<string, number>();
|
|
2197
|
+
for (let p = 0; p < pixels.data.length; p++) {
|
|
2198
|
+
if (pixels.data[p] === 0 || holderAt[p] < 0) continue;
|
|
2199
|
+
const n = names[holderAt[p]];
|
|
2200
|
+
count.set(n, (count.get(n) ?? 0) + 1);
|
|
2201
|
+
}
|
|
2202
|
+
if (![...count.keys()].some((n) => n !== part)) return null;
|
|
2203
|
+
const holders = [...count.entries()].map(([q, px]) => ({ part: q, px })).sort((a, b) => b.px - a.px || (a.part < b.part ? -1 : a.part > b.part ? 1 : 0));
|
|
2204
|
+
return { part, from, refusedPx, minPx, bodies, holders };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* One `drift` line under the `uncovered` lines: the part, its refused pixels,
|
|
2209
|
+
* their bodies, and the parts the plan's draw order puts on them —
|
|
2210
|
+
* `drift: "topwear" refuses 196 px of full:topwear (its colour more than 90
|
|
2211
|
+
* off the painting, max channel), in a body of 196 px at 16,12 14x14 (at
|
|
2212
|
+
* least 150 px past a 5x5 opening); by the plan's draw order "face" holds 196
|
|
2213
|
+
* px of it (the top-most part at alpha >= 250), drawn in front of "topwear"`.
|
|
2214
|
+
*/
|
|
2215
|
+
export function driftLine(d: DriftBodies): string {
|
|
2216
|
+
const total = d.bodies.reduce((a, b) => a + b.px, 0);
|
|
2217
|
+
const big = d.bodies[0];
|
|
2218
|
+
const held = d.bodies.length > 1 ? `${d.bodies.length} bodies of ${total} px, the largest ${big.px} px at ${bodyBox(big)}` : `a body of ${big.px} px at ${bodyBox(big)}`;
|
|
2219
|
+
const others = d.holders.filter((h) => h.part !== d.part).map((h) => `"${h.part}"`);
|
|
2220
|
+
return (
|
|
2221
|
+
`drift: "${d.part}" refuses ${d.refusedPx} px of ${d.from} (its colour more than ${DRIFT_LIMIT} off the painting, max channel), in ${held} (at least ${fmtPx(d.minPx)} px past a ${BODY_KERNEL}x${BODY_KERNEL} opening); ` +
|
|
2222
|
+
`by the plan's draw order ${d.holders.map((h) => `"${h.part}" holds ${h.px} px`).join(', ')} of it (the top-most part at alpha >= ${CORE_ALPHA}), ${others.join(', ')} drawn in front of "${d.part}"`
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/** A rig-pixel bar as the lines print it: a whole number as it is, otherwise to one decimal. */
|
|
2227
|
+
function fmtPx(n: number): string {
|
|
2228
|
+
return Number.isInteger(n) ? String(n) : n.toFixed(1);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2056
2231
|
// ---------------------------------------------------------------------------
|
|
2057
2232
|
// uncovered bodies: a full-run layer the plan leaves out (issue #233)
|
|
2058
2233
|
// ---------------------------------------------------------------------------
|
|
@@ -2104,12 +2279,20 @@ export interface UncoveredLayer {
|
|
|
2104
2279
|
* the box's top row and left column.
|
|
2105
2280
|
*/
|
|
2106
2281
|
export function bodiesOf(mask: Mask, minPx: number): UncoveredBody[] {
|
|
2282
|
+
return bodiesWithPixels(mask, minPx).bodies;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/** `bodiesOf`, and the mask of every pixel that lies in one of the bodies it returns. */
|
|
2286
|
+
export function bodiesWithPixels(mask: Mask, minPx: number): { bodies: UncoveredBody[]; pixels: Mask } {
|
|
2107
2287
|
const opened = dilate(erode(mask, BODY_KERNEL), BODY_KERNEL);
|
|
2108
2288
|
for (let p = 0; p < opened.data.length; p++) if (mask.data[p] === 0) opened.data[p] = 0;
|
|
2109
2289
|
const cc = connectedComponents(opened, 8);
|
|
2110
|
-
const
|
|
2290
|
+
const keep = cc.stats.map((s, l) => l > 0 && s.area > 0 && s.area >= minPx);
|
|
2291
|
+
const all = cc.stats.filter((s, l) => keep[l]);
|
|
2111
2292
|
all.sort((a, b) => b.area - a.area || a.top - b.top || a.left - b.left);
|
|
2112
|
-
|
|
2293
|
+
const pixels = newMask(mask.width, mask.height);
|
|
2294
|
+
for (let p = 0; p < pixels.data.length; p++) if (keep[cc.labels[p]]) pixels.data[p] = 1;
|
|
2295
|
+
return { bodies: all.map((s) => ({ px: s.area, x: s.left, y: s.top, w: s.width, h: s.height })), pixels };
|
|
2113
2296
|
}
|
|
2114
2297
|
|
|
2115
2298
|
/** A body's box as the hole lines print one: `x,y wxh`. */
|
package/src/build.ts
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
44
44
|
import { dirname, join, resolve } from 'node:path';
|
|
45
45
|
import { type AnimFrame, EncodeError, encodeApng, encodeIndexedApng, INDEXED_DEFAULTS } from './apng.ts';
|
|
46
|
-
import { assemble, type AssembleResult, cutLines, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
|
|
46
|
+
import { assemble, type AssembleResult, cutLines, driftLine, figuresLine, holeLines, type ProjectRule, type SeamRule, stageFields, uncoveredLine } from './assemble.ts';
|
|
47
47
|
import { allocationClause, type Reducer, stageBClause, unboundedClause } from './automesh.ts';
|
|
48
48
|
import { residualClause } from './autoenvelope.ts';
|
|
49
49
|
import { type AutoMotionCase, failingRows, idleSchedule, localRow, motionClause, motionDeformation, motionDocument, motionInput, motionRowText, motionStimulus, motionVerdict, noStimulusProblem, type ReplayCandidate, runComparison, stimulusMotion, withStimulus } from './automotion.ts';
|
|
@@ -70,7 +70,7 @@ import {
|
|
|
70
70
|
splitSchedule,
|
|
71
71
|
untestedIntervals,
|
|
72
72
|
} from './autoreplay.ts';
|
|
73
|
-
import { BARS, besideIdleLine, causeLines, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
|
|
73
|
+
import { BARS, besideIdleLine, buildGateLines, causeLines, IDLE_FPS, IDLE_MAX_PX, maxRgbDiff, REQUIREMENTS_DIR, type CheckReport, type PackLine, DEFAULT_PACK_SHAPE, DEFAULT_PAGE_EDGES, JUDGEMENT_LINES, type JudgementLine, packedBuildArgs, packedBuildLabel, type PackMode, type PackShape, type PageEdges, readFrameSet, rigcFailed, REPORTED_LINES, type ReportedLine, type RigcRunner, runCheck, SEAM_DIR, SEAM_MEAN_BAR, seamPairText, seamRigPxText, SOURCE_LINE, SEAM_PX_BAR, SEAM_PX_LEVEL, SEAM_PX_LEVEL_HIGH, SPINEBOY_YARDSTICK, STILL_DIR } from './check.ts';
|
|
74
74
|
import { type Cut, loadConfig, loadConfigAndAnimations, loadEarlyConfig } from './config.ts';
|
|
75
75
|
import { PartsError, type Problem, problemLine, refuseIfAny } from './errors.ts';
|
|
76
76
|
import type { MeshQualityReport, MotionSchedule } from 'rig-c/mesh';
|
|
@@ -82,7 +82,8 @@ import { type PartRecord, readParts, writeParts } from './parts.ts';
|
|
|
82
82
|
import { encodePngBytes, readPng, writePng } from './raster/png.ts';
|
|
83
83
|
import type { Raster } from './raster/types.ts';
|
|
84
84
|
import { readRequirements, type RequirementLine, summaryText } from './requirements.ts';
|
|
85
|
-
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
|
|
85
|
+
import { type AutoMeshReport, buildRig, DEFAULT_IDLE_KEYS, type FaceAxis, type IdleKeys, type MeshReport, type RigCommand, rigJsonText, type RigOutput, type RigSpec, type StrayCleared } from './rig.ts';
|
|
86
|
+
import { pyRound } from './round.ts';
|
|
86
87
|
|
|
87
88
|
/** Where a stage's lines go. The commands hand it `console.log`; `build` hands it a prefixing wrapper. */
|
|
88
89
|
export type Log = (line: string) => void;
|
|
@@ -229,6 +230,7 @@ export function assembleStage(input: AssembleStageInput, outs: AssembleOutputs,
|
|
|
229
230
|
log(figuresLine(result.figures));
|
|
230
231
|
for (const l of holeLines(result.figures)) log(l);
|
|
231
232
|
for (const u of result.uncovered) log(uncoveredLine(u));
|
|
233
|
+
for (const d of result.drift) log(driftLine(d));
|
|
232
234
|
log(`wrote ${outs.partsJson}, ${result.images.length} PNG(s) in ${outs.partsDir}, ${outs.recomposite}, ${outs.errorMap} (uncovered error px red, covered error px blue, the painting grey)`);
|
|
233
235
|
return result;
|
|
234
236
|
}
|
|
@@ -819,6 +821,7 @@ export function rigStage(input: RigStageInput, rigc: RigcRunner, scratch: string
|
|
|
819
821
|
` bones ${rig.rig.bones.length} (${rig.controls.length} control) slots ${rig.rig.slots.length} meshes ${rig.meshReport.length} regions ${regions} vertices ${vertices}; idle ${rig.motion.animations.idle.duration} s, ${tracks.length} track(s), ${keys} key(s)`,
|
|
820
822
|
);
|
|
821
823
|
if (from !== null) log(` animations beside the idle, from ${from.file} as written: ${Object.keys(from.table).join(', ') || 'none'}`);
|
|
824
|
+
log(faceAxisLine(rig.faceAxis));
|
|
822
825
|
log(
|
|
823
826
|
rig.idleKeys === 'ctl'
|
|
824
827
|
? ` idle keys ctl: ${rig.meshKeyed.length} mesh-driving bone(s) keyed by the idle, each keyed through a same-origin <bone>_ctl parent`
|
|
@@ -941,6 +944,33 @@ export function barsSummary(r: CheckReport): string {
|
|
|
941
944
|
return `${measured.length} of ${BARS.length} bar(s) measured, ${skipped.length} skipped${skipped.length === 0 ? '' : ` (${skipped.map((s) => s.bar).join(', ')})`}`;
|
|
942
945
|
}
|
|
943
946
|
|
|
947
|
+
/**
|
|
948
|
+
* The rig stage's face-axis line (issue #239): the eye line's angle, the parts it runs between and the face bones
|
|
949
|
+
* turned to it, or why there is no line and the face bones keep the page's axes — the note a one-eyed painting gets.
|
|
950
|
+
* With a line it also names the `invariants.axes` entry written for rigc's A51, its tolerance, or why none was; and
|
|
951
|
+
* the entry holding the face bones whose own tip lies along the line, or each such bone with why it is not held
|
|
952
|
+
* (issue #258) — a face with no entry is never silent.
|
|
953
|
+
*/
|
|
954
|
+
export function faceAxisLine(f: FaceAxis): string {
|
|
955
|
+
const own = f.ownTip.length === 0 ? '' : `; ${f.ownTip.join(', ')} ${f.ownTip.length === 1 ? 'takes its' : 'take their'} own tip`;
|
|
956
|
+
if (f.degrees === null) return ` face axis: none — ${f.none}; ${f.ownTip.length === 0 ? 'the face bones keep' : 'the other face bones keep'} the page's axes${own}`;
|
|
957
|
+
const turned = f.turned.length === 0 ? 'no face bone to turn' : `${f.turned.join(', ')} turned to it`;
|
|
958
|
+
const held =
|
|
959
|
+
f.axis !== null
|
|
960
|
+
? `; invariants.axes "${f.axis.name}" holds ${f.axis.bones.join(', ')} to the line through ${f.axis.through.join(' -> ')}, tolerance ${f.axis.tolerance} degrees (rigc's A51)`
|
|
961
|
+
: f.noAxis !== null
|
|
962
|
+
? `; no invariants.axes entry: ${f.noAxis}`
|
|
963
|
+
: '';
|
|
964
|
+
// Issue #258: the tipped face bones are held in an entry of their own, or each is named with why it is not.
|
|
965
|
+
const tips =
|
|
966
|
+
f.noAxis !== null && f.turned.length === 0
|
|
967
|
+
? ''
|
|
968
|
+
: `${f.tipAxis === null ? '' : `; invariants.axes "${f.tipAxis.name}" holds ${f.tipAxis.bones.join(', ')}, whose own tips lie along the line within whole-pixel rounding, tolerance ${f.tipAxis.tolerance} degrees (rigc's A51)`}${f.tipOff.map((t) => `; ${t}`).join('')}`;
|
|
969
|
+
// Issue #250: the slotless bone carrying the face's up axis under head, or why there is none.
|
|
970
|
+
const up = f.headUp !== null ? `; head_up under head along the face's up axis, ${f.headUp.rotation} degrees, ${f.headUp.length} px (head's origin to the eye midpoint along it)` : f.headUpNone !== null ? `; no head_up: ${f.headUpNone}` : '';
|
|
971
|
+
return ` face axis: ${pyRound(f.degrees, 6)} degrees, the line through the eye bones eye_r and eye_l (on ${f.parts[0]} and ${f.parts[1]}); ${turned}${own}${held}${tips}${up}`;
|
|
972
|
+
}
|
|
973
|
+
|
|
944
974
|
/** A judgement line as the console prints it: its name, its status, then its figures and bars as check.json holds them (a SKIP prints its reason). */
|
|
945
975
|
export function judgementLine(name: string, line: JudgementLine | ReportedLine): string {
|
|
946
976
|
if (line.status === 'SKIP') return `${name}: SKIP — ${String(line.reason)}`;
|
|
@@ -1027,19 +1057,141 @@ export interface LoopResult {
|
|
|
1027
1057
|
error: PaletteError | null;
|
|
1028
1058
|
}
|
|
1029
1059
|
|
|
1060
|
+
/**
|
|
1061
|
+
* The idle `build --loop --lead-in <n>` renders (issue #224's follow-up):
|
|
1062
|
+
* `idle` played n + 1 times back to back, of which the loop keeps the last
|
|
1063
|
+
* period. rigc's render resets physics at frame 0, so an idle whose rig
|
|
1064
|
+
* carries a physics constraint does not end where it starts; played on, a
|
|
1065
|
+
* spring the idle drives settles into the idle's period, and the last period
|
|
1066
|
+
* of the run is that steady state.
|
|
1067
|
+
*/
|
|
1068
|
+
export const LEAD_IN_ANIMATION = 'idle_lead_in';
|
|
1069
|
+
|
|
1070
|
+
/** Which frames of a lead-in run the loop keeps: the last of `periods + 1` periods. */
|
|
1071
|
+
export interface LoopWindow {
|
|
1072
|
+
periods: number;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const LEAD_IN_ANIMATION_KEYS = ['duration', 'loop', 'note', 'tracks'];
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* `motion` with {@link LEAD_IN_ANIMATION} added: every idle track's keys
|
|
1079
|
+
* repeated `periods + 1` times, each copy shifted by one idle duration, its
|
|
1080
|
+
* Bezier `curve` times with it, and the key at the end of a copy dropped for
|
|
1081
|
+
* the next copy's first key at the same time. Refused by name
|
|
1082
|
+
* (`LOOP_LEAD_IN_IDLE`), every problem at once, when the motion has no idle,
|
|
1083
|
+
* already has an animation of that name, or the idle is not a loop this can
|
|
1084
|
+
* repeat without inventing a value: a field besides duration, loop, note and
|
|
1085
|
+
* tracks; a track whose keys do not start at 0 and end at the duration; or one
|
|
1086
|
+
* whose last value is not its first.
|
|
1087
|
+
*/
|
|
1088
|
+
export function repeatIdle(motion: Record<string, unknown>, periods: number): Record<string, unknown> {
|
|
1089
|
+
const problems: Problem[] = [];
|
|
1090
|
+
const fail = (object: string, detail: string): void => {
|
|
1091
|
+
problems.push({ code: 'LOOP_LEAD_IN_IDLE', object, detail });
|
|
1092
|
+
};
|
|
1093
|
+
const anims = motion.animations;
|
|
1094
|
+
const table = typeof anims === 'object' && anims !== null && !Array.isArray(anims) ? (anims as Record<string, unknown>) : null;
|
|
1095
|
+
const idle = table?.idle;
|
|
1096
|
+
if (table === null || typeof idle !== 'object' || idle === null || Array.isArray(idle)) {
|
|
1097
|
+
fail('motion.json animations.idle', 'is absent; the lead-in repeats the idle');
|
|
1098
|
+
refuseIfAny(problems);
|
|
1099
|
+
}
|
|
1100
|
+
const t = table as Record<string, unknown>;
|
|
1101
|
+
const a = idle as Record<string, unknown>;
|
|
1102
|
+
if (LEAD_IN_ANIMATION in t) fail(`motion.json animations.${LEAD_IN_ANIMATION}`, 'exists; the lead-in writes an animation of that name and never replaces one');
|
|
1103
|
+
for (const k of Object.keys(a)) if (!LEAD_IN_ANIMATION_KEYS.includes(k)) fail(`motion.json animations.idle.${k}`, `is a field the lead-in does not repeat; the idle it repeats holds ${LEAD_IN_ANIMATION_KEYS.join(', ')}`);
|
|
1104
|
+
const d = a.duration;
|
|
1105
|
+
if (typeof d !== 'number' || !(d > 0)) fail('motion.json animations.idle.duration', `is ${JSON.stringify(d)}; a positive number is required`);
|
|
1106
|
+
const tracks = Array.isArray(a.tracks) ? a.tracks : [];
|
|
1107
|
+
const duration = d as number;
|
|
1108
|
+
const out: unknown[] = [];
|
|
1109
|
+
tracks.forEach((track, i) => {
|
|
1110
|
+
const tr = track as Record<string, unknown>;
|
|
1111
|
+
const keys = Array.isArray(tr.keys) ? (tr.keys as Array<Record<string, unknown>>) : [];
|
|
1112
|
+
const at = `motion.json animations.idle.tracks[${i}]`;
|
|
1113
|
+
const first = keys[0];
|
|
1114
|
+
const last = keys[keys.length - 1];
|
|
1115
|
+
if (first === undefined || first.t !== 0 || last.t !== duration) {
|
|
1116
|
+
fail(at, `keys run from ${String(first?.t)} to ${String(last?.t)}; a track the lead-in repeats starts at 0 and ends at the duration ${duration}`);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
if (JSON.stringify(first.v) !== JSON.stringify(last.v)) {
|
|
1120
|
+
fail(at, `ends at ${JSON.stringify(last.v)} and starts at ${JSON.stringify(first.v)}; repeated, the idle would jump where one copy meets the next`);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const repeated: Array<Record<string, unknown>> = [];
|
|
1124
|
+
for (let r = 0; r <= periods; r++) {
|
|
1125
|
+
const shift = r * duration;
|
|
1126
|
+
keys.forEach((k, j) => {
|
|
1127
|
+
if (r < periods && j === keys.length - 1) return;
|
|
1128
|
+
const moved: Record<string, unknown> = { ...k, t: (k.t as number) + shift };
|
|
1129
|
+
if (Array.isArray(k.curve)) moved.curve = (k.curve as number[]).map((c, n) => (n % 2 === 0 ? c + shift : c));
|
|
1130
|
+
repeated.push(moved);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
out.push({ ...tr, keys: repeated });
|
|
1134
|
+
});
|
|
1135
|
+
refuseIfAny(problems);
|
|
1136
|
+
return { ...motion, animations: { ...t, [LEAD_IN_ANIMATION]: { duration: duration * (periods + 1), loop: false, tracks: out } } };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Render {@link LEAD_IN_ANIMATION} of the rig in `rigDir` as `check` renders
|
|
1141
|
+
* the idle (the packed build, `IDLE_FPS`, `IDLE_MAX_PX`), every other
|
|
1142
|
+
* animation kept so the viewport is fitted as for the idle frames. Writes
|
|
1143
|
+
* under `dir` and returns the frame set's directory.
|
|
1144
|
+
*/
|
|
1145
|
+
export function leadInFrames(rigDir: string, dir: string, periods: number, rigc: RigcRunner, mode: PackMode): string {
|
|
1146
|
+
const rig = JSON.parse(readFileSync(join(rigDir, 'rig.json'), 'utf8')) as Record<string, unknown>;
|
|
1147
|
+
const motion = repeatIdle(JSON.parse(readFileSync(join(rigDir, 'motion.json'), 'utf8')) as Record<string, unknown>, periods);
|
|
1148
|
+
const images = typeof rig.images === 'string' ? resolve(rigDir, rig.images) : rig.images;
|
|
1149
|
+
mkdirSync(dir, { recursive: true });
|
|
1150
|
+
writeFileSync(join(dir, 'rig.json'), JSON.stringify({ ...rig, images }));
|
|
1151
|
+
writeFileSync(join(dir, 'motion.json'), JSON.stringify(motion));
|
|
1152
|
+
const what = `the idle played ${periods + 1} times (${LEAD_IN_ANIMATION})`;
|
|
1153
|
+
const b = rigc(['build', '--rig', join(dir, 'rig.json'), '--motion', join(dir, 'motion.json'), '--out', join(dir, 'build'), ...packedBuildArgs(mode)]);
|
|
1154
|
+
if (b.status !== 0) refuseIfAny(rigcFailed(`${packedBuildLabel(mode)} (${what})`, b, buildGateLines(b.out)));
|
|
1155
|
+
const r = rigc(['render', '--candidate', join(dir, 'build'), '--animation', LEAD_IN_ANIMATION, '--fps', String(IDLE_FPS), '--max', String(IDLE_MAX_PX), '--out', join(dir, 'frames')]);
|
|
1156
|
+
if (r.status !== 0) refuseIfAny(rigcFailed(`render --animation ${LEAD_IN_ANIMATION} (${what})`, r, r.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1157
|
+
return join(dir, 'frames');
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/** Pixels whose largest RGB channel difference between `a` and `b` is above `level`. */
|
|
1161
|
+
function pixelsOver(a: Raster, b: Raster, level: number): number {
|
|
1162
|
+
let n = 0;
|
|
1163
|
+
for (let p = 0; p < a.width * a.height; p++) {
|
|
1164
|
+
let m = 0;
|
|
1165
|
+
for (let c = 0; c < 3; c++) m = Math.max(m, Math.abs(a.data[p * 4 + c] - b.data[p * 4 + c]));
|
|
1166
|
+
if (m > level) n++;
|
|
1167
|
+
}
|
|
1168
|
+
return n;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1030
1171
|
/**
|
|
1031
1172
|
* Encode a rigc frame set as a looping APNG, indexed APNG or GIF and write it.
|
|
1032
1173
|
* An encoder refusal is a PartsError coded `LOOP_ENCODE`, so it prints like
|
|
1033
1174
|
* every other.
|
|
1034
1175
|
*/
|
|
1035
|
-
export function loopStage(frames: string, out: string, format: LoopFormat, log: Log): LoopResult {
|
|
1176
|
+
export function loopStage(frames: string, out: string, format: LoopFormat, log: Log, window?: LoopWindow): LoopResult {
|
|
1036
1177
|
const set = readFrameSet(frames);
|
|
1037
1178
|
const images = set.frames.map((fr) => fr.image);
|
|
1038
1179
|
log(`rig-parts loop: ${set.dir} -> ${out}`);
|
|
1039
1180
|
log(` ${images.length} frame(s) ${images[0]?.width ?? 0}x${images[0]?.height ?? 0} at ${set.fps} fps (from frames.json), animation ${set.animation ?? '(none)'}`);
|
|
1040
1181
|
let used = images;
|
|
1041
1182
|
const lastName = set.frames[set.frames.length - 1]?.name ?? '';
|
|
1042
|
-
if (
|
|
1183
|
+
if (window !== undefined) {
|
|
1184
|
+
const per = (images.length - 1) / (window.periods + 1);
|
|
1185
|
+
if (!Number.isInteger(per) || per < 1) {
|
|
1186
|
+
throw new PartsError([{ code: 'LOOP_LEAD_IN_IDLE', object: set.dir, detail: `holds ${images.length} frame(s), which is not ${window.periods + 1} whole period(s) and the end frame; the lead-in keeps the last period` }]);
|
|
1187
|
+
}
|
|
1188
|
+
const start = window.periods * per;
|
|
1189
|
+
const end = start + per;
|
|
1190
|
+
const jump = maxRgbDiff(images[start], images[end]);
|
|
1191
|
+
used = images.slice(start, end);
|
|
1192
|
+
const name = (i: number): string => set.frames[i].name.replace('.png', '');
|
|
1193
|
+
log(` lead-in: ${window.periods} period(s) of the idle played ahead, physics running; ${name(start)}–${name(end - 1)} kept (${per} frame(s), the last period); ${name(end)}, one period after ${name(start)}, differs from it by max |d| ${jump.max}, ${pixelsOver(images[start], images[end], 40)} px over 40 — the jump at the wrap`);
|
|
1194
|
+
} else if (images.length > 1 && sameRgba(images[0].data, images[images.length - 1].data)) {
|
|
1043
1195
|
used = images.slice(0, -1);
|
|
1044
1196
|
log(` ${lastName} equals f0000.png byte for byte, so it is dropped: the loop wraps onto frame 0, and showing it twice would hold that pose for two ticks`);
|
|
1045
1197
|
} else if (images.length > 1) {
|
|
@@ -1103,6 +1255,12 @@ export interface BuildInput {
|
|
|
1103
1255
|
requirements?: string;
|
|
1104
1256
|
/** `--idle-keys`, forwarded to the rig stage as `rig` takes it (issue #95); {@link DEFAULT_IDLE_KEYS} when absent. */
|
|
1105
1257
|
idleKeys?: IdleKeys;
|
|
1258
|
+
/**
|
|
1259
|
+
* `--lead-in <n>`, with `loop` only: the loop is encoded from the last of n + 1
|
|
1260
|
+
* idle periods played back to back ({@link leadInFrames}) instead of from
|
|
1261
|
+
* `check/idle_frames/`; absent, the loop is as before.
|
|
1262
|
+
*/
|
|
1263
|
+
leadIn?: number;
|
|
1106
1264
|
}
|
|
1107
1265
|
|
|
1108
1266
|
/**
|
|
@@ -1116,7 +1274,7 @@ export interface BuildRunners {
|
|
|
1116
1274
|
check: RigcRunner;
|
|
1117
1275
|
/** Where the check's rigc binary is, for its header line. */
|
|
1118
1276
|
checkBin: string;
|
|
1119
|
-
/** A directory the rig stage may stage its gate in; emptied before and after. */
|
|
1277
|
+
/** A directory the rig stage may stage its gate in, and the loop its lead-in render after it; emptied before and after each. */
|
|
1120
1278
|
scratch: string;
|
|
1121
1279
|
}
|
|
1122
1280
|
|
|
@@ -1178,7 +1336,7 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
1178
1336
|
for (const p of BUILD_OWNS) rmSync(join(out, p), { recursive: true, force: true });
|
|
1179
1337
|
// The idle-keys value is named only when it is not the default, so a build without the flag, or with the default, prints the line it printed before issue #95.
|
|
1180
1338
|
const idleKeys = input.idleKeys ?? DEFAULT_IDLE_KEYS;
|
|
1181
|
-
log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ?
|
|
1339
|
+
log(`rig-parts build: ${input.config} -> ${out}, seam rule ${input.seam}, projection rule ${input.project}, page edges ${input.pageEdges}, pack shape ${input.packShape ?? DEFAULT_PACK_SHAPE}${idleKeys === DEFAULT_IDLE_KEYS ? '' : `, idle keys ${idleKeys}`}${input.loop ? `, with the idle loop${input.leadIn === undefined ? '' : ` after a lead-in of ${input.leadIn} period(s)`}` : ''}`);
|
|
1182
1340
|
|
|
1183
1341
|
try {
|
|
1184
1342
|
// build runs rig next, which needs the whole config, so the full loader is
|
|
@@ -1215,12 +1373,15 @@ export function build(input: BuildInput, run: BuildRunners, log: Log): BuildResu
|
|
|
1215
1373
|
}
|
|
1216
1374
|
|
|
1217
1375
|
if (input.loop) {
|
|
1218
|
-
const frames = join(out, 'check', 'idle_frames');
|
|
1219
1376
|
try {
|
|
1220
1377
|
const say = prefixed('loop');
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
const
|
|
1378
|
+
const window = input.leadIn === undefined ? undefined : { periods: input.leadIn };
|
|
1379
|
+
if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
|
|
1380
|
+
const frames = window === undefined ? join(out, 'check', 'idle_frames') : leadInFrames(join(out, 'rig'), run.scratch, window.periods, run.check, { pageEdges: input.pageEdges, packShape: input.packShape ?? DEFAULT_PACK_SHAPE });
|
|
1381
|
+
const lossless = loopStage(frames, join(out, 'idle.png'), 'apng', say, window);
|
|
1382
|
+
const indexed = loopStage(frames, join(out, 'idle-indexed.png'), 'indexed', say, window);
|
|
1383
|
+
const gif = loopStage(frames, join(out, 'idle.gif'), 'gif', say, window);
|
|
1384
|
+
if (window !== undefined) rmSync(run.scratch, { recursive: true, force: true });
|
|
1224
1385
|
const fig = (e: PaletteError | null): string => (e === null ? 'lossless' : `max ${e.max}, mean ${e.mean.toFixed(3)}`);
|
|
1225
1386
|
say(`loop: idle.png ${lossless.bytes} B (${fig(lossless.error)}); idle-indexed.png ${indexed.bytes} B (${fig(indexed.error)}); idle.gif ${gif.bytes} B (${fig(gif.error)}) — palette error per channel over all frames`);
|
|
1226
1387
|
} catch (err) {
|
package/src/check.ts
CHANGED
|
@@ -68,14 +68,15 @@
|
|
|
68
68
|
* was that for every rig it ever saw); the grey is read from rigc's
|
|
69
69
|
* `frames.json` rather than typed; the crop-to-world map comes from the rig's
|
|
70
70
|
* own stage box through `src/coords.ts` rather than an open-coded `-W/2`; and
|
|
71
|
-
* the last idle frame is refused unless it sits at t = duration
|
|
71
|
+
* the last idle frame is refused unless it sits at t = duration (within
|
|
72
|
+
* `TIME_SLACK`, issue #244).
|
|
72
73
|
*/
|
|
73
74
|
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
74
75
|
import { basename, delimiter, dirname, join, resolve } from 'node:path';
|
|
75
76
|
import { measureRecomposite, type RecompositeFigures, recompositeRecord, sourceInRig } from './assemble.ts';
|
|
76
77
|
import { cropToSpineY } from './coords.ts';
|
|
77
78
|
import { PartsError, type Problem, refuseIfAny } from './errors.ts';
|
|
78
|
-
import { framesInside, IDLE_FPS } from './motion.ts';
|
|
79
|
+
import { framesInside, IDLE_FPS, TIME_SLACK } from './motion.ts';
|
|
79
80
|
import {
|
|
80
81
|
blinkFigures,
|
|
81
82
|
type GeometryPose,
|
|
@@ -1816,12 +1817,14 @@ export function runCheck(rigDir: string, outDir: string, rigc: RigcRunner, parts
|
|
|
1816
1817
|
if (render.status !== 0) refuseIfAny(rigcFailed('render --animation idle --geometry', render, render.out.split('\n').filter((l) => l.includes('FAIL') || l.includes('rigc:'))));
|
|
1817
1818
|
idle = readFrameSet(idleDir);
|
|
1818
1819
|
const last = idle.frames[idle.frames.length - 1];
|
|
1819
|
-
|
|
1820
|
+
// Issue #244: the last frame's time and the duration are compared within TIME_SLACK, not with `!==` — a whole
|
|
1821
|
+
// number of frames (40/12 s) is not exact in binary, and rigc writes its last frame at 40 * (1/12), one ulp off.
|
|
1822
|
+
if (idle.stride !== 1 || idle.written !== idle.sampled || Math.abs(idle.duration - inp.idleDuration) > TIME_SLACK || last.index !== idle.sampled - 1) {
|
|
1820
1823
|
refuseIfAny([
|
|
1821
1824
|
{
|
|
1822
1825
|
code: 'CHECK_LOOP_LAST_FRAME_AT_DURATION',
|
|
1823
1826
|
object: `idle frame ${last.name}`,
|
|
1824
|
-
detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks and every frame written are required`,
|
|
1827
|
+
detail: `rigc sampled ${idle.sampled} frame(s), wrote ${idle.written} at stride ${idle.stride}, the last at t = ${idle.duration}s; the loop compares frame 0 with the frame at the idle's duration, ${inp.idleDuration}s, so a duration that is a whole number of 1/${IDLE_FPS} s ticks (within ${TIME_SLACK} s) and every frame written are required`,
|
|
1825
1828
|
},
|
|
1826
1829
|
]);
|
|
1827
1830
|
}
|