@melite/formats 0.0.0-stage → 0.1.4-alpha
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/LICENSE +557 -0
- package/lib/b64.d.ts +2 -0
- package/lib/b64.js +7 -0
- package/lib/bodyPresets.d.ts +40 -0
- package/lib/bodyPresets.js +77 -0
- package/lib/capabilityCards.d.ts +54 -0
- package/lib/capabilityCards.js +127 -0
- package/lib/captionSplitter.d.ts +49 -0
- package/lib/captionSplitter.js +172 -0
- package/lib/cardMutations.d.ts +52 -0
- package/lib/cardMutations.js +67 -0
- package/lib/downloads.d.ts +18 -0
- package/lib/downloads.js +64 -0
- package/lib/extractVideoFrame.d.ts +23 -0
- package/lib/extractVideoFrame.js +114 -0
- package/lib/glbLoad.d.ts +54 -0
- package/lib/glbLoad.js +96 -0
- package/lib/glbSkin.d.ts +14 -0
- package/lib/glbSkin.js +124 -0
- package/lib/handPose.d.ts +46 -0
- package/lib/handPose.js +122 -0
- package/lib/index.d.ts +26 -0
- package/lib/index.js +26 -0
- package/lib/keyframeExtract.d.ts +46 -0
- package/lib/keyframeExtract.js +89 -0
- package/lib/maskPrep.d.ts +88 -0
- package/lib/maskPrep.js +386 -0
- package/lib/motionConstraints.d.ts +237 -0
- package/lib/motionConstraints.js +369 -0
- package/lib/motionEnvelope.d.ts +56 -0
- package/lib/motionEnvelope.js +59 -0
- package/lib/motionNpz.d.ts +120 -0
- package/lib/motionNpz.js +497 -0
- package/lib/npz.d.ts +24 -0
- package/lib/npz.js +158 -0
- package/lib/openPoseCanvas.d.ts +17 -0
- package/lib/openPoseCanvas.js +90 -0
- package/lib/pngMeta.d.ts +42 -0
- package/lib/pngMeta.js +212 -0
- package/lib/pngMetaInfo.d.ts +14 -0
- package/lib/pngMetaInfo.js +127 -0
- package/lib/poseCodec.d.ts +20 -0
- package/lib/poseCodec.js +61 -0
- package/lib/poseTransforms.d.ts +73 -0
- package/lib/poseTransforms.js +237 -0
- package/lib/poserSkin.d.ts +75 -0
- package/lib/poserSkin.js +126 -0
- package/lib/prompt-tags.json +132 -0
- package/lib/promptTags.d.ts +42 -0
- package/lib/promptTags.js +116 -0
- package/lib/psdExport.d.ts +28 -0
- package/lib/psdExport.js +272 -0
- package/lib/readFile.d.ts +9 -0
- package/lib/readFile.js +16 -0
- package/lib/renderSnapshot.d.ts +34 -0
- package/lib/renderSnapshot.js +55 -0
- package/lib/somaSkin.d.ts +82 -0
- package/lib/somaSkin.js +267 -0
- package/lib/textToPose.d.ts +388 -0
- package/lib/textToPose.js +604 -0
- package/lib/uploadToEstate.d.ts +25 -0
- package/lib/uploadToEstate.js +97 -0
- package/package.json +148 -4
- package/README.md +0 -3
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Anny's 11 phenotype dims in order:
|
|
2
|
+
// [gender, age, muscle, weight, height, proportions, cupsize, firmness,
|
|
3
|
+
// african, asian, caucasian]
|
|
4
|
+
// gender: 0.0 = masculine, 1.0 = feminine (verified against the live mesh:
|
|
5
|
+
// gender=0 widens jaw and shoulders; gender=1 narrows them and widens hips).
|
|
6
|
+
// age/muscle/weight: 0.5 = average adult. height/proportions: 0.5 = standard.
|
|
7
|
+
// race dims are left at neutral 0.5 so race-specific features stay default.
|
|
8
|
+
const NEUTRAL_RACE = [0.5, 0.5, 0.5]; // african, asian, caucasian
|
|
9
|
+
/**
|
|
10
|
+
* Neutral - reset to the Anny default with no stylization.
|
|
11
|
+
*
|
|
12
|
+
* Provides a quick "back to neutral" affordance in the preset bar without
|
|
13
|
+
* the user having to hit the Reset button. Coeffs all 0.5 = Anny's neutral
|
|
14
|
+
* adult, no local changes, no custom target blends.
|
|
15
|
+
*/
|
|
16
|
+
const NEUTRAL = {
|
|
17
|
+
id: "neutral",
|
|
18
|
+
name: "Neutral",
|
|
19
|
+
description: "Default Anny - no stylization, no morphs",
|
|
20
|
+
identityModel: "anny",
|
|
21
|
+
coeffs: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, ...NEUTRAL_RACE],
|
|
22
|
+
localChanges: null,
|
|
23
|
+
customTargets: null,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Male - basic masculine baseline with no stylization.
|
|
27
|
+
*
|
|
28
|
+
* Gender 0.0 (fully masculine → wider jaw, broader shoulders, narrower
|
|
29
|
+
* hips). Age/muscle/weight tuned to a healthy adult male build. No
|
|
30
|
+
* local_changes — this is a clean starting point the user can sculpt
|
|
31
|
+
* further. Height slightly above average.
|
|
32
|
+
*/
|
|
33
|
+
const MALE = {
|
|
34
|
+
id: "male",
|
|
35
|
+
name: "Male",
|
|
36
|
+
description: "Basic masculine baseline - no stylization",
|
|
37
|
+
identityModel: "anny",
|
|
38
|
+
coeffs: [0.0, 0.45, 0.55, 0.50, 0.55, 0.50, 0.40, 0.55, ...NEUTRAL_RACE],
|
|
39
|
+
// gen age mus wt ht prop cup firm
|
|
40
|
+
localChanges: null,
|
|
41
|
+
customTargets: null,
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Female - basic feminine baseline with no stylization.
|
|
45
|
+
*
|
|
46
|
+
* Gender 1.0 (fully feminine → narrower jaw, defined waist, wider hips).
|
|
47
|
+
* Age/muscle/weight tuned to a healthy adult female build. No
|
|
48
|
+
* local_changes — clean starting point for further sculpting.
|
|
49
|
+
*/
|
|
50
|
+
const FEMALE = {
|
|
51
|
+
id: "female",
|
|
52
|
+
name: "Female",
|
|
53
|
+
description: "Basic feminine baseline - no stylization",
|
|
54
|
+
identityModel: "anny",
|
|
55
|
+
coeffs: [1.0, 0.40, 0.35, 0.38, 0.52, 0.52, 0.45, 0.55, ...NEUTRAL_RACE],
|
|
56
|
+
// gen age mus wt ht prop cup firm
|
|
57
|
+
localChanges: null,
|
|
58
|
+
customTargets: null,
|
|
59
|
+
};
|
|
60
|
+
export const BODY_PRESETS = [
|
|
61
|
+
NEUTRAL, MALE, FEMALE,
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* Find a preset by id (returns undefined if not found).
|
|
65
|
+
*
|
|
66
|
+
* Used by tests and any future "load preset by URL hash" affordances.
|
|
67
|
+
*/
|
|
68
|
+
export function getPresetById(id) {
|
|
69
|
+
return BODY_PRESETS.find((p) => p.id === id);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Default preset (the one applied to the very first Body Studio mount).
|
|
73
|
+
* Returns null so we DON'T clobber the user's saved edits on subsequent
|
|
74
|
+
* loads - the default is the un-morphed Anny.
|
|
75
|
+
*/
|
|
76
|
+
export const DEFAULT_PRESET_ID = null;
|
|
77
|
+
//# sourceMappingURL=bodyPresets.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type CapabilityEntry, type CapabilityRoute, type Catalog, type FamilyEntry } from "@melite/catalog";
|
|
2
|
+
import type { CardGenre } from "@melite/contracts";
|
|
3
|
+
/** A card ready to land on the canvas (what onPick hands the store).
|
|
4
|
+
*
|
|
5
|
+
* ``kind`` is optional — capabilities/families always produce
|
|
6
|
+
* ``"generation"`` (the default), so the existing helpers don't set it
|
|
7
|
+
* and the canvas defaults to "generation". The Input / Media Prep tile
|
|
8
|
+
* sets ``kind: "input"`` so the canvas routes the stage through the
|
|
9
|
+
* doc-driven input family (POST /v1/flows/start, 1-step inline
|
|
10
|
+
* doc) — NOT the generation wire. This is rule 4: the shape carries
|
|
11
|
+
* the kind, so pick → addStage never silently misroutes. */
|
|
12
|
+
export interface PickedCard {
|
|
13
|
+
service: string;
|
|
14
|
+
title: string;
|
|
15
|
+
genre?: CardGenre;
|
|
16
|
+
params: Record<string, unknown>;
|
|
17
|
+
kind?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Seed params for a route: the backend-computed defaults + an empty prompt
|
|
21
|
+
* ONLY when the model's form actually serves a prompt field
|
|
22
|
+
* (prompt_in_form — backend-computed from the builder schema) + a null
|
|
23
|
+
* seed for EVERY input slot the model declares (input_slots — multi-slot
|
|
24
|
+
* forms like edit's three images / video's two frames get one null per
|
|
25
|
+
* slot, so the card renders all its squares from the start). Unknown
|
|
26
|
+
* prompt_in_form → seed (old behavior).
|
|
27
|
+
*/
|
|
28
|
+
export declare function seededParams(cat: Catalog | null, route: string, base?: Record<string, unknown>): Record<string, unknown>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolve a capability tile (+ optional route override) to a card. The
|
|
31
|
+
* default tile click uses cap.resolved; the tile's model switcher passes a
|
|
32
|
+
* different CapabilityRoute — same capability intent, model switched
|
|
33
|
+
* underneath. The route's params are backend-precomputed (capability
|
|
34
|
+
* default_params as fallback, model's own defaults winning), so switching
|
|
35
|
+
* the model correctly swaps the per-model recipe (steps / cfg / sampler).
|
|
36
|
+
*/
|
|
37
|
+
export declare function capabilityToCard(cat: Catalog | null, cap: CapabilityEntry, route: CapabilityRoute): PickedCard;
|
|
38
|
+
/** Resolve a specific family entry (the "All models" list) to a card. */
|
|
39
|
+
export declare function familyEntryToCard(cat: Catalog | null, familyId: string, fam: FamilyEntry): PickedCard;
|
|
40
|
+
export declare const CAPABILITY_DRAG_MIME = "application/tech-noir-capability";
|
|
41
|
+
/** What a capability tile writes to the drag data store. Carries the fully
|
|
42
|
+
* resolved card (service + title + params + kind) so the drop target can
|
|
43
|
+
* apply it WITHOUT a catalog lookup — the sidebar already paid that cost
|
|
44
|
+
* via capabilityToCard. ``capId`` is diagnostics-only (not read by the
|
|
45
|
+
* drop target; useful for e2e traces). */
|
|
46
|
+
export type CapabilityDropPayload = PickedCard & {
|
|
47
|
+
capId?: string;
|
|
48
|
+
};
|
|
49
|
+
/** Serialize a picked card onto the drag data store. */
|
|
50
|
+
export declare function capabilityDragPayload(capId: string, card: PickedCard): string;
|
|
51
|
+
/** Parse the capability MIME off a drop event. null = absent or malformed
|
|
52
|
+
* (the drop target falls through to the asset/file path). */
|
|
53
|
+
export declare function parseCapabilityDrop(raw: string): CapabilityDropPayload | null;
|
|
54
|
+
//# sourceMappingURL=capabilityCards.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// capabilityCards — the ONE capability→card derivation path.
|
|
2
|
+
//
|
|
3
|
+
// A capability tile (Generate / Edit / Upscale / ...) resolves to a card
|
|
4
|
+
// descriptor: the backend-precomputed default model route + merged params
|
|
5
|
+
// (capabilities.<id>.resolved). This module is the single implementation —
|
|
6
|
+
// the ServicePicker dialog AND the Library's right sidebar (LibrarySidebar)
|
|
7
|
+
// both derive their "what lands on the canvas" from these helpers, so a
|
|
8
|
+
// catalog or schema change cannot make the two surfaces drift.
|
|
9
|
+
//
|
|
10
|
+
|
|
11
|
+
// • an empty prompt is seeded ONLY when the model's form actually serves
|
|
12
|
+
// a prompt field (prompt_in_form — backend-computed from the builder
|
|
13
|
+
// schema); a prompt-less card (upscale) must never carry one
|
|
14
|
+
// • a null input slot is seeded when the model's schema has an input
|
|
15
|
+
// (the card modal renders the attach control for it)
|
|
16
|
+
import { familyGenre, narrowGenre } from "@melite/catalog";
|
|
17
|
+
/**
|
|
18
|
+
* Seed params for a route: the backend-computed defaults + an empty prompt
|
|
19
|
+
* ONLY when the model's form actually serves a prompt field
|
|
20
|
+
* (prompt_in_form — backend-computed from the builder schema) + a null
|
|
21
|
+
* seed for EVERY input slot the model declares (input_slots — multi-slot
|
|
22
|
+
* forms like edit's three images / video's two frames get one null per
|
|
23
|
+
* slot, so the card renders all its squares from the start). Unknown
|
|
24
|
+
* prompt_in_form → seed (old behavior).
|
|
25
|
+
*/
|
|
26
|
+
export function seededParams(cat, route, base = {}) {
|
|
27
|
+
const model = cat?.models[route];
|
|
28
|
+
const slots = model?.input_slots?.length
|
|
29
|
+
? model.input_slots
|
|
30
|
+
: (model?.input ? [model.input] : []);
|
|
31
|
+
const hasPrompt = model?.prompt_in_form ?? true; // unknown → seed (old behavior)
|
|
32
|
+
return {
|
|
33
|
+
...base,
|
|
34
|
+
...(hasPrompt ? { prompt: "" } : {}),
|
|
35
|
+
...(slots.length ? Object.fromEntries(slots.map((k) => [k, null])) : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a capability tile (+ optional route override) to a card. The
|
|
40
|
+
* default tile click uses cap.resolved; the tile's model switcher passes a
|
|
41
|
+
* different CapabilityRoute — same capability intent, model switched
|
|
42
|
+
* underneath. The route's params are backend-precomputed (capability
|
|
43
|
+
* default_params as fallback, model's own defaults winning), so switching
|
|
44
|
+
* the model correctly swaps the per-model recipe (steps / cfg / sampler).
|
|
45
|
+
*/
|
|
46
|
+
export function capabilityToCard(cat, cap, route) {
|
|
47
|
+
// Model-less capabilities (kind:"input") carry their params VERBATIM — no
|
|
48
|
+
// prompt/slot seeding. Those are generation-form concerns (prompt_in_form,
|
|
49
|
+
// input_slots); input's editor is the fixed-field InputCardEditor, which
|
|
50
|
+
// owns crop/scale/trim/volume/mask. Seeding a prompt here would give the
|
|
51
|
+
|
|
52
|
+
// regression, generalized). The kind rides through to PickedCard so the
|
|
53
|
+
// canvas routes the stage through executeInput, not executeGeneration.
|
|
54
|
+
const isModelLess = cap.kind !== undefined && cap.kind !== "generation";
|
|
55
|
+
return {
|
|
56
|
+
service: route.model,
|
|
57
|
+
title: cap.label,
|
|
58
|
+
genre: genreForRoute(cat, route.model),
|
|
59
|
+
params: isModelLess ? { ...route.params } : seededParams(cat, route.model, route.params),
|
|
60
|
+
...(isModelLess ? { kind: cap.kind } : {}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Resolve a specific family entry (the "All models" list) to a card. */
|
|
64
|
+
export function familyEntryToCard(cat, familyId, fam) {
|
|
65
|
+
// Route resolution — the SAME chain the backend's capability resolver
|
|
66
|
+
// and familiesForForm use: the catalog-declared ``default_model`` (when
|
|
67
|
+
// it is a member), else the member whose key equals the family id, else
|
|
68
|
+
// the FIRST member. NEVER the bare familyId: mask_studio's family id
|
|
69
|
+
// and model key differ (mask_studio vs mask-studio), and a family-id
|
|
70
|
+
// service is a PHANTOM ROUTE — no models[] entry, no familyGenre, and
|
|
71
|
+
// no studio registry match (the "mask studio does not appear" bug,
|
|
72
|
+
|
|
73
|
+
// card zoomed as a plain form card). The catalog now declares
|
|
74
|
+
// default_model for every id-divergent family; this chain is the
|
|
75
|
+
// backstop for the next one.
|
|
76
|
+
const declared = fam.default_model;
|
|
77
|
+
const members = Object.entries(cat?.models ?? {})
|
|
78
|
+
.filter(([, m]) => m.family === familyId)
|
|
79
|
+
.map(([k]) => k);
|
|
80
|
+
const route = (declared && members.includes(declared) ? declared : undefined) ??
|
|
81
|
+
members.find((k) => k === familyId) ??
|
|
82
|
+
members[0] ??
|
|
83
|
+
familyId;
|
|
84
|
+
return {
|
|
85
|
+
service: route,
|
|
86
|
+
title: fam.label ?? familyId,
|
|
87
|
+
genre: narrowGenre(fam.genre) ?? genreForRoute(cat, route),
|
|
88
|
+
params: seededParams(cat, route, cat?.models[route]?.defaults ?? {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Genre for a model route — the catalog's familyGenre derivation. */
|
|
92
|
+
function genreForRoute(cat, route) {
|
|
93
|
+
return cat ? familyGenre(route, cat) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A capability tile in the LibrarySidebar is DRAGGABLE onto a card in the
|
|
97
|
+
// canvas — "clicking and dragging from the right sidebar onto the middle
|
|
98
|
+
// part should populate the card". The drop REPURPOSES the target card: its
|
|
99
|
+
// service + params + title become the dragged capability's resolved card
|
|
100
|
+
// (the same shape a click would `addStage`, applied to an EXISTING stage).
|
|
101
|
+
//
|
|
102
|
+
// The MIME is deliberately distinct from `application/tech-noir-asset`
|
|
103
|
+
// (AppSidebar's asset drag) so a card's drop handler can tell "populate
|
|
104
|
+
// this card's identity" (capability) from "attach an asset to a slot"
|
|
105
|
+
// (asset) — one drop target, two contracts, no ambiguity.
|
|
106
|
+
export const CAPABILITY_DRAG_MIME = "application/tech-noir-capability";
|
|
107
|
+
/** Serialize a picked card onto the drag data store. */
|
|
108
|
+
export function capabilityDragPayload(capId, card) {
|
|
109
|
+
return JSON.stringify({ ...card, capId });
|
|
110
|
+
}
|
|
111
|
+
/** Parse the capability MIME off a drop event. null = absent or malformed
|
|
112
|
+
* (the drop target falls through to the asset/file path). */
|
|
113
|
+
export function parseCapabilityDrop(raw) {
|
|
114
|
+
if (!raw)
|
|
115
|
+
return null;
|
|
116
|
+
try {
|
|
117
|
+
const p = JSON.parse(raw);
|
|
118
|
+
if (typeof p.service === "string" && typeof p.title === "string" && p.params && typeof p.params === "object") {
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
catch { /* not our payload — the JSON probe answers absent (row 28) */
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=capabilityCards.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E3.7 — Caption text splitter.
|
|
3
|
+
*
|
|
4
|
+
* Converts a plain-text transcription into a sequence of CaptionCues with
|
|
5
|
+
* proportional timing. When the ASR backend provides word-level timestamps
|
|
6
|
+
* (via `tokens`), we use those directly. Otherwise we fall back to
|
|
7
|
+
* sentence-aware splitting with proportional duration allocation (by
|
|
8
|
+
* character count — longer sentences get proportionally more time).
|
|
9
|
+
*
|
|
10
|
+
* Caption length targets ~42 characters per cue (the industry standard
|
|
11
|
+
* for readable subtitles, per the BBC/NBC style guides). Sentences longer
|
|
12
|
+
* than that are split at clause boundaries (commas, semicolons, dashes)
|
|
13
|
+
* or at word boundaries as a last resort.
|
|
14
|
+
*
|
|
15
|
+
* Timing model: cue.start/end are SECONDS RELATIVE TO THE SEGMENT START
|
|
16
|
+
* (not the global timeline). The program monitor maps global time →
|
|
17
|
+
* segment-relative time before looking up the active cue.
|
|
18
|
+
*/
|
|
19
|
+
import type { CaptionCue } from "@melite/contracts";
|
|
20
|
+
export interface SplitArgs {
|
|
21
|
+
/** Full transcription text. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** Segment duration in seconds (the cues' timing window). */
|
|
24
|
+
duration: number;
|
|
25
|
+
/** Optional word-level timestamps from the ASR backend. When provided,
|
|
26
|
+
* cues are built from the actual word timings instead of proportional
|
|
27
|
+
* splitting. Each token: { text, start, end } in seconds. */
|
|
28
|
+
tokens?: Array<{
|
|
29
|
+
text: string;
|
|
30
|
+
start: number;
|
|
31
|
+
end: number;
|
|
32
|
+
}> | null;
|
|
33
|
+
/** Max characters per cue (default 42). */
|
|
34
|
+
maxChars?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build CaptionCues from a transcription. Returns an empty array if the
|
|
38
|
+
* text is empty/whitespace or the duration is <= 0.
|
|
39
|
+
*/
|
|
40
|
+
export declare function splitCaptions(args: SplitArgs): CaptionCue[];
|
|
41
|
+
/**
|
|
42
|
+
* Split text into caption-length chunks. Strategy (in priority order):
|
|
43
|
+
* 1. Split into sentences (terminators . ! ? …).
|
|
44
|
+
* 2. If a sentence exceeds maxChars, split at clause boundaries
|
|
45
|
+
* (, ; : — –) then at word boundaries as a last resort.
|
|
46
|
+
* 3. Merge short consecutive fragments up to maxChars.
|
|
47
|
+
*/
|
|
48
|
+
export declare function splitIntoChunks(text: string, maxChars?: number): string[];
|
|
49
|
+
//# sourceMappingURL=captionSplitter.d.ts.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
const DEFAULT_MAX_CHARS = 42;
|
|
2
|
+
/**
|
|
3
|
+
* Build CaptionCues from a transcription. Returns an empty array if the
|
|
4
|
+
* text is empty/whitespace or the duration is <= 0.
|
|
5
|
+
*/
|
|
6
|
+
export function splitCaptions(args) {
|
|
7
|
+
const { text, duration, tokens = null, maxChars = DEFAULT_MAX_CHARS } = args;
|
|
8
|
+
if (duration <= 0)
|
|
9
|
+
return [];
|
|
10
|
+
// ── Timestamped path takes precedence — tokens can build cues even
|
|
11
|
+
// with empty text (the text is derived from the tokens). ─────────────
|
|
12
|
+
if (tokens && tokens.length > 0) {
|
|
13
|
+
return buildFromTokens(tokens, maxChars, duration);
|
|
14
|
+
}
|
|
15
|
+
const trimmed = text.trim();
|
|
16
|
+
if (!trimmed)
|
|
17
|
+
return [];
|
|
18
|
+
// ── Proportional path: split text + distribute by character count ──────
|
|
19
|
+
const chunks = splitIntoChunks(trimmed, maxChars);
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if (chunks.length === 0)
|
|
24
|
+
return [];
|
|
25
|
+
const totalChars = chunks.reduce((s, c) => s + c.length, 0);
|
|
26
|
+
let elapsed = 0;
|
|
27
|
+
return chunks.map((chunk, i) => {
|
|
28
|
+
|
|
29
|
+
// chunks.length guard above), so totalChars is positive — no fallback.
|
|
30
|
+
const proportion = chunk.length / totalChars;
|
|
31
|
+
const start = elapsed;
|
|
32
|
+
const segDur = proportion * duration;
|
|
33
|
+
// Last cue gets the remaining time (avoids float drift at the end).
|
|
34
|
+
const end = i === chunks.length - 1 ? duration : Math.min(duration, start + segDur);
|
|
35
|
+
elapsed = end;
|
|
36
|
+
return {
|
|
37
|
+
id: `cap_${i}_${Math.random().toString(36).slice(2, 8)}`,
|
|
38
|
+
start: Number(start.toFixed(3)),
|
|
39
|
+
end: Number(end.toFixed(3)),
|
|
40
|
+
text: chunk,
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Split text into caption-length chunks. Strategy (in priority order):
|
|
46
|
+
* 1. Split into sentences (terminators . ! ? …).
|
|
47
|
+
* 2. If a sentence exceeds maxChars, split at clause boundaries
|
|
48
|
+
* (, ; : — –) then at word boundaries as a last resort.
|
|
49
|
+
* 3. Merge short consecutive fragments up to maxChars.
|
|
50
|
+
*/
|
|
51
|
+
export function splitIntoChunks(text, maxChars = DEFAULT_MAX_CHARS) {
|
|
52
|
+
// Normalize whitespace.
|
|
53
|
+
const clean = text.replace(/\s+/g, ' ').trim();
|
|
54
|
+
if (!clean)
|
|
55
|
+
return [];
|
|
56
|
+
// Step 1: split into sentences, keeping the terminator.
|
|
57
|
+
// The second alternative matches any trailing non-space run, so a
|
|
58
|
+
// non-empty input always yields >=1 match (the `as` is proven, not blind:
|
|
59
|
+
// `clean` is non-empty per the guard above).
|
|
60
|
+
const sentences = clean.match(/[^.!?…]+[.!?…]+|\S[^.!?…]*$/g);
|
|
61
|
+
// Step 2: break long sentences at clause / word boundaries.
|
|
62
|
+
const fragments = [];
|
|
63
|
+
for (const s of sentences) {
|
|
64
|
+
const trimmed = s.trim();
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if (!trimmed)
|
|
69
|
+
continue;
|
|
70
|
+
if (trimmed.length <= maxChars) {
|
|
71
|
+
fragments.push(trimmed);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
fragments.push(...splitLongSentence(trimmed, maxChars));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Step 3: greedily merge short fragments up to maxChars.
|
|
78
|
+
return mergeFragments(fragments, maxChars);
|
|
79
|
+
}
|
|
80
|
+
/** Split a long sentence at clause boundaries, then word boundaries. */
|
|
81
|
+
function splitLongSentence(s, maxChars) {
|
|
82
|
+
// Try clause boundaries first: , ; : — –
|
|
83
|
+
const clauseParts = s.split(/(?<=[,;:—–])\s+/).map((p) => p.trim()).filter(Boolean);
|
|
84
|
+
if (clauseParts.length > 1) {
|
|
85
|
+
return clauseParts.flatMap((p) => p.length <= maxChars ? [p] : splitAtWordBoundary(p, maxChars));
|
|
86
|
+
}
|
|
87
|
+
// No clause boundaries — split at word boundaries.
|
|
88
|
+
return splitAtWordBoundary(s, maxChars);
|
|
89
|
+
}
|
|
90
|
+
/** Split at word boundaries, each chunk <= maxChars. */
|
|
91
|
+
function splitAtWordBoundary(s, maxChars) {
|
|
92
|
+
const words = s.split(/\s+/);
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let cur = '';
|
|
95
|
+
for (const w of words) {
|
|
96
|
+
if ((cur + ' ' + w).trim().length <= maxChars) {
|
|
97
|
+
cur = (cur + ' ' + w).trim();
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
if (cur)
|
|
101
|
+
chunks.push(cur);
|
|
102
|
+
cur = w;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cur)
|
|
107
|
+
chunks.push(cur);
|
|
108
|
+
return chunks;
|
|
109
|
+
}
|
|
110
|
+
/** Greedily merge short fragments that fit within maxChars. */
|
|
111
|
+
function mergeFragments(frags, maxChars) {
|
|
112
|
+
const merged = [];
|
|
113
|
+
let cur = '';
|
|
114
|
+
for (const f of frags) {
|
|
115
|
+
const candidate = cur ? `${cur} ${f}` : f;
|
|
116
|
+
if (candidate.length <= maxChars) {
|
|
117
|
+
cur = candidate;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
if (cur)
|
|
121
|
+
merged.push(cur);
|
|
122
|
+
cur = f;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if (cur)
|
|
128
|
+
merged.push(cur);
|
|
129
|
+
return merged;
|
|
130
|
+
}
|
|
131
|
+
/** Build cues from timestamped word tokens, grouping words into cues. */
|
|
132
|
+
function buildFromTokens(tokens, maxChars, fallbackDuration) {
|
|
133
|
+
const cues = [];
|
|
134
|
+
let curWords = [];
|
|
135
|
+
let curLen = 0;
|
|
136
|
+
for (const tok of tokens) {
|
|
137
|
+
const addedLen = (curLen > 0 ? 1 : 0) + tok.text.length;
|
|
138
|
+
if (curLen + addedLen > maxChars && curWords.length > 0) {
|
|
139
|
+
// Flush the current group.
|
|
140
|
+
cues.push(makeTokenCue(curWords, cues.length));
|
|
141
|
+
curWords = [];
|
|
142
|
+
curLen = 0;
|
|
143
|
+
}
|
|
144
|
+
curWords.push(tok);
|
|
145
|
+
curLen += (curLen > 0 ? 1 : 0) + tok.text.length;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if (curWords.length > 0) {
|
|
151
|
+
cues.push(makeTokenCue(curWords, cues.length));
|
|
152
|
+
}
|
|
153
|
+
// Clamp the last cue's end to the segment duration if tokens overshoot.
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if (cues.length > 0) {
|
|
157
|
+
const last = cues[cues.length - 1];
|
|
158
|
+
if (last.end > fallbackDuration) {
|
|
159
|
+
last.end = fallbackDuration;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return cues;
|
|
163
|
+
}
|
|
164
|
+
function makeTokenCue(words, idx) {
|
|
165
|
+
return {
|
|
166
|
+
id: `cap_${idx}_${Math.random().toString(36).slice(2, 8)}`,
|
|
167
|
+
start: Number(words[0].start.toFixed(3)),
|
|
168
|
+
end: Number(words[words.length - 1].end.toFixed(3)),
|
|
169
|
+
text: words.map((w) => w.text).join(' '),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=captionSplitter.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Max length of a card title / flow name, enforced by this gate at edit time. The wire types carry titles as bare strings — the gate is the bound's single home. */
|
|
2
|
+
export declare const TITLE_MAX_LEN = 120;
|
|
3
|
+
export interface MutationSpec {
|
|
4
|
+
/** The field this mutation writes (the patch key). */
|
|
5
|
+
field: string;
|
|
6
|
+
/** Coerce the raw value before validation (trim). */
|
|
7
|
+
coerce: (v: unknown) => unknown;
|
|
8
|
+
/** Type-guard gate: string, non-empty after trim, <= TITLE_MAX_LEN. */
|
|
9
|
+
validate: (v: unknown) => v is string;
|
|
10
|
+
}
|
|
11
|
+
/** The closed set of stage mutations. Add a member + a CARD_MUTATIONS entry
|
|
12
|
+
* to extend the gate. */
|
|
13
|
+
export type StageMutationKind = "rename";
|
|
14
|
+
export type StageMutation = {
|
|
15
|
+
kind: "rename";
|
|
16
|
+
value: string;
|
|
17
|
+
};
|
|
18
|
+
export declare const CARD_MUTATIONS: Record<StageMutationKind, MutationSpec>;
|
|
19
|
+
export type StageMutationResult = {
|
|
20
|
+
ok: true;
|
|
21
|
+
patch: {
|
|
22
|
+
title: string;
|
|
23
|
+
};
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
error: string;
|
|
27
|
+
};
|
|
28
|
+
/** Pure gate. Unknown kinds error at runtime (defense in depth beyond the
|
|
29
|
+
* tsc union). Every card mutation must pass through here. */
|
|
30
|
+
export declare function applyStageMutation(mutation: StageMutation): StageMutationResult;
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
export type DocMutationKind = "rename";
|
|
34
|
+
export type DocMutation = {
|
|
35
|
+
kind: "rename";
|
|
36
|
+
value: string;
|
|
37
|
+
};
|
|
38
|
+
export declare const DOC_MUTATIONS: Record<DocMutationKind, MutationSpec>;
|
|
39
|
+
export type DocMutationResult = {
|
|
40
|
+
ok: true;
|
|
41
|
+
patch: {
|
|
42
|
+
name: string;
|
|
43
|
+
};
|
|
44
|
+
} | {
|
|
45
|
+
ok: false;
|
|
46
|
+
error: string;
|
|
47
|
+
};
|
|
48
|
+
/** Pure gate for flow-doc mutations — the flow-level sibling of
|
|
49
|
+
* applyStageMutation. Cosmetic: the store action must NOT mark stale or
|
|
50
|
+
* recompute keys (a rename is not a semantic change). */
|
|
51
|
+
export declare function applyDocMutation(mutation: DocMutation): DocMutationResult;
|
|
52
|
+
//# sourceMappingURL=cardMutations.d.ts.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
|
|
2
|
+
//
|
|
3
|
+
// DECLARATIVE way. One table, one gate — no per-kind imperative store code.
|
|
4
|
+
// Each row declares the field a mutation kind writes + its validation
|
|
5
|
+
// + coercion; extending the gate = adding a union member + a table row
|
|
6
|
+
// (same discipline as _FORM_CONFIGS: one table the gate mirrors).
|
|
7
|
+
//
|
|
8
|
+
// The enforcement is two-layer, mirroring the catalog schema's pattern:
|
|
9
|
+
// • HERE (frontend, edit time): the typed unions (tsc) + this runtime gate —
|
|
10
|
+
// every card/doc mutation passes through applyStageMutation /
|
|
11
|
+
// applyDocMutation (the shared runMutation core).
|
|
12
|
+
// • WIRE (run start): FlowDoc.title AND BackendStage.title in FlowTypes.ts are bare strings — this gate is the bound's single home; a title that passes here fits the wire.
|
|
13
|
+
// Bump all together.
|
|
14
|
+
//
|
|
15
|
+
|
|
16
|
+
// name gets a row in one of these tables + a bound in the owning layer
|
|
17
|
+
// (this gate where the card owns the name, the wire contract where the
|
|
18
|
+
// run owns it). No ad-hoc name writes — that's how drift and breakage
|
|
19
|
+
// creep in.
|
|
20
|
+
/** Max length of a card title / flow name, enforced by this gate at edit time. The wire types carry titles as bare strings — the gate is the bound's single home. */
|
|
21
|
+
export const TITLE_MAX_LEN = 120;
|
|
22
|
+
/** Shared core: coerce → validate → patch. Every table's apply* entry is a
|
|
23
|
+
* thin wrapper over this — one gate, one error language across targets. */
|
|
24
|
+
function runMutation(spec, value) {
|
|
25
|
+
const v = spec.coerce(value);
|
|
26
|
+
if (!spec.validate(v)) {
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
error: `Invalid ${spec.field}: must be 1-${TITLE_MAX_LEN} non-empty characters`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { ok: true, patch: { [spec.field]: v } };
|
|
33
|
+
}
|
|
34
|
+
export const CARD_MUTATIONS = {
|
|
35
|
+
rename: {
|
|
36
|
+
field: "title",
|
|
37
|
+
coerce: (v) => (typeof v === "string" ? v.trim() : v),
|
|
38
|
+
validate: (v) => typeof v === "string" && v.trim().length > 0 && v.trim().length <= TITLE_MAX_LEN,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
/** Pure gate. Unknown kinds error at runtime (defense in depth beyond the
|
|
42
|
+
* tsc union). Every card mutation must pass through here. */
|
|
43
|
+
export function applyStageMutation(mutation) {
|
|
44
|
+
const spec = CARD_MUTATIONS[mutation.kind];
|
|
45
|
+
if (!spec) {
|
|
46
|
+
return { ok: false, error: `Unknown stage mutation kind: ${mutation.kind}` };
|
|
47
|
+
}
|
|
48
|
+
return runMutation(spec, mutation.value);
|
|
49
|
+
}
|
|
50
|
+
export const DOC_MUTATIONS = {
|
|
51
|
+
rename: {
|
|
52
|
+
field: "name",
|
|
53
|
+
coerce: (v) => (typeof v === "string" ? v.trim() : v),
|
|
54
|
+
validate: (v) => typeof v === "string" && v.trim().length > 0 && v.trim().length <= TITLE_MAX_LEN,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
/** Pure gate for flow-doc mutations — the flow-level sibling of
|
|
58
|
+
* applyStageMutation. Cosmetic: the store action must NOT mark stale or
|
|
59
|
+
* recompute keys (a rename is not a semantic change). */
|
|
60
|
+
export function applyDocMutation(mutation) {
|
|
61
|
+
const spec = DOC_MUTATIONS[mutation.kind];
|
|
62
|
+
if (!spec) {
|
|
63
|
+
return { ok: false, error: `Unknown doc mutation kind: ${mutation.kind}` };
|
|
64
|
+
}
|
|
65
|
+
return runMutation(spec, mutation.value);
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=cardMutations.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** downloads - browser download triggers for pose artifacts
|
|
2
|
+
* (ex-lib/text-to-pose export helpers). */
|
|
3
|
+
import type { PoseMotion } from "./textToPose.js";
|
|
4
|
+
/**
|
|
5
|
+
* Trigger a browser download from a base64-encoded payload.
|
|
6
|
+
* Mirrors Kimodo demo's _download_bytes_to_browser (ui.py).
|
|
7
|
+
*/
|
|
8
|
+
export declare function downloadBase64Payload(b64: string, filename: string, mimeType?: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Download the SOMA-77 motion as an .npz file (poses + rotmats + deformed verts).
|
|
11
|
+
*/
|
|
12
|
+
export declare function downloadMotionNpz(motion: PoseMotion, filename?: string): void;
|
|
13
|
+
/**
|
|
14
|
+
* Capture the largest <canvas> on the page as a PNG download.
|
|
15
|
+
* Used for screenshots of the 3D viewer.
|
|
16
|
+
*/
|
|
17
|
+
export declare function downloadCanvasScreenshot(filename?: string): void;
|
|
18
|
+
//# sourceMappingURL=downloads.d.ts.map
|