nuxt-ui-elements 0.1.29 → 0.1.30

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/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-ui-elements",
3
3
  "configKey": "uiElements",
4
- "version": "0.1.29",
4
+ "version": "0.1.30",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -0,0 +1,14 @@
1
+ interface FFMPegOptions {
2
+ inputUrl: string;
3
+ convertOptions?: string[];
4
+ }
5
+ export declare const useFFMpeg: (options: FFMPegOptions) => {
6
+ status: import("vue").Ref<"success" | "error" | "paused" | "converting", "success" | "error" | "paused" | "converting">;
7
+ progress: import("vue").Ref<number, number>;
8
+ convertedFile: import("vue").Ref<File | undefined, File | undefined>;
9
+ load: () => Promise<void>;
10
+ unload: () => void;
11
+ convert: (convertOptions: string[]) => Promise<File | undefined>;
12
+ onConvertSuccess: (callback: (updatedVideo: File) => void) => (updatedVideo: File) => void;
13
+ };
14
+ export {};
@@ -0,0 +1,66 @@
1
+ import { FFmpeg } from "@ffmpeg/ffmpeg";
2
+ import { fetchFile, toBlobURL } from "@ffmpeg/util";
3
+ import { ref } from "vue";
4
+ const defaultOptions = {
5
+ convertOptions: []
6
+ };
7
+ const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm";
8
+ export const useFFMpeg = (options) => {
9
+ options = { ...defaultOptions, ...options };
10
+ const ffmpeg = new FFmpeg();
11
+ const status = ref("paused");
12
+ const progress = ref(0);
13
+ const originalFile = ref();
14
+ const convertedFile = ref();
15
+ let _onConvertSuccess;
16
+ ffmpeg.on("progress", ({ time }) => {
17
+ progress.value = time / 1e6;
18
+ });
19
+ const load = async () => {
20
+ await ffmpeg.load({
21
+ coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
22
+ wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, "application/wasm")
23
+ });
24
+ };
25
+ const unload = () => ffmpeg.terminate();
26
+ const convert = async (convertOptions) => {
27
+ status.value = "converting";
28
+ const command = ["-i", "input.avi", ...options.convertOptions, ...convertOptions, "-c", "copy", "output.mp4"];
29
+ try {
30
+ originalFile.value = await fetchFile(options.inputUrl);
31
+ await ffmpeg.writeFile("input.avi", originalFile.value);
32
+ await ffmpeg.exec(command);
33
+ convertedFile.value = await getModifiedVideo();
34
+ await ffmpeg.deleteFile("input.avi");
35
+ status.value = "success";
36
+ if (_onConvertSuccess) _onConvertSuccess(convertedFile.value);
37
+ return convertedFile.value;
38
+ } catch (error) {
39
+ if (import.meta.env.DEV) console.error(error);
40
+ status.value = "error";
41
+ }
42
+ };
43
+ const getModifiedVideo = async () => {
44
+ const data = await ffmpeg.readFile("output.mp4");
45
+ let bytes;
46
+ if (typeof data === "string") {
47
+ bytes = new TextEncoder().encode(data);
48
+ } else {
49
+ const buffer = new ArrayBuffer(data.byteLength);
50
+ new Uint8Array(buffer).set(data);
51
+ bytes = new Uint8Array(buffer);
52
+ }
53
+ const updatedFile = new File([bytes], "output.mp4", { type: "video/mp4" });
54
+ return updatedFile;
55
+ };
56
+ const onConvertSuccess = (callback) => _onConvertSuccess = callback;
57
+ return {
58
+ status,
59
+ progress,
60
+ convertedFile,
61
+ load,
62
+ unload,
63
+ convert,
64
+ onConvertSuccess
65
+ };
66
+ };
@@ -1,10 +1,7 @@
1
1
  import type { UploaderEvents, UploadFile, UploadOptions, UploadStatus, UploadFn, GetRemoteFileFn, Plugin as UploaderPlugin } from "./types.js";
2
2
  export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOptions) => {
3
- files: Readonly<import("vue").Ref<readonly {
4
- readonly id: string;
5
- readonly name: string;
6
- readonly size: number;
7
- readonly mimeType: string;
3
+ files: Readonly<import("vue").Ref<readonly ({
4
+ readonly source: "local";
8
5
  readonly data: {
9
6
  readonly lastModified: number;
10
7
  readonly name: string;
@@ -55,6 +52,11 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
55
52
  (): Promise<string>;
56
53
  };
57
54
  };
55
+ readonly remoteUrl?: string | undefined;
56
+ readonly id: string;
57
+ readonly name: string;
58
+ readonly size: number;
59
+ readonly mimeType: string;
58
60
  readonly status: import("./types.js").FileStatus;
59
61
  readonly preview?: string | undefined;
60
62
  readonly progress: {
@@ -65,16 +67,32 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
65
67
  readonly details?: Readonly<unknown> | undefined;
66
68
  } | undefined;
67
69
  readonly uploadResult?: import("vue").DeepReadonly<import("vue").UnwrapRef<TUploadResult>> | undefined;
68
- readonly isRemote?: boolean | undefined;
69
- readonly remoteUrl?: string | undefined;
70
70
  readonly meta: {
71
71
  readonly [x: string]: Readonly<unknown>;
72
72
  };
73
- }[], readonly {
73
+ } | {
74
+ readonly source: Exclude<import("./types.js").FileSource, "local">;
75
+ readonly data: null;
76
+ readonly remoteUrl: string;
74
77
  readonly id: string;
75
78
  readonly name: string;
76
79
  readonly size: number;
77
80
  readonly mimeType: string;
81
+ readonly status: import("./types.js").FileStatus;
82
+ readonly preview?: string | undefined;
83
+ readonly progress: {
84
+ readonly percentage: number;
85
+ };
86
+ readonly error?: {
87
+ readonly message: string;
88
+ readonly details?: Readonly<unknown> | undefined;
89
+ } | undefined;
90
+ readonly uploadResult?: import("vue").DeepReadonly<import("vue").UnwrapRef<TUploadResult>> | undefined;
91
+ readonly meta: {
92
+ readonly [x: string]: Readonly<unknown>;
93
+ };
94
+ })[], readonly ({
95
+ readonly source: "local";
78
96
  readonly data: {
79
97
  readonly lastModified: number;
80
98
  readonly name: string;
@@ -125,6 +143,11 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
125
143
  (): Promise<string>;
126
144
  };
127
145
  };
146
+ readonly remoteUrl?: string | undefined;
147
+ readonly id: string;
148
+ readonly name: string;
149
+ readonly size: number;
150
+ readonly mimeType: string;
128
151
  readonly status: import("./types.js").FileStatus;
129
152
  readonly preview?: string | undefined;
130
153
  readonly progress: {
@@ -135,23 +158,39 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
135
158
  readonly details?: Readonly<unknown> | undefined;
136
159
  } | undefined;
137
160
  readonly uploadResult?: import("vue").DeepReadonly<import("vue").UnwrapRef<TUploadResult>> | undefined;
138
- readonly isRemote?: boolean | undefined;
139
- readonly remoteUrl?: string | undefined;
140
161
  readonly meta: {
141
162
  readonly [x: string]: Readonly<unknown>;
142
163
  };
143
- }[]>>;
164
+ } | {
165
+ readonly source: Exclude<import("./types.js").FileSource, "local">;
166
+ readonly data: null;
167
+ readonly remoteUrl: string;
168
+ readonly id: string;
169
+ readonly name: string;
170
+ readonly size: number;
171
+ readonly mimeType: string;
172
+ readonly status: import("./types.js").FileStatus;
173
+ readonly preview?: string | undefined;
174
+ readonly progress: {
175
+ readonly percentage: number;
176
+ };
177
+ readonly error?: {
178
+ readonly message: string;
179
+ readonly details?: Readonly<unknown> | undefined;
180
+ } | undefined;
181
+ readonly uploadResult?: import("vue").DeepReadonly<import("vue").UnwrapRef<TUploadResult>> | undefined;
182
+ readonly meta: {
183
+ readonly [x: string]: Readonly<unknown>;
184
+ };
185
+ })[]>>;
144
186
  totalProgress: import("vue").ComputedRef<number>;
145
- addFiles: (newFiles: File[]) => Promise<(UploadFile<any> | null)[]>;
146
- addFile: (file: File) => Promise<UploadFile<any> | null>;
187
+ addFiles: (newFiles: File[]) => Promise<(UploadFile | null)[]>;
188
+ addFile: (file: File) => Promise<UploadFile | null>;
147
189
  onGetRemoteFile: (fn: GetRemoteFileFn) => void;
148
190
  onUpload: (fn: UploadFn<TUploadResult>) => void;
149
191
  removeFile: (fileId: string) => Promise<void>;
150
- removeFiles: (fileIds: string[]) => {
151
- id: string;
152
- name: string;
153
- size: number;
154
- mimeType: string;
192
+ removeFiles: (fileIds: string[]) => ({
193
+ source: "local";
155
194
  data: {
156
195
  readonly lastModified: number;
157
196
  readonly name: string;
@@ -202,6 +241,11 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
202
241
  (): Promise<string>;
203
242
  };
204
243
  };
244
+ remoteUrl?: string | undefined;
245
+ id: string;
246
+ name: string;
247
+ size: number;
248
+ mimeType: string;
205
249
  status: import("./types.js").FileStatus;
206
250
  preview?: string | undefined;
207
251
  progress: {
@@ -212,15 +256,29 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
212
256
  details?: unknown;
213
257
  } | undefined;
214
258
  uploadResult?: import("vue").UnwrapRef<TUploadResult> | undefined;
215
- isRemote?: boolean | undefined;
216
- remoteUrl?: string | undefined;
217
259
  meta: Record<string, unknown>;
218
- }[];
219
- clearFiles: () => {
260
+ } | {
261
+ source: Exclude<import("./types.js").FileSource, "local">;
262
+ data: null;
263
+ remoteUrl: string;
220
264
  id: string;
221
265
  name: string;
222
266
  size: number;
223
267
  mimeType: string;
268
+ status: import("./types.js").FileStatus;
269
+ preview?: string | undefined;
270
+ progress: {
271
+ percentage: number;
272
+ };
273
+ error?: {
274
+ message: string;
275
+ details?: unknown;
276
+ } | undefined;
277
+ uploadResult?: import("vue").UnwrapRef<TUploadResult> | undefined;
278
+ meta: Record<string, unknown>;
279
+ })[];
280
+ clearFiles: () => ({
281
+ source: "local";
224
282
  data: {
225
283
  readonly lastModified: number;
226
284
  readonly name: string;
@@ -271,6 +329,11 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
271
329
  (): Promise<string>;
272
330
  };
273
331
  };
332
+ remoteUrl?: string | undefined;
333
+ id: string;
334
+ name: string;
335
+ size: number;
336
+ mimeType: string;
274
337
  status: import("./types.js").FileStatus;
275
338
  preview?: string | undefined;
276
339
  progress: {
@@ -281,16 +344,30 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
281
344
  details?: unknown;
282
345
  } | undefined;
283
346
  uploadResult?: import("vue").UnwrapRef<TUploadResult> | undefined;
284
- isRemote?: boolean | undefined;
285
- remoteUrl?: string | undefined;
286
347
  meta: Record<string, unknown>;
287
- }[];
288
- reorderFile: (oldIndex: number, newIndex: number) => void;
289
- getFile: (fileId: string) => {
348
+ } | {
349
+ source: Exclude<import("./types.js").FileSource, "local">;
350
+ data: null;
351
+ remoteUrl: string;
290
352
  id: string;
291
353
  name: string;
292
354
  size: number;
293
355
  mimeType: string;
356
+ status: import("./types.js").FileStatus;
357
+ preview?: string | undefined;
358
+ progress: {
359
+ percentage: number;
360
+ };
361
+ error?: {
362
+ message: string;
363
+ details?: unknown;
364
+ } | undefined;
365
+ uploadResult?: import("vue").UnwrapRef<TUploadResult> | undefined;
366
+ meta: Record<string, unknown>;
367
+ })[];
368
+ reorderFile: (oldIndex: number, newIndex: number) => void;
369
+ getFile: (fileId: string) => {
370
+ source: "local";
294
371
  data: {
295
372
  readonly lastModified: number;
296
373
  readonly name: string;
@@ -341,6 +418,30 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
341
418
  (): Promise<string>;
342
419
  };
343
420
  };
421
+ remoteUrl?: string | undefined;
422
+ id: string;
423
+ name: string;
424
+ size: number;
425
+ mimeType: string;
426
+ status: import("./types.js").FileStatus;
427
+ preview?: string | undefined;
428
+ progress: {
429
+ percentage: number;
430
+ };
431
+ error?: {
432
+ message: string;
433
+ details?: unknown;
434
+ } | undefined;
435
+ uploadResult?: import("vue").UnwrapRef<TUploadResult> | undefined;
436
+ meta: Record<string, unknown>;
437
+ } | {
438
+ source: Exclude<import("./types.js").FileSource, "local">;
439
+ data: null;
440
+ remoteUrl: string;
441
+ id: string;
442
+ name: string;
443
+ size: number;
444
+ mimeType: string;
344
445
  status: import("./types.js").FileStatus;
345
446
  preview?: string | undefined;
346
447
  progress: {
@@ -351,13 +452,15 @@ export declare const useUploadManager: <TUploadResult = any>(_options?: UploadOp
351
452
  details?: unknown;
352
453
  } | undefined;
353
454
  uploadResult?: import("vue").UnwrapRef<TUploadResult> | undefined;
354
- isRemote?: boolean | undefined;
355
- remoteUrl?: string | undefined;
356
455
  meta: Record<string, unknown>;
357
456
  } | undefined;
358
457
  upload: () => Promise<void>;
359
458
  reset: () => void;
360
459
  status: import("vue").Ref<UploadStatus, UploadStatus>;
460
+ getFileData: (fileId: string) => Promise<Blob>;
461
+ getFileURL: (fileId: string) => Promise<string>;
462
+ getFileStream: (fileId: string) => Promise<ReadableStream<Uint8Array>>;
463
+ replaceFileData: (fileId: string, newData: Blob, newName?: string) => Promise<void>;
361
464
  updateFile: (fileId: string, updatedFile: Partial<UploadFile<TUploadResult>>) => void;
362
465
  initializeExistingFiles: (initialFiles: Array<Partial<UploadFile>>) => Promise<void>;
363
466
  addPlugin: (plugin: UploaderPlugin<any, any>) => void;
@@ -147,13 +147,15 @@ The LAST storage plugin ("${plugin.id}") will be used for uploads.
147
147
  ...file,
148
148
  id: file.id,
149
149
  name: file.id,
150
- data: new Blob(),
150
+ data: null,
151
151
  status: "complete",
152
152
  progress: { percentage: 100 },
153
153
  meta: {},
154
154
  size: remoteFileData.size,
155
155
  mimeType: remoteFileData.mimeType,
156
- remoteUrl: remoteFileData.remoteUrl
156
+ remoteUrl: remoteFileData.remoteUrl,
157
+ source: "storage"
158
+ // File loaded from remote storage
157
159
  };
158
160
  const processedFile = await runPluginStage("process", existingFile);
159
161
  if (!processedFile) return null;
@@ -176,6 +178,7 @@ The LAST storage plugin ("${plugin.id}") will be used for uploads.
176
178
  status: "waiting",
177
179
  mimeType: file.type,
178
180
  data: file,
181
+ source: "local",
179
182
  meta: {
180
183
  extension
181
184
  }
@@ -195,22 +198,24 @@ The LAST storage plugin ("${plugin.id}") will be used for uploads.
195
198
  return Promise.all(newFiles.map((file) => addFile(file)));
196
199
  };
197
200
  const removeFile = async (fileId) => {
198
- const file = files.value.find((f) => f.id === fileId);
201
+ const file = getFile(fileId);
199
202
  if (!file) return;
200
- const storagePlugin = getStoragePlugin();
201
- if (storagePlugin?.hooks.remove) {
202
- try {
203
- const context = {
204
- files: files.value,
205
- options,
206
- emit: (event, payload) => {
207
- const prefixedEvent = `${storagePlugin.id}:${String(event)}`;
208
- emitter.emit(prefixedEvent, payload);
209
- }
210
- };
211
- await storagePlugin.hooks.remove(file, context);
212
- } catch (error) {
213
- console.error(`Storage plugin remove error:`, error);
203
+ if (file.remoteUrl) {
204
+ const storagePlugin = getStoragePlugin();
205
+ if (storagePlugin?.hooks.remove) {
206
+ try {
207
+ const context = {
208
+ files: files.value,
209
+ options,
210
+ emit: (event, payload) => {
211
+ const prefixedEvent = `${storagePlugin.id}:${String(event)}`;
212
+ emitter.emit(prefixedEvent, payload);
213
+ }
214
+ };
215
+ await storagePlugin.hooks.remove(file, context);
216
+ } catch (error) {
217
+ console.error(`Storage plugin remove error:`, error);
218
+ }
214
219
  }
215
220
  }
216
221
  files.value = files.value.filter((f) => f.id !== fileId);
@@ -232,6 +237,70 @@ The LAST storage plugin ("${plugin.id}") will be used for uploads.
232
237
  });
233
238
  return allFiles;
234
239
  };
240
+ const getFileData = async (fileId) => {
241
+ const file = getFile(fileId);
242
+ if (!file) {
243
+ throw new Error(`File not found: ${fileId}`);
244
+ }
245
+ if (file.size > 100 * 1024 * 1024) {
246
+ console.warn(
247
+ `getFileData: Loading large file (${(file.size / 1024 / 1024).toFixed(2)}MB) into memory. Consider using getFileURL() or getFileStream() for better performance.`
248
+ );
249
+ }
250
+ if (file.source === "local") {
251
+ return file.data;
252
+ }
253
+ const response = await fetch(file.remoteUrl);
254
+ if (!response.ok) {
255
+ throw new Error(`Failed to fetch file: ${response.statusText}`);
256
+ }
257
+ return await response.blob();
258
+ };
259
+ const getFileURL = async (fileId) => {
260
+ const file = getFile(fileId);
261
+ if (!file) {
262
+ throw new Error(`File not found: ${fileId}`);
263
+ }
264
+ if (file.source === "local") {
265
+ return URL.createObjectURL(file.data);
266
+ }
267
+ return file.remoteUrl;
268
+ };
269
+ const getFileStream = async (fileId) => {
270
+ const file = getFile(fileId);
271
+ if (!file) {
272
+ throw new Error(`File not found: ${fileId}`);
273
+ }
274
+ if (file.source === "local") {
275
+ return file.data.stream();
276
+ }
277
+ const response = await fetch(file.remoteUrl);
278
+ if (!response.ok || !response.body) {
279
+ throw new Error(`Failed to fetch file stream: ${response.statusText}`);
280
+ }
281
+ return response.body;
282
+ };
283
+ const replaceFileData = async (fileId, newData, newName) => {
284
+ const file = getFile(fileId);
285
+ if (!file) {
286
+ throw new Error(`File not found: ${fileId}`);
287
+ }
288
+ const updatedFile = {
289
+ ...file,
290
+ source: "local",
291
+ data: newData,
292
+ name: newName || file.name,
293
+ size: newData.size,
294
+ status: "waiting",
295
+ // Mark as needing upload
296
+ progress: { percentage: 0 },
297
+ remoteUrl: void 0
298
+ // Clear old remote URL
299
+ };
300
+ const index = files.value.findIndex((f) => f.id === fileId);
301
+ files.value[index] = updatedFile;
302
+ emitter.emit("file:added", updatedFile);
303
+ };
235
304
  const reorderFile = (oldIndex, newIndex) => {
236
305
  if (oldIndex === newIndex) {
237
306
  if (import.meta.dev) {
@@ -271,16 +340,14 @@ The LAST storage plugin ("${plugin.id}") will be used for uploads.
271
340
  files: files.value,
272
341
  options,
273
342
  onProgress,
274
- emit: (event, payload) => {
275
- const prefixedEvent = `${storagePlugin.id}:${String(event)}`;
276
- emitter.emit(prefixedEvent, payload);
277
- }
343
+ emit: getPluginEmitFn(storagePlugin.id)
278
344
  };
279
345
  uploadResult = await storagePlugin.hooks.upload(file, context);
280
346
  } else {
281
347
  uploadResult = await uploadFn(file, onProgress);
282
348
  }
283
- updateFile(file.id, { status: "complete", uploadResult });
349
+ const remoteUrl = uploadResult?.url;
350
+ updateFile(file.id, { status: "complete", uploadResult, remoteUrl });
284
351
  } catch (err) {
285
352
  const error = {
286
353
  message: err instanceof Error ? err.message : "Unknown upload error",
@@ -359,6 +426,11 @@ The LAST storage plugin ("${plugin.id}") will be used for uploads.
359
426
  upload,
360
427
  reset,
361
428
  status,
429
+ // File Data Access (for editing/processing)
430
+ getFileData,
431
+ getFileURL,
432
+ getFileStream,
433
+ replaceFileData,
362
434
  updateFile,
363
435
  initializeExistingFiles,
364
436
  // Utilities
@@ -15,6 +15,10 @@ export const PluginImageCompressor = defineUploaderPlugin((pluginOptions) => {
15
15
  if (!file.mimeType.startsWith("image/")) {
16
16
  return file;
17
17
  }
18
+ if (file.source !== "local") {
19
+ context.emit("skip", { file, reason: "Remote file, no local data to compress" });
20
+ return file;
21
+ }
18
22
  if (file.mimeType === "image/gif") {
19
23
  context.emit("skip", { file, reason: "GIF format not supported" });
20
24
  return file;
@@ -1,3 +1,4 @@
1
1
  export * from "./thumbnail-generator.js";
2
2
  export * from "./image-compressor.js";
3
+ export * from "./video-compressor.js";
3
4
  export * from "./storage/index.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./thumbnail-generator.js";
2
2
  export * from "./image-compressor.js";
3
+ export * from "./video-compressor.js";
3
4
  export * from "./storage/index.js";
@@ -58,6 +58,9 @@ export const PluginAzureDataLake = defineUploaderPlugin((options) => {
58
58
  * Upload file to Azure Blob Storage
59
59
  */
60
60
  async upload(file, context) {
61
+ if (file.source !== "local" || file.data === null) {
62
+ throw new Error("Cannot upload remote file - no local data available");
63
+ }
61
64
  const fileClient = await getFileClient(file.id);
62
65
  await fileClient.upload(file.data, {
63
66
  metadata: {
@@ -5,7 +5,7 @@ export const PluginThumbnailGenerator = defineUploaderPlugin((pluginOptions) =>
5
5
  hooks: {
6
6
  process: async (file, _context) => {
7
7
  const { width = 100, height = 100, quality = 0.7 } = pluginOptions;
8
- const sourceUrl = file.isRemote ? file.remoteUrl : URL.createObjectURL(file.data);
8
+ const sourceUrl = file.source === "local" ? URL.createObjectURL(file.data) : file.remoteUrl;
9
9
  if (file.mimeType.startsWith("image/")) {
10
10
  const image = new Image();
11
11
  image.crossOrigin = "anonymous";
@@ -0,0 +1,72 @@
1
+ interface VideoCompressorOptions {
2
+ /**
3
+ * Target video codec
4
+ * @default 'libx264'
5
+ */
6
+ codec?: string;
7
+ /**
8
+ * Constant Rate Factor (0-51, lower = better quality, larger file)
9
+ * @default 28
10
+ */
11
+ crf?: number;
12
+ /**
13
+ * Target bitrate (e.g., '1M' = 1 megabit/sec)
14
+ * If specified, overrides CRF
15
+ */
16
+ bitrate?: string;
17
+ /**
18
+ * Maximum width (maintains aspect ratio)
19
+ */
20
+ maxWidth?: number;
21
+ /**
22
+ * Maximum height (maintains aspect ratio)
23
+ */
24
+ maxHeight?: number;
25
+ /**
26
+ * Output format
27
+ * @default 'mp4'
28
+ */
29
+ format?: "mp4" | "webm" | "mov";
30
+ /**
31
+ * Minimum file size to compress (in bytes)
32
+ * Files smaller than this will be skipped
33
+ * @default 10MB
34
+ */
35
+ minSizeToCompress?: number;
36
+ /**
37
+ * Audio codec
38
+ * @default 'aac'
39
+ */
40
+ audioCodec?: string;
41
+ /**
42
+ * Audio bitrate
43
+ * @default '128k'
44
+ */
45
+ audioBitrate?: string;
46
+ }
47
+ type VideoCompressorEvents = {
48
+ start: {
49
+ file: any;
50
+ originalSize: number;
51
+ };
52
+ progress: {
53
+ file: any;
54
+ percentage: number;
55
+ };
56
+ complete: {
57
+ file: any;
58
+ originalSize: number;
59
+ compressedSize: number;
60
+ savedBytes: number;
61
+ };
62
+ skip: {
63
+ file: any;
64
+ reason: string;
65
+ };
66
+ error: {
67
+ file: any;
68
+ error: Error;
69
+ };
70
+ };
71
+ export declare const PluginVideoCompressor: (options: VideoCompressorOptions) => import("../types.js").Plugin<any, VideoCompressorEvents>;
72
+ export {};
@@ -0,0 +1,102 @@
1
+ import { defineUploaderPlugin } from "../types.js";
2
+ import { useFFMpeg } from "../../useFFMpeg.js";
3
+ import { watchEffect } from "vue";
4
+ export const PluginVideoCompressor = defineUploaderPlugin((pluginOptions) => {
5
+ return {
6
+ id: "video-compressor",
7
+ hooks: {
8
+ process: async (file, context) => {
9
+ const {
10
+ codec = "libx264",
11
+ crf = 28,
12
+ bitrate,
13
+ maxWidth,
14
+ maxHeight,
15
+ format = "mp4",
16
+ minSizeToCompress = 10 * 1024 * 1024,
17
+ // 10MB
18
+ audioCodec = "aac",
19
+ audioBitrate = "128k"
20
+ } = pluginOptions;
21
+ if (!file.mimeType.startsWith("video/")) {
22
+ return file;
23
+ }
24
+ if (file.source !== "local") {
25
+ context.emit("skip", { file, reason: "Remote file, no local data to compress" });
26
+ return file;
27
+ }
28
+ if (file.size < minSizeToCompress) {
29
+ context.emit("skip", {
30
+ file,
31
+ reason: `File size (${(file.size / 1024 / 1024).toFixed(2)}MB) is below minimum (${(minSizeToCompress / 1024 / 1024).toFixed(2)}MB)`
32
+ });
33
+ return file;
34
+ }
35
+ try {
36
+ context.emit("start", { file, originalSize: file.size });
37
+ const inputUrl = URL.createObjectURL(file.data);
38
+ const ffmpeg = useFFMpeg({
39
+ inputUrl,
40
+ convertOptions: []
41
+ });
42
+ await ffmpeg.load();
43
+ watchEffect(() => {
44
+ context.emit("progress", { file, percentage: Math.round(ffmpeg.progress.value * 100) });
45
+ });
46
+ const convertOptions = ["-c:v", codec];
47
+ if (bitrate) {
48
+ convertOptions.push("-b:v", bitrate);
49
+ } else {
50
+ convertOptions.push("-crf", crf.toString());
51
+ }
52
+ if (maxWidth || maxHeight) {
53
+ let scaleFilter = "";
54
+ if (maxWidth && maxHeight) {
55
+ scaleFilter = `scale='min(${maxWidth},iw)':'min(${maxHeight},ih)':force_original_aspect_ratio=decrease`;
56
+ } else if (maxWidth) {
57
+ scaleFilter = `scale=${maxWidth}:-2`;
58
+ } else if (maxHeight) {
59
+ scaleFilter = `scale=-2:${maxHeight}`;
60
+ }
61
+ convertOptions.push("-vf", scaleFilter);
62
+ }
63
+ convertOptions.push("-c:a", audioCodec, "-b:a", audioBitrate);
64
+ const compressedFile = await ffmpeg.convert(convertOptions);
65
+ ffmpeg.unload();
66
+ if (!compressedFile) {
67
+ context.emit("error", { file, error: new Error("Compression failed: no output file") });
68
+ return file;
69
+ }
70
+ const originalSize = file.size;
71
+ const compressedSize = compressedFile.size;
72
+ const savedBytes = originalSize - compressedSize;
73
+ if (compressedSize < originalSize) {
74
+ context.emit("complete", {
75
+ file,
76
+ originalSize,
77
+ compressedSize,
78
+ savedBytes
79
+ });
80
+ return {
81
+ ...file,
82
+ data: compressedFile,
83
+ size: compressedFile.size,
84
+ name: file.name.replace(/\.[^.]+$/, `.${format}`),
85
+ mimeType: `video/${format}`
86
+ };
87
+ } else {
88
+ context.emit("skip", {
89
+ file,
90
+ reason: `Compressed size (${(compressedSize / 1024 / 1024).toFixed(2)}MB) is larger than original`
91
+ });
92
+ return file;
93
+ }
94
+ } catch (error) {
95
+ context.emit("error", { file, error });
96
+ console.error(`Video compression error for ${file.name}:`, error);
97
+ return file;
98
+ }
99
+ }
100
+ }
101
+ };
102
+ });
@@ -12,21 +12,68 @@ export interface FileError {
12
12
  message: string;
13
13
  details?: unknown;
14
14
  }
15
- export interface UploadFile<TUploadResult = any> {
15
+ /**
16
+ * File source - indicates where the file originated from
17
+ *
18
+ * - 'local': File selected from user's device
19
+ * - 'storage': File loaded from remote storage (was previously uploaded)
20
+ * - Cloud picker sources: Files picked from cloud providers (future)
21
+ * - 'instagram': Instagram picker
22
+ * - 'dropbox': Dropbox picker
23
+ * - 'google-drive': Google Drive picker
24
+ * - 'onedrive': OneDrive picker
25
+ * - ... add more as needed
26
+ */
27
+ export type FileSource = 'local' | 'storage' | 'instagram' | 'dropbox' | 'google-drive' | 'onedrive';
28
+ /**
29
+ * Base properties shared by both local and remote upload files
30
+ */
31
+ export interface BaseUploadFile<TUploadResult = any> {
16
32
  id: string;
17
33
  name: string;
18
34
  size: number;
19
35
  mimeType: string;
20
- data: File | Blob;
21
36
  status: FileStatus;
22
37
  preview?: string;
23
38
  progress: FileProgress;
24
39
  error?: FileError;
25
40
  uploadResult?: TUploadResult;
26
- isRemote?: boolean;
27
- remoteUrl?: string;
28
41
  meta: Record<string, unknown>;
29
42
  }
43
+ /**
44
+ * Local upload file - originates from user's device
45
+ * Has local data (File/Blob) and may get a remoteUrl after upload
46
+ */
47
+ export interface LocalUploadFile<TUploadResult = any> extends BaseUploadFile<TUploadResult> {
48
+ source: 'local';
49
+ data: File | Blob;
50
+ remoteUrl?: string;
51
+ }
52
+ /**
53
+ * Remote upload file - originates from remote source (cloud pickers, etc.)
54
+ * Has remoteUrl but no local data
55
+ */
56
+ export interface RemoteUploadFile<TUploadResult = any> extends BaseUploadFile<TUploadResult> {
57
+ source: Exclude<FileSource, 'local'>;
58
+ data: null;
59
+ remoteUrl: string;
60
+ }
61
+ /**
62
+ * Upload file discriminated union
63
+ * Use file.source to narrow the type in your code:
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * if (file.source === 'local') {
68
+ * // TypeScript knows: file is LocalUploadFile
69
+ * URL.createObjectURL(file.data)
70
+ * } else {
71
+ * // TypeScript knows: file is RemoteUploadFile
72
+ * console.log(file.remoteUrl)
73
+ * }
74
+ * ```
75
+ */
76
+ export type UploadFile<TUploadResult = any> = LocalUploadFile<TUploadResult> | RemoteUploadFile<TUploadResult>;
30
77
  export type UploadFn<TUploadResult = any> = (file: UploadFile<TUploadResult>, onProgress: (progress: number) => void) => Promise<TUploadResult>;
31
78
  export type GetRemoteFileFn = (fileId: string) => Promise<MinimumRemoteFileAttributes>;
32
79
  export interface UploadOptions {
@@ -129,10 +176,27 @@ export type ProcessingHook<TPluginEvents extends Record<string, any> = Record<st
129
176
  export type SetupHook<TPluginEvents extends Record<string, any> = Record<string, never>> = (context: PluginContext<TPluginEvents>) => Promise<void>;
130
177
  /**
131
178
  * Storage hooks for handling upload/download/deletion operations
179
+ *
180
+ * Storage plugins MUST return an object containing a `url` property.
181
+ * This URL will be set as the file's `remoteUrl` after successful upload.
182
+ *
183
+ * @example
184
+ * ```typescript
185
+ * upload: async (file, context) => {
186
+ * // Upload logic...
187
+ * return {
188
+ * url: 'https://storage.example.com/file.jpg', // Required
189
+ * key: 'uploads/file.jpg', // Optional
190
+ * etag: 'abc123' // Optional
191
+ * }
192
+ * }
193
+ * ```
132
194
  */
133
195
  export type UploadHook<TUploadResult = any, TPluginEvents extends Record<string, any> = Record<string, never>> = (file: UploadFile<TUploadResult>, context: PluginContext<TPluginEvents> & {
134
196
  onProgress: (progress: number) => void;
135
- }) => Promise<TUploadResult>;
197
+ }) => Promise<TUploadResult & {
198
+ url: string;
199
+ }>;
136
200
  export type GetRemoteFileHook<TPluginEvents extends Record<string, any> = Record<string, never>> = (fileId: string, context: PluginContext<TPluginEvents>) => Promise<MinimumRemoteFileAttributes>;
137
201
  export type RemoveHook<TPluginEvents extends Record<string, any> = Record<string, never>> = (file: UploadFile, context: PluginContext<TPluginEvents>) => Promise<void>;
138
202
  export type PluginLifecycleStage = "validate" | "process" | "upload" | "complete";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-ui-elements",
3
- "version": "0.1.29",
3
+ "version": "0.1.30",
4
4
  "description": "A collection of beautiful, animated UI components for Nuxt applications",
5
5
  "license": "MIT",
6
6
  "repository": "https://github.com/genu/nuxt-ui-elements.git",
@@ -35,6 +35,8 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@azure/storage-file-datalake": "^12.28.1",
38
+ "@ffmpeg/ffmpeg": "0.12.15",
39
+ "@ffmpeg/util": "0.12.2",
38
40
  "@nuxt/devtools": "^3.1.1",
39
41
  "@nuxt/eslint-config": "^1.12.1",
40
42
  "@nuxt/module-builder": "^1.0.2",
@@ -59,6 +61,12 @@
59
61
  "peerDependenciesMeta": {
60
62
  "@azure/storage-file-datalake": {
61
63
  "optional": true
64
+ },
65
+ "@ffmpeg/ffmpeg": {
66
+ "optional": true
67
+ },
68
+ "@ffmpeg/util": {
69
+ "optional": true
62
70
  }
63
71
  },
64
72
  "scripts": {