create-cloudinary-react 1.0.0-beta.13 → 1.0.0-beta.14

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.0.0-beta.14](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.13...v1.0.0-beta.14) (2026-02-04)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * improve upload widget reliability and add video player poster options ([ac51e42](https://github.com/cloudinary-devs/create-cloudinary-react/commit/ac51e420f1b81f2a055bb3fb7e0331841e86b37a))
7
+
1
8
  # [1.0.0-beta.13](https://github.com/cloudinary-devs/create-cloudinary-react/compare/v1.0.0-beta.12...v1.0.0-beta.13) (2026-02-03)
2
9
 
3
10
 
package/README.md CHANGED
@@ -22,7 +22,7 @@ npx create-cloudinary-react
22
22
  The CLI will prompt you for:
23
23
  - Project name
24
24
  - **Cloudinary cloud name** (found in your [dashboard](https://console.cloudinary.com/app/home/dashboard))
25
- - Unsigned Upload preset (optional - required for uploads, but transformations work without it)
25
+ - Unsigned upload preset (optional - required for uploads, but transformations work without it)
26
26
  - AI coding assistant(s) you're using (Cursor, GitHub Copilot, Claude, etc.)
27
27
  - Whether to install dependencies
28
28
  - Whether to start dev server
@@ -44,7 +44,7 @@ During setup, you'll be asked which AI coding assistant(s) you're using. The CLI
44
44
 
45
45
  - ✅ **Cursor** → `.cursorrules` + `.cursor/mcp.json` (if selected)
46
46
  - ✅ **GitHub Copilot** → `.github/copilot-instructions.md`
47
- - ✅ **Claude Code / Claude Desktop** → `.claude`, `claude.md` + `.cursor/mcp.json` (if selected)
47
+ - ✅ **Claude Code (VS Code extension)** → `.claude`, `claude.md` + `.cursor/mcp.json` (if selected)
48
48
  - ✅ **Generic AI tools** → `AI_INSTRUCTIONS.md`, `PROMPT.md`
49
49
 
50
50
  **MCP Configuration**: The `.cursor/mcp.json` file is automatically generated if you select Cursor or Claude, as it works with both tools.
package/cli.js CHANGED
@@ -139,7 +139,7 @@ async function main() {
139
139
  choices: [
140
140
  { name: 'Cursor', value: 'cursor' },
141
141
  { name: 'GitHub Copilot', value: 'copilot' },
142
- { name: 'Claude Code / Claude Desktop', value: 'claude' },
142
+ { name: 'Claude Code (VS Code extension)', value: 'claude' },
143
143
  { name: 'Other / Generic AI tools', value: 'generic' },
144
144
  ],
145
145
  default: ['cursor'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-cloudinary-react",
3
- "version": "1.0.0-beta.13",
3
+ "version": "1.0.0-beta.14",
4
4
  "description": "Scaffold a Cloudinary React + Vite + TypeScript project with interactive setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,15 +4,15 @@
4
4
 
5
5
  ## Official Documentation
6
6
  - **Transformation Rules**: https://cloudinary.com/documentation/cloudinary_transformation_rules.md
7
- - **Transformation Reference**: https://cloudinary.com/documentation/transformation_reference
8
- - **React Image Transformations & Plugins**: https://cloudinary.com/documentation/react_image_transformations#plugins
9
- - **React Video Transformations**: https://cloudinary.com/documentation/react_video_transformations
10
- - **Cloudinary Video Player** (standalone player): https://cloudinary.com/documentation/cloudinary_video_player
11
- - **Video Player React Tutorial**: https://cloudinary.com/documentation/video_player_react_tutorial#banner
12
- - **Upload Widget (signed uploads)**: https://cloudinary.com/documentation/upload_widget#signed_uploads
13
- - **Upload assets in Next.js (backend signature)**: https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial
14
- - **Cloudinary Node.js SDK (server-side signing)** — use **v2**: `import { v2 as cloudinary } from 'cloudinary'`; do not use v1 (e.g. 1.47.0). https://cloudinary.com/documentation/node_integration
15
- - **React Native image and video upload (signed)**: https://cloudinary.com/documentation/react_native_image_and_video_upload#signed_upload
7
+ - **Transformation Reference**: https://cloudinary.com/documentation/transformation_reference.md
8
+ - **React Image Transformations & Plugins**: https://cloudinary.com/documentation/react_image_transformations.md#plugins
9
+ - **React Video Transformations**: https://cloudinary.com/documentation/react_video_transformations.md
10
+ - **Cloudinary Video Player** (standalone player): https://cloudinary.com/documentation/cloudinary_video_player.md
11
+ - **Video Player React Tutorial**: https://cloudinary.com/documentation/video_player_react_tutorial.md
12
+ - **Upload Widget (signed uploads)**: https://cloudinary.com/documentation/upload_widget.md#signed_uploads
13
+ - **Upload assets in Next.js (backend signature)**: https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial.md
14
+ - **Cloudinary Node.js SDK (server-side signing)** — use **v2**: `import { v2 as cloudinary } from 'cloudinary'`; do not use v1 (e.g. 1.47.0). https://cloudinary.com/documentation/node_integration.md
15
+ - **React Native image and video upload (signed)**: https://cloudinary.com/documentation/react_native_image_and_video_upload.md#signed_upload
16
16
  - Always consult the official transformation rules when creating transformations
17
17
  - Use only officially supported parameters from the transformation reference
18
18
 
@@ -48,10 +48,15 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
48
48
  - Use **this** pattern for the reusable instance. Everywhere else: `import { cld } from './cloudinary/config'` (or the path the user chose) and call `cld.image(publicId)` / `cld.video(publicId)`.
49
49
 
50
50
  **3. Upload Widget (unsigned, from scratch)**
51
- - **Script**: Add to `index.html`: `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>`. Because the script loads **async**, React's useEffect can run before it's ready — **do not** call `createUploadWidget` until the script is loaded.
52
- - **Wait for script**: Before calling `window.cloudinary.createUploadWidget(...)`, ensure `typeof window.cloudinary?.createUploadWidget === 'function'`. If not ready, poll (e.g. setInterval) or wait for the script's `onload` (if you inject the script in code). Otherwise you get "createUploadWidget is not a function".
53
- - **Create widget in useEffect**, not in render. Store the widget in a **ref**. Pass options: `{ cloudName: import.meta.env.VITE_CLOUDINARY_CLOUD_NAME, uploadPreset: uploadPreset || undefined, sources: ['local', 'camera', 'url'], multiple: false }`. Use `uploadPreset` from config or env.
54
- - **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup. Handle script load failures (e.g. show error state if script never loads).
51
+
52
+ **Strict pattern (always follow this exactly):**
53
+ 1. **Script in `index.html`** (required): Add `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>` to `index.html`. Do **not** rely only on dynamic script injection from React it's fragile.
54
+ 2. **Poll in useEffect** (required): In `useEffect`, poll with `setInterval` (e.g. every 100ms) until `typeof window.cloudinary?.createUploadWidget === 'function'`. Only then create the widget. A single check (even in `onload`) is **not** reliable because `window.cloudinary` can exist before `createUploadWidget` is attached.
55
+ 3. **Add a timeout**: Set a timeout (e.g. 10 seconds) to stop polling and show an error if the script never loads. Clear both interval and timeout in cleanup.
56
+ 4. **Create widget once**: When `createUploadWidget` is available, create the widget and store it in a **ref**. Clear the interval and timeout. Pass options: `{ cloudName, uploadPreset, sources: ['local', 'camera', 'url'], multiple: false }`.
57
+ 5. **Open on click**: Attach a click listener to a button that calls `widgetRef.current?.open()`. Remove the listener in useEffect cleanup.
58
+
59
+ ❌ **Do NOT**: Check only `window.cloudinary` (not enough); do a single check in `onload` (unreliable); skip the script in `index.html`; poll forever without a timeout.
55
60
  - **Signed uploads**: Do not use only `uploadPreset`; use the pattern under "Secure (Signed) Uploads" (uploadSignature as function, fetch api_key, server includes upload_preset in signature).
56
61
 
57
62
  **4. Video player**
@@ -60,7 +65,7 @@ export const uploadPreset = import.meta.env.VITE_CLOUDINARY_UPLOAD_PRESET || '';
60
65
  **5. Summary for rules-only users**
61
66
  - **Env**: Use your bundler's client env prefix and access (Vite: `VITE_` + `import.meta.env.VITE_*`; see "Other bundlers" if not Vite).
62
67
  - **Reusable instance**: One config file that creates and exports `cld` (and optionally `uploadPreset`) from `@cloudinary/url-gen`; use it everywhere.
63
- - **Upload widget**: Script in index.html (or equivalent); create widget once in useEffect with ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
68
+ - **Upload widget**: Script in index.html (required); in useEffect, **poll** until `createUploadWidget` is a function, then create widget once and store in ref; unsigned = cloudName + uploadPreset; signed = use uploadSignature function and backend.
64
69
  - **Video player**: Imperative video element (createElement, append to container ref, pass to videoPlayer); dispose + removeChild in cleanup; fall back to AdvancedVideo if init fails.
65
70
 
66
71
  **If the user is not using Vite:** Use their bundler's client env prefix and access in the config file and everywhere you read env. Examples: Create React App → `REACT_APP_CLOUDINARY_CLOUD_NAME`, `process.env.REACT_APP_CLOUDINARY_CLOUD_NAME`; Next.js (client) → `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`. The rest (cld instance, widget options, video player) is the same.
@@ -141,7 +146,7 @@ cld.image('id').overlay(
141
146
 
142
147
  // Image overlay (logo/image with resize)
143
148
  cld.image('id').overlay(
144
- source(image('logo').transformation(new Transformation().resize(scale().width(100).height(100))))
149
+ source(image('logo').transformation(new Transformation().resize(scale().width(100))))
145
150
  .position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20))
146
151
  );
147
152
  ```
@@ -192,7 +197,7 @@ cld.image('id').overlay(
192
197
 
193
198
  ### Transformation Best Practices
194
199
  - ✅ Format and quality must use separate `.delivery()` calls
195
- - ✅ Always end with auto format/quality: `.delivery(format(auto())).delivery(quality(autoQuality()))`
200
+ - ✅ Always end with auto format/quality: `.delivery(format(auto())).delivery(quality(autoQuality()))` unless user specifies a particular format or quality
196
201
  - ✅ Use `gravity(auto())` unless user specifies a focal point
197
202
  - ✅ Same transformation syntax works for both images and videos
198
203
 
@@ -201,16 +206,16 @@ cld.image('id').overlay(
201
206
  - ✅ Import plugins from `@cloudinary/react`
202
207
  - ✅ Pass plugins as array: `plugins={[responsive(), lazyload(), placeholder()]}`
203
208
  - ✅ Recommended plugin order:
204
- 1. `responsive()` - First
205
- 2. `lazyload()` - Second
206
- 3. `accessibility()` - Third
207
- 4. `placeholder()` - Last
209
+ 1. `responsive()` - First (handles breakpoints)
210
+ 2. `placeholder()` - Second (shows placeholder while loading)
211
+ 3. `lazyload()` - Third (delays loading until in viewport)
212
+ 4. `accessibility()` - Last (if needed)
208
213
  - ✅ Always add `width` and `height` attributes to prevent layout shift
209
214
  - ✅ Example:
210
215
  ```tsx
211
216
  <AdvancedImage
212
217
  cldImg={img}
213
- plugins={[responsive(), lazyload(), placeholder({ mode: 'blur' })]}
218
+ plugins={[responsive(), placeholder({ mode: 'blur' }), lazyload()]}
214
219
  width={800}
215
220
  height={600}
216
221
  />
@@ -241,20 +246,27 @@ cld.image('id').overlay(
241
246
  - ✅ **When the user asks for image overlays with text or logos**: Use `@cloudinary/url-gen` overlay APIs. Copy imports and patterns from the **"Import reference"** table and **"Canonical overlay block"** in these rules. Do not import `text` or `image` from `actions/overlay` — they are from **`qualifiers/source`**; only `source` is from `actions/overlay`.
242
247
  - ✅ **Import** `source` from `actions/overlay`; **`text` and `image` from `qualifiers/source`**. Also: `Position` from `qualifiers/position`, `TextStyle` from `qualifiers/textStyle`, `compass` from `qualifiers/gravity`, `Transformation` from `transformation/Transformation`, `scale` from `actions/resize`.
243
248
  - ✅ **compass()** takes **string** values, with **underscores**: `compass('center')`, `compass('south_east')`, `compass('north_west')`. ❌ WRONG: `compass(southEast)` or `'southEast'` (no camelCase).
244
- - ✅ **Overlay image**: Use `new Transformation()` **inside** `.transformation()`: `image('logo').transformation(new Transformation().resize(scale().width(100).height(100)))`. ❌ WRONG: `image('logo').transformation().resize(...)` (`.transformation()` does not return a chainable object).
249
+ - ✅ **Overlay image**: Use `new Transformation()` **inside** `.transformation()`: `image('logo').transformation(new Transformation().resize(scale().width(100)))`. ❌ WRONG: `image('logo').transformation().resize(...)` (`.transformation()` does not return a chainable object).
245
250
  - ✅ **Text overlay**: `fontWeight` goes on **TextStyle**: `new TextStyle('Arial', 60).fontWeight('bold')`. `textColor` goes on the **text source** (chained after `text(...)`): `text('Hello', new TextStyle('Arial', 60)).textColor('white')`.
246
251
  - ✅ **Position** is chained **after** `source(...)`, not inside: `source(image('logo').transformation(...)).position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20))`.
247
- - ✅ **Image overlay pattern**: `baseImage.overlay(source(image('id').transformation(new Transformation().resize(scale().width(100).height(100)))).position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20)))`. (Import `scale` from `@cloudinary/url-gen/actions/resize` if needed.)
252
+ - ✅ **Image overlay pattern**: `baseImage.overlay(source(image('id').transformation(new Transformation().resize(scale().width(100)))).position(new Position().gravity(compass('south_east')).offsetX(20).offsetY(20)))`. (Import `scale` from `@cloudinary/url-gen/actions/resize` if needed.)
248
253
  - ✅ **Text overlay pattern**: `baseImage.overlay(source(text('Your Text', new TextStyle('Arial', 60).fontWeight('bold')).textColor('white')).position(new Position().gravity(compass('center'))))`.
249
254
  - ✅ Docs: React Image Transformations and transformation reference for overlay syntax.
250
255
 
251
256
  ## Upload Widget Pattern
252
257
  - ✅ Use component: `import { UploadWidget } from './cloudinary/UploadWidget'`
253
- - ✅ Load script in `index.html`:
258
+
259
+ **Strict initialization pattern (always follow this exactly):**
260
+ 1. ✅ **Script in `index.html`** (required):
254
261
  ```html
255
262
  <script src="https://upload-widget.cloudinary.com/global/all.js" async></script>
256
263
  ```
257
- - ✅ **Race condition**: The script loads **async**, so React's useEffect may run before `createUploadWidget` exists. **Wait until** `typeof window.cloudinary?.createUploadWidget === 'function'` before calling it (e.g. poll with setInterval or wait for script onload). Checking only `window.cloudinary` is not enough `createUploadWidget` might not be attached yet. Otherwise: "createUploadWidget is not a function".
264
+ 2. ✅ **Poll in useEffect until `createUploadWidget` is available** (required): Use `setInterval` (e.g. every 100ms) to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
265
+ 3. ✅ **Add a timeout** (e.g. 10 seconds) to stop polling and show an error state if the script never loads. Clear both interval and timeout in cleanup and when ready.
266
+ 4. ✅ **Create widget once**, store in a ref. Cleanup: clear interval, clear timeout, remove click listener.
267
+
268
+ ❌ **Do NOT**: Check only `window.cloudinary` (the function may not be attached yet); do a single check in `onload` (unreliable timing); skip `index.html` and rely only on dynamic injection; poll forever without a timeout.
269
+
258
270
  - ✅ Create unsigned upload preset in dashboard at `settings/upload/presets`
259
271
  - ✅ Add to `.env`: `VITE_CLOUDINARY_UPLOAD_PRESET=your_preset_name`
260
272
  - ✅ Handle callbacks:
@@ -343,7 +355,7 @@ res.json({ signature, timestamp: paramsToSign.timestamp, api_key: process.env.CL
343
355
  ```
344
356
 
345
357
  - ❌ **Avoid `signatureEndpoint`** — it may not be called reliably by all widget versions. Prefer the `uploadSignature` function.
346
- - ✅ Docs: [Upload widget — signed uploads](https://cloudinary.com/documentation/upload_widget#signed_uploads), [Upload assets in Next.js](https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial).
358
+ - ✅ Docs: [Upload widget — signed uploads](https://cloudinary.com/documentation/upload_widget.md#signed_uploads), [Upload assets in Next.js](https://cloudinary.com/documentation/upload_assets_in_nextjs_tutorial.md).
347
359
 
348
360
  ### Rules for secure uploads
349
361
  - ✅ Use a **signed** upload preset (dashboard → Upload presets → Signed). Do not use an unsigned preset when the user wants secure uploads. **Default:** Accounts have a built-in signed preset `ml_default` — use it if the user hasn't created their own (they can delete `ml_default`, in which case they must create a signed preset in the dashboard).
@@ -400,7 +412,7 @@ res.json({ signature, timestamp: paramsToSign.timestamp, api_key: process.env.CL
400
412
  muted
401
413
  />
402
414
  ```
403
- - ✅ **Documentation**: https://cloudinary.com/documentation/react_video_transformations
415
+ - ✅ **Documentation**: https://cloudinary.com/documentation/react_video_transformations.md
404
416
 
405
417
  ### Cloudinary Video Player (The Player)
406
418
  Use when the user asks for a **video player** (styled UI, controls, playlists). For just **displaying** a video, use AdvancedVideo instead.
@@ -413,6 +425,11 @@ Use when the user asks for a **video player** (styled UI, controls, playlists).
413
425
  - **Cleanup**: Call `player.dispose()`, then **only if** `el.parentNode` exists call `el.parentNode.removeChild(el)` (avoids NotFoundError).
414
426
  - **If init fails** (CSP, extensions, timing): render **AdvancedVideo** with the same publicId. Do not relax CSP in index.html or ask the user to disable extensions.
415
427
 
428
+ **Poster options**: Always include `posterOptions` for a predictable poster image with a fallback color:
429
+ - `transformation: { startOffset: '0' }` — use the first frame of the video as the poster (consistent and loads reliably)
430
+ - `posterColor: '#0f0f0f'` — if the poster image fails to load, shows a dark background instead of blank/broken
431
+ - These can be overridden via props (e.g. `posterOptions={{ transformation: { startOffset: '5' } }}` for a different frame)
432
+
416
433
  **Example (copy this pattern):**
417
434
  ```tsx
418
435
  const containerRef = useRef<HTMLDivElement>(null);
@@ -423,7 +440,16 @@ useLayoutEffect(() => {
423
440
  el.className = 'cld-video-player cld-fluid';
424
441
  containerRef.current.appendChild(el);
425
442
  try {
426
- const player = videoPlayer(el, { cloudName, secure: true, controls: true, fluid: true });
443
+ const player = videoPlayer(el, {
444
+ cloudName,
445
+ secure: true,
446
+ controls: true,
447
+ fluid: true,
448
+ posterOptions: {
449
+ transformation: { startOffset: '0' },
450
+ posterColor: '#0f0f0f',
451
+ },
452
+ });
427
453
  player.source({ publicId: 'samples/elephants' });
428
454
  playerRef.current = player;
429
455
  } catch (err) { console.error(err); }
@@ -437,7 +463,7 @@ useLayoutEffect(() => {
437
463
  }, [cloudName]);
438
464
  return <div ref={containerRef} />;
439
465
  ```
440
- Docs: https://cloudinary.com/documentation/cloudinary_video_player
466
+ Docs: https://cloudinary.com/documentation/cloudinary_video_player.md
441
467
 
442
468
  ### When to Use Which?
443
469
  - ✅ **Use AdvancedVideo** when: User wants to **display** or **show** a video (no full player). It just displays a video with transformations.
@@ -494,18 +520,28 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
494
520
  - ✅ Access with type safety: `import.meta.env.VITE_CLOUDINARY_CLOUD_NAME`
495
521
 
496
522
  ### Type Guards and Safety
497
- - ✅ Type guard for window.cloudinary:
523
+ - ✅ Type guard for window.cloudinary (check `createUploadWidget`, not just `cloudinary`):
498
524
  ```tsx
499
- function isCloudinaryLoaded(): boolean {
525
+ function isUploadWidgetReady(): boolean {
500
526
  return typeof window !== 'undefined' &&
501
- typeof window.cloudinary !== 'undefined';
527
+ typeof window.cloudinary?.createUploadWidget === 'function';
502
528
  }
503
529
  ```
504
- - ✅ Use type guards before accessing:
530
+ - ✅ Use type guards before accessing (but **always poll with timeout** in useEffect — don't rely on a single check):
505
531
  ```tsx
506
- if (isCloudinaryLoaded()) {
507
- window.cloudinary.createUploadWidget(...);
508
- }
532
+ // In useEffect, poll until ready with timeout:
533
+ const interval = setInterval(() => {
534
+ if (isUploadWidgetReady()) {
535
+ clearInterval(interval);
536
+ clearTimeout(timeout);
537
+ window.cloudinary.createUploadWidget(...);
538
+ }
539
+ }, 100);
540
+ const timeout = setTimeout(() => {
541
+ clearInterval(interval);
542
+ console.error('Upload widget script failed to load');
543
+ }, 10000);
544
+ // Cleanup: clearInterval(interval); clearTimeout(timeout);
509
545
  ```
510
546
 
511
547
  ### Ref Typing Patterns
@@ -548,12 +584,12 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
548
584
  ```
549
585
 
550
586
  ## Best Practices
551
- - ✅ Always use `fill()` resize for responsive images
552
- - ✅ Always end transformations with `.delivery(format(auto())).delivery(quality(autoQuality()))`
587
+ - ✅ Always use `fill()` resize with automatic gravity for responsive images
588
+ - ✅ Always end transformations with `.delivery(format(auto())).delivery(quality(autoQuality()))` unless the user specifies a format or quality
553
589
  - ✅ Use `placeholder()` and `lazyload()` plugins together
554
590
  - ✅ Always add `width` and `height` attributes to `AdvancedImage`
555
591
  - ✅ Store `public_id` from upload success, not full URL
556
- - ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`
592
+ - ✅ Video player: use imperative element only; dispose in useLayoutEffect cleanup and remove element with `if (el.parentNode) el.parentNode.removeChild(el)`; always include `posterOptions` with `transformation: { startOffset: '0' }` and `posterColor: '#0f0f0f'` for reliable poster display
557
593
  - ✅ Use TypeScript for better autocomplete and error catching
558
594
  - ✅ Prefer `unknown` over `any` when types aren't available
559
595
  - ✅ Use type guards for runtime type checking
@@ -656,16 +692,17 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
656
692
  4. Consider using server-side upload for very large files
657
693
 
658
694
  ### Widget not opening
659
- - ❌ Problem: Script not loaded or initialization issue
695
+ - ❌ Problem: Script not loaded, or widget created before `createUploadWidget` was available
660
696
  - ✅ Solution:
661
697
  1. Ensure script is in `index.html`: `<script src="https://upload-widget.cloudinary.com/global/all.js" async></script>`
662
- 2. Check widget initializes in `useEffect` after `window.cloudinary` is available
698
+ 2. In `useEffect`, **poll** with `setInterval` until `typeof window.cloudinary?.createUploadWidget === 'function'` only then create the widget. Do **not** check only `window.cloudinary`.
663
699
  3. Verify upload preset is set correctly
664
700
 
665
701
  ### "createUploadWidget is not a function"
666
- - ❌ Problem: **Race condition** — the script in index.html loads **async**, so React's useEffect can run before the script has finished loading. `window.cloudinary` might exist but `createUploadWidget` isn't attached yet.
667
- - ✅ **Wait for script**: Before calling `window.cloudinary.createUploadWidget(...)`, ensure `typeof window.cloudinary?.createUploadWidget === 'function'`. If not ready, poll (e.g. setInterval until it exists) or inject the script in code and call createUploadWidget in the script's `onload`. Don't assume `window.cloudinary` means the API is ready.
668
- - See PATTERNS Upload Widget Pattern ("Race condition") and Project setup Upload Widget ("Wait for script").
702
+ - ❌ Problem: **Race condition** — the script loads **async**, so `window.cloudinary` can exist before `createUploadWidget` is attached. A single check (even in `onload`) is **not** reliable.
703
+ - ✅ **Always poll**: In `useEffect`, use `setInterval` to check `typeof window.cloudinary?.createUploadWidget === 'function'`. Only create the widget when this returns `true`. Clear the interval once ready.
704
+ - **Do NOT**: Check only `window.cloudinary`; do a single check in `onload`; skip the script in `index.html`.
705
+ - ✅ See PATTERNS → Upload Widget Pattern and Project setup → Upload Widget for the strict pattern.
669
706
 
670
707
  ### Video player: "Invalid target for null#on" or React removeChild or NotFoundError
671
708
  - ❌ Problem: Passing a React-managed `<video ref={...} />` to the player causes removeChild errors (the player mutates the DOM). Or container/ref not in DOM yet when init runs.
@@ -718,7 +755,7 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
718
755
 
719
756
  ### Cloudinary package install fails or "version doesn't exist"
720
757
  - ❌ Problem: Agent pinned a Cloudinary package to a specific version (e.g. `cloudinary-video-player@1.2.3`) that doesn't exist on npm, or used a wrong package name.
721
- - ✅ **Install latest**: Use `npm install <package>` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/tps://console.cloudinary.com/app/settings/upload/presets\n\n'-gen`, `cloudinary-video-player`, `cloudinary`. See PATTERNS → "Installing Cloudinary packages".
758
+ - ✅ **Install latest**: Use `npm install <package>` with **no version** so npm gets the latest compatible. In package.json use a **caret** (e.g. `"cloudinary-video-player": "^1.0.0"`). Use only correct package names: `@cloudinary/react`, `@cloudinary/url-gen`, `cloudinary-video-player`, `cloudinary`. See PATTERNS → "Installing Cloudinary packages".
722
759
 
723
760
  ### Confusion between AdvancedVideo and Video Player
724
761
  - **AdvancedVideo** = for **displaying** a video (not a full player). **Cloudinary Video Player** = the **player** (styled UI, controls, playlists, etc.).
@@ -742,6 +779,11 @@ Docs: https://cloudinary.com/documentation/cloudinary_video_player
742
779
  ### Video player: "source is not a function" or video not playing
743
780
  - **player.source()** takes an **object**: `player.source({ publicId: 'samples/elephants' })`, not a string. Use named import: `import { videoPlayer } from 'cloudinary-video-player'`. See PATTERNS → Cloudinary Video Player (The Player).
744
781
 
782
+ ### Video player: poster image missing, wrong frame, or broken
783
+ - ❌ Problem: Video player shows no poster, wrong poster frame, or blank area before video loads.
784
+ - ✅ **Always include `posterOptions`** in the player config: `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }`. This uses the first frame as the poster (reliable) and provides a dark fallback color if the poster fails to load.
785
+ - ✅ **Override if needed**: Pass different values via props, e.g. `startOffset: '5'` for a frame 5 seconds in, or a different `posterColor` for your design.
786
+
745
787
  ### Overlay: "Cannot read properties of undefined" or overlay not showing
746
788
  - ❌ Problem: Wrong overlay API usage (Overlay.source, compass constants, .transformation().resize, fontWeight on wrong object).
747
789
  - ✅ Import `source` directly from `@cloudinary/url-gen/actions/overlay` (not `Overlay.source`). Use **string** values for compass: `compass('south_east')` (underscores, not camelCase). Use `new Transformation()` inside `.transformation()`. Put `fontWeight` on **TextStyle**; put `textColor` on the **text source**. See PATTERNS → Image Overlays (text or logos).
@@ -815,8 +857,8 @@ When something isn't working, check:
815
857
  - [ ] Format/quality use separate `.delivery()` calls
816
858
  - [ ] Plugins are in array format
817
859
  - [ ] Upload widget script is loaded in `index.html`
818
- - [ ] **"createUploadWidget is not a function"?** → Wait until `typeof window.cloudinary?.createUploadWidget === 'function'` before calling it (script loads async; poll or use script onload)
819
- - [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
860
+ - [ ] **"createUploadWidget is not a function"?** → In useEffect, **poll** with setInterval until `typeof window.cloudinary?.createUploadWidget === 'function'`. Do NOT check only `window.cloudinary`; do NOT rely on a single onload check
861
+ - [ ] **Video player?** → **Imperative element only**: createElement('video'), append to container ref, pass to videoPlayer(el, ...); include `posterOptions: { transformation: { startOffset: '0' }, posterColor: '#0f0f0f' }` for reliable poster; player.source({ publicId }); cleanup: dispose then if (el.parentNode) el.parentNode.removeChild(el). CSS: cloudinary-video-player/cld-video-player.min.css. If init fails, fall back to AdvancedVideo (do not relax CSP).
820
862
  - [ ] **Upload fails (unsigned)?** → Is `VITE_CLOUDINARY_UPLOAD_PRESET` set? Preset exists and is Unsigned in dashboard?
821
863
  - [ ] **Upload default?** → Default to **unsigned** uploads (cloudName + uploadPreset); use signed only when the user explicitly asks for secure/signed uploads (signed requires a running backend)
822
864
  - [ ] **Secure uploads?** → Use `uploadSignature` as function (not `signatureEndpoint`); fetch `api_key` from server first; include `uploadPreset` in widget config; server includes `upload_preset` in signed params; use Cloudinary Node SDK v2 on server; never expose or commit API secret
@@ -8,6 +8,8 @@
8
8
  </head>
9
9
  <body>
10
10
  <div id="root"></div>
11
+ <!-- Cloudinary upload widget - load early so it's ready before React; avoids races with other components -->
12
+ <script src="https://upload-widget.cloudinary.com/global/all.js" async></script>
11
13
  <script type="module" src="/src/main.tsx"></script>
12
14
  </body>
13
15
  </html>
@@ -4,14 +4,16 @@ import { fill } from '@cloudinary/url-gen/actions/resize';
4
4
  import { format, quality } from '@cloudinary/url-gen/actions/delivery';
5
5
  import { auto } from '@cloudinary/url-gen/qualifiers/format';
6
6
  import { auto as autoQuality } from '@cloudinary/url-gen/qualifiers/quality';
7
+ import { autoGravity } from '@cloudinary/url-gen/qualifiers/gravity';
7
8
  import { cld } from './cloudinary/config';
8
9
  import { UploadWidget } from './cloudinary/UploadWidget';
10
+ import type { CloudinaryUploadResult } from './cloudinary/UploadWidget';
9
11
  import './App.css';
10
12
 
11
13
  function App() {
12
14
  const [uploadedImageId, setUploadedImageId] = useState<string | null>(null);
13
15
 
14
- const handleUploadSuccess = (result: any) => {
16
+ const handleUploadSuccess = (result: CloudinaryUploadResult) => {
15
17
  console.log('Upload successful:', result);
16
18
  setUploadedImageId(result.public_id);
17
19
  };
@@ -26,7 +28,7 @@ function App() {
26
28
 
27
29
  const displayImage = cld
28
30
  .image(imageId)
29
- .resize(fill().width(600).height(400))
31
+ .resize(fill().width(600).height(400).gravity(autoGravity()))
30
32
  .delivery(format(auto()))
31
33
  .delivery(quality(autoQuality()));
32
34
 
@@ -1,16 +1,42 @@
1
- import { useEffect, useRef } from 'react';
1
+ import { useEffect, useRef, useState } from 'react';
2
2
  import { uploadPreset } from './config';
3
3
 
4
+ export interface CloudinaryUploadResult {
5
+ public_id: string;
6
+ secure_url: string;
7
+ url: string;
8
+ width: number;
9
+ height: number;
10
+ format: string;
11
+ resource_type: string;
12
+ bytes: number;
13
+ created_at: string;
14
+ }
15
+
4
16
  interface UploadWidgetProps {
5
- onUploadSuccess?: (result: any) => void;
17
+ onUploadSuccess?: (result: CloudinaryUploadResult) => void;
6
18
  onUploadError?: (error: Error) => void;
7
19
  buttonText?: string;
8
20
  className?: string;
9
21
  }
10
22
 
23
+ interface CloudinaryWidgetResult {
24
+ event: string;
25
+ info: CloudinaryUploadResult;
26
+ }
27
+
28
+ interface CloudinaryWidgetError {
29
+ message?: string;
30
+ }
31
+
11
32
  declare global {
12
33
  interface Window {
13
- cloudinary: any;
34
+ cloudinary?: {
35
+ createUploadWidget: (
36
+ config: Record<string, unknown>,
37
+ callback: (error: CloudinaryWidgetError | null, result: CloudinaryWidgetResult | null) => void
38
+ ) => { open: () => void };
39
+ };
14
40
  }
15
41
  }
16
42
 
@@ -20,13 +46,17 @@ export function UploadWidget({
20
46
  buttonText = 'Upload Image',
21
47
  className = '',
22
48
  }: UploadWidgetProps) {
23
- const widgetRef = useRef<any>(null);
24
- const buttonRef = useRef<HTMLButtonElement>(null);
25
- const clickHandlerRef = useRef<(() => void) | null>(null);
49
+ const widgetRef = useRef<{ open: () => void } | null>(null);
50
+ const [isReady, setIsReady] = useState(false);
51
+ const [scriptError, setScriptError] = useState(false);
26
52
 
27
53
  useEffect(() => {
54
+ let poll: ReturnType<typeof setInterval> | null = null;
55
+ let timeout: ReturnType<typeof setTimeout> | null = null;
56
+ let mounted = true;
57
+
28
58
  function initializeWidget() {
29
- if (typeof window.cloudinary?.createUploadWidget !== 'function' || !buttonRef.current) return;
59
+ if (!mounted || typeof window.cloudinary?.createUploadWidget !== 'function') return;
30
60
 
31
61
  if (!uploadPreset) {
32
62
  console.warn(
@@ -42,7 +72,7 @@ export function UploadWidget({
42
72
  sources: ['local', 'camera', 'url'],
43
73
  multiple: false,
44
74
  },
45
- (error: any, result: any) => {
75
+ (error: CloudinaryWidgetError | null, result: CloudinaryWidgetResult | null) => {
46
76
  if (error) {
47
77
  console.error('Upload error:', error);
48
78
  onUploadError?.(new Error(error.message || 'Upload failed'));
@@ -56,73 +86,88 @@ export function UploadWidget({
56
86
  }
57
87
  );
58
88
 
59
- const handler = () => widgetRef.current?.open();
60
- clickHandlerRef.current = handler;
61
- buttonRef.current.addEventListener('click', handler);
89
+ setIsReady(true);
90
+ }
91
+
92
+ function isWidgetReady(): boolean {
93
+ return typeof window.cloudinary?.createUploadWidget === 'function';
62
94
  }
63
95
 
64
- function waitThenInit() {
65
- if (typeof window.cloudinary?.createUploadWidget === 'function') {
96
+ // Poll until createUploadWidget is available
97
+ // Script should be in index.html, but poll handles any load timing
98
+ poll = setInterval(() => {
99
+ if (isWidgetReady()) {
100
+ if (poll) clearInterval(poll);
101
+ if (timeout) clearTimeout(timeout);
66
102
  initializeWidget();
67
- return true;
68
103
  }
69
- return false;
70
- }
104
+ }, 100);
71
105
 
72
- if (!window.cloudinary) {
73
- const script = document.createElement('script');
74
- script.src = 'https://upload-widget.cloudinary.com/global/all.js';
75
- script.async = true;
76
- document.body.appendChild(script);
77
- script.onload = () => waitThenInit();
78
- } else if (!waitThenInit()) {
79
- const poll = setInterval(() => {
80
- if (waitThenInit()) {
81
- clearInterval(poll);
82
- clearTimeout(timeout);
83
- }
84
- }, 100);
85
- const timeout = setTimeout(() => clearInterval(poll), 10000);
86
- return () => {
87
- clearInterval(poll);
88
- clearTimeout(timeout);
89
- if (buttonRef.current && clickHandlerRef.current) {
90
- buttonRef.current.removeEventListener('click', clickHandlerRef.current);
91
- }
92
- };
106
+ // Timeout after 10 seconds
107
+ timeout = setTimeout(() => {
108
+ if (poll) clearInterval(poll);
109
+ if (mounted && !isWidgetReady()) {
110
+ console.error('Upload widget script failed to load within 10 seconds');
111
+ setScriptError(true);
112
+ }
113
+ }, 10000);
114
+
115
+ // Check immediately in case script is already loaded
116
+ if (isWidgetReady()) {
117
+ if (poll) clearInterval(poll);
118
+ if (timeout) clearTimeout(timeout);
119
+ initializeWidget();
93
120
  }
94
121
 
95
122
  return () => {
96
- if (buttonRef.current && clickHandlerRef.current) {
97
- buttonRef.current.removeEventListener('click', clickHandlerRef.current);
98
- }
123
+ mounted = false;
124
+ if (poll) clearInterval(poll);
125
+ if (timeout) clearTimeout(timeout);
99
126
  };
100
127
  }, [onUploadSuccess, onUploadError]);
101
128
 
129
+ const handleClick = () => {
130
+ if (widgetRef.current) {
131
+ widgetRef.current.open();
132
+ } else if (!scriptError) {
133
+ console.warn('Upload widget is still loading, please try again.');
134
+ }
135
+ };
136
+
137
+ if (scriptError) {
138
+ return (
139
+ <div style={{ color: '#dc2626', fontSize: '0.875rem' }}>
140
+ Upload widget failed to load. Please refresh the page or check your network connection.
141
+ </div>
142
+ );
143
+ }
144
+
102
145
  return (
103
146
  <button
104
- ref={buttonRef}
105
147
  type="button"
148
+ onClick={handleClick}
149
+ disabled={!isReady}
106
150
  className={className}
107
151
  style={{
108
152
  padding: '0.75rem 1.5rem',
109
153
  fontSize: '1rem',
110
154
  fontWeight: 500,
111
155
  color: 'white',
112
- backgroundColor: '#6366f1',
156
+ backgroundColor: isReady ? '#6366f1' : '#9ca3af',
113
157
  border: 'none',
114
158
  borderRadius: '0.5rem',
115
- cursor: 'pointer',
159
+ cursor: isReady ? 'pointer' : 'wait',
116
160
  transition: 'background-color 0.2s',
161
+ opacity: isReady ? 1 : 0.7,
117
162
  }}
118
163
  onMouseEnter={(e) => {
119
- e.currentTarget.style.backgroundColor = '#4f46e5';
164
+ if (isReady) e.currentTarget.style.backgroundColor = '#4f46e5';
120
165
  }}
121
166
  onMouseLeave={(e) => {
122
- e.currentTarget.style.backgroundColor = '#6366f1';
167
+ if (isReady) e.currentTarget.style.backgroundColor = '#6366f1';
123
168
  }}
124
169
  >
125
- {buttonText}
170
+ {isReady ? buttonText : 'Loading...'}
126
171
  </button>
127
172
  );
128
173
  }