cursedbelt-core 1.0.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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Pure ffmpeg/ffprobe argument builders + output parsers. No Bun.spawn, no I/O — the ONE
|
|
2
|
+
// source of truth for ffmpeg argv construction, shared by the Bun server adapter
|
|
3
|
+
// (`server/media-bun/video.ts` spawns these against native ffmpeg) and the browser editor
|
|
4
|
+
// (`react/media` executes the same argv against ffmpeg.wasm). Unit tested without ffmpeg.
|
|
5
|
+
const X264_CRF = { high: 18, medium: 23, low: 28 };
|
|
6
|
+
const VP9_CRF = { high: 24, medium: 31, low: 37 };
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the codec plan for a trim. mp4 → H.264/AAC (universal playback) with
|
|
9
|
+
* `+faststart` so the moov atom is up front (web streaming); webm → VP9/Opus.
|
|
10
|
+
*/
|
|
11
|
+
export function videoCodecPlan(format = 'mp4', quality = 'medium') {
|
|
12
|
+
if (format === 'webm') {
|
|
13
|
+
return {
|
|
14
|
+
videoCodec: 'libvpx-vp9',
|
|
15
|
+
audioCodec: 'libopus',
|
|
16
|
+
crf: VP9_CRF[quality],
|
|
17
|
+
preset: 'good',
|
|
18
|
+
extraOutputArgs: ['-b:v', '0', '-row-mt', '1'],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
videoCodec: 'libx264',
|
|
23
|
+
audioCodec: 'aac',
|
|
24
|
+
crf: X264_CRF[quality],
|
|
25
|
+
preset: 'fast',
|
|
26
|
+
// yuv420p = broad decoder compatibility; faststart = progressive web playback.
|
|
27
|
+
extraOutputArgs: ['-pix_fmt', 'yuv420p', '-movflags', '+faststart'],
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Build the ffmpeg argv (without the `ffmpeg` binary) for a re-encoding trim.
|
|
32
|
+
* Uses accurate input seeking (`-ss` before `-i`, accurate when transcoding) plus
|
|
33
|
+
* `-t duration` so the clip is exactly `end - start` long regardless of keyframes.
|
|
34
|
+
*/
|
|
35
|
+
export function buildTrimArgs(spec) {
|
|
36
|
+
const plan = videoCodecPlan(spec.format, spec.quality);
|
|
37
|
+
const duration = Math.max(0, spec.endSeconds - spec.startSeconds);
|
|
38
|
+
return [
|
|
39
|
+
'-hide_banner',
|
|
40
|
+
'-nostdin',
|
|
41
|
+
'-y',
|
|
42
|
+
'-ss',
|
|
43
|
+
String(spec.startSeconds),
|
|
44
|
+
'-i',
|
|
45
|
+
spec.inputPath,
|
|
46
|
+
'-t',
|
|
47
|
+
String(duration),
|
|
48
|
+
'-c:v',
|
|
49
|
+
plan.videoCodec,
|
|
50
|
+
'-preset',
|
|
51
|
+
plan.preset,
|
|
52
|
+
'-crf',
|
|
53
|
+
String(plan.crf),
|
|
54
|
+
'-c:a',
|
|
55
|
+
plan.audioCodec,
|
|
56
|
+
...plan.extraOutputArgs,
|
|
57
|
+
spec.outputPath,
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
/** ffprobe argv for `probeMedia` — quiet JSON with streams + format. */
|
|
61
|
+
export function buildProbeArgs(inputPath) {
|
|
62
|
+
return ['-v', 'quiet', '-print_format', 'json', '-show_streams', '-show_format', inputPath];
|
|
63
|
+
}
|
|
64
|
+
/** ffmpeg argv to grab a single frame at `atSeconds` and write it to `output`. */
|
|
65
|
+
export function buildThumbnailArgs(inputPath, atSeconds, output) {
|
|
66
|
+
return [
|
|
67
|
+
'-hide_banner',
|
|
68
|
+
'-nostdin',
|
|
69
|
+
'-y',
|
|
70
|
+
'-ss',
|
|
71
|
+
String(Math.max(0, atSeconds)),
|
|
72
|
+
'-i',
|
|
73
|
+
inputPath,
|
|
74
|
+
'-frames:v',
|
|
75
|
+
'1',
|
|
76
|
+
'-q:v',
|
|
77
|
+
'3',
|
|
78
|
+
output,
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
/** Parse an ffmpeg `HH:MM:SS.ss` (or bare seconds) timestamp into seconds. */
|
|
82
|
+
export function ffmpegTimeToSeconds(time) {
|
|
83
|
+
const t = time.trim();
|
|
84
|
+
if (t === 'N/A' || t === '')
|
|
85
|
+
return Number.NaN;
|
|
86
|
+
const parts = t.split(':');
|
|
87
|
+
if (parts.length === 1)
|
|
88
|
+
return Number.parseFloat(parts[0]);
|
|
89
|
+
let seconds = 0;
|
|
90
|
+
for (const part of parts)
|
|
91
|
+
seconds = seconds * 60 + Number.parseFloat(part);
|
|
92
|
+
return seconds;
|
|
93
|
+
}
|
|
94
|
+
const TIME_RE = /time=\s*([0-9:.]+)/;
|
|
95
|
+
/**
|
|
96
|
+
* Parse one line/chunk of ffmpeg stderr into a progress event. ffmpeg emits
|
|
97
|
+
* `frame=… time=00:00:03.00 …` (carriage-return-updated). Returns an `encode`
|
|
98
|
+
* event with a clamped percent when `totalSeconds` is known, else just the phase;
|
|
99
|
+
* `null` when the line carries no timestamp.
|
|
100
|
+
*/
|
|
101
|
+
export function parseFfmpegProgress(line, totalSeconds) {
|
|
102
|
+
const m = TIME_RE.exec(line);
|
|
103
|
+
if (!m)
|
|
104
|
+
return null;
|
|
105
|
+
const elapsed = ffmpegTimeToSeconds(m[1]);
|
|
106
|
+
if (!Number.isFinite(elapsed))
|
|
107
|
+
return null;
|
|
108
|
+
if (totalSeconds && totalSeconds > 0) {
|
|
109
|
+
const pct = Math.min(100, Math.max(0, (elapsed / totalSeconds) * 100));
|
|
110
|
+
return { phase: 'encode', progressPercent: Math.round(pct) };
|
|
111
|
+
}
|
|
112
|
+
return { phase: 'encode' };
|
|
113
|
+
}
|
|
114
|
+
/** Extract width/height/duration/format from parsed ffprobe JSON. */
|
|
115
|
+
export function parseFfprobe(json) {
|
|
116
|
+
const streams = json.streams ?? [];
|
|
117
|
+
const video = streams.find((s) => s.codec_type === 'video');
|
|
118
|
+
const durationStr = json.format?.duration ?? video?.duration ?? '';
|
|
119
|
+
const durationSeconds = durationStr ? Number.parseFloat(durationStr) : Number.NaN;
|
|
120
|
+
return {
|
|
121
|
+
width: video?.width ?? 0,
|
|
122
|
+
height: video?.height ?? 0,
|
|
123
|
+
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
|
|
124
|
+
format: json.format?.format_name ?? '',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ImageOutputFormat } from './types';
|
|
2
|
+
export declare const IMAGE_OUTPUT_FORMATS: readonly ImageOutputFormat[];
|
|
3
|
+
export declare function imageFormatToMime(format: ImageOutputFormat): string;
|
|
4
|
+
export declare function imageFormatToExtension(format: ImageOutputFormat): string;
|
|
5
|
+
/** Best-effort MIME → output format (e.g. `image/jpeg` → `jpeg`). */
|
|
6
|
+
export declare function mimeToImageFormat(mime: string | undefined): ImageOutputFormat | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Clamp an image quality to a sane 1–100 integer. A missing/NaN/out-of-range value
|
|
9
|
+
* falls back to `fallback` (default 80), so a malformed spec never yields a 0-quality
|
|
10
|
+
* (blank) or >100 (sharp-throwing) encode.
|
|
11
|
+
*/
|
|
12
|
+
export declare function clampQuality(quality: number | undefined, fallback?: number): number;
|
|
13
|
+
/**
|
|
14
|
+
* The thumbnail encode format: webp by default (small + wide support); png when
|
|
15
|
+
* transparency must survive (the source already has alpha). Keeps thumbnail policy
|
|
16
|
+
* in one tested place rather than scattered across server handlers.
|
|
17
|
+
*/
|
|
18
|
+
export declare function pickThumbnailFormat(opts?: {
|
|
19
|
+
hasAlpha?: boolean;
|
|
20
|
+
}): ImageOutputFormat;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Pure format helpers — MIME/extension lookup, quality clamping, thumbnail-format
|
|
2
|
+
// selection. No npm deps; shared by the server adapter, the React layer, and tests.
|
|
3
|
+
export const IMAGE_OUTPUT_FORMATS = ['jpeg', 'png', 'webp', 'avif'];
|
|
4
|
+
const IMAGE_MIME = {
|
|
5
|
+
jpeg: 'image/jpeg',
|
|
6
|
+
png: 'image/png',
|
|
7
|
+
webp: 'image/webp',
|
|
8
|
+
avif: 'image/avif',
|
|
9
|
+
};
|
|
10
|
+
const IMAGE_EXT = {
|
|
11
|
+
jpeg: 'jpg',
|
|
12
|
+
png: 'png',
|
|
13
|
+
webp: 'webp',
|
|
14
|
+
avif: 'avif',
|
|
15
|
+
};
|
|
16
|
+
export function imageFormatToMime(format) {
|
|
17
|
+
return IMAGE_MIME[format];
|
|
18
|
+
}
|
|
19
|
+
export function imageFormatToExtension(format) {
|
|
20
|
+
return IMAGE_EXT[format];
|
|
21
|
+
}
|
|
22
|
+
/** Best-effort MIME → output format (e.g. `image/jpeg` → `jpeg`). */
|
|
23
|
+
export function mimeToImageFormat(mime) {
|
|
24
|
+
if (!mime)
|
|
25
|
+
return undefined;
|
|
26
|
+
const m = mime.toLowerCase();
|
|
27
|
+
if (m.includes('jpeg') || m.includes('jpg'))
|
|
28
|
+
return 'jpeg';
|
|
29
|
+
if (m.includes('png'))
|
|
30
|
+
return 'png';
|
|
31
|
+
if (m.includes('webp'))
|
|
32
|
+
return 'webp';
|
|
33
|
+
if (m.includes('avif'))
|
|
34
|
+
return 'avif';
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Clamp an image quality to a sane 1–100 integer. A missing/NaN/out-of-range value
|
|
39
|
+
* falls back to `fallback` (default 80), so a malformed spec never yields a 0-quality
|
|
40
|
+
* (blank) or >100 (sharp-throwing) encode.
|
|
41
|
+
*/
|
|
42
|
+
export function clampQuality(quality, fallback = 80) {
|
|
43
|
+
if (quality == null || !Number.isFinite(quality))
|
|
44
|
+
return fallback;
|
|
45
|
+
return Math.min(100, Math.max(1, Math.round(quality)));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The thumbnail encode format: webp by default (small + wide support); png when
|
|
49
|
+
* transparency must survive (the source already has alpha). Keeps thumbnail policy
|
|
50
|
+
* in one tested place rather than scattered across server handlers.
|
|
51
|
+
*/
|
|
52
|
+
export function pickThumbnailFormat(opts) {
|
|
53
|
+
return opts?.hasAlpha ? 'png' : 'webp';
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { type FloodFillBGOptions, floodFillBGS } from './bgRemoval';
|
|
2
|
+
export { buildProbeArgs, buildThumbnailArgs, buildTrimArgs, ffmpegTimeToSeconds, type ProbeResult, parseFfmpegProgress, parseFfprobe, type VideoCodecPlan, videoCodecPlan, } from './ffmpeg';
|
|
3
|
+
export { clampQuality, IMAGE_OUTPUT_FORMATS, imageFormatToExtension, imageFormatToMime, mimeToImageFormat, pickThumbnailFormat, } from './format';
|
|
4
|
+
export { isAudioItem, isImageItem, isVideoItem, mediaItemAspect, mediaItemDimensions, } from './mediaItem';
|
|
5
|
+
export { buildAtempoChain, buildTimelineRenderPlan, DEFAULT_NORMALIZE, type FfmpegCommand, type NormalizeSpec, type RenderPlanFile, speedFilters, type TimelineRenderOptions, type TimelineRenderPlan, } from './renderPlan';
|
|
6
|
+
export { closeMask, createRegionSegmenter, DEFAULT_CLOSE_RADIUS, DEFAULT_TOLERANCE, dilateMask, downscaleRgba, erodeMask, fitWorkingSize, type ImageSegmenter, type SegmentOptions, type SegmentPrompt, type SegmentSource, sensitivityToTolerance, upscaleMask, } from './segment';
|
|
7
|
+
export { formatBytes } from './size';
|
|
8
|
+
export { type ClampTrimOptions, clampTrimRange, formatTimecode, type TrimRange, trimDurationSeconds, } from './trim';
|
|
9
|
+
export type { GalleryMedia, GalleryPhoto, ImageOperation, ImageOutputFormat, ImagePipelineSpec, MediaItem, MediaKind, MediaKindFilter, PlayableVideo, ProcessingStatus, VideoFormat, VideoProgressEvent, VideoQuality, VideoTrimSpec, } from './types';
|
|
10
|
+
export { DEFAULT_UPLOAD_CHUNK_SIZE, MAX_SINGLE_UPLOAD_BYTES } from './uploadLimits';
|
|
11
|
+
export { assertValidImagePipelineSpec, assertValidVideoTrimSpec, type ValidationResult, validateImagePipelineSpec, validateVideoTrimSpec, } from './validate';
|
|
12
|
+
export { cutListProblem, cutSeconds, cutTimecode, keptSeconds, keptSegments, MIN_CUT_SECONDS, normalizeCuts, type VideoCut, } from './videoCuts';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// cursedbelt-core/media — pure, dependency-free media contract + utilities shared by
|
|
2
|
+
// the Bun server adapter (`src/server/media-bun`), the React components
|
|
3
|
+
// (`cursedbelt/react/media`), and any consuming app/test. No React, no Bun, no npm
|
|
4
|
+
// dep — the same module loads identically everywhere.
|
|
5
|
+
export { floodFillBGS } from './bgRemoval';
|
|
6
|
+
export { buildProbeArgs, buildThumbnailArgs, buildTrimArgs, ffmpegTimeToSeconds, parseFfmpegProgress, parseFfprobe, videoCodecPlan, } from './ffmpeg';
|
|
7
|
+
export { clampQuality, IMAGE_OUTPUT_FORMATS, imageFormatToExtension, imageFormatToMime, mimeToImageFormat, pickThumbnailFormat, } from './format';
|
|
8
|
+
export { isAudioItem, isImageItem, isVideoItem, mediaItemAspect, mediaItemDimensions, } from './mediaItem';
|
|
9
|
+
export { buildAtempoChain, buildTimelineRenderPlan, DEFAULT_NORMALIZE, speedFilters, } from './renderPlan';
|
|
10
|
+
export { closeMask, createRegionSegmenter, DEFAULT_CLOSE_RADIUS, DEFAULT_TOLERANCE, dilateMask, downscaleRgba, erodeMask, fitWorkingSize, sensitivityToTolerance, upscaleMask, } from './segment';
|
|
11
|
+
export { formatBytes } from './size';
|
|
12
|
+
export { clampTrimRange, formatTimecode, trimDurationSeconds, } from './trim';
|
|
13
|
+
export { DEFAULT_UPLOAD_CHUNK_SIZE, MAX_SINGLE_UPLOAD_BYTES } from './uploadLimits';
|
|
14
|
+
export { assertValidImagePipelineSpec, assertValidVideoTrimSpec, validateImagePipelineSpec, validateVideoTrimSpec, } from './validate';
|
|
15
|
+
// Marking sections of a video to CUT OUT. Framework-free on purpose: the dialog
|
|
16
|
+
// (`react/media/VideoCutEditor`), the app route that refuses an unexecutable list and the
|
|
17
|
+
// binary-server job that runs it all read the same rules from here.
|
|
18
|
+
export { cutListProblem, cutSeconds, cutTimecode, keptSeconds, keptSegments, MIN_CUT_SECONDS, normalizeCuts, } from './videoCuts';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MediaItem } from './types';
|
|
2
|
+
export declare const isImageItem: (item: MediaItem) => boolean;
|
|
3
|
+
export declare const isVideoItem: (item: MediaItem) => boolean;
|
|
4
|
+
export declare const isAudioItem: (item: MediaItem) => boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve renderable dimensions for an item. Real `width`/`height` win; otherwise a
|
|
7
|
+
* per-kind default (4:3 image, 16:9 video, 1:1 audio) keeps the justified/masonry
|
|
8
|
+
* layout from collapsing a dimensionless item to zero height.
|
|
9
|
+
*/
|
|
10
|
+
export declare function mediaItemDimensions(item: MediaItem): {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
/** width / height (clamped to a sane range), falling back to `fallback` (default 1). */
|
|
15
|
+
export declare function mediaItemAspect(item: MediaItem, fallback?: number): number;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Pure MediaItem helpers — kind predicates + dimension/aspect resolution. The React
|
|
2
|
+
// catalogue maps items to layout photos through these, so the "what size is this
|
|
3
|
+
// thumbnail" logic is tested here, not buried in a component.
|
|
4
|
+
export const isImageItem = (item) => item.type === 'image';
|
|
5
|
+
export const isVideoItem = (item) => item.type === 'video';
|
|
6
|
+
export const isAudioItem = (item) => item.type === 'audio';
|
|
7
|
+
/** Default cell shape when an item carries no intrinsic dimensions. */
|
|
8
|
+
const FALLBACK_DIMS = {
|
|
9
|
+
image: { width: 4, height: 3 },
|
|
10
|
+
video: { width: 16, height: 9 },
|
|
11
|
+
audio: { width: 1, height: 1 },
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Resolve renderable dimensions for an item. Real `width`/`height` win; otherwise a
|
|
15
|
+
* per-kind default (4:3 image, 16:9 video, 1:1 audio) keeps the justified/masonry
|
|
16
|
+
* layout from collapsing a dimensionless item to zero height.
|
|
17
|
+
*/
|
|
18
|
+
export function mediaItemDimensions(item) {
|
|
19
|
+
if (isPositive(item.width) && isPositive(item.height)) {
|
|
20
|
+
return { width: item.width, height: item.height };
|
|
21
|
+
}
|
|
22
|
+
return FALLBACK_DIMS[item.type];
|
|
23
|
+
}
|
|
24
|
+
/** width / height (clamped to a sane range), falling back to `fallback` (default 1). */
|
|
25
|
+
export function mediaItemAspect(item, fallback = 1) {
|
|
26
|
+
const { width, height } = mediaItemDimensions(item);
|
|
27
|
+
if (!isPositive(width) || !isPositive(height))
|
|
28
|
+
return fallback;
|
|
29
|
+
const ratio = width / height;
|
|
30
|
+
return Number.isFinite(ratio) && ratio > 0 ? ratio : fallback;
|
|
31
|
+
}
|
|
32
|
+
function isPositive(n) {
|
|
33
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0;
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type TimelineTrack } from 'cwip/timeline';
|
|
2
|
+
import type { VideoFormat, VideoQuality } from './types';
|
|
3
|
+
/** An auxiliary text file the executor writes before running the commands. */
|
|
4
|
+
export interface RenderPlanFile {
|
|
5
|
+
name: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
/** One ffmpeg invocation (argv without the binary). */
|
|
9
|
+
export interface FfmpegCommand {
|
|
10
|
+
args: string[];
|
|
11
|
+
/** The file this command produces. */
|
|
12
|
+
outputName: string;
|
|
13
|
+
/** Output seconds this command encodes — weight for progress reporting
|
|
14
|
+
* (0 for stream-copy steps, which are ~instant). */
|
|
15
|
+
encodeSeconds: number;
|
|
16
|
+
label: string;
|
|
17
|
+
}
|
|
18
|
+
export interface TimelineRenderPlan {
|
|
19
|
+
files: RenderPlanFile[];
|
|
20
|
+
commands: FfmpegCommand[];
|
|
21
|
+
outputName: string;
|
|
22
|
+
/** Intermediates to delete after the export (NOT the inputs or the output). */
|
|
23
|
+
intermediateNames: string[];
|
|
24
|
+
totalEncodeSeconds: number;
|
|
25
|
+
/** Asset ids the executor must materialize under their mapped file names. */
|
|
26
|
+
requiredAssetIds: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface NormalizeSpec {
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
fps: number;
|
|
32
|
+
audioRate: number;
|
|
33
|
+
}
|
|
34
|
+
/** 720p30 keeps in-browser (wasm) export times tolerable; hosts can raise it. */
|
|
35
|
+
export declare const DEFAULT_NORMALIZE: NormalizeSpec;
|
|
36
|
+
export interface TimelineRenderOptions {
|
|
37
|
+
/** assetId → file name in the executor's filesystem. */
|
|
38
|
+
assetFileNames: Record<string, string>;
|
|
39
|
+
/** assetId → whether the file HAS an audio stream (default true). Sources
|
|
40
|
+
* without one get a silent bed so every chunk concats consistently. */
|
|
41
|
+
assetHasAudio?: Record<string, boolean>;
|
|
42
|
+
outputName?: string;
|
|
43
|
+
format?: VideoFormat;
|
|
44
|
+
quality?: VideoQuality;
|
|
45
|
+
normalize?: NormalizeSpec;
|
|
46
|
+
/** Prefix for every intermediate file (default "csrp_"). */
|
|
47
|
+
intermediatePrefix?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* ffmpeg's `atempo` only accepts 0.5–2.0 — chain filters for anything beyond
|
|
51
|
+
* (4× → `atempo=2.0,atempo=2.0`).
|
|
52
|
+
*/
|
|
53
|
+
export declare const buildAtempoChain: (speed: number) => string;
|
|
54
|
+
/** Video/audio speed filter fragments for a clip (null at speed 1). */
|
|
55
|
+
export declare const speedFilters: (speed: number) => {
|
|
56
|
+
video: string;
|
|
57
|
+
audio: string;
|
|
58
|
+
} | null;
|
|
59
|
+
/**
|
|
60
|
+
* Compile the timeline into an ordered command list. Throws when the first
|
|
61
|
+
* video track has no clips (nothing to render) or an asset is unmapped.
|
|
62
|
+
*/
|
|
63
|
+
export declare const buildTimelineRenderPlan: (tracks: TimelineTrack[], options: TimelineRenderOptions) => TimelineRenderPlan;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Timeline → ffmpeg render plan. Pure compiler from a cwip/timeline document to
|
|
2
|
+
// an ordered list of ffmpeg argv commands + auxiliary files — executor-agnostic:
|
|
3
|
+
// the browser runs the SAME plan on ffmpeg.wasm (`react/media/ffmpeg-wasm`) and a
|
|
4
|
+
// server can spawn native ffmpeg against it. Nothing here touches an FS.
|
|
5
|
+
//
|
|
6
|
+
// Pipeline shape (the two-pass normalize+concat that survives mixed sources):
|
|
7
|
+
// 1. per video clip — extract the source window, apply speed (setpts/atempo),
|
|
8
|
+
// volume, and NORMALIZE to one spec (scale+pad+fps, resampled audio, a
|
|
9
|
+
// silent bed when the source has no audio) → chunk_i
|
|
10
|
+
// 2. concat — all chunks share codec/fps/resolution, so `-c copy`
|
|
11
|
+
// 3. audio mix — audio-track clips are extracted the same way and
|
|
12
|
+
// `adelay`ed to their timeline offsets, then `amix`ed under the video lane
|
|
13
|
+
//
|
|
14
|
+
// v1 model notes: ONE video lane (the first video track) — clips are laid
|
|
15
|
+
// end-to-end in timeline order, so close gaps before exporting if you want cuts
|
|
16
|
+
// where the gaps were. Audio clip offsets are their absolute `timelineStart`.
|
|
17
|
+
import { clipDuration, roundSeconds } from 'cwip/timeline';
|
|
18
|
+
import { videoCodecPlan } from './ffmpeg';
|
|
19
|
+
/** 720p30 keeps in-browser (wasm) export times tolerable; hosts can raise it. */
|
|
20
|
+
export const DEFAULT_NORMALIZE = {
|
|
21
|
+
width: 1280,
|
|
22
|
+
height: 720,
|
|
23
|
+
fps: 30,
|
|
24
|
+
audioRate: 44100,
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* ffmpeg's `atempo` only accepts 0.5–2.0 — chain filters for anything beyond
|
|
28
|
+
* (4× → `atempo=2.0,atempo=2.0`).
|
|
29
|
+
*/
|
|
30
|
+
export const buildAtempoChain = (speed) => {
|
|
31
|
+
const filters = [];
|
|
32
|
+
let remaining = speed;
|
|
33
|
+
while (remaining > 2) {
|
|
34
|
+
filters.push('atempo=2.0');
|
|
35
|
+
remaining /= 2;
|
|
36
|
+
}
|
|
37
|
+
while (remaining < 0.5) {
|
|
38
|
+
filters.push('atempo=0.5');
|
|
39
|
+
remaining /= 0.5;
|
|
40
|
+
}
|
|
41
|
+
filters.push(`atempo=${remaining.toFixed(4)}`);
|
|
42
|
+
return filters.join(',');
|
|
43
|
+
};
|
|
44
|
+
/** Video/audio speed filter fragments for a clip (null at speed 1). */
|
|
45
|
+
export const speedFilters = (speed) => speed === 1
|
|
46
|
+
? null
|
|
47
|
+
: { video: `setpts=${(1 / speed).toFixed(6)}*PTS`, audio: buildAtempoChain(speed) };
|
|
48
|
+
const scalePad = (n) => `scale=${n.width}:${n.height}:force_original_aspect_ratio=decrease,` +
|
|
49
|
+
`pad=${n.width}:${n.height}:(ow-iw)/2:(oh-ih)/2,fps=${n.fps}`;
|
|
50
|
+
/** The `-vf` chain for one clip: reset timestamps, speed, then normalize.
|
|
51
|
+
* `setpts=PTS-STARTPTS` rebases the extracted window to t=0 — without it a
|
|
52
|
+
* source whose first frame carries a non-zero PTS (common for screen/live
|
|
53
|
+
* recordings) makes the `fps` filter emit a BLACK frame to fill the gap before
|
|
54
|
+
* the real first frame, so the export opens on black instead of the picture. */
|
|
55
|
+
const videoFilter = (clip, normalize) => {
|
|
56
|
+
const speed = speedFilters(clip.speed);
|
|
57
|
+
return ['setpts=PTS-STARTPTS', speed?.video, scalePad(normalize)].filter(Boolean).join(',');
|
|
58
|
+
};
|
|
59
|
+
/** The audio chain for one clip: reset timestamps, speed, volume, resample.
|
|
60
|
+
* `asetpts=PTS-STARTPTS` mirrors the video rebase so audio stays aligned. */
|
|
61
|
+
const audioFilter = (clip, normalize) => {
|
|
62
|
+
const speed = speedFilters(clip.speed);
|
|
63
|
+
const volume = clip.volume !== 1 ? `volume=${clip.volume.toFixed(3)}` : null;
|
|
64
|
+
return ['asetpts=PTS-STARTPTS', speed?.audio, volume, `aresample=${normalize.audioRate}`]
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
.join(',');
|
|
67
|
+
};
|
|
68
|
+
const BASE_ARGS = ['-hide_banner', '-nostdin', '-y'];
|
|
69
|
+
const sourceFileOf = (ctx, clip) => {
|
|
70
|
+
const file = ctx.options.assetFileNames[clip.sourceAssetId];
|
|
71
|
+
if (!file) {
|
|
72
|
+
throw new Error(`[renderPlan] no file name mapped for asset "${clip.sourceAssetId}" (clip "${clip.name}") — add it to assetFileNames`);
|
|
73
|
+
}
|
|
74
|
+
return file;
|
|
75
|
+
};
|
|
76
|
+
const hasAudio = (ctx, clip) => ctx.options.assetHasAudio?.[clip.sourceAssetId] ?? true;
|
|
77
|
+
/** Extract + normalize one VIDEO clip into a self-contained chunk. */
|
|
78
|
+
const videoChunkCommand = (ctx, clip, index) => {
|
|
79
|
+
const plan = videoCodecPlan(ctx.options.format, ctx.options.quality);
|
|
80
|
+
const outputName = `${ctx.prefix}v${index}.${ctx.options.format === 'webm' ? 'webm' : 'mp4'}`;
|
|
81
|
+
const silent = !hasAudio(ctx, clip);
|
|
82
|
+
const args = [
|
|
83
|
+
...BASE_ARGS,
|
|
84
|
+
'-ss',
|
|
85
|
+
String(clip.trackIn),
|
|
86
|
+
'-to',
|
|
87
|
+
String(clip.trackOut),
|
|
88
|
+
'-i',
|
|
89
|
+
sourceFileOf(ctx, clip),
|
|
90
|
+
// A silent stereo bed when the source has no audio track, so EVERY chunk
|
|
91
|
+
// carries an audio stream and the concat/copy step stays consistent.
|
|
92
|
+
...(silent
|
|
93
|
+
? [
|
|
94
|
+
'-f',
|
|
95
|
+
'lavfi',
|
|
96
|
+
'-t',
|
|
97
|
+
String(clipDuration(clip)),
|
|
98
|
+
'-i',
|
|
99
|
+
`anullsrc=channel_layout=stereo:sample_rate=${ctx.normalize.audioRate}`,
|
|
100
|
+
]
|
|
101
|
+
: []),
|
|
102
|
+
'-vf',
|
|
103
|
+
videoFilter(clip, ctx.normalize),
|
|
104
|
+
...(silent ? ['-map', '0:v:0', '-map', '1:a:0'] : ['-af', audioFilter(clip, ctx.normalize)]),
|
|
105
|
+
'-c:v',
|
|
106
|
+
plan.videoCodec,
|
|
107
|
+
'-preset',
|
|
108
|
+
plan.preset,
|
|
109
|
+
'-crf',
|
|
110
|
+
String(plan.crf),
|
|
111
|
+
'-c:a',
|
|
112
|
+
plan.audioCodec,
|
|
113
|
+
...plan.extraOutputArgs,
|
|
114
|
+
outputName,
|
|
115
|
+
];
|
|
116
|
+
return {
|
|
117
|
+
args,
|
|
118
|
+
outputName,
|
|
119
|
+
encodeSeconds: clipDuration(clip),
|
|
120
|
+
label: `video clip ${index + 1} (${clip.name})`,
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
/** Extract one AUDIO-track clip as a lone audio chunk (m4a). */
|
|
124
|
+
const audioChunkCommand = (ctx, clip, index) => {
|
|
125
|
+
const outputName = `${ctx.prefix}a${index}.m4a`;
|
|
126
|
+
const args = [
|
|
127
|
+
...BASE_ARGS,
|
|
128
|
+
'-ss',
|
|
129
|
+
String(clip.trackIn),
|
|
130
|
+
'-to',
|
|
131
|
+
String(clip.trackOut),
|
|
132
|
+
'-i',
|
|
133
|
+
sourceFileOf(ctx, clip),
|
|
134
|
+
'-vn',
|
|
135
|
+
'-af',
|
|
136
|
+
audioFilter(clip, ctx.normalize),
|
|
137
|
+
'-c:a',
|
|
138
|
+
'aac',
|
|
139
|
+
outputName,
|
|
140
|
+
];
|
|
141
|
+
return {
|
|
142
|
+
args,
|
|
143
|
+
outputName,
|
|
144
|
+
encodeSeconds: clipDuration(clip),
|
|
145
|
+
label: `audio clip ${index + 1} (${clip.name})`,
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Compile the timeline into an ordered command list. Throws when the first
|
|
150
|
+
* video track has no clips (nothing to render) or an asset is unmapped.
|
|
151
|
+
*/
|
|
152
|
+
export const buildTimelineRenderPlan = (tracks, options) => {
|
|
153
|
+
const normalize = options.normalize ?? DEFAULT_NORMALIZE;
|
|
154
|
+
const prefix = options.intermediatePrefix ?? 'csrp_';
|
|
155
|
+
const outputName = options.outputName ?? 'output.mp4';
|
|
156
|
+
const ctx = { options: options, normalize, prefix };
|
|
157
|
+
const videoTrack = tracks.find((t) => t.type === 'video');
|
|
158
|
+
const videoClips = [...(videoTrack?.clips ?? [])].sort((a, b) => a.timelineStart - b.timelineStart);
|
|
159
|
+
if (videoClips.length === 0) {
|
|
160
|
+
throw new Error('[renderPlan] the timeline has no video clips — nothing to render');
|
|
161
|
+
}
|
|
162
|
+
const audioClips = tracks
|
|
163
|
+
.filter((t) => t.type === 'audio')
|
|
164
|
+
.flatMap((t) => t.clips)
|
|
165
|
+
.sort((a, b) => a.timelineStart - b.timelineStart);
|
|
166
|
+
const commands = [];
|
|
167
|
+
const files = [];
|
|
168
|
+
const intermediateNames = [];
|
|
169
|
+
// 1. video chunks
|
|
170
|
+
const videoChunks = videoClips.map((clip, i) => videoChunkCommand(ctx, clip, i));
|
|
171
|
+
commands.push(...videoChunks);
|
|
172
|
+
intermediateNames.push(...videoChunks.map((c) => c.outputName));
|
|
173
|
+
// 2. concat (stream copy — chunks are normalized to one spec)
|
|
174
|
+
const listName = `${prefix}concat.txt`;
|
|
175
|
+
files.push({ name: listName, text: videoChunks.map((c) => `file '${c.outputName}'`).join('\n') });
|
|
176
|
+
intermediateNames.push(listName);
|
|
177
|
+
const needsMix = audioClips.length > 0;
|
|
178
|
+
const videoLaneName = needsMix ? `${prefix}lane.mp4` : outputName;
|
|
179
|
+
commands.push({
|
|
180
|
+
args: [...BASE_ARGS, '-f', 'concat', '-safe', '0', '-i', listName, '-c', 'copy', videoLaneName],
|
|
181
|
+
outputName: videoLaneName,
|
|
182
|
+
encodeSeconds: 0,
|
|
183
|
+
label: 'concat video lane',
|
|
184
|
+
});
|
|
185
|
+
// 3. audio mix under the lane
|
|
186
|
+
if (needsMix) {
|
|
187
|
+
intermediateNames.push(videoLaneName);
|
|
188
|
+
const audioChunks = audioClips.map((clip, i) => audioChunkCommand(ctx, clip, i));
|
|
189
|
+
commands.push(...audioChunks);
|
|
190
|
+
intermediateNames.push(...audioChunks.map((c) => c.outputName));
|
|
191
|
+
const inputs = [videoLaneName, ...audioChunks.map((c) => c.outputName)];
|
|
192
|
+
const delays = audioClips.map((clip, i) => {
|
|
193
|
+
const ms = Math.max(0, Math.round(clip.timelineStart * 1000));
|
|
194
|
+
return `[${i + 1}:a]adelay=${ms}|${ms}[d${i}]`;
|
|
195
|
+
});
|
|
196
|
+
const mixInputs = ['[0:a]', ...audioClips.map((_, i) => `[d${i}]`)].join('');
|
|
197
|
+
const filterComplex = [
|
|
198
|
+
...delays,
|
|
199
|
+
`${mixInputs}amix=inputs=${audioClips.length + 1}:duration=first:normalize=0[aout]`,
|
|
200
|
+
].join(';');
|
|
201
|
+
commands.push({
|
|
202
|
+
args: [
|
|
203
|
+
...BASE_ARGS,
|
|
204
|
+
...inputs.flatMap((name) => ['-i', name]),
|
|
205
|
+
'-filter_complex',
|
|
206
|
+
filterComplex,
|
|
207
|
+
'-map',
|
|
208
|
+
'0:v',
|
|
209
|
+
'-map',
|
|
210
|
+
'[aout]',
|
|
211
|
+
'-c:v',
|
|
212
|
+
'copy',
|
|
213
|
+
'-c:a',
|
|
214
|
+
'aac',
|
|
215
|
+
outputName,
|
|
216
|
+
],
|
|
217
|
+
outputName,
|
|
218
|
+
// re-encodes the full audio lane under a copied video stream — cheap but
|
|
219
|
+
// not instant; weight it by the lane length.
|
|
220
|
+
encodeSeconds: roundSeconds(videoClips.reduce((sum, c) => sum + clipDuration(c), 0)),
|
|
221
|
+
label: 'mix audio tracks',
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
files,
|
|
226
|
+
commands,
|
|
227
|
+
outputName,
|
|
228
|
+
intermediateNames,
|
|
229
|
+
totalEncodeSeconds: roundSeconds(commands.reduce((sum, c) => sum + c.encodeSeconds, 0)),
|
|
230
|
+
requiredAssetIds: [...new Set([...videoClips, ...audioClips].map((c) => c.sourceAssetId))],
|
|
231
|
+
};
|
|
232
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** A source image as raw RGBA pixels at its working dimensions. */
|
|
2
|
+
export interface SegmentSource {
|
|
3
|
+
/** RGBA pixels, length `width * height * 4`. */
|
|
4
|
+
readonly data: Uint8ClampedArray;
|
|
5
|
+
readonly width: number;
|
|
6
|
+
readonly height: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* What to select. `point` = magic-wand from a clicked pixel; `box` = the dominant object
|
|
10
|
+
* inside a dragged rectangle. Coordinates are in the SOURCE pixel space passed in `source`
|
|
11
|
+
* (the painter downscales both the image and the prompt together for large images).
|
|
12
|
+
*/
|
|
13
|
+
export type SegmentPrompt = {
|
|
14
|
+
readonly type: 'point';
|
|
15
|
+
readonly x: number;
|
|
16
|
+
readonly y: number;
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: 'box';
|
|
19
|
+
readonly x0: number;
|
|
20
|
+
readonly y0: number;
|
|
21
|
+
readonly x1: number;
|
|
22
|
+
readonly y1: number;
|
|
23
|
+
};
|
|
24
|
+
export interface SegmentOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Color-distance tolerance (RGB euclidean, 0–441) for region growth: a neighbor joins
|
|
27
|
+
* the region when it is within this distance of the seed color. Higher = looser (selects
|
|
28
|
+
* more, leaks across gradients sooner). Default {@link DEFAULT_TOLERANCE}. The painter maps
|
|
29
|
+
* a 0–100 "sensitivity" slider onto this via {@link sensitivityToTolerance}.
|
|
30
|
+
*/
|
|
31
|
+
readonly tolerance?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Morphological close radius (px) applied after growth to fill speckle + small holes and
|
|
34
|
+
* smooth the edge. 0 disables it. Default {@link DEFAULT_CLOSE_RADIUS}.
|
|
35
|
+
*/
|
|
36
|
+
readonly closeRadius?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A promptable image segmenter: `(source, prompt) → binary alpha plane (0/255)` at the
|
|
40
|
+
* source's dimensions, white where selected. `segment` may be sync (the pure region grower)
|
|
41
|
+
* or async (a model-backed engine); the painter always awaits it. `dispose` releases any
|
|
42
|
+
* held resources (e.g. an ONNX session) and is optional.
|
|
43
|
+
*/
|
|
44
|
+
export interface ImageSegmenter {
|
|
45
|
+
/** Stable identifier for diagnostics / telemetry (e.g. `'region-grow'`, `'mobile-sam'`). */
|
|
46
|
+
readonly id: string;
|
|
47
|
+
segment(source: SegmentSource, prompt: SegmentPrompt, options?: SegmentOptions): Uint8ClampedArray | Promise<Uint8ClampedArray>;
|
|
48
|
+
dispose?(): void;
|
|
49
|
+
}
|
|
50
|
+
export declare const DEFAULT_TOLERANCE = 28;
|
|
51
|
+
export declare const DEFAULT_CLOSE_RADIUS = 2;
|
|
52
|
+
/** Map a 0–100 "sensitivity" slider to a growth tolerance. 0 → tight (exact color), 100 →
|
|
53
|
+
* loose. A gentle quadratic keeps the low end fine-grained where most clicks land. */
|
|
54
|
+
export declare function sensitivityToTolerance(sensitivity: number): number;
|
|
55
|
+
/** 4-connected binary dilate (grow by 1px). */
|
|
56
|
+
export declare function dilateMask(mask: Uint8ClampedArray, width: number, height: number): Uint8ClampedArray;
|
|
57
|
+
/** 4-connected binary erode (shrink by 1px). */
|
|
58
|
+
export declare function erodeMask(mask: Uint8ClampedArray, width: number, height: number): Uint8ClampedArray;
|
|
59
|
+
/** Morphological close (dilate×r then erode×r): fills holes/gaps ≤ ~2·radius and smooths
|
|
60
|
+
* the edge without changing the overall area. */
|
|
61
|
+
export declare function closeMask(mask: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray;
|
|
62
|
+
/** Working size that fits within `maxPixels` while preserving aspect (never upsizes). */
|
|
63
|
+
export declare function fitWorkingSize(width: number, height: number, maxPixels: number): {
|
|
64
|
+
width: number;
|
|
65
|
+
height: number;
|
|
66
|
+
scale: number;
|
|
67
|
+
};
|
|
68
|
+
/** Area-average downscale of an RGBA buffer (stable colors → cleaner growth than nearest). */
|
|
69
|
+
export declare function downscaleRgba(data: Uint8ClampedArray, sw: number, sh: number, dw: number, dh: number): Uint8ClampedArray;
|
|
70
|
+
/** Nearest-neighbor upscale of a 0/255 mask back to the source's natural dimensions. */
|
|
71
|
+
export declare function upscaleMask(mask: Uint8ClampedArray, sw: number, sh: number, dw: number, dh: number): Uint8ClampedArray;
|
|
72
|
+
/**
|
|
73
|
+
* The default, pure region-growing segmenter — no model, no download, instant + offline.
|
|
74
|
+
* `point` grows a magic-wand selection from the clicked pixel; `box` selects the dominant
|
|
75
|
+
* object inside the rectangle by growing from a spread of interior seeds, clipped to the
|
|
76
|
+
* box so it can't leak into the surrounding scene. Always returns a binary alpha plane at
|
|
77
|
+
* the source dimensions; the painter lands it as an editable region for accept/refine.
|
|
78
|
+
*/
|
|
79
|
+
export declare function createRegionSegmenter(defaults?: SegmentOptions): ImageSegmenter;
|