@streamscloud/kit 0.30.0 → 0.32.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/dist/core/files/app-upload-activity.svelte.d.ts +20 -0
- package/dist/core/files/app-upload-activity.svelte.js +34 -0
- package/dist/core/files/file-types.d.ts +7 -0
- package/dist/core/files/file-types.js +8 -0
- package/dist/core/files/file-validation-rules.d.ts +1 -0
- package/dist/core/files/file-validation-rules.js +32 -38
- package/dist/core/files/index.d.ts +4 -3
- package/dist/core/files/index.js +2 -1
- package/dist/core/files/upload-media-store.svelte.d.ts +39 -9
- package/dist/core/files/upload-media-store.svelte.js +71 -14
- package/dist/core/files/upload-types.d.ts +16 -0
- package/dist/core/media/index.d.ts +2 -0
- package/dist/core/media/index.js +1 -0
- package/dist/core/media/video-probe.d.ts +28 -0
- package/dist/core/media/video-probe.js +82 -0
- package/dist/ui/file-uploader/cmp.file-row.svelte +19 -15
- package/dist/ui/file-uploader/cmp.file-row.svelte.d.ts +9 -8
- package/dist/ui/file-uploader/cmp.file-upload-progress.svelte +10 -31
- package/dist/ui/file-uploader/cmp.file-upload-progress.svelte.d.ts +2 -3
- package/dist/ui/file-uploader/cmp.upload-progress-toaster.svelte +132 -0
- package/dist/ui/file-uploader/cmp.upload-progress-toaster.svelte.d.ts +27 -0
- package/dist/ui/file-uploader/file-type-colors.d.ts +5 -0
- package/dist/ui/file-uploader/file-type-colors.js +11 -1
- package/dist/ui/file-uploader/file-type-stamp.svelte +71 -0
- package/dist/ui/file-uploader/file-type-stamp.svelte.d.ts +8 -0
- package/dist/ui/file-uploader/index.d.ts +3 -1
- package/dist/ui/file-uploader/index.js +2 -1
- package/dist/ui/file-uploader/types.d.ts +16 -0
- package/dist/ui/file-uploader/types.js +1 -0
- package/dist/ui/file-uploader/upload-progress-toaster-localization.d.ts +4 -0
- package/dist/ui/file-uploader/upload-progress-toaster-localization.js +23 -0
- package/dist/ui/select/_select-trigger.scss +16 -12
- package/dist/ui/select/cmp.input-suggest.svelte +495 -0
- package/dist/ui/select/cmp.input-suggest.svelte.d.ts +97 -0
- package/dist/ui/select/index.d.ts +3 -1
- package/dist/ui/select/index.js +1 -0
- package/dist/ui/select/types.d.ts +10 -0
- package/package.json +1 -1
- package/dist/ui/file-uploader/to-uploading-file.d.ts +0 -9
- package/dist/ui/file-uploader/to-uploading-file.js +0 -24
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { UploadingFile } from './upload-types';
|
|
2
|
+
declare class AppUploadActivityContainer {
|
|
3
|
+
private _runs;
|
|
4
|
+
private _nextRunId;
|
|
5
|
+
/** Files of every in-flight run, including ones that already finished inside a still-running run. Deduplicated by id. */
|
|
6
|
+
get files(): UploadingFile[];
|
|
7
|
+
/** True while at least one run is in flight. Flips only on run start / end, never on a progress tick. */
|
|
8
|
+
get isUploading(): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Register an in-flight upload run and get its id back. `files` is called on every read, so pass
|
|
11
|
+
* an accessor over live state, not a snapshot. `UploadMediaStore.upload()` registers itself — call
|
|
12
|
+
* this directly only when uploading outside the store.
|
|
13
|
+
*/
|
|
14
|
+
beginRun: (files: () => UploadingFile[]) => number;
|
|
15
|
+
/** End a run started with `beginRun` — always from a `finally`, or the run never leaves the list. */
|
|
16
|
+
endRun: (id: number) => void;
|
|
17
|
+
}
|
|
18
|
+
/** Reactive, app-wide view of what is uploading right now. Drives `UploadProgressToaster`. */
|
|
19
|
+
export declare const AppUploadActivity: AppUploadActivityContainer;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class AppUploadActivityContainer {
|
|
2
|
+
_runs = $state.raw([]);
|
|
3
|
+
_nextRunId = 0;
|
|
4
|
+
/** Files of every in-flight run, including ones that already finished inside a still-running run. Deduplicated by id. */
|
|
5
|
+
get files() {
|
|
6
|
+
const byId = new Map();
|
|
7
|
+
for (const run of this._runs) {
|
|
8
|
+
for (const file of run.files()) {
|
|
9
|
+
byId.set(file.id, file);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return [...byId.values()];
|
|
13
|
+
}
|
|
14
|
+
/** True while at least one run is in flight. Flips only on run start / end, never on a progress tick. */
|
|
15
|
+
get isUploading() {
|
|
16
|
+
return this._runs.length > 0;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Register an in-flight upload run and get its id back. `files` is called on every read, so pass
|
|
20
|
+
* an accessor over live state, not a snapshot. `UploadMediaStore.upload()` registers itself — call
|
|
21
|
+
* this directly only when uploading outside the store.
|
|
22
|
+
*/
|
|
23
|
+
beginRun = (files) => {
|
|
24
|
+
const id = this._nextRunId++;
|
|
25
|
+
this._runs = [...this._runs, { id, files }];
|
|
26
|
+
return id;
|
|
27
|
+
};
|
|
28
|
+
/** End a run started with `beginRun` — always from a `finally`, or the run never leaves the list. */
|
|
29
|
+
endRun = (id) => {
|
|
30
|
+
this._runs = this._runs.filter((run) => run.id !== id);
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Reactive, app-wide view of what is uploading right now. Drives `UploadProgressToaster`. */
|
|
34
|
+
export const AppUploadActivity = new AppUploadActivityContainer();
|
|
@@ -13,6 +13,13 @@ export declare class AcceptFileType {
|
|
|
13
13
|
static readonly webAssetImageOrVideo: string;
|
|
14
14
|
static readonly webAssetIcon = "image/png";
|
|
15
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Whether a MIME type names media a browser can present on its own — an image, a video or a
|
|
18
|
+
* sound. `false` for documents and archives, and for a blank type (`.heic`, `.mkv` and files with
|
|
19
|
+
* no extension often arrive with `file.type === ''`). Drives `UploadMediaStore`'s
|
|
20
|
+
* `{ preview: 'mime' }` mode.
|
|
21
|
+
*/
|
|
22
|
+
export declare const isPreviewableType: (type: string) => boolean;
|
|
16
23
|
/**
|
|
17
24
|
* `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
|
|
18
25
|
* extensions (`.pdf`), case-insensitive. Empty accept = match anything.
|
|
@@ -14,6 +14,14 @@ export class AcceptFileType {
|
|
|
14
14
|
static webAssetImageOrVideo = [this.webAssetImage, this.webAssetVideo].join(',');
|
|
15
15
|
static webAssetIcon = 'image/png';
|
|
16
16
|
}
|
|
17
|
+
const PREVIEWABLE_TYPE_PREFIXES = ['image/', 'video/', 'audio/'];
|
|
18
|
+
/**
|
|
19
|
+
* Whether a MIME type names media a browser can present on its own — an image, a video or a
|
|
20
|
+
* sound. `false` for documents and archives, and for a blank type (`.heic`, `.mkv` and files with
|
|
21
|
+
* no extension often arrive with `file.type === ''`). Drives `UploadMediaStore`'s
|
|
22
|
+
* `{ preview: 'mime' }` mode.
|
|
23
|
+
*/
|
|
24
|
+
export const isPreviewableType = (type) => PREVIEWABLE_TYPE_PREFIXES.some((prefix) => type.toLowerCase().startsWith(prefix));
|
|
17
25
|
/**
|
|
18
26
|
* `accept`-style matcher. Tokens may be MIMEs (`image/*`, `*\/*`, `application/pdf`) or
|
|
19
27
|
* extensions (`.pdf`), case-insensitive. Empty accept = match anything.
|
|
@@ -16,6 +16,7 @@ import type { AspectRatioBound, FileValidationRule, VideoOrientationValue } from
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
export declare const FileRules: {
|
|
19
|
+
/** Passes when the container reports no duration (e.g. a live-stream WebM) — bitrate cannot be derived without it. */
|
|
19
20
|
AudioBitrate: (maxKbps: number, message?: string) => FileValidationRule;
|
|
20
21
|
AudioDuration: (maxSeconds: number, message?: string) => FileValidationRule;
|
|
21
22
|
AudioOnly: (message?: string) => FileValidationRule;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { probeVideo } from '../media';
|
|
1
2
|
import { StringHelper } from '../utils';
|
|
2
3
|
import { FileHelper } from './file-helper';
|
|
3
4
|
import { matchesAcceptedFileTypes } from './file-types';
|
|
@@ -15,34 +16,26 @@ const readImageDimensions = (file) => new Promise((resolve, reject) => {
|
|
|
15
16
|
};
|
|
16
17
|
img.src = url;
|
|
17
18
|
});
|
|
18
|
-
const
|
|
19
|
+
const readMedia = async (file) => {
|
|
19
20
|
const url = URL.createObjectURL(file);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
reject(new Error(`Failed to read ${kind} duration`));
|
|
29
|
-
};
|
|
30
|
-
el.src = url;
|
|
31
|
-
});
|
|
32
|
-
const readVideoDimensions = (file) => new Promise((resolve, reject) => {
|
|
33
|
-
const url = URL.createObjectURL(file);
|
|
34
|
-
const video = document.createElement('video');
|
|
35
|
-
video.preload = 'metadata';
|
|
36
|
-
video.onloadedmetadata = () => {
|
|
37
|
-
URL.revokeObjectURL(url);
|
|
38
|
-
resolve({ width: video.videoWidth, height: video.videoHeight });
|
|
39
|
-
};
|
|
40
|
-
video.onerror = () => {
|
|
21
|
+
try {
|
|
22
|
+
const probe = await probeVideo(url, { cover: false });
|
|
23
|
+
if (!probe.readable) {
|
|
24
|
+
throw new Error('Failed to read media metadata');
|
|
25
|
+
}
|
|
26
|
+
return probe;
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
41
29
|
URL.revokeObjectURL(url);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
});
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const readDuration = async (file) => {
|
|
33
|
+
const { durationSec } = await readMedia(file);
|
|
34
|
+
if (durationSec === null) {
|
|
35
|
+
throw new Error('Media duration is not reported by the container');
|
|
36
|
+
}
|
|
37
|
+
return durationSec;
|
|
38
|
+
};
|
|
46
39
|
const parseRatio = (value) => {
|
|
47
40
|
if (typeof value === 'number') {
|
|
48
41
|
return Number.isFinite(value) && value > 0 ? value : NaN;
|
|
@@ -81,20 +74,21 @@ const messages = new FileValidationLocalization();
|
|
|
81
74
|
* ```
|
|
82
75
|
*/
|
|
83
76
|
export const FileRules = {
|
|
77
|
+
/** Passes when the container reports no duration (e.g. a live-stream WebM) — bitrate cannot be derived without it. */
|
|
84
78
|
AudioBitrate: (maxKbps, message) => async (file) => {
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
79
|
+
const { durationSec } = await readMedia(file);
|
|
80
|
+
if (durationSec === null || durationSec === 0) {
|
|
87
81
|
return null;
|
|
88
82
|
}
|
|
89
|
-
const kbps = (file.size * 8) /
|
|
83
|
+
const kbps = (file.size * 8) / durationSec / 1000;
|
|
90
84
|
return kbps > maxKbps ? (message ?? messages.maxBitrate(maxKbps)) : null;
|
|
91
85
|
},
|
|
92
86
|
AudioDuration: (maxSeconds, message) => async (file) => {
|
|
93
|
-
const
|
|
94
|
-
return
|
|
87
|
+
const durationSec = await readDuration(file);
|
|
88
|
+
return durationSec > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
|
|
95
89
|
},
|
|
96
90
|
AudioOnly: (message) => async (file) => {
|
|
97
|
-
const { width, height } = await
|
|
91
|
+
const { width, height } = await readMedia(file);
|
|
98
92
|
return width > 0 || height > 0 ? (message ?? messages.audioOnly) : null;
|
|
99
93
|
},
|
|
100
94
|
Extensions: (extensions, message) => (file) => {
|
|
@@ -117,7 +111,7 @@ export const FileRules = {
|
|
|
117
111
|
return rule;
|
|
118
112
|
},
|
|
119
113
|
VideoAspectRatio: (ratios, message, tolerancePercent = 1) => async (file) => {
|
|
120
|
-
const { width, height } = await
|
|
114
|
+
const { width, height } = await readMedia(file);
|
|
121
115
|
if (!width || !height) {
|
|
122
116
|
return message ?? messages.aspectRatio(ratios.join(', '));
|
|
123
117
|
}
|
|
@@ -135,7 +129,7 @@ export const FileRules = {
|
|
|
135
129
|
* interval outward on both edges. An empty `{}` specifies no constraint and accepts everything.
|
|
136
130
|
*/
|
|
137
131
|
VideoAspectRatioRange: (range, message, tolerancePercent = 1) => async (file) => {
|
|
138
|
-
const { width, height } = await
|
|
132
|
+
const { width, height } = await readMedia(file);
|
|
139
133
|
if (!width || !height) {
|
|
140
134
|
return message ?? messages.aspectRatioRange(formatBound(range.min), formatBound(range.max));
|
|
141
135
|
}
|
|
@@ -148,7 +142,7 @@ export const FileRules = {
|
|
|
148
142
|
return okLo && okHi ? null : (message ?? messages.aspectRatioRange(formatBound(range.min), formatBound(range.max)));
|
|
149
143
|
},
|
|
150
144
|
VideoOrientation: (orientation, message, tolerancePercent = 1) => async (file) => {
|
|
151
|
-
const { width, height } = await
|
|
145
|
+
const { width, height } = await readMedia(file);
|
|
152
146
|
if (!width || !height) {
|
|
153
147
|
return message ?? messages.orientation(orientation);
|
|
154
148
|
}
|
|
@@ -158,11 +152,11 @@ export const FileRules = {
|
|
|
158
152
|
return ok ? null : (message ?? messages.orientation(orientation));
|
|
159
153
|
},
|
|
160
154
|
VideoDimensions: (max, message) => async (file) => {
|
|
161
|
-
const { width, height } = await
|
|
155
|
+
const { width, height } = await readMedia(file);
|
|
162
156
|
return width > max.width || height > max.height ? (message ?? messages.maxDimensions(max.width, max.height)) : null;
|
|
163
157
|
},
|
|
164
158
|
VideoDuration: (maxSeconds, message) => async (file) => {
|
|
165
|
-
const
|
|
166
|
-
return
|
|
159
|
+
const durationSec = await readDuration(file);
|
|
160
|
+
return durationSec > maxSeconds ? (message ?? messages.maxDuration(maxSeconds)) : null;
|
|
167
161
|
}
|
|
168
162
|
};
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl } from './types';
|
|
2
2
|
export type { AspectRatioBound, FileValidationResult, FileValidationRule, FileValidationRuleSets, VideoOrientationValue } from './file-validation-types';
|
|
3
|
-
export type { BlobUpload, BlobUploadStrategy, UploadingFile, UploadingFileStatus } from './upload-types';
|
|
4
|
-
export type { UploadMediaStoreOptions } from './upload-media-store.svelte';
|
|
3
|
+
export type { BlobUpload, BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile, UploadingFileStatus } from './upload-types';
|
|
4
|
+
export type { AddOptions, UploadMediaStoreOptions } from './upload-media-store.svelte';
|
|
5
5
|
export { toBlobWithName, toFileWithUploadUrl } from './types';
|
|
6
6
|
export { uploadBlob } from './blob-storage';
|
|
7
7
|
export { openFile } from './open-file';
|
|
8
8
|
export { FileHelper } from './file-helper';
|
|
9
9
|
export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
|
|
10
|
-
export { AcceptFileType, matchesAcceptedFileTypes } from './file-types';
|
|
10
|
+
export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
|
|
11
11
|
export { deriveAccept } from './file-validation-rule-sets';
|
|
12
12
|
export { resizeBlob, resizeImage } from './image-resizer';
|
|
13
13
|
export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
|
|
@@ -15,3 +15,4 @@ export { FilesProvider } from './files-provider';
|
|
|
15
15
|
export { FileValidator } from './file-validator';
|
|
16
16
|
export { FileRules } from './file-validation-rules';
|
|
17
17
|
export { UploadMediaStore } from './upload-media-store.svelte';
|
|
18
|
+
export { AppUploadActivity } from './app-upload-activity.svelte';
|
package/dist/core/files/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { uploadBlob } from './blob-storage';
|
|
|
4
4
|
export { openFile } from './open-file';
|
|
5
5
|
export { FileHelper } from './file-helper';
|
|
6
6
|
export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
|
|
7
|
-
export { AcceptFileType, matchesAcceptedFileTypes } from './file-types';
|
|
7
|
+
export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
|
|
8
8
|
export { deriveAccept } from './file-validation-rule-sets';
|
|
9
9
|
export { resizeBlob, resizeImage } from './image-resizer';
|
|
10
10
|
export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
|
|
@@ -12,3 +12,4 @@ export { FilesProvider } from './files-provider';
|
|
|
12
12
|
export { FileValidator } from './file-validator';
|
|
13
13
|
export { FileRules } from './file-validation-rules';
|
|
14
14
|
export { UploadMediaStore } from './upload-media-store.svelte';
|
|
15
|
+
export { AppUploadActivity } from './app-upload-activity.svelte';
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import type { BlobUploadStrategy, UploadingFile } from './upload-types';
|
|
1
|
+
import type { BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile } from './upload-types';
|
|
2
2
|
type SuccessfulUpload = Extract<UploadingFile, {
|
|
3
3
|
status: 'success';
|
|
4
4
|
}>;
|
|
5
5
|
type FailedUpload = Extract<UploadingFile, {
|
|
6
6
|
status: 'error';
|
|
7
7
|
}>;
|
|
8
|
+
export type AddOptions = {
|
|
9
|
+
/** Stage an object URL for local preview. @default 'never' */
|
|
10
|
+
preview?: UploadPreviewMode;
|
|
11
|
+
};
|
|
8
12
|
export type UploadMediaStoreOptions = {
|
|
9
13
|
/**
|
|
10
14
|
* Auto-resize images before upload. Pass `false` to disable; pass an object to override the
|
|
@@ -48,25 +52,51 @@ export type UploadMediaStoreOptions = {
|
|
|
48
52
|
*/
|
|
49
53
|
export declare class UploadMediaStore {
|
|
50
54
|
private _files;
|
|
55
|
+
private _inFlight;
|
|
56
|
+
private _nextRunId;
|
|
51
57
|
private _strategy;
|
|
52
58
|
private opts;
|
|
53
59
|
constructor(strategy: BlobUploadStrategy, opts?: UploadMediaStoreOptions);
|
|
54
60
|
/** Reactive read of the queue. */
|
|
55
61
|
get files(): UploadingFile[];
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Append a raw `File`, returns its `UploadingFile` wrapper with a stable `id`. Pass
|
|
64
|
+
* `{ preview: 'always' }` when the caller knows this file gets previewed — the returned wrapper is
|
|
65
|
+
* then typed with a non-null `previewUrl`. The wrapper is a snapshot: `previewUrl` never changes,
|
|
66
|
+
* but read `status` / `progress` back from `files` rather than from it.
|
|
67
|
+
*
|
|
68
|
+
* `'always'` is the only mode that narrows the type — `'mime'` resolves against `file.type` at
|
|
69
|
+
* runtime, so its `previewUrl` stays `string | null` however previewable the file looks.
|
|
70
|
+
*/
|
|
71
|
+
add(file: File, options: AddOptions & {
|
|
72
|
+
preview: 'always';
|
|
73
|
+
}): PreviewableUploadingFile;
|
|
74
|
+
add(file: File, options?: AddOptions): UploadingFile;
|
|
75
|
+
/** Append multiple raw files at once, with the same `preview` semantics as `add()` — only `'always'` narrows the returned type. */
|
|
76
|
+
addMany(files: File[], options: AddOptions & {
|
|
77
|
+
preview: 'always';
|
|
78
|
+
}): PreviewableUploadingFile[];
|
|
79
|
+
addMany(files: File[], options?: AddOptions): UploadingFile[];
|
|
80
|
+
/** Remove a file from the queue by its `UploadingFile.id`, revoking its `previewUrl`. Does NOT cancel an in-flight upload. */
|
|
61
81
|
remove(id: string): void;
|
|
62
|
-
/** Clear all queued / completed files. Does NOT cancel any in-flight uploads. */
|
|
82
|
+
/** Clear all queued / completed files, revoking their `previewUrl`s. Does NOT cancel any in-flight uploads. */
|
|
63
83
|
clear(): void;
|
|
84
|
+
/** Resolved blob for a file id — `null` while it is still queued / uploading, or if it failed. */
|
|
85
|
+
resolve(id: string): {
|
|
86
|
+
blobId: string;
|
|
87
|
+
readUrl: string;
|
|
88
|
+
} | null;
|
|
64
89
|
/**
|
|
65
90
|
* Run the upload pipeline for every file currently in `'queued'` status. Files already
|
|
66
|
-
* `uploading` / `success` / `error` are skipped. Resolves once every
|
|
67
|
-
* terminal status
|
|
91
|
+
* `uploading` / `success` / `error` are skipped. Resolves once **every** run of this store —
|
|
92
|
+
* including ones started by earlier `upload()` calls — has reached a terminal status, so
|
|
93
|
+
* `await store.upload()` always means "nothing of mine is in flight any more". Each run registers
|
|
94
|
+
* itself in `AppUploadActivity` for its duration, so a mounted `UploadProgressToaster` picks it up
|
|
95
|
+
* with no extra wiring.
|
|
68
96
|
*/
|
|
69
97
|
upload(): Promise<void>;
|
|
98
|
+
private settled;
|
|
99
|
+
private runQueued;
|
|
70
100
|
private maybeResize;
|
|
71
101
|
private fail;
|
|
72
102
|
private failAll;
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { AppUploadActivity } from './app-upload-activity.svelte';
|
|
1
2
|
import { uploadBlob } from './blob-storage';
|
|
3
|
+
import { isPreviewableType } from './file-types';
|
|
2
4
|
import { resizeBlob } from './image-resizer';
|
|
3
5
|
import { nanoid } from 'nanoid';
|
|
4
6
|
import { default as pLimit } from 'p-limit';
|
|
5
7
|
const DEFAULT_RESIZE_LIMITS = { max1: 1600, max2: 1000 };
|
|
6
8
|
const DEFAULT_CONCURRENCY = 20;
|
|
9
|
+
const toPreviewUrl = (file, mode) => mode === 'always' || (mode === 'mime' && isPreviewableType(file.type)) ? URL.createObjectURL(file) : null;
|
|
10
|
+
const revokePreviewUrl = (file) => {
|
|
11
|
+
if (file.previewUrl !== null) {
|
|
12
|
+
URL.revokeObjectURL(file.previewUrl);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
7
15
|
/**
|
|
8
16
|
* Queue-and-upload coordinator. Pure logic — no UI. Pair with `FileRow` / `FileUploadProgress`
|
|
9
17
|
* for visual progress, both of which consume the `files: UploadingFile[]` array reactively.
|
|
@@ -26,6 +34,8 @@ const DEFAULT_CONCURRENCY = 20;
|
|
|
26
34
|
*/
|
|
27
35
|
export class UploadMediaStore {
|
|
28
36
|
_files = $state.raw([]);
|
|
37
|
+
_inFlight = new Map();
|
|
38
|
+
_nextRunId = 0;
|
|
29
39
|
_strategy;
|
|
30
40
|
opts;
|
|
31
41
|
constructor(strategy, opts = {}) {
|
|
@@ -36,36 +46,76 @@ export class UploadMediaStore {
|
|
|
36
46
|
get files() {
|
|
37
47
|
return this._files;
|
|
38
48
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const uploadingFile = { id: nanoid(), file, progress: 0, status: 'queued' };
|
|
49
|
+
add(file, options = {}) {
|
|
50
|
+
const uploadingFile = { id: nanoid(), file, progress: 0, previewUrl: toPreviewUrl(file, options.preview ?? 'never'), status: 'queued' };
|
|
42
51
|
this._files = [...this._files, uploadingFile];
|
|
43
52
|
return uploadingFile;
|
|
44
53
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const wrappers = files.map((file) => ({ id: nanoid(), file, progress: 0, status: 'queued' }));
|
|
54
|
+
addMany(files, options = {}) {
|
|
55
|
+
const preview = options.preview ?? 'never';
|
|
56
|
+
const wrappers = files.map((file) => ({ id: nanoid(), file, progress: 0, previewUrl: toPreviewUrl(file, preview), status: 'queued' }));
|
|
48
57
|
this._files = [...this._files, ...wrappers];
|
|
49
58
|
return wrappers;
|
|
50
59
|
}
|
|
51
|
-
/** Remove a file from the queue by its `UploadingFile.id`. Does NOT cancel an in-flight upload. */
|
|
60
|
+
/** Remove a file from the queue by its `UploadingFile.id`, revoking its `previewUrl`. Does NOT cancel an in-flight upload. */
|
|
52
61
|
remove(id) {
|
|
53
|
-
this._files = this._files.filter((f) =>
|
|
62
|
+
this._files = this._files.filter((f) => {
|
|
63
|
+
if (f.id !== id) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
revokePreviewUrl(f);
|
|
67
|
+
return false;
|
|
68
|
+
});
|
|
54
69
|
}
|
|
55
|
-
/** Clear all queued / completed files. Does NOT cancel any in-flight uploads. */
|
|
70
|
+
/** Clear all queued / completed files, revoking their `previewUrl`s. Does NOT cancel any in-flight uploads. */
|
|
56
71
|
clear() {
|
|
72
|
+
this._files.forEach(revokePreviewUrl);
|
|
57
73
|
this._files = [];
|
|
58
74
|
}
|
|
75
|
+
/** Resolved blob for a file id — `null` while it is still queued / uploading, or if it failed. */
|
|
76
|
+
resolve(id) {
|
|
77
|
+
const file = this._files.find((f) => f.id === id);
|
|
78
|
+
return file?.status === 'success' ? { blobId: file.blobId, readUrl: file.readUrl } : null;
|
|
79
|
+
}
|
|
59
80
|
/**
|
|
60
81
|
* Run the upload pipeline for every file currently in `'queued'` status. Files already
|
|
61
|
-
* `uploading` / `success` / `error` are skipped. Resolves once every
|
|
62
|
-
* terminal status
|
|
82
|
+
* `uploading` / `success` / `error` are skipped. Resolves once **every** run of this store —
|
|
83
|
+
* including ones started by earlier `upload()` calls — has reached a terminal status, so
|
|
84
|
+
* `await store.upload()` always means "nothing of mine is in flight any more". Each run registers
|
|
85
|
+
* itself in `AppUploadActivity` for its duration, so a mounted `UploadProgressToaster` picks it up
|
|
86
|
+
* with no extra wiring.
|
|
63
87
|
*/
|
|
64
88
|
async upload() {
|
|
65
89
|
const queued = this._files.filter((f) => f.status === 'queued');
|
|
66
90
|
if (queued.length === 0) {
|
|
91
|
+
await this.settled();
|
|
67
92
|
return;
|
|
68
93
|
}
|
|
94
|
+
// Claim before the first await, or a second upload() during the strategy call re-claims the same files.
|
|
95
|
+
for (const entry of queued) {
|
|
96
|
+
this.transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, previewUrl: f.previewUrl, status: 'uploading' }));
|
|
97
|
+
}
|
|
98
|
+
const fileIds = new Set(queued.map((f) => f.id));
|
|
99
|
+
const activityRunId = AppUploadActivity.beginRun(() => this._files.filter((f) => fileIds.has(f.id)));
|
|
100
|
+
const runId = this._nextRunId++;
|
|
101
|
+
const run = (async () => {
|
|
102
|
+
try {
|
|
103
|
+
await this.runQueued(queued);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
AppUploadActivity.endRun(activityRunId);
|
|
107
|
+
this._inFlight.delete(runId);
|
|
108
|
+
}
|
|
109
|
+
})();
|
|
110
|
+
this._inFlight.set(runId, run);
|
|
111
|
+
await this.settled();
|
|
112
|
+
}
|
|
113
|
+
async settled() {
|
|
114
|
+
while (this._inFlight.size > 0) {
|
|
115
|
+
await Promise.all([...this._inFlight.values()]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async runQueued(queued) {
|
|
69
119
|
let presigned;
|
|
70
120
|
try {
|
|
71
121
|
presigned = await this._strategy(queued.length);
|
|
@@ -81,14 +131,21 @@ export class UploadMediaStore {
|
|
|
81
131
|
const limit = pLimit(this.opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
82
132
|
const resizeOpt = this.opts.resize;
|
|
83
133
|
await Promise.all(queued.map((entry, i) => limit(async () => {
|
|
84
|
-
this.transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, status: 'uploading' }));
|
|
85
134
|
try {
|
|
86
135
|
const blob = resizeOpt === false ? entry.file : await this.maybeResize(entry.file, resizeOpt ?? DEFAULT_RESIZE_LIMITS);
|
|
87
136
|
const slot = presigned[i];
|
|
88
137
|
await uploadBlob(slot.uploadUrl, blob, (loaded, total) => {
|
|
89
138
|
this.transition(entry.id, (f) => (f.status === 'uploading' ? { ...f, progress: total > 0 ? loaded / total : 0 } : f));
|
|
90
139
|
});
|
|
91
|
-
const done = this.transition(entry.id, (f) => ({
|
|
140
|
+
const done = this.transition(entry.id, (f) => ({
|
|
141
|
+
id: f.id,
|
|
142
|
+
file: f.file,
|
|
143
|
+
progress: 1,
|
|
144
|
+
previewUrl: f.previewUrl,
|
|
145
|
+
status: 'success',
|
|
146
|
+
blobId: slot.id,
|
|
147
|
+
readUrl: slot.readUrl
|
|
148
|
+
}));
|
|
92
149
|
if (done?.status === 'success') {
|
|
93
150
|
this.opts.on?.blobId?.(done, slot.id);
|
|
94
151
|
}
|
|
@@ -114,7 +171,7 @@ export class UploadMediaStore {
|
|
|
114
171
|
}
|
|
115
172
|
fail(id, error) {
|
|
116
173
|
const message = error instanceof Error ? error.message : 'Upload failed';
|
|
117
|
-
const failed = this.transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, status: 'error', error: message }));
|
|
174
|
+
const failed = this.transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, previewUrl: f.previewUrl, status: 'error', error: message }));
|
|
118
175
|
if (failed?.status === 'error') {
|
|
119
176
|
this.opts.on?.error?.(failed, error);
|
|
120
177
|
}
|
|
@@ -20,6 +20,12 @@ type UploadingFileBase = {
|
|
|
20
20
|
file: File;
|
|
21
21
|
/** 0..1 — fraction uploaded. 1 on success. */
|
|
22
22
|
progress: number;
|
|
23
|
+
/**
|
|
24
|
+
* Object URL for local preview, or `null` when none was staged. Controlled per call by
|
|
25
|
+
* `add()` / `addMany()`'s `preview` option, which defaults to `'never'`. Revoked by `remove()` /
|
|
26
|
+
* `clear()`; never revoke it yourself.
|
|
27
|
+
*/
|
|
28
|
+
previewUrl: string | null;
|
|
23
29
|
};
|
|
24
30
|
/**
|
|
25
31
|
* Canonical shape passed to `FileRow` / `FileUploadProgress` and emitted by `UploadMediaStore`,
|
|
@@ -39,4 +45,14 @@ export type UploadingFile = (UploadingFileBase & {
|
|
|
39
45
|
status: 'error';
|
|
40
46
|
error: string;
|
|
41
47
|
});
|
|
48
|
+
/** What `add()` / `addMany()` return for `{ preview: 'always' }` — a `previewUrl` that needs no null check. */
|
|
49
|
+
export type PreviewableUploadingFile = UploadingFile & {
|
|
50
|
+
previewUrl: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Whether `add()` / `addMany()` stage an object URL for local preview: `'always'` regardless of
|
|
54
|
+
* type, `'never'`, or `'mime'` to let `isPreviewableType(file.type)` decide (images, videos and
|
|
55
|
+
* audio get one). @default 'never'
|
|
56
|
+
*/
|
|
57
|
+
export type UploadPreviewMode = 'always' | 'never' | 'mime';
|
|
42
58
|
export {};
|
package/dist/core/media/index.js
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type VideoProbe = {
|
|
2
|
+
/** `false` when the browser could not read the media at all (decode error / timeout before metadata) — every other field is then meaningless. */
|
|
3
|
+
readable: boolean;
|
|
4
|
+
/** `0` for a readable file with no video track (e.g. an audio-only container). */
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
/** Exact, unrounded seconds, or `null` when the container reports no usable duration (`Infinity` for MediaRecorder WebM, `NaN` for broken metadata). */
|
|
8
|
+
durationSec: number | null;
|
|
9
|
+
/** First readable frame as a JPEG blob. `null` when `cover` was not requested, or the browser could not decode / export one. */
|
|
10
|
+
cover: Blob | null;
|
|
11
|
+
};
|
|
12
|
+
export type VideoProbeOptions = {
|
|
13
|
+
/** Grab the first frame as a JPEG. Off means metadata-only — no full buffering, no decode, no canvas encode. @default true */
|
|
14
|
+
cover?: boolean;
|
|
15
|
+
/** @default 10000 */
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Read dimensions, duration and (optionally) a first-frame cover out of a local media URL — an
|
|
20
|
+
* object URL from `UploadingFile.previewUrl` or `URL.createObjectURL`. Always resolves: media the
|
|
21
|
+
* browser cannot decode comes back as `readable: false` with zeroed metrics, and a readable file
|
|
22
|
+
* whose cover cannot be exported comes back `readable: true` with `cover: null`.
|
|
23
|
+
*
|
|
24
|
+
* The single media-probing primitive in the kit — every `FileRules` audio / video rule reads through
|
|
25
|
+
* it with `cover: false`. Audio-only containers are read by the same `<video>` element (same media
|
|
26
|
+
* pipeline, same codec support) and come back `readable` with `width` / `height` of `0`.
|
|
27
|
+
*/
|
|
28
|
+
export declare const probeVideo: (url: string, options?: VideoProbeOptions) => Promise<VideoProbe>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const READ_TIMEOUT_MS = 10000;
|
|
2
|
+
const COVER_SEEK_SEC = 0.001;
|
|
3
|
+
const COVER_QUALITY = 0.85;
|
|
4
|
+
/**
|
|
5
|
+
* Read dimensions, duration and (optionally) a first-frame cover out of a local media URL — an
|
|
6
|
+
* object URL from `UploadingFile.previewUrl` or `URL.createObjectURL`. Always resolves: media the
|
|
7
|
+
* browser cannot decode comes back as `readable: false` with zeroed metrics, and a readable file
|
|
8
|
+
* whose cover cannot be exported comes back `readable: true` with `cover: null`.
|
|
9
|
+
*
|
|
10
|
+
* The single media-probing primitive in the kit — every `FileRules` audio / video rule reads through
|
|
11
|
+
* it with `cover: false`. Audio-only containers are read by the same `<video>` element (same media
|
|
12
|
+
* pipeline, same codec support) and come back `readable` with `width` / `height` of `0`.
|
|
13
|
+
*/
|
|
14
|
+
export const probeVideo = (url, options = {}) => new Promise((resolve) => {
|
|
15
|
+
const { cover: withCover = true, timeoutMs = READ_TIMEOUT_MS } = options;
|
|
16
|
+
const video = document.createElement('video');
|
|
17
|
+
video.preload = withCover ? 'auto' : 'metadata';
|
|
18
|
+
video.muted = true;
|
|
19
|
+
video.crossOrigin = 'anonymous';
|
|
20
|
+
let settled = false;
|
|
21
|
+
let metadataRead = false;
|
|
22
|
+
let corsRetried = false;
|
|
23
|
+
const finish = (readable, cover) => {
|
|
24
|
+
if (settled) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
settled = true;
|
|
28
|
+
clearTimeout(timeoutId);
|
|
29
|
+
const duration = video.duration;
|
|
30
|
+
const probe = {
|
|
31
|
+
readable,
|
|
32
|
+
width: video.videoWidth,
|
|
33
|
+
height: video.videoHeight,
|
|
34
|
+
durationSec: Number.isFinite(duration) ? duration : null,
|
|
35
|
+
cover
|
|
36
|
+
};
|
|
37
|
+
video.pause();
|
|
38
|
+
video.removeAttribute('src');
|
|
39
|
+
video.load();
|
|
40
|
+
resolve(probe);
|
|
41
|
+
};
|
|
42
|
+
// Unseekable containers fire neither seeked nor error — without the timeout the promise never settles.
|
|
43
|
+
const timeoutId = setTimeout(() => finish(metadataRead, null), timeoutMs);
|
|
44
|
+
video.onerror = () => {
|
|
45
|
+
// Without this retry a non-CORS remote URL never loads at all while crossOrigin is set.
|
|
46
|
+
if (!corsRetried && video.crossOrigin !== null) {
|
|
47
|
+
corsRetried = true;
|
|
48
|
+
video.crossOrigin = null;
|
|
49
|
+
video.src = url;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
finish(false, null);
|
|
53
|
+
};
|
|
54
|
+
video.onloadedmetadata = () => {
|
|
55
|
+
metadataRead = true;
|
|
56
|
+
if (!withCover) {
|
|
57
|
+
finish(true, null);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
video.onloadeddata = () => {
|
|
61
|
+
video.currentTime = COVER_SEEK_SEC;
|
|
62
|
+
};
|
|
63
|
+
video.onseeked = () => {
|
|
64
|
+
try {
|
|
65
|
+
const canvas = document.createElement('canvas');
|
|
66
|
+
canvas.width = video.videoWidth;
|
|
67
|
+
canvas.height = video.videoHeight;
|
|
68
|
+
const context = canvas.getContext('2d');
|
|
69
|
+
if (!context || !canvas.width || !canvas.height) {
|
|
70
|
+
finish(true, null);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
context.drawImage(video, 0, 0);
|
|
74
|
+
canvas.toBlob((blob) => finish(true, blob), 'image/jpeg', COVER_QUALITY);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// toBlob throws on a tainted canvas; uncaught here it would leave the promise pending until the timeout.
|
|
78
|
+
finish(true, null);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
video.src = url;
|
|
82
|
+
});
|