@streamscloud/kit 0.85.1 → 0.86.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/README.md CHANGED
@@ -15,8 +15,8 @@ without it agent sessions run with no hooks and no shared skills. Re-run after c
15
15
  ## Checks
16
16
 
17
17
  ```sh
18
- npm run check # svelte-check + tsc for node files
19
- npm run lint # prettier --check + eslint
18
+ npm run check # the gate: strict svelte-check + tsc for node files + eslint + prettier --check
19
+ npm run lint:fix # eslint --fix + prettier --write
20
20
  ```
21
21
 
22
22
  ## Release
@@ -0,0 +1,3 @@
1
+ import type { BlobWithName, FileWithUploadUrl } from './types';
2
+ export declare const toFileWithUploadUrl: <T extends File>(file: T, uploadUrl: string) => FileWithUploadUrl<T>;
3
+ export declare const toBlobWithName: (blob: Blob, name: string) => BlobWithName;
@@ -0,0 +1,2 @@
1
+ export const toFileWithUploadUrl = (file, uploadUrl) => Object.assign(file, { uploadUrl });
2
+ export const toBlobWithName = (blob, name) => Object.assign(blob, { name });
@@ -1,4 +1,5 @@
1
1
  import type { AspectRatioBound, FileValidationRule, VideoOrientationValue } from './file-validation-types';
2
+ import type { ResizeLimits } from './types';
2
3
  /**
3
4
  * Built-in rule factories. Each takes a constraint and an optional `message`; when omitted, the
4
5
  * rule falls back to a localized default from `FileValidationLocalization` (kit ships no English
@@ -21,10 +22,7 @@ export declare const FileRules: {
21
22
  AudioDuration: (maxSeconds: number, message?: string) => FileValidationRule;
22
23
  AudioOnly: (message?: string) => FileValidationRule;
23
24
  Extensions: (extensions: string[], message?: string) => FileValidationRule;
24
- ImageDimensions: (limits: {
25
- max1: number;
26
- max2: number;
27
- }, message?: string) => FileValidationRule;
25
+ ImageDimensions: (limits: ResizeLimits, message?: string) => FileValidationRule;
28
26
  ImageMinDimensions: (limits: {
29
27
  min1: number;
30
28
  min2: number;
@@ -55,9 +53,6 @@ export declare const FileRules: {
55
53
  VideoOrientation: (orientation: VideoOrientationValue, message?: string, options?: {
56
54
  tolerancePercent?: number;
57
55
  }) => FileValidationRule;
58
- VideoDimensions: (limits: {
59
- max1: number;
60
- max2: number;
61
- }, message?: string) => FileValidationRule;
56
+ VideoDimensions: (limits: ResizeLimits, message?: string) => FileValidationRule;
62
57
  VideoDuration: (maxSeconds: number, message?: string) => FileValidationRule;
63
58
  };
@@ -1,4 +1,4 @@
1
- import { type BlobWithName, type FileWithBlobUrl } from './types';
1
+ import type { BlobWithName, FileWithBlobUrl } from './types';
2
2
  type FileGenerationOptions = {
3
3
  id?: string;
4
4
  maxNameLength?: number;
@@ -1,7 +1,7 @@
1
1
  import { Base64Helper } from './base64-helper';
2
+ import { toBlobWithName } from './file-decorators';
2
3
  import { FileHelper } from './file-helper';
3
4
  import { fetchFile } from './file-service';
4
- import { toBlobWithName } from './types';
5
5
  import { default as mime } from 'mime';
6
6
  import { nanoid } from 'nanoid';
7
7
  export class FileWithBlobDataHelper {
@@ -1,31 +1,19 @@
1
- import type { BlobWithName } from './types';
2
- export declare const resizeImage: <T extends File>(file: T, limits: {
3
- max1: number;
4
- max2: number;
5
- }) => Promise<BlobWithName>;
1
+ import type { ResizedBlob, ResizeLimits, SizedFile } from './types';
2
+ export declare const DEFAULT_RESIZE_LIMITS: Readonly<ResizeLimits>;
3
+ type PixelSize = {
4
+ width: number;
5
+ height: number;
6
+ };
7
+ /** `size` rounded down to whole pixels, at least one per side. */
8
+ export declare const toPixelSize: (size: PixelSize) => PixelSize;
9
+ /** `size` downscaled, keeping its aspect ratio, until it fits `limits` (never enlarged), in whole pixels. */
10
+ export declare const fitSize: (size: PixelSize, limits: ResizeLimits) => PixelSize;
11
+ /** `resizeBlob` for a file: always a new `File` under the source's name, with its size. */
12
+ export declare const resizeImage: (file: File, limits: ResizeLimits) => Promise<SizedFile>;
6
13
  /**
7
- * Resizes an image while maintaining its aspect ratio, ensuring that both dimensions fit within the given constraints.
8
- *
9
- * The function takes two numeric constraints (`max1` and `max2`) and determines which one applies to the long side
10
- * and which one applies to the short side of the image.
11
- *
12
- * - If the image already fits within the constraints (both sides are within their respective limits),
13
- * it is returned unchanged.
14
- * - If both constraints are provided, the image is resized so that the long side matches its limit.
15
- * However, if this causes the short side to exceed its limit, the short side constraint is applied instead.
16
- * - If only one constraint is provided, it is applied to the longer side, and the other side is scaled proportionally.
17
- * - If both constraints are zero or negative, the original image is returned without modifications.
18
- *
19
- * @param blob - The original image as a `Blob` object.
20
- * @param limits - An object containing two numeric constraints, `max1` and `max2`.
21
- * - **`max1`** - One of the two size constraints.
22
- * - **`max2`** - The second size constraint.
23
- * **Note:** `max1` and `max2` are not tied to specific dimensions (width or height).
24
- * The function determines which one applies to the long side and which to the short side.
25
- *
26
- * @returns A promise that resolves to a new resized `Blob` object, or the original `Blob` if resizing is not needed.
14
+ * Downscales an image, keeping its aspect ratio, until both sides fit `limits` (see `ResizeLimits`).
15
+ * Only JPEG, PNG and WebP are re-encoded; an image that already fits, or of any other type (SVG, GIF, …),
16
+ * comes back as the very same `Blob`. Either way the result carries the image's pixel size.
27
17
  */
28
- export declare const resizeBlob: <T extends Blob>(blob: T, limits: {
29
- max1: number;
30
- max2: number;
31
- }) => Promise<Blob>;
18
+ export declare const resizeBlob: (blob: Blob, limits: ResizeLimits) => Promise<ResizedBlob>;
19
+ export {};
@@ -1,70 +1,40 @@
1
- let cachedCanvas = null;
2
- const getCanvas = () => (cachedCanvas ??= document.createElement('canvas'));
1
+ export const DEFAULT_RESIZE_LIMITS = Object.freeze({ max1: 1600, max2: 1000 });
2
+ const RESIZABLE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
3
+ // A scaled side lands a hair under a whole number (999.9999999999999); a bare floor would drop a pixel.
4
+ const PIXEL_EPSILON = 1e-6;
5
+ /** `size` rounded down to whole pixels, at least one per side. */
6
+ export const toPixelSize = (size) => ({
7
+ width: Math.max(1, Math.floor(size.width + PIXEL_EPSILON)),
8
+ height: Math.max(1, Math.floor(size.height + PIXEL_EPSILON))
9
+ });
10
+ /** `size` downscaled, keeping its aspect ratio, until it fits `limits` (never enlarged), in whole pixels. */
11
+ export const fitSize = (size, limits) => {
12
+ const [longMax, shortMax] = limits.max1 >= limits.max2 ? [limits.max1, limits.max2] : [limits.max2, limits.max1];
13
+ const scale = Math.min(1, longMax / Math.max(size.width, size.height), shortMax / Math.min(size.width, size.height));
14
+ return toPixelSize({ width: size.width * scale, height: size.height * scale });
15
+ };
16
+ /** `resizeBlob` for a file: always a new `File` under the source's name, with its size. */
3
17
  export const resizeImage = async (file, limits) => {
4
- const blob = await resizeBlob(file, limits);
5
- const result = blob;
6
- if (!(blob instanceof File)) {
7
- result.name = file.name;
8
- }
9
- return result;
18
+ const { blob, width, height } = await resizeBlob(file, limits);
19
+ return Object.assign(new File([blob], file.name, { type: blob.type }), { width, height });
10
20
  };
11
21
  /**
12
- * Resizes an image while maintaining its aspect ratio, ensuring that both dimensions fit within the given constraints.
13
- *
14
- * The function takes two numeric constraints (`max1` and `max2`) and determines which one applies to the long side
15
- * and which one applies to the short side of the image.
16
- *
17
- * - If the image already fits within the constraints (both sides are within their respective limits),
18
- * it is returned unchanged.
19
- * - If both constraints are provided, the image is resized so that the long side matches its limit.
20
- * However, if this causes the short side to exceed its limit, the short side constraint is applied instead.
21
- * - If only one constraint is provided, it is applied to the longer side, and the other side is scaled proportionally.
22
- * - If both constraints are zero or negative, the original image is returned without modifications.
23
- *
24
- * @param blob - The original image as a `Blob` object.
25
- * @param limits - An object containing two numeric constraints, `max1` and `max2`.
26
- * - **`max1`** - One of the two size constraints.
27
- * - **`max2`** - The second size constraint.
28
- * **Note:** `max1` and `max2` are not tied to specific dimensions (width or height).
29
- * The function determines which one applies to the long side and which to the short side.
30
- *
31
- * @returns A promise that resolves to a new resized `Blob` object, or the original `Blob` if resizing is not needed.
22
+ * Downscales an image, keeping its aspect ratio, until both sides fit `limits` (see `ResizeLimits`).
23
+ * Only JPEG, PNG and WebP are re-encoded; an image that already fits, or of any other type (SVG, GIF, …),
24
+ * comes back as the very same `Blob`. Either way the result carries the image's pixel size.
32
25
  */
33
26
  export const resizeBlob = (blob, limits) => {
34
- const { max1, max2 } = limits;
35
- if (max1 <= 0 && max2 <= 0) {
36
- return Promise.resolve(blob);
37
- }
38
27
  return new Promise((resolve, reject) => {
39
28
  const image = new Image();
40
29
  image.src = URL.createObjectURL(blob);
41
30
  image.onload = () => {
42
31
  const { width, height } = image;
43
- const isHorizontal = width > height;
44
- const originalLongSide = isHorizontal ? width : height;
45
- const originalShortSide = isHorizontal ? height : width;
46
- const [longSideMax, shortSideMax] = max1 >= max2 ? [max1, max2] : [max2, max1];
47
- if (longSideMax > 0 && originalLongSide <= longSideMax && shortSideMax > 0 && originalShortSide <= shortSideMax) {
48
- URL.revokeObjectURL(image.src);
49
- return resolve(blob);
50
- }
51
- let newLongSide = Math.min(originalLongSide, longSideMax);
52
- let newShortSide = originalShortSide * (newLongSide / originalLongSide);
53
- if (shortSideMax > 0 && newShortSide > shortSideMax) {
54
- const reducer = newShortSide / shortSideMax;
55
- newShortSide = shortSideMax;
56
- newLongSide = newLongSide / reducer;
57
- }
58
- newLongSide = Math.floor(newLongSide);
59
- newShortSide = Math.floor(newShortSide);
60
- // Skip resize if dimensions unchanged (avoids lossy re-encoding)
61
- if (newLongSide === originalLongSide && newShortSide === originalShortSide) {
32
+ const { width: newWidth, height: newHeight } = fitSize({ width, height }, limits);
33
+ if (!RESIZABLE_TYPES.has(blob.type) || (newWidth === width && newHeight === height)) {
62
34
  URL.revokeObjectURL(image.src);
63
- return resolve(blob);
35
+ return resolve({ blob, width, height });
64
36
  }
65
- const newWidth = isHorizontal ? newLongSide : newShortSide;
66
- const newHeight = isHorizontal ? newShortSide : newLongSide;
67
- const canvas = getCanvas();
37
+ const canvas = document.createElement('canvas');
68
38
  canvas.width = newWidth;
69
39
  canvas.height = newHeight;
70
40
  const ctx = canvas.getContext('2d');
@@ -75,9 +45,12 @@ export const resizeBlob = (blob, limits) => {
75
45
  ctx.drawImage(image, 0, 0, newWidth, newHeight);
76
46
  canvas.toBlob((resizedBlob) => {
77
47
  URL.revokeObjectURL(image.src);
78
- ctx.clearRect(0, 0, canvas.width, canvas.height);
48
+ // Safari caps total canvas memory and holds a dropped canvas until GC; shrinking it to 1×1 releases it now.
49
+ canvas.width = 1;
50
+ canvas.height = 1;
51
+ ctx.clearRect(0, 0, 1, 1);
79
52
  if (resizedBlob) {
80
- resolve(resizedBlob);
53
+ resolve({ blob: resizedBlob, width: newWidth, height: newHeight });
81
54
  }
82
55
  else {
83
56
  reject(new Error('Failed to create blob from canvas'));
@@ -1,16 +1,16 @@
1
- export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl } from './types';
1
+ export type { BlobWithName, FileWithBlobUrl, FileWithUploadUrl, ResizedBlob, ResizeLimits, SizedFile } from './types';
2
2
  export type { AspectRatioBound, FileValidationResult, FileValidationRule, FileValidationRuleSets, VideoOrientationValue } from './file-validation-types';
3
3
  export type { BlobUpload, BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile, UploadingFileStatus } from './upload-types';
4
- export type { AddOptions, UploadMediaStoreOptions } from './upload-media-store.svelte';
4
+ export type { AddOptions, UploadMediaStoreOptions, UploadOutcome } from './upload-media-store.svelte';
5
5
  export type { FileTypeCheck } from './file-helper';
6
- export { toBlobWithName, toFileWithUploadUrl } from './types';
6
+ export { toBlobWithName, toFileWithUploadUrl } from './file-decorators';
7
7
  export { uploadBlob } from './blob-storage';
8
8
  export { openFile } from './open-file';
9
9
  export { FileHelper } from './file-helper';
10
10
  export { downloadBlob, downloadFromUrl, downloadJson, fetchFile } from './file-service';
11
11
  export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
12
12
  export { deriveAccept } from './file-validation-rule-sets';
13
- export { resizeBlob, resizeImage } from './image-resizer';
13
+ export { DEFAULT_RESIZE_LIMITS, fitSize, resizeBlob, resizeImage, toPixelSize } from './image-resizer';
14
14
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
15
15
  export { FilesProvider } from './files-provider';
16
16
  export { FileValidator } from './file-validator';
@@ -1,12 +1,12 @@
1
1
  // Utilities
2
- export { toBlobWithName, toFileWithUploadUrl } from './types';
2
+ export { toBlobWithName, toFileWithUploadUrl } from './file-decorators';
3
3
  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
7
  export { AcceptFileType, isPreviewableType, matchesAcceptedFileTypes } from './file-types';
8
8
  export { deriveAccept } from './file-validation-rule-sets';
9
- export { resizeBlob, resizeImage } from './image-resizer';
9
+ export { DEFAULT_RESIZE_LIMITS, fitSize, resizeBlob, resizeImage, toPixelSize } from './image-resizer';
10
10
  export { FileWithBlobDataHelper } from './file-with-blob-data-helper';
11
11
  export { FilesProvider } from './files-provider';
12
12
  export { FileValidator } from './file-validator';
@@ -2,11 +2,23 @@ export type FileWithBlobUrl = File & {
2
2
  blobUrl: string;
3
3
  id: string;
4
4
  };
5
+ export type SizedFile = File & {
6
+ width: number;
7
+ height: number;
8
+ };
9
+ /** Two positive pixel limits not tied to width / height — the image's longer side fits the larger one, its shorter side the smaller. */
10
+ export type ResizeLimits = {
11
+ max1: number;
12
+ max2: number;
13
+ };
14
+ export type ResizedBlob = {
15
+ blob: Blob;
16
+ width: number;
17
+ height: number;
18
+ };
5
19
  export type FileWithUploadUrl<T extends File = File> = T & {
6
20
  uploadUrl: string;
7
21
  };
8
- export declare const toFileWithUploadUrl: <T extends File>(file: T, uploadUrl: string) => FileWithUploadUrl<T>;
9
22
  export type BlobWithName = Blob & {
10
23
  name: string;
11
24
  };
12
- export declare const toBlobWithName: (blob: Blob, name: string) => BlobWithName;
@@ -1,10 +1 @@
1
- export const toFileWithUploadUrl = (file, uploadUrl) => {
2
- const fileWithUploadUrl = file;
3
- fileWithUploadUrl.uploadUrl = uploadUrl;
4
- return fileWithUploadUrl;
5
- };
6
- export const toBlobWithName = (blob, name) => {
7
- const blobWithName = blob;
8
- blobWithName.name = name;
9
- return blobWithName;
10
- };
1
+ export {};
@@ -1,3 +1,4 @@
1
+ import type { ResizeLimits } from './types';
1
2
  import type { BlobUploadStrategy, PreviewableUploadingFile, UploadPreviewMode, UploadingFile } from './upload-types';
2
3
  type SuccessfulUpload = Extract<UploadingFile, {
3
4
  status: 'success';
@@ -16,15 +17,8 @@ export type UploadOutcome = {
16
17
  succeeded: SuccessfulUpload[];
17
18
  };
18
19
  export type UploadMediaStoreOptions = {
19
- /**
20
- * Auto-resize images before upload. Pass `false` to disable; pass an object to override the
21
- * defaults. `max1` / `max2` are not tied to width / height — the longer side is matched to
22
- * the larger limit. @default `{ max1: 1600, max2: 1000 }`
23
- */
24
- resize?: {
25
- max1: number;
26
- max2: number;
27
- } | false;
20
+ /** Downscale images to these limits before upload; `false` uploads them as they are. @default DEFAULT_RESIZE_LIMITS (1600 / 1000) */
21
+ resize?: ResizeLimits | false;
28
22
  /** Parallel upload limit. @default 20 */
29
23
  concurrency?: number;
30
24
  on?: {
@@ -62,7 +56,7 @@ export declare class UploadMediaStore {
62
56
  private _inFlight;
63
57
  private _nextRunId;
64
58
  private _strategy;
65
- private opts;
59
+ private _opts;
66
60
  constructor(strategy: BlobUploadStrategy, opts?: UploadMediaStoreOptions);
67
61
  /** Reactive read of the queue. */
68
62
  get files(): UploadingFile[];
@@ -102,12 +96,12 @@ export declare class UploadMediaStore {
102
96
  * with no extra wiring.
103
97
  */
104
98
  upload(): Promise<UploadOutcome>;
105
- private outcome;
106
- private settled;
107
- private runQueued;
108
- private maybeResize;
109
- private fail;
110
- private failAll;
111
- private transition;
99
+ private _outcome;
100
+ private _settled;
101
+ private _runQueued;
102
+ private _maybeResize;
103
+ private _fail;
104
+ private _failAll;
105
+ private _transition;
112
106
  }
113
107
  export {};
@@ -1,10 +1,9 @@
1
1
  import { AppUploadActivity } from './app-upload-activity.svelte';
2
2
  import { uploadBlob } from './blob-storage';
3
3
  import { isPreviewableType } from './file-types';
4
- import { resizeBlob } from './image-resizer';
4
+ import { DEFAULT_RESIZE_LIMITS, resizeBlob } from './image-resizer';
5
5
  import { nanoid } from 'nanoid';
6
6
  import { default as pLimit } from 'p-limit';
7
- const DEFAULT_RESIZE_LIMITS = { max1: 1600, max2: 1000 };
8
7
  const DEFAULT_CONCURRENCY = 20;
9
8
  const toPreviewUrl = (file, mode) => mode === 'always' || (mode === 'mime' && isPreviewableType(file.type)) ? URL.createObjectURL(file) : null;
10
9
  const revokePreviewUrl = (file) => {
@@ -38,10 +37,10 @@ export class UploadMediaStore {
38
37
  _inFlight = new Map();
39
38
  _nextRunId = 0;
40
39
  _strategy;
41
- opts;
40
+ _opts;
42
41
  constructor(strategy, opts = {}) {
43
42
  this._strategy = strategy;
44
- this.opts = opts;
43
+ this._opts = opts;
45
44
  }
46
45
  /** Reactive read of the queue. */
47
46
  get files() {
@@ -89,19 +88,19 @@ export class UploadMediaStore {
89
88
  async upload() {
90
89
  const queued = this._files.filter((f) => f.status === 'queued' || f.status === 'error');
91
90
  if (queued.length === 0) {
92
- await this.settled();
93
- return this.outcome();
91
+ await this._settled();
92
+ return this._outcome();
94
93
  }
95
94
  // Claim before the first await, or a second upload() during the strategy call re-claims the same files.
96
95
  for (const entry of queued) {
97
- this.transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, previewUrl: f.previewUrl, status: 'uploading' }));
96
+ this._transition(entry.id, (f) => ({ id: f.id, file: f.file, progress: 0, previewUrl: f.previewUrl, status: 'uploading' }));
98
97
  }
99
98
  const fileIds = new Set(queued.map((f) => f.id));
100
99
  const activityRunId = AppUploadActivity.beginRun(() => this._files.filter((f) => fileIds.has(f.id)));
101
100
  const runId = this._nextRunId++;
102
101
  const run = (async () => {
103
102
  try {
104
- await this.runQueued(queued);
103
+ await this._runQueued(queued);
105
104
  }
106
105
  finally {
107
106
  AppUploadActivity.endRun(activityRunId);
@@ -109,43 +108,43 @@ export class UploadMediaStore {
109
108
  }
110
109
  })();
111
110
  this._inFlight.set(runId, run);
112
- await this.settled();
113
- return this.outcome();
111
+ await this._settled();
112
+ return this._outcome();
114
113
  }
115
- outcome() {
114
+ _outcome() {
116
115
  return {
117
116
  failed: this._files.filter((f) => f.status === 'error'),
118
117
  succeeded: this._files.filter((f) => f.status === 'success')
119
118
  };
120
119
  }
121
- async settled() {
120
+ async _settled() {
122
121
  while (this._inFlight.size > 0) {
123
122
  await Promise.all([...this._inFlight.values()]);
124
123
  }
125
124
  }
126
- async runQueued(queued) {
125
+ async _runQueued(queued) {
127
126
  let presigned;
128
127
  try {
129
128
  presigned = await this._strategy(queued.length);
130
129
  }
131
130
  catch (error) {
132
- this.failAll(queued, error);
131
+ this._failAll(queued, error);
133
132
  return;
134
133
  }
135
134
  if (presigned.length < queued.length) {
136
- this.failAll(queued, new Error(`BlobUploadStrategy returned ${presigned.length} slots for ${queued.length} files`));
135
+ this._failAll(queued, new Error(`BlobUploadStrategy returned ${presigned.length} slots for ${queued.length} files`));
137
136
  return;
138
137
  }
139
- const limit = pLimit(this.opts.concurrency ?? DEFAULT_CONCURRENCY);
140
- const resizeOpt = this.opts.resize;
138
+ const limit = pLimit(this._opts.concurrency ?? DEFAULT_CONCURRENCY);
139
+ const resizeOpt = this._opts.resize;
141
140
  await Promise.all(queued.map((entry, i) => limit(async () => {
142
141
  try {
143
- const blob = resizeOpt === false ? entry.file : await this.maybeResize(entry.file, resizeOpt ?? DEFAULT_RESIZE_LIMITS);
142
+ const blob = resizeOpt === false ? entry.file : await this._maybeResize(entry.file, resizeOpt ?? DEFAULT_RESIZE_LIMITS);
144
143
  const slot = presigned[i];
145
144
  await uploadBlob(slot.uploadUrl, blob, (loaded, total) => {
146
- this.transition(entry.id, (f) => (f.status === 'uploading' ? { ...f, progress: total > 0 ? loaded / total : 0 } : f));
145
+ this._transition(entry.id, (f) => (f.status === 'uploading' ? { ...f, progress: total > 0 ? loaded / total : 0 } : f));
147
146
  });
148
- const done = this.transition(entry.id, (f) => ({
147
+ const done = this._transition(entry.id, (f) => ({
149
148
  id: f.id,
150
149
  file: f.file,
151
150
  progress: 1,
@@ -155,42 +154,40 @@ export class UploadMediaStore {
155
154
  readUrl: slot.readUrl
156
155
  }));
157
156
  if (done?.status === 'success') {
158
- this.opts.on?.blobId?.(done, slot.id);
157
+ this._opts.on?.blobId?.(done, slot.id);
159
158
  }
160
159
  }
161
160
  catch (error) {
162
- this.fail(entry.id, error);
161
+ this._fail(entry.id, error);
163
162
  }
164
163
  })));
165
- this.opts.on?.done?.();
164
+ this._opts.on?.done?.();
166
165
  }
167
- async maybeResize(file, limits) {
168
- // Only resize images — leave other media alone.
169
- if (!file.type.startsWith('image/') || file.type === 'image/gif') {
166
+ async _maybeResize(file, limits) {
167
+ if (!file.type.startsWith('image/')) {
170
168
  return file;
171
169
  }
172
170
  try {
173
- return await resizeBlob(file, limits);
171
+ return (await resizeBlob(file, limits)).blob;
174
172
  }
175
173
  catch {
176
- // Resize is best-effort: fall back to the original on failure (e.g. corrupt file).
177
174
  return file;
178
175
  }
179
176
  }
180
- fail(id, error) {
177
+ _fail(id, error) {
181
178
  const message = error instanceof Error ? error.message : 'Upload failed';
182
- const failed = this.transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, previewUrl: f.previewUrl, status: 'error', error: message }));
179
+ const failed = this._transition(id, (f) => ({ id: f.id, file: f.file, progress: f.progress, previewUrl: f.previewUrl, status: 'error', error: message }));
183
180
  if (failed?.status === 'error') {
184
- this.opts.on?.error?.(failed, error);
181
+ this._opts.on?.error?.(failed, error);
185
182
  }
186
183
  }
187
- failAll(files, error) {
184
+ _failAll(files, error) {
188
185
  for (const entry of files) {
189
- this.fail(entry.id, error);
186
+ this._fail(entry.id, error);
190
187
  }
191
- this.opts.on?.done?.();
188
+ this._opts.on?.done?.();
192
189
  }
193
- transition(id, next) {
190
+ _transition(id, next) {
194
191
  let updated;
195
192
  this._files = this._files.map((f) => {
196
193
  if (f.id !== id) {
@@ -1,8 +1,10 @@
1
- <script lang="ts">import { FileWithBlobDataHelper } from '../../../core/files';
1
+ <script lang="ts">import { FileHelper } from '../../../core/files';
2
2
  import { Dialog, DialogButton, DialogCancelButton } from '../../dialog';
3
3
  import { Toastr } from '../../toast';
4
4
  import { ImgCropper, ImgCropperToolbar, ImgCropperView } from '../img-cropper';
5
5
  import { ImageEditorDialogLocalization } from './image-editor-dialog-localization';
6
+ import { default as mime } from 'mime';
7
+ import { nanoid } from 'nanoid';
6
8
  import { untrack } from 'svelte';
7
9
  const { controller, data } = $props();
8
10
  const loc = new ImageEditorDialogLocalization();
@@ -12,22 +14,16 @@ const cropper = untrack(() => new ImgCropper({
12
14
  fillColor: data.fillColor,
13
15
  tools: data.tools
14
16
  }));
15
- const url = $derived(data.url);
16
17
  const save = async () => {
17
18
  try {
18
- const result = await cropper.save();
19
- // Closed mid-save: destroy() already ran, and a file made now would leak its blob URL.
20
- if (!cropper.ready) {
21
- return;
22
- }
19
+ const result = await cropper.save(data.resize);
23
20
  if (!result) {
24
21
  Toastr.error(loc.saveError);
25
22
  return;
26
23
  }
27
- const croppedFile = Object.assign(FileWithBlobDataHelper.fromBlob(result.blob), {
28
- width: result.width,
29
- height: result.height
30
- });
24
+ const baseName = data.fileName ? FileHelper.omitFileExtension(data.fileName) : nanoid(15);
25
+ const file = FileHelper.blobToFile(result.blob, `${baseName}.${mime.getExtension(result.blob.type)}`);
26
+ const croppedFile = Object.assign(file, { width: result.width, height: result.height });
31
27
  controller.ok({ croppedFile, selectedRatio: cropper.aspectRatio });
32
28
  }
33
29
  catch {
@@ -50,7 +46,7 @@ $effect(() => untrack(() => {
50
46
  {/snippet}
51
47
  {#snippet bodySection()}
52
48
  <div class="image-editor-dialog__workspace">
53
- <ImgCropperView src={url} cropper={cropper}>
49
+ <ImgCropperView src={data.url} cropper={cropper}>
54
50
  {#snippet toolbar()}
55
51
  <ImgCropperToolbar cropper={cropper} />
56
52
  {/snippet}
@@ -1,5 +1,5 @@
1
- import type { FileWithBlobUrl } from '../../../core/files';
2
- import type { ImageEditorDynamicAspectRatio, ImageEditorTool } from './types';
1
+ import { type ResizeLimits } from '../../../core/files';
2
+ import type { CroppedFile, ImageEditorDynamicAspectRatio, ImageEditorTool } from './types';
3
3
  export type CropAndResizeOptions = {
4
4
  /** Fixed aspect ratio, or dynamic selector, or omit to follow the picture's own. */
5
5
  aspectRatio?: number | ImageEditorDynamicAspectRatio;
@@ -7,20 +7,13 @@ export type CropAndResizeOptions = {
7
7
  mode?: 'contain' | 'cover';
8
8
  /** Fill color for whatever the picture doesn't cover — `contain` gaps, free position, its own transparency (transparent by default). */
9
9
  fillColor?: string;
10
- /**
11
- * After cropping, downscale to fit these limits. `max1` / `max2` are not tied to width /
12
- * height — the longer side matches the larger limit. Pass `false` to disable resize.
13
- * @default `{ max1: 1600, max2: 1000 }`
14
- */
15
- resize?: {
16
- max1: number;
17
- max2: number;
18
- } | false;
10
+ /** Renders the cropped picture straight at a size that fits these limits; `false` keeps its natural resolution. @default DEFAULT_RESIZE_LIMITS (1600 / 1000) */
11
+ resize?: ResizeLimits | false;
19
12
  /** Actions that rework the picture itself (e.g. background removal), one toolbar button each. */
20
13
  tools?: ImageEditorTool[];
21
14
  };
22
15
  /**
23
- * Opens the kit's image editor dialog, lets the user crop, and (optionally) downscales the result before returning.
16
+ * Opens the kit's image editor dialog and returns the cropped picture, rendered at a size that fits `resize`.
24
17
  * Returns `null` when the user cancels the dialog.
25
18
  */
26
- export declare const cropAndResizeImage: (file: File, options?: CropAndResizeOptions) => Promise<FileWithBlobUrl | null>;
19
+ export declare const cropAndResizeImage: (file: File, options?: CropAndResizeOptions) => Promise<CroppedFile | null>;
@@ -1,8 +1,7 @@
1
- import { FileWithBlobDataHelper, resizeBlob } from '../../../core/files';
1
+ import { DEFAULT_RESIZE_LIMITS } from '../../../core/files';
2
2
  import { openImageEditorDialog } from './open-image-editor-dialog';
3
- const DEFAULT_RESIZE_LIMITS = { max1: 1600, max2: 1000 };
4
3
  /**
5
- * Opens the kit's image editor dialog, lets the user crop, and (optionally) downscales the result before returning.
4
+ * Opens the kit's image editor dialog and returns the cropped picture, rendered at a size that fits `resize`.
6
5
  * Returns `null` when the user cancels the dialog.
7
6
  */
8
7
  export const cropAndResizeImage = async (file, options = {}) => {
@@ -10,25 +9,14 @@ export const cropAndResizeImage = async (file, options = {}) => {
10
9
  try {
11
10
  const result = await openImageEditorDialog({
12
11
  url: sourceUrl,
12
+ fileName: file.name,
13
13
  aspectRatio: options.aspectRatio,
14
14
  mode: options.mode,
15
15
  fillColor: options.fillColor,
16
- tools: options.tools
16
+ tools: options.tools,
17
+ resize: options.resize === false ? undefined : (options.resize ?? DEFAULT_RESIZE_LIMITS)
17
18
  });
18
- if (result.wasCanceled) {
19
- return null;
20
- }
21
- const cropped = result.value.croppedFile;
22
- if (options.resize === false) {
23
- return cropped;
24
- }
25
- const limits = options.resize ?? DEFAULT_RESIZE_LIMITS;
26
- const resizedBlob = await resizeBlob(cropped, limits);
27
- if (resizedBlob === cropped) {
28
- return cropped;
29
- }
30
- FileWithBlobDataHelper.dispose(cropped.blobUrl);
31
- return FileWithBlobDataHelper.fromBlob(resizedBlob, { id: cropped.id });
19
+ return result.wasCanceled ? null : result.value.croppedFile;
32
20
  }
33
21
  finally {
34
22
  URL.revokeObjectURL(sourceUrl);
@@ -18,8 +18,13 @@ import type { ImageEditorDialogOptions, ImageEditorDialogResult } from './types'
18
18
  * - `tools` — one toolbar button per tool; `run` gets the picture as shown — cropped and rotated, at its natural
19
19
  * resolution — and its result replaces it (`null` leaves it as it is). Reset returns to the source.
20
20
  *
21
+ * ### Size
22
+ * - `resize` — the saved picture is rendered straight at a size that fits these limits (one encode, no upscale past
23
+ * them). Omitted — its natural resolution.
24
+ *
21
25
  * ### Result
22
- * - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
26
+ * - `croppedFile` — a plain `File` with its `width`/`height` — natural resolution, or fitted to `resize` when given; no
27
+ * object URL is created for it. Named after `fileName` with the saved picture's extension, or randomly without it.
23
28
  * - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
24
29
  */
25
30
  export declare const openImageEditorDialog: (options: ImageEditorDialogOptions) => Promise<DialogResult<ImageEditorDialogResult>>;
@@ -18,8 +18,13 @@ import { default as ImageEditorDialog } from './cmp.image-editor-dialog.svelte';
18
18
  * - `tools` — one toolbar button per tool; `run` gets the picture as shown — cropped and rotated, at its natural
19
19
  * resolution — and its result replaces it (`null` leaves it as it is). Reset returns to the source.
20
20
  *
21
+ * ### Size
22
+ * - `resize` — the saved picture is rendered straight at a size that fits these limits (one encode, no upscale past
23
+ * them). Omitted — its natural resolution.
24
+ *
21
25
  * ### Result
22
- * - `croppedFile` — `FileWithBlobUrl` with `width`/`height` at natural resolution.
26
+ * - `croppedFile` — a plain `File` with its `width`/`height` — natural resolution, or fitted to `resize` when given; no
27
+ * object URL is created for it. Named after `fileName` with the saved picture's extension, or randomly without it.
23
28
  * - `selectedRatio` — the active aspect ratio at save time (`null` when following the picture's own).
24
29
  */
25
30
  export const openImageEditorDialog = async (options) => {
@@ -1,4 +1,4 @@
1
- import type { FileWithBlobUrl } from '../../../core/files';
1
+ import type { ResizeLimits, SizedFile } from '../../../core/files';
2
2
  export type ImageEditorDynamicAspectRatio = {
3
3
  initial: number;
4
4
  supported: number[];
@@ -23,17 +23,20 @@ export type ImageEditorTool = {
23
23
  export type ImageEditorDialogOptions = {
24
24
  /** The picture to edit — same-origin, a `blob:` / `data:` URL, or a server that sends CORS headers; anything else fails to load. */
25
25
  url: string;
26
+ /** Name of the saved file; its extension follows the saved picture's type. Omitted — a random name. */
27
+ fileName?: string;
28
+ /** Fill color for whatever the picture doesn't cover — `contain` gaps, free position, its own transparency (transparent by default). */
26
29
  fillColor?: string;
27
30
  /** @default 'cover' */
28
31
  mode?: 'contain' | 'cover';
32
+ /** Fixed aspect ratio, or dynamic selector, or omit to follow the picture's own. */
29
33
  aspectRatio?: number | ImageEditorDynamicAspectRatio;
30
34
  /** Actions that rework the picture itself (e.g. background removal), one toolbar button each. */
31
35
  tools?: ImageEditorTool[];
36
+ /** Renders the saved picture straight at a size that fits these limits. Omitted — its natural resolution. */
37
+ resize?: ResizeLimits;
32
38
  };
33
- export type CroppedFile = FileWithBlobUrl & {
34
- width: number;
35
- height: number;
36
- };
39
+ export type CroppedFile = SizedFile;
37
40
  export type ImageEditorDialogResult = {
38
41
  croppedFile: CroppedFile;
39
42
  selectedRatio: number | null;
@@ -1,3 +1,4 @@
1
+ import { type ResizeLimits } from '../../../core/files';
1
2
  import { type FrameFitMode, type Point, type Rect, type Size } from './frame-geometry';
2
3
  export type CropperMode = FrameFitMode;
3
4
  export type { Point, Rect };
@@ -135,8 +136,11 @@ export declare class ImgCropper {
135
136
  * along and keeps what's inside it; a fixed-aspect frame can't turn, so the picture is laid out afresh instead. */
136
137
  rotate: (direction: 1 | -1) => void;
137
138
  reset: () => void;
138
- /** Renders the frame to an image, applying a pending selection first; `null` when there's nothing to render or the browser can't. */
139
- save: () => Promise<ImgCropperSaveResult | null>;
139
+ /**
140
+ * Renders the frame to an image, applying a pending selection first, downscaled to fit `limits` when given;
141
+ * `null` when there's nothing to render or the browser can't.
142
+ */
143
+ save: (limits?: ResizeLimits) => Promise<ImgCropperSaveResult | null>;
140
144
  /**
141
145
  * Where the whole turned source lands when the picture is drawn into `picture` — its center and its size on each axis,
142
146
  * before `rotation` swaps them. The view and `save()` both place the source through this, so they can't drift apart.
@@ -1,3 +1,4 @@
1
+ import { fitSize, toPixelSize } from '../../../core/files';
1
2
  import { ColorHelper, Utils } from '../../../core/utils';
2
3
  import { alignBox, boxAtZoom, boxTurnedWithFrame, clampBox, containBox, containZoom, coverBox, fitFrame, MAX_FRAME_ZOOM, MIN_FREE_ZOOM, moveBox, scaleBox, selectionFromDrag, shiftSelection, zoomOf } from './frame-geometry';
3
4
  import { Picture } from './picture';
@@ -403,14 +404,17 @@ export class ImgCropper {
403
404
  this.aspectRatio = this._initialAspectRatio;
404
405
  this.layout();
405
406
  };
406
- /** Renders the frame to an image, applying a pending selection first; `null` when there's nothing to render or the browser can't. */
407
- save = async () => {
407
+ /**
408
+ * Renders the frame to an image, applying a pending selection first, downscaled to fit `limits` when given;
409
+ * `null` when there's nothing to render or the browser can't.
410
+ */
411
+ save = async (limits) => {
408
412
  if (this.hasSelection) {
409
413
  this.applyCrop();
410
414
  }
411
415
  this._processing = true;
412
416
  try {
413
- const rendered = this._render();
417
+ const rendered = this._render(limits);
414
418
  if (!rendered) {
415
419
  return null;
416
420
  }
@@ -441,7 +445,7 @@ export class ImgCropper {
441
445
  size: { w: turned.w * perX, h: turned.h * perY }
442
446
  };
443
447
  };
444
- _render = () => {
448
+ _render = (limits) => {
445
449
  const source = this._current;
446
450
  const { box, frame, croppedSize } = this;
447
451
  if (this.failed || !source || !box || !frame || !croppedSize) {
@@ -450,13 +454,16 @@ export class ImgCropper {
450
454
  const nativeScale = croppedSize.w / box.w;
451
455
  const nativeArea = frame.w * frame.h * nativeScale * nativeScale;
452
456
  const areaLimit = Math.min(croppedSize.w * croppedSize.h * MAX_OUTPUT_AREA_UPSCALE, MAX_OUTPUT_PIXELS);
453
- const scale = nativeArea > areaLimit ? nativeScale * Math.sqrt(areaLimit / nativeArea) : nativeScale;
454
- const width = Math.max(1, Math.floor(frame.w * scale));
455
- const height = Math.max(1, Math.floor(frame.h * scale));
456
- const left = Math.round(box.x * scale);
457
- const top = Math.round(box.y * scale);
458
- const right = Math.round((box.x + box.w) * scale);
459
- const bottom = Math.round((box.y + box.h) * scale);
457
+ const cappedScale = nativeArea > areaLimit ? nativeScale * Math.sqrt(areaLimit / nativeArea) : nativeScale;
458
+ const cappedSize = { width: frame.w * cappedScale, height: frame.h * cappedScale };
459
+ const { width, height } = limits ? fitSize(cappedSize, limits) : toPixelSize(cappedSize);
460
+ // Per axis from the whole-pixel canvas: one shared scale rounds a frame-wide box a pixel past the canvas edge.
461
+ const scaleX = width / frame.w;
462
+ const scaleY = height / frame.h;
463
+ const left = Math.round(box.x * scaleX);
464
+ const top = Math.round(box.y * scaleY);
465
+ const right = Math.round((box.x + box.w) * scaleX);
466
+ const bottom = Math.round((box.y + box.h) * scaleY);
460
467
  const canvas = document.createElement('canvas');
461
468
  canvas.width = width;
462
469
  canvas.height = height;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@streamscloud/kit",
3
- "version": "0.85.1",
3
+ "version": "0.86.0",
4
4
  "author": "StreamsCloud",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,18 +9,15 @@
9
9
  "type": "module",
10
10
  "scripts": {
11
11
  "dev": "vite dev --port 3011 --host",
12
- "build": "svelte-package --tsconfig ./tsconfig.app.json && npm run build:css && prettier --write --plugin prettier-plugin-svelte . && eslint --fix .",
12
+ "build": "npm run check && svelte-package --tsconfig ./tsconfig.app.json && npm run build:css",
13
13
  "build:css": "sass dist/styles/_normalize.scss dist/styles/normalize.css --no-source-map && sass dist/styles/_reset.scss dist/styles/reset.css --no-source-map",
14
14
  "publish:prod": "npm run build && npm publish --access public --tag latest",
15
15
  "version:dev": "node -e \"const p=require('./package.json');p.version=p.version.split('-')[0]+'-'+Date.now();require('fs').writeFileSync('package.json',JSON.stringify(p,null,2)+'\\n')\"",
16
16
  "publish:dev": "npm run build && npm run version:dev && npm publish --tag next && git checkout -- package.json",
17
17
  "pack": "npm run build && npm pack",
18
18
  "preview": "vite preview",
19
- "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
20
- "check:strict": "svelte-check --tsconfig ./tsconfig.app.json --fail-on-warnings --output machine && eslint . --format codeframe --max-warnings 0 && prettier --check --plugin prettier-plugin-svelte --cache .",
21
- "lint": "prettier --check --plugin prettier-plugin-svelte . && eslint .",
22
- "lint-format": "prettier --write --plugin prettier-plugin-svelte . && eslint --fix .",
23
- "format": "prettier --write --plugin prettier-plugin-svelte ."
19
+ "check": "svelte-check --tsconfig ./tsconfig.app.json --fail-on-warnings --output machine && tsc -p tsconfig.node.json && eslint . --format codeframe --max-warnings 0 && prettier --check --plugin prettier-plugin-svelte --cache .",
20
+ "lint:fix": "eslint --fix . && prettier --write --plugin prettier-plugin-svelte ."
24
21
  },
25
22
  "files": [
26
23
  "dist"