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,106 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { ImagePipelineSpec, VideoTrimSpec } from './types';
|
|
3
|
+
import {
|
|
4
|
+
assertValidImagePipelineSpec,
|
|
5
|
+
assertValidVideoTrimSpec,
|
|
6
|
+
validateImagePipelineSpec,
|
|
7
|
+
validateVideoTrimSpec,
|
|
8
|
+
} from './validate';
|
|
9
|
+
|
|
10
|
+
const baseImage = (over: Partial<ImagePipelineSpec> = {}): ImagePipelineSpec => ({
|
|
11
|
+
input: '/tmp/in.jpg',
|
|
12
|
+
operations: [],
|
|
13
|
+
output: { format: 'webp', quality: 80 },
|
|
14
|
+
...over,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('validateImagePipelineSpec', () => {
|
|
18
|
+
test('accepts a well-formed spec', () => {
|
|
19
|
+
const r = validateImagePipelineSpec(
|
|
20
|
+
baseImage({
|
|
21
|
+
operations: [
|
|
22
|
+
{ type: 'resize', width: 200 },
|
|
23
|
+
{ type: 'crop', left: 0, top: 0, width: 50, height: 50 },
|
|
24
|
+
{ type: 'rotate', angle: 90 },
|
|
25
|
+
{ type: 'flip', direction: 'horizontal' },
|
|
26
|
+
{ type: 'remove-bg', tolerance: 30 },
|
|
27
|
+
{ type: 'format-convert' },
|
|
28
|
+
],
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
expect(r.valid).toBe(true);
|
|
32
|
+
expect(r.errors).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('accepts a Uint8Array input', () => {
|
|
36
|
+
expect(validateImagePipelineSpec(baseImage({ input: new Uint8Array([1, 2, 3]) })).valid).toBe(
|
|
37
|
+
true,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('rejects a bad output format + out-of-range quality', () => {
|
|
42
|
+
const r = validateImagePipelineSpec(
|
|
43
|
+
baseImage({
|
|
44
|
+
output: { format: 'gif' as unknown as ImagePipelineSpec['output']['format'], quality: 200 },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
expect(r.valid).toBe(false);
|
|
48
|
+
expect(r.errors.join(' ')).toContain('output.format');
|
|
49
|
+
expect(r.errors.join(' ')).toContain('quality');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('flags an empty resize, a non-positive crop, and a non-finite rotate', () => {
|
|
53
|
+
const r = validateImagePipelineSpec(
|
|
54
|
+
baseImage({
|
|
55
|
+
operations: [
|
|
56
|
+
{ type: 'resize' },
|
|
57
|
+
{ type: 'crop', left: -1, top: 0, width: 0, height: 10 },
|
|
58
|
+
{ type: 'rotate', angle: Number.NaN },
|
|
59
|
+
],
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
expect(r.valid).toBe(false);
|
|
63
|
+
expect(r.errors.length).toBeGreaterThanOrEqual(3);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('assert throws with a joined message', () => {
|
|
67
|
+
expect(() => assertValidImagePipelineSpec(baseImage({ input: '' }))).toThrow(
|
|
68
|
+
/Invalid ImagePipelineSpec/,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const baseTrim = (over: Partial<VideoTrimSpec> = {}): VideoTrimSpec => ({
|
|
74
|
+
inputPath: '/tmp/in.mp4',
|
|
75
|
+
outputPath: '/tmp/out.mp4',
|
|
76
|
+
startSeconds: 2,
|
|
77
|
+
endSeconds: 8,
|
|
78
|
+
...over,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('validateVideoTrimSpec', () => {
|
|
82
|
+
test('accepts a valid trim', () => {
|
|
83
|
+
expect(validateVideoTrimSpec(baseTrim({ format: 'webm', quality: 'high' })).valid).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('rejects end <= start', () => {
|
|
87
|
+
const r = validateVideoTrimSpec(baseTrim({ startSeconds: 5, endSeconds: 5 }));
|
|
88
|
+
expect(r.valid).toBe(false);
|
|
89
|
+
expect(r.errors.join(' ')).toContain('greater than startSeconds');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('rejects negative bounds, missing paths, bad enums', () => {
|
|
93
|
+
expect(validateVideoTrimSpec(baseTrim({ startSeconds: -1 })).valid).toBe(false);
|
|
94
|
+
expect(validateVideoTrimSpec(baseTrim({ inputPath: '' })).valid).toBe(false);
|
|
95
|
+
expect(
|
|
96
|
+
validateVideoTrimSpec(baseTrim({ format: 'mov' as unknown as VideoTrimSpec['format'] }))
|
|
97
|
+
.valid,
|
|
98
|
+
).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('assert throws', () => {
|
|
102
|
+
expect(() => assertValidVideoTrimSpec(baseTrim({ outputPath: '' }))).toThrow(
|
|
103
|
+
/Invalid VideoTrimSpec/,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Pure spec validation — catches a malformed pipeline/trim spec BEFORE it reaches
|
|
2
|
+
// sharp or ffmpeg (where the failure mode is an opaque native error). No deps;
|
|
3
|
+
// `validate*` returns a collected error list, `assert*` throws on the first problem.
|
|
4
|
+
|
|
5
|
+
import { IMAGE_OUTPUT_FORMATS } from './format';
|
|
6
|
+
import type { ImageOperation, ImagePipelineSpec, VideoTrimSpec } from './types';
|
|
7
|
+
|
|
8
|
+
export interface ValidationResult {
|
|
9
|
+
valid: boolean;
|
|
10
|
+
errors: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ok(errors: string[]): ValidationResult {
|
|
14
|
+
return { valid: errors.length === 0, errors };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isPositive = (n: unknown): n is number =>
|
|
18
|
+
typeof n === 'number' && Number.isFinite(n) && n > 0;
|
|
19
|
+
const isNonNeg = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
20
|
+
|
|
21
|
+
function validateOperation(op: ImageOperation, i: number, errors: string[]): void {
|
|
22
|
+
const at = `operations[${i}]`;
|
|
23
|
+
switch (op.type) {
|
|
24
|
+
case 'resize':
|
|
25
|
+
if (op.width === undefined && op.height === undefined) {
|
|
26
|
+
errors.push(`${at} (resize) needs at least one of width/height`);
|
|
27
|
+
}
|
|
28
|
+
if (op.width !== undefined && !isPositive(op.width))
|
|
29
|
+
errors.push(`${at} resize.width must be > 0`);
|
|
30
|
+
if (op.height !== undefined && !isPositive(op.height)) {
|
|
31
|
+
errors.push(`${at} resize.height must be > 0`);
|
|
32
|
+
}
|
|
33
|
+
break;
|
|
34
|
+
case 'crop':
|
|
35
|
+
if (!isNonNeg(op.left) || !isNonNeg(op.top)) errors.push(`${at} crop.left/top must be >= 0`);
|
|
36
|
+
if (!isPositive(op.width) || !isPositive(op.height)) {
|
|
37
|
+
errors.push(`${at} crop.width/height must be > 0`);
|
|
38
|
+
}
|
|
39
|
+
break;
|
|
40
|
+
case 'rotate':
|
|
41
|
+
if (typeof op.angle !== 'number' || !Number.isFinite(op.angle)) {
|
|
42
|
+
errors.push(`${at} rotate.angle must be a finite number`);
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
case 'flip':
|
|
46
|
+
if (op.direction !== 'horizontal' && op.direction !== 'vertical') {
|
|
47
|
+
errors.push(`${at} flip.direction must be 'horizontal' | 'vertical'`);
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
case 'compress':
|
|
51
|
+
if (!isPositive(op.quality) || op.quality > 100) {
|
|
52
|
+
errors.push(`${at} compress.quality must be in 1..100`);
|
|
53
|
+
}
|
|
54
|
+
break;
|
|
55
|
+
case 'remove-bg':
|
|
56
|
+
if (op.tolerance !== undefined && (!isNonNeg(op.tolerance) || op.tolerance > 441)) {
|
|
57
|
+
errors.push(`${at} remove-bg.tolerance must be in 0..441`);
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
case 'format-convert':
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function validateImagePipelineSpec(spec: ImagePipelineSpec): ValidationResult {
|
|
66
|
+
const errors: string[] = [];
|
|
67
|
+
if (spec == null || typeof spec !== 'object') return ok(['spec must be an object']);
|
|
68
|
+
if (typeof spec.input !== 'string' && !(spec.input instanceof Uint8Array)) {
|
|
69
|
+
errors.push('input must be a file path (string) or Uint8Array');
|
|
70
|
+
} else if (typeof spec.input === 'string' && spec.input.length === 0) {
|
|
71
|
+
errors.push('input path must be non-empty');
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(spec.operations)) {
|
|
74
|
+
errors.push('operations must be an array');
|
|
75
|
+
} else {
|
|
76
|
+
spec.operations.forEach((op, i) => {
|
|
77
|
+
validateOperation(op, i, errors);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (!spec.output || !IMAGE_OUTPUT_FORMATS.includes(spec.output.format)) {
|
|
81
|
+
errors.push(`output.format must be one of ${IMAGE_OUTPUT_FORMATS.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
if (spec.output && spec.output.quality !== undefined) {
|
|
84
|
+
if (!isPositive(spec.output.quality) || spec.output.quality > 100) {
|
|
85
|
+
errors.push('output.quality must be in 1..100');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return ok(errors);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function assertValidImagePipelineSpec(spec: ImagePipelineSpec): void {
|
|
92
|
+
const { valid, errors } = validateImagePipelineSpec(spec);
|
|
93
|
+
if (!valid) throw new Error(`Invalid ImagePipelineSpec: ${errors.join('; ')}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function validateVideoTrimSpec(spec: VideoTrimSpec): ValidationResult {
|
|
97
|
+
const errors: string[] = [];
|
|
98
|
+
if (spec == null || typeof spec !== 'object') return ok(['spec must be an object']);
|
|
99
|
+
if (typeof spec.inputPath !== 'string' || spec.inputPath.length === 0) {
|
|
100
|
+
errors.push('inputPath must be a non-empty string');
|
|
101
|
+
}
|
|
102
|
+
if (typeof spec.outputPath !== 'string' || spec.outputPath.length === 0) {
|
|
103
|
+
errors.push('outputPath must be a non-empty string');
|
|
104
|
+
}
|
|
105
|
+
if (!isNonNeg(spec.startSeconds)) errors.push('startSeconds must be >= 0');
|
|
106
|
+
if (!isNonNeg(spec.endSeconds)) errors.push('endSeconds must be >= 0');
|
|
107
|
+
if (
|
|
108
|
+
isNonNeg(spec.startSeconds) &&
|
|
109
|
+
isNonNeg(spec.endSeconds) &&
|
|
110
|
+
spec.endSeconds <= spec.startSeconds
|
|
111
|
+
) {
|
|
112
|
+
errors.push('endSeconds must be greater than startSeconds');
|
|
113
|
+
}
|
|
114
|
+
if (spec.format !== undefined && spec.format !== 'mp4' && spec.format !== 'webm') {
|
|
115
|
+
errors.push("format must be 'mp4' | 'webm'");
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
spec.quality !== undefined &&
|
|
119
|
+
spec.quality !== 'high' &&
|
|
120
|
+
spec.quality !== 'medium' &&
|
|
121
|
+
spec.quality !== 'low'
|
|
122
|
+
) {
|
|
123
|
+
errors.push("quality must be 'high' | 'medium' | 'low'");
|
|
124
|
+
}
|
|
125
|
+
return ok(errors);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function assertValidVideoTrimSpec(spec: VideoTrimSpec): void {
|
|
129
|
+
const { valid, errors } = validateVideoTrimSpec(spec);
|
|
130
|
+
if (!valid) throw new Error(`Invalid VideoTrimSpec: ${errors.join('; ')}`);
|
|
131
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cut-list algebra. Pure, so every rule the editor and the encoder both depend on is
|
|
3
|
+
* asserted once here rather than twice, differently, in two repos.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
addSplit,
|
|
8
|
+
type CutPlan,
|
|
9
|
+
cutListProblem,
|
|
10
|
+
cutSeconds,
|
|
11
|
+
cutTimecode,
|
|
12
|
+
keptSeconds,
|
|
13
|
+
keptSegments,
|
|
14
|
+
MIN_CUT_SECONDS,
|
|
15
|
+
normalizeCuts,
|
|
16
|
+
planCuts,
|
|
17
|
+
planProblem,
|
|
18
|
+
planSegments,
|
|
19
|
+
removeSplit,
|
|
20
|
+
toggleSegment,
|
|
21
|
+
type VideoCut,
|
|
22
|
+
} from './videoCuts';
|
|
23
|
+
|
|
24
|
+
const cut = (start: number, end: number): VideoCut => ({ start, end });
|
|
25
|
+
const shape = (cuts: readonly VideoCut[]): string =>
|
|
26
|
+
cuts.map((c) => `${c.start}-${c.end}`).join(' ');
|
|
27
|
+
|
|
28
|
+
describe('normalizeCuts', () => {
|
|
29
|
+
test('orders marks by where they are, not by when they were made', () => {
|
|
30
|
+
expect(shape(normalizeCuts([cut(30, 40), cut(5, 10)], 60))).toBe('5-10 30-40');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('a mark dragged backwards is still a mark', () => {
|
|
34
|
+
// Scrubbing right-to-left is how half of these get made.
|
|
35
|
+
expect(shape(normalizeCuts([cut(40, 30)], 60))).toBe('30-40');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('🔴 overlapping marks MERGE rather than being refused', () => {
|
|
39
|
+
// Not a mistake to report: cutting a passage, watching it back and cutting a span that
|
|
40
|
+
// includes part of what you already removed is the ordinary way this gets used.
|
|
41
|
+
expect(shape(normalizeCuts([cut(5, 20), cut(15, 30)], 60))).toBe('5-30');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('🔴 marks that TOUCH are one mark — the spans are half-open', () => {
|
|
45
|
+
// `[5,10)` then `[10,20)` leaves no frame between them, so two rows here would be a list
|
|
46
|
+
// that cannot be acted on differently from one row.
|
|
47
|
+
expect(shape(normalizeCuts([cut(5, 10), cut(10, 20)], 60))).toBe('5-20');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a mark inside another disappears into it', () => {
|
|
51
|
+
expect(shape(normalizeCuts([cut(5, 40), cut(10, 20)], 60))).toBe('5-40');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('marks are clamped into the clip', () => {
|
|
55
|
+
expect(shape(normalizeCuts([cut(-10, 5), cut(55, 900)], 60))).toBe('0-5 55-60');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a span too short to hold a frame is dropped', () => {
|
|
59
|
+
expect(normalizeCuts([cut(5, 5 + MIN_CUT_SECONDS / 2)], 60)).toEqual([]);
|
|
60
|
+
expect(normalizeCuts([cut(5, 5 + MIN_CUT_SECONDS)], 60)).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('🔴 a NaN bound drops its mark instead of poisoning the sort', () => {
|
|
64
|
+
/*
|
|
65
|
+
* The usual source is a `<video>` whose metadata has not loaded, which reports NaN for
|
|
66
|
+
* duration and therefore for anything derived from the playhead. Every comparison against
|
|
67
|
+
* NaN is false, so leaving it in produces an array `sort` calls sorted and is not —
|
|
68
|
+
* and the merge pass below then leaves real overlaps in, silently.
|
|
69
|
+
*/
|
|
70
|
+
expect(shape(normalizeCuts([cut(30, 40), cut(Number.NaN, 10), cut(5, 8)], 60))).toBe(
|
|
71
|
+
'5-8 30-40',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('an unknown duration means no cuts can be placed', () => {
|
|
76
|
+
expect(normalizeCuts([cut(5, 10)], Number.NaN)).toEqual([]);
|
|
77
|
+
expect(normalizeCuts([cut(5, 10)], 0)).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('keptSegments — what the encoder actually concatenates', () => {
|
|
82
|
+
test('one cut in the middle leaves the head and the tail', () => {
|
|
83
|
+
expect(shape(keptSegments([cut(10, 20)], 60))).toBe('0-10 20-60');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a cut at the very start leaves only the tail', () => {
|
|
87
|
+
expect(shape(keptSegments([cut(0, 10)], 60))).toBe('10-60');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a cut running to the end leaves only the head', () => {
|
|
91
|
+
expect(shape(keptSegments([cut(50, 60)], 60))).toBe('0-50');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('two cuts leave three pieces', () => {
|
|
95
|
+
expect(shape(keptSegments([cut(10, 20), cut(30, 40)], 60))).toBe('0-10 20-30 40-60');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('cutting everything keeps nothing', () => {
|
|
99
|
+
expect(keptSegments([cut(0, 60)], 60)).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a sliver of footage between two cuts is not kept', () => {
|
|
103
|
+
// A gap shorter than a frame is not a piece of video; concatenating it would ask the
|
|
104
|
+
// encoder for a segment with nothing in it.
|
|
105
|
+
expect(shape(keptSegments([cut(0, 10), cut(10 + MIN_CUT_SECONDS / 2, 60)], 60))).toBe('');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the two halves add up to the whole clip', () => {
|
|
109
|
+
const cuts = [cut(10, 20), cut(35, 41)];
|
|
110
|
+
expect(cutSeconds(cuts, 60) + keptSeconds(cuts, 60)).toBeCloseTo(60, 6);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('🔴 cutListProblem — the same refusal in the editor and on the server', () => {
|
|
115
|
+
test('an empty list is refused, because it would re-encode a file to no purpose', () => {
|
|
116
|
+
// Sending it queues a full pass over a multi-gigabyte master to produce a second copy
|
|
117
|
+
// that differs from the first in nothing, and then asks the owner to choose between them.
|
|
118
|
+
expect(cutListProblem([], 60)).toContain('Nothing is marked');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('🔴 cutting the whole clip is refused, because ffmpeg does NOT refuse it', () => {
|
|
122
|
+
/*
|
|
123
|
+
* `concat` with zero segments writes a valid container with no frames in it and exits 0.
|
|
124
|
+
* So there is no error anywhere: the job succeeds, the file arrives, the review screen
|
|
125
|
+
* offers a choice between the original and a video that plays nothing. The refusal has
|
|
126
|
+
* to be here, before it is queued.
|
|
127
|
+
*/
|
|
128
|
+
expect(cutListProblem([cut(0, 60)], 60)).toContain('whole video');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('an unknown duration is refused with the reason, not with a crash', () => {
|
|
132
|
+
expect(cutListProblem([cut(1, 2)], Number.NaN)).toContain('length is not known');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('an ordinary list has no problem', () => {
|
|
136
|
+
expect(cutListProblem([cut(10, 20)], 60)).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('cutTimecode', () => {
|
|
141
|
+
test('tenths, because whole seconds make two nearby marks look identical', () => {
|
|
142
|
+
expect(cutTimecode(0)).toBe('0:00.0');
|
|
143
|
+
expect(cutTimecode(9.44)).toBe('0:09.4');
|
|
144
|
+
expect(cutTimecode(9.46)).toBe('0:09.4');
|
|
145
|
+
expect(cutTimecode(75.5)).toBe('1:15.5');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('hours appear only when there are hours, and the minutes pad when they do', () => {
|
|
149
|
+
expect(cutTimecode(3725.2)).toBe('1:02:05.2');
|
|
150
|
+
expect(cutTimecode(600)).toBe('10:00.0');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('a bad number reads as zero rather than as NaN:NaN', () => {
|
|
154
|
+
expect(cutTimecode(Number.NaN)).toBe('0:00.0');
|
|
155
|
+
expect(cutTimecode(-5)).toBe('0:00.0');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
/*
|
|
161
|
+
* ── The MARKING model ─────────────────────────────────────────────────────────────────
|
|
162
|
+
*
|
|
163
|
+
* Owner, 2026-08-26: *"There are always start and end points needed for mid section removals
|
|
164
|
+
* to be valid. I think there could be ambiguity about which side to cut in some cases if the
|
|
165
|
+
* same type of marker is used for every cut marker. You can make it allow clicking 'x' on a
|
|
166
|
+
* section to indicate remove it."*
|
|
167
|
+
*
|
|
168
|
+
* So: a cut is a SPLIT, splits make SEGMENTS, and a segment is in or out. Every assertion
|
|
169
|
+
* below is about the property that buys — there is no pairing to get wrong, and every point
|
|
170
|
+
* in the clip belongs to exactly one segment whose fate is stated rather than inferred.
|
|
171
|
+
*/
|
|
172
|
+
const plan = (splits: number[], removed: VideoCut[] = []): CutPlan => ({ splits, removed });
|
|
173
|
+
const shapeOf = (p: CutPlan, duration: number): string =>
|
|
174
|
+
planSegments(p, duration)
|
|
175
|
+
.map((segment) => `${segment.removed ? 'X' : '='}${segment.start}-${segment.end}`)
|
|
176
|
+
.join(' ');
|
|
177
|
+
|
|
178
|
+
describe('splitting the clip', () => {
|
|
179
|
+
test('no splits is one segment, the whole thing, kept', () => {
|
|
180
|
+
expect(shapeOf(plan([]), 60)).toBe('=0-60');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a cut in the middle makes two segments', () => {
|
|
184
|
+
expect(shapeOf(addSplit(plan([]), 30, 60), 60)).toBe('=0-30 =30-60');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the segments always cover the whole clip with no gaps', () => {
|
|
188
|
+
// The property that removes the ambiguity: there is nowhere in the video that is not in
|
|
189
|
+
// exactly one segment, so "which side" is never a question anybody has to answer.
|
|
190
|
+
const segments = planSegments(plan([10, 20, 45]), 60);
|
|
191
|
+
expect(segments[0]?.start).toBe(0);
|
|
192
|
+
expect(segments[segments.length - 1]?.end).toBe(60);
|
|
193
|
+
for (let i = 1; i < segments.length; i++)
|
|
194
|
+
expect(segments[i]?.start).toBe(segments[i - 1]?.end as number);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('cuts are sorted however they were made, and a repeat is a no-op', () => {
|
|
198
|
+
expect(shapeOf(plan([45, 10, 10]), 60)).toBe('=0-10 =10-45 =45-60');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('a cut at the very start or end is refused — it would make a segment with no frames', () => {
|
|
202
|
+
expect(planSegments(plan([0, 60]), 60)).toHaveLength(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('two cuts a frame apart are one cut', () => {
|
|
206
|
+
// A sliver no frame can live in is also a target nobody can click.
|
|
207
|
+
expect(shapeOf(plan([30, 30.01]), 60)).toBe('=0-30 =30-60');
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("🔴 clicking ✗ on a piece — the owner's example, both halves", () => {
|
|
212
|
+
test('one cut in the middle, remove the LEFT', () => {
|
|
213
|
+
const one = addSplit(plan([]), 30, 60);
|
|
214
|
+
const after = toggleSegment(one, { start: 0, end: 30 }, 60);
|
|
215
|
+
expect(shapeOf(after, 60)).toBe('X0-30 =30-60');
|
|
216
|
+
expect(planCuts(after, 60)).toEqual([{ start: 0, end: 30 }]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('one cut in the middle, remove the RIGHT — the same gesture, no new concept', () => {
|
|
220
|
+
const one = addSplit(plan([]), 30, 60);
|
|
221
|
+
const after = toggleSegment(one, { start: 30, end: 60 }, 60);
|
|
222
|
+
expect(planCuts(after, 60)).toEqual([{ start: 30, end: 60 }]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('many sections out of the middle, which is not a special case either', () => {
|
|
226
|
+
let p = plan([]);
|
|
227
|
+
for (const at of [10, 20, 30, 40]) p = addSplit(p, at, 60);
|
|
228
|
+
p = toggleSegment(p, { start: 10, end: 20 }, 60);
|
|
229
|
+
p = toggleSegment(p, { start: 30, end: 40 }, 60);
|
|
230
|
+
expect(shapeOf(p, 60)).toBe('=0-10 X10-20 =20-30 X30-40 =40-60');
|
|
231
|
+
expect(planCuts(p, 60)).toEqual([
|
|
232
|
+
{ start: 10, end: 20 },
|
|
233
|
+
{ start: 30, end: 40 },
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('marking two pieces in a row produces ONE cut, not two', () => {
|
|
238
|
+
// The encoder should not be handed a join it would immediately have to close up.
|
|
239
|
+
let p = plan([20, 30]);
|
|
240
|
+
p = toggleSegment(p, { start: 20, end: 30 }, 60);
|
|
241
|
+
p = toggleSegment(p, { start: 30, end: 60 }, 60);
|
|
242
|
+
expect(planCuts(p, 60)).toEqual([{ start: 20, end: 60 }]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('✗ again puts it back', () => {
|
|
246
|
+
const p = toggleSegment(addSplit(plan([]), 30, 60), { start: 0, end: 30 }, 60);
|
|
247
|
+
expect(planCuts(toggleSegment(p, { start: 0, end: 30 }, 60), 60)).toEqual([]);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('🔴 cutting again after marking keeps the decision', () => {
|
|
252
|
+
test('splitting a removed piece leaves BOTH halves removed', () => {
|
|
253
|
+
/*
|
|
254
|
+
* The gesture people actually use: mark a section out, watch it back, and cut again to
|
|
255
|
+
* take a little more. If the new split dropped the mark, the footage somebody had already
|
|
256
|
+
* decided against would silently come back — and the strip would look like the cut had
|
|
257
|
+
* been undone by an action that says nothing about it.
|
|
258
|
+
*/
|
|
259
|
+
const marked = toggleSegment(addSplit(plan([]), 30, 60), { start: 0, end: 30 }, 60);
|
|
260
|
+
const again = addSplit(marked, 15, 60);
|
|
261
|
+
expect(shapeOf(again, 60)).toBe('X0-15 X15-30 =30-60');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('and un-marking one half does not resurrect the other', () => {
|
|
265
|
+
const marked = toggleSegment(addSplit(plan([]), 30, 60), { start: 0, end: 30 }, 60);
|
|
266
|
+
const again = addSplit(marked, 15, 60);
|
|
267
|
+
const restored = toggleSegment(again, { start: 0, end: 15 }, 60);
|
|
268
|
+
expect(shapeOf(restored, 60)).toBe('=0-15 X15-30 =30-60');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('🔴 undoing a cut merges, and only agreement survives', () => {
|
|
273
|
+
test('two kept pieces merge into a kept piece', () => {
|
|
274
|
+
expect(shapeOf(removeSplit(plan([30]), 30, 60), 60)).toBe('=0-60');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('two removed pieces merge into a removed piece', () => {
|
|
278
|
+
let p = plan([30]);
|
|
279
|
+
p = toggleSegment(p, { start: 0, end: 30 }, 60);
|
|
280
|
+
p = toggleSegment(p, { start: 30, end: 60 }, 60);
|
|
281
|
+
expect(shapeOf(removeSplit(p, 30, 60), 60)).toBe('X0-60');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('🔴 one of each merges to KEPT — the safe reading of a contradiction', () => {
|
|
285
|
+
/*
|
|
286
|
+
* Anything else invents an intention. Promoting it to removed deletes footage nobody
|
|
287
|
+
* marked; that is unrecoverable from the strip, because the piece it belonged to no longer
|
|
288
|
+
* exists to un-mark. Demoting costs one more click and can be seen.
|
|
289
|
+
*/
|
|
290
|
+
const p = toggleSegment(plan([30]), { start: 0, end: 30 }, 60);
|
|
291
|
+
expect(shapeOf(removeSplit(p, 30, 60), 60)).toBe('=0-60');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('undoing a cut that is not there changes nothing', () => {
|
|
295
|
+
const p = plan([30]);
|
|
296
|
+
expect(removeSplit(p, 45, 60)).toBe(p);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('a plan is refused for the same two reasons a cut list is', () => {
|
|
301
|
+
test('nothing marked', () => {
|
|
302
|
+
expect(planProblem(plan([30]), 60)).toContain('Nothing is marked');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('everything marked', () => {
|
|
306
|
+
const p = toggleSegment(plan([]), { start: 0, end: 60 }, 60);
|
|
307
|
+
expect(planProblem(p, 60)).toContain('whole video');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('an ordinary plan is fine', () => {
|
|
311
|
+
expect(planProblem(toggleSegment(plan([30]), { start: 0, end: 30 }, 60), 60)).toBeNull();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('segment keys', () => {
|
|
316
|
+
test('🔴 a key is its BOUNDS, so it cannot drift when a cut lands to its left', () => {
|
|
317
|
+
/*
|
|
318
|
+
* Stored as an index, `removed: [2]` comes to mean a different piece of footage the moment
|
|
319
|
+
* anything is split before it — during the one gesture people use most. This is also the
|
|
320
|
+
* React key, so a wrong one re-renders the wrong row.
|
|
321
|
+
*/
|
|
322
|
+
const before = planSegments(plan([20, 40]), 60);
|
|
323
|
+
const after = planSegments(addSplit(plan([20, 40]), 10, 60), 60);
|
|
324
|
+
const survivor = before.find((segment) => segment.start === 40);
|
|
325
|
+
expect(after.some((segment) => segment.key === survivor?.key)).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
});
|