@melite/formats 0.0.0-stage → 0.1.5-pre-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/README.md +37 -2
- 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/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 +25 -0
- package/lib/index.js +25 -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 +66 -0
- package/lib/poseTransforms.js +227 -0
- package/lib/poserSkin.d.ts +75 -0
- package/lib/poserSkin.js +141 -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 +394 -0
- package/lib/textToPose.js +607 -0
- package/lib/uploadToEstate.d.ts +25 -0
- package/lib/uploadToEstate.js +97 -0
- package/package.json +149 -4
|
@@ -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,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
|
package/lib/downloads.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ── Export helpers ─────────────────────────────────────────────────────────
|
|
2
|
+
/**
|
|
3
|
+
* Trigger a browser download from a base64-encoded payload.
|
|
4
|
+
* Mirrors Kimodo demo's _download_bytes_to_browser (ui.py).
|
|
5
|
+
*/
|
|
6
|
+
export function downloadBase64Payload(b64, filename, mimeType = "application/octet-stream") {
|
|
7
|
+
let binStr;
|
|
8
|
+
try {
|
|
9
|
+
binStr = atob(b64);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
console.error("downloadBase64Payload: invalid base64 string");
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const bytes = new Uint8Array(binStr.length);
|
|
16
|
+
for (let i = 0; i < binStr.length; i++)
|
|
17
|
+
bytes[i] = binStr.charCodeAt(i);
|
|
18
|
+
const blob = new Blob([bytes], { type: mimeType });
|
|
19
|
+
const url = URL.createObjectURL(blob);
|
|
20
|
+
const a = document.createElement("a");
|
|
21
|
+
a.href = url;
|
|
22
|
+
a.download = filename;
|
|
23
|
+
document.body.appendChild(a);
|
|
24
|
+
a.click();
|
|
25
|
+
a.remove();
|
|
26
|
+
URL.revokeObjectURL(url);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Download the SOMA-77 motion as an .npz file (poses + rotmats + deformed verts).
|
|
30
|
+
*/
|
|
31
|
+
export function downloadMotionNpz(motion, filename = "motion.npz") {
|
|
32
|
+
if (!motion.soma_npz_b64)
|
|
33
|
+
throw new Error("No NPZ data in motion result");
|
|
34
|
+
downloadBase64Payload(motion.soma_npz_b64, filename, "application/octet-stream");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Capture the largest <canvas> on the page as a PNG download.
|
|
38
|
+
* Used for screenshots of the 3D viewer.
|
|
39
|
+
*/
|
|
40
|
+
export function downloadCanvasScreenshot(filename = "pose.png") {
|
|
41
|
+
const canvases = Array.from(document.querySelectorAll("canvas"));
|
|
42
|
+
if (!canvases.length)
|
|
43
|
+
return;
|
|
44
|
+
const canvas = canvases.reduce((best, cur) => {
|
|
45
|
+
const ba = (best?.width || 0) * (best?.height || 0);
|
|
46
|
+
const ca = (cur.width || 0) * (cur.height || 0);
|
|
47
|
+
return ca > ba ? cur : best;
|
|
48
|
+
}, null);
|
|
49
|
+
if (!canvas)
|
|
50
|
+
return;
|
|
51
|
+
canvas.toBlob((blob) => {
|
|
52
|
+
if (!blob)
|
|
53
|
+
return;
|
|
54
|
+
const url = URL.createObjectURL(blob);
|
|
55
|
+
const a = document.createElement("a");
|
|
56
|
+
a.href = url;
|
|
57
|
+
a.download = filename;
|
|
58
|
+
document.body.appendChild(a);
|
|
59
|
+
a.click();
|
|
60
|
+
a.remove();
|
|
61
|
+
URL.revokeObjectURL(url);
|
|
62
|
+
}, "image/png");
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=downloads.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E3.8 — Client-side video frame extraction.
|
|
3
|
+
*
|
|
4
|
+
* Extracts a single frame from a video at a given timestamp by seeking
|
|
5
|
+
* a hidden <video> element and drawing it to a <canvas>. Returns a PNG
|
|
6
|
+
* data URL, or null if extraction fails (best-effort — the caller
|
|
7
|
+
* continues without a first-frame anchor rather than blocking).
|
|
8
|
+
*
|
|
9
|
+
* Used by the SmartSplit flow to grab the opening frame of each detected
|
|
10
|
+
* scene and set it as the segment's firstFrameB64 anchor.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Extract a frame from `videoUrl` at `timeSeconds`.
|
|
14
|
+
* @returns PNG data URL, or null on any failure.
|
|
15
|
+
*/
|
|
16
|
+
export declare function extractVideoFrame(videoUrl: string, timeSeconds: number): Promise<string | null>;
|
|
17
|
+
/**
|
|
18
|
+
* Extract the opening frame for each scene in parallel (bounded — at most
|
|
19
|
+
* `concurrency` extractions in flight at once). Returns an array aligned
|
|
20
|
+
* with the input `times` (null where extraction failed).
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractFramesBatch(videoUrl: string, times: number[], concurrency?: number): Promise<(string | null)[]>;
|
|
23
|
+
//# sourceMappingURL=extractVideoFrame.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E3.8 — Client-side video frame extraction.
|
|
3
|
+
*
|
|
4
|
+
* Extracts a single frame from a video at a given timestamp by seeking
|
|
5
|
+
* a hidden <video> element and drawing it to a <canvas>. Returns a PNG
|
|
6
|
+
* data URL, or null if extraction fails (best-effort — the caller
|
|
7
|
+
* continues without a first-frame anchor rather than blocking).
|
|
8
|
+
*
|
|
9
|
+
* Used by the SmartSplit flow to grab the opening frame of each detected
|
|
10
|
+
* scene and set it as the segment's firstFrameB64 anchor.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Extract a frame from `videoUrl` at `timeSeconds`.
|
|
14
|
+
* @returns PNG data URL, or null on any failure.
|
|
15
|
+
*/
|
|
16
|
+
export async function extractVideoFrame(videoUrl, timeSeconds) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
if (!videoUrl || timeSeconds < 0) {
|
|
19
|
+
resolve(null);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const video = document.createElement('video');
|
|
23
|
+
video.src = videoUrl;
|
|
24
|
+
video.crossOrigin = 'anonymous';
|
|
25
|
+
video.muted = true;
|
|
26
|
+
video.preload = 'auto';
|
|
27
|
+
video.playsInline = true;
|
|
28
|
+
let settled = false;
|
|
29
|
+
const cleanup = () => {
|
|
30
|
+
video.removeAttribute('src');
|
|
31
|
+
try {
|
|
32
|
+
video.load();
|
|
33
|
+
}
|
|
34
|
+
catch { /* noop */ }
|
|
35
|
+
};
|
|
36
|
+
const done = (result) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
cleanup();
|
|
41
|
+
resolve(result);
|
|
42
|
+
};
|
|
43
|
+
// Timeout — if the video can't seek in 5s, give up.
|
|
44
|
+
const timer = setTimeout(() => done(null), 5000);
|
|
45
|
+
video.onloadedmetadata = () => {
|
|
46
|
+
// Clamp the seek time to the video duration.
|
|
47
|
+
const t = Math.min(timeSeconds, Math.max(0, (video.duration || timeSeconds) - 0.05));
|
|
48
|
+
try {
|
|
49
|
+
video.currentTime = t;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Browser API: currentTime setter throws if the media isn't seekable
|
|
53
|
+
// yet (e.g. blob not fully loaded). Same policy exemption as play()/
|
|
54
|
+
// fullscreen — browser-API rejection, not a backend failure.
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
done(null);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
video.onseeked = () => {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
try {
|
|
62
|
+
const canvas = document.createElement('canvas');
|
|
63
|
+
// Cap the extraction resolution to keep data URLs manageable.
|
|
64
|
+
// The generation model snaps to VAE factors anyway, so a 512px
|
|
65
|
+
// long edge is plenty for a first-frame anchor.
|
|
66
|
+
const maxDim = 512;
|
|
67
|
+
const vw = video.videoWidth || 640;
|
|
68
|
+
const vh = video.videoHeight || 480;
|
|
69
|
+
const scale = Math.min(1, maxDim / Math.max(vw, vh));
|
|
70
|
+
canvas.width = Math.round(vw * scale);
|
|
71
|
+
canvas.height = Math.round(vh * scale);
|
|
72
|
+
const ctx = canvas.getContext('2d');
|
|
73
|
+
if (!ctx) {
|
|
74
|
+
done(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
78
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
79
|
+
done(dataUrl || null);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
// NO SILENT SWALLOW — canvas drawing can fail on tainted cross-origin
|
|
83
|
+
// media or if the video element is in an invalid state. Log so we know
|
|
84
|
+
// when frame extraction is silently falling back to null.
|
|
85
|
+
console.error('[extract-video-frame] canvas draw/toDataURL failed:', e);
|
|
86
|
+
done(null);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
video.onerror = () => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
done(null);
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Extract the opening frame for each scene in parallel (bounded — at most
|
|
97
|
+
* `concurrency` extractions in flight at once). Returns an array aligned
|
|
98
|
+
* with the input `times` (null where extraction failed).
|
|
99
|
+
*/
|
|
100
|
+
export async function extractFramesBatch(videoUrl, times, concurrency = 3) {
|
|
101
|
+
const results = new Array(times.length).fill(null);
|
|
102
|
+
let next = 0;
|
|
103
|
+
const workers = Array.from({ length: Math.min(concurrency, times.length) }, async () => {
|
|
104
|
+
while (true) {
|
|
105
|
+
const idx = next++;
|
|
106
|
+
if (idx >= times.length)
|
|
107
|
+
break;
|
|
108
|
+
results[idx] = await extractVideoFrame(videoUrl, times[idx]);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
await Promise.all(workers);
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=extractVideoFrame.js.map
|