@twick/studio 0.15.16 → 0.15.19

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.
@@ -1,5 +1,6 @@
1
1
  import { default as React } from 'react';
2
2
  import { Size, TrackElement } from '@twick/timeline';
3
+ import { UploadConfig } from '../../types';
3
4
 
4
5
  /**
5
6
  * Props interface for the ElementPanelContainer component.
@@ -12,6 +13,7 @@ interface ElementPanelContainerProps {
12
13
  setSelectedTool: (tool: string) => void;
13
14
  addElement: (element: TrackElement) => void;
14
15
  updateElement: (element: TrackElement) => void;
16
+ uploadConfig?: UploadConfig;
15
17
  }
16
18
  /**
17
19
  * ElementPanelContainer component that renders the appropriate element panel
@@ -34,5 +36,5 @@ interface ElementPanelContainerProps {
34
36
  * />
35
37
  * ```
36
38
  */
37
- declare const ElementPanelContainer: ({ selectedTool, videoResolution, selectedElement, addElement, updateElement, }: ElementPanelContainerProps) => React.ReactElement;
39
+ declare const ElementPanelContainer: ({ selectedTool, videoResolution, selectedElement, addElement, updateElement, uploadConfig, }: ElementPanelContainerProps) => React.ReactElement;
38
40
  export default ElementPanelContainer;
@@ -0,0 +1,15 @@
1
+ import { CloudUploadProvider } from '../../hooks/use-cloud-media-upload';
2
+
3
+ export interface CloudMediaUploadProps {
4
+ onSuccess: (url: string, file: File) => void;
5
+ onError?: (error: string) => void;
6
+ accept?: string;
7
+ uploadApiUrl: string;
8
+ provider: CloudUploadProvider;
9
+ buttonText?: string;
10
+ className?: string;
11
+ disabled?: boolean;
12
+ id?: string;
13
+ icon?: React.ReactNode;
14
+ }
15
+ export declare const CloudMediaUpload: ({ onSuccess, onError, accept, uploadApiUrl, provider, buttonText, className, disabled, id: providedId, icon, }: CloudMediaUploadProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,3 +1,4 @@
1
+ export * from './cloud-media-upload';
1
2
  export * from './color-input';
2
3
  export * from './file-input';
3
4
  export * from './media-manager';
@@ -0,0 +1,27 @@
1
+ export type CloudUploadProvider = "s3" | "gcs";
2
+ export interface UseCloudMediaUploadConfig {
3
+ uploadApiUrl: string;
4
+ provider: CloudUploadProvider;
5
+ }
6
+ /** Response from S3 presign API (e.g. file-uploader Lambda). */
7
+ export interface S3PresignResponse {
8
+ uploadUrl: string;
9
+ key?: string;
10
+ bucket?: string;
11
+ contentType?: string;
12
+ expiresIn?: number;
13
+ }
14
+ /** Response from GCS upload API (server-side upload). */
15
+ export interface GCSUploadResponse {
16
+ url: string;
17
+ }
18
+ export interface UseCloudMediaUploadReturn {
19
+ uploadFile: (file: File) => Promise<{
20
+ url: string;
21
+ }>;
22
+ isUploading: boolean;
23
+ progress: number;
24
+ error: string | null;
25
+ resetError: () => void;
26
+ }
27
+ export declare const useCloudMediaUpload: (config: UseCloudMediaUploadConfig) => UseCloudMediaUploadReturn;
package/dist/index.d.ts CHANGED
@@ -10,6 +10,8 @@ import { TextPanel } from './components/panel/text-panel';
10
10
  import { CirclePanel } from './components/panel/circle-panel';
11
11
  import { RectPanel } from './components/panel/rect-panel';
12
12
  import { CaptionsPanel } from './components/panel/captions-panel';
13
+ import { CloudMediaUpload } from './components/shared/cloud-media-upload';
14
+ import { useCloudMediaUpload } from './hooks/use-cloud-media-upload';
13
15
 
14
16
  export {
15
17
  /** Main studio editing environment */
@@ -37,10 +39,17 @@ export {
37
39
  /** Hook for managing studio state and selections */
38
40
  useStudioManager,
39
41
  /** Hook for polling-based caption generation */
40
- useGenerateCaptions, };
42
+ useGenerateCaptions,
43
+ /** Hook for S3/GCS cloud media upload */
44
+ useCloudMediaUpload, };
45
+ export {
46
+ /** Cloud media upload (S3 or GCS) for use in media panels */
47
+ CloudMediaUpload, };
41
48
  export * from './helpers/generate-captions.service';
42
49
  export * from './helpers/constant';
43
50
  export * from './types';
51
+ export type { CloudUploadProvider, UseCloudMediaUploadConfig, UseCloudMediaUploadReturn, S3PresignResponse, GCSUploadResponse, } from './hooks/use-cloud-media-upload';
52
+ export type { CloudMediaUploadProps } from './components/shared/cloud-media-upload';
44
53
  /**
45
54
  * ============================================================================
46
55
  * RE-EXPORTS FROM DEPENDENCY PACKAGES
package/dist/index.js CHANGED
@@ -751,6 +751,182 @@ const useStudioManager = () => {
751
751
  updateElement
752
752
  };
753
753
  };
754
+ const putFileWithProgress = (uploadUrl, file, onProgress) => {
755
+ return new Promise((resolve, reject) => {
756
+ const xhr = new XMLHttpRequest();
757
+ xhr.upload.addEventListener("progress", (e) => {
758
+ if (e.lengthComputable) {
759
+ onProgress(e.loaded / e.total * 100);
760
+ }
761
+ });
762
+ xhr.addEventListener("load", () => {
763
+ if (xhr.status >= 200 && xhr.status < 300) {
764
+ onProgress(100);
765
+ resolve();
766
+ } else {
767
+ reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
768
+ }
769
+ });
770
+ xhr.addEventListener("error", () => reject(new Error("Upload failed")));
771
+ xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
772
+ xhr.open("PUT", uploadUrl);
773
+ xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream");
774
+ xhr.send(file);
775
+ });
776
+ };
777
+ const useCloudMediaUpload = (config) => {
778
+ const { uploadApiUrl, provider } = config;
779
+ const [isUploading, setIsUploading] = react.useState(false);
780
+ const [progress, setProgress] = react.useState(0);
781
+ const [error, setError] = react.useState(null);
782
+ const resetError = react.useCallback(() => {
783
+ setError(null);
784
+ }, []);
785
+ const uploadFile = react.useCallback(
786
+ async (file) => {
787
+ setIsUploading(true);
788
+ setProgress(0);
789
+ setError(null);
790
+ try {
791
+ if (provider === "s3") {
792
+ const presignRes = await fetch(uploadApiUrl, {
793
+ method: "POST",
794
+ headers: { "Content-Type": "application/json" },
795
+ body: JSON.stringify({
796
+ filename: file.name,
797
+ contentType: file.type || "application/octet-stream"
798
+ })
799
+ });
800
+ if (!presignRes.ok) {
801
+ const errBody = await presignRes.json().catch(() => ({}));
802
+ throw new Error(
803
+ errBody.error ?? `Failed to get upload URL: ${presignRes.statusText}`
804
+ );
805
+ }
806
+ const presignData = await presignRes.json();
807
+ const uploadUrl = presignData.uploadUrl;
808
+ await putFileWithProgress(uploadUrl, file, setProgress);
809
+ const publicUrl = uploadUrl.split("?")[0];
810
+ return { url: publicUrl };
811
+ }
812
+ if (provider === "gcs") {
813
+ setProgress(10);
814
+ const formData = new FormData();
815
+ formData.append("file", file);
816
+ const uploadRes = await fetch(uploadApiUrl, {
817
+ method: "POST",
818
+ body: formData
819
+ });
820
+ if (!uploadRes.ok) {
821
+ const errBody = await uploadRes.json().catch(() => ({}));
822
+ throw new Error(
823
+ errBody.error ?? `Upload failed: ${uploadRes.statusText}`
824
+ );
825
+ }
826
+ setProgress(100);
827
+ const data = await uploadRes.json();
828
+ if (!data.url) {
829
+ throw new Error("Upload response missing url");
830
+ }
831
+ return { url: data.url };
832
+ }
833
+ throw new Error(`Unknown provider: ${provider}`);
834
+ } catch (err) {
835
+ const message = err instanceof Error ? err.message : "Upload failed";
836
+ setError(message);
837
+ throw err;
838
+ } finally {
839
+ setIsUploading(false);
840
+ setProgress(0);
841
+ }
842
+ },
843
+ [uploadApiUrl, provider]
844
+ );
845
+ return {
846
+ uploadFile,
847
+ isUploading,
848
+ progress,
849
+ error,
850
+ resetError
851
+ };
852
+ };
853
+ const CloudMediaUpload = ({
854
+ onSuccess,
855
+ onError,
856
+ accept,
857
+ uploadApiUrl,
858
+ provider,
859
+ buttonText = "Upload to cloud",
860
+ className,
861
+ disabled = false,
862
+ id: providedId,
863
+ icon
864
+ }) => {
865
+ const id = providedId ?? `cloud-media-upload-${Math.random().toString(36).slice(2, 9)}`;
866
+ const inputRef = react.useRef(null);
867
+ const {
868
+ uploadFile,
869
+ isUploading,
870
+ progress,
871
+ error,
872
+ resetError
873
+ } = useCloudMediaUpload({ uploadApiUrl, provider });
874
+ const handleFileChange = async (e) => {
875
+ var _a;
876
+ const file = (_a = e.target.files) == null ? void 0 : _a[0];
877
+ if (!file) return;
878
+ try {
879
+ const { url } = await uploadFile(file);
880
+ onSuccess(url, file);
881
+ if (inputRef.current) {
882
+ inputRef.current.value = "";
883
+ }
884
+ } catch (err) {
885
+ const message = err instanceof Error ? err.message : "Upload failed";
886
+ onError == null ? void 0 : onError(message);
887
+ }
888
+ };
889
+ const handleLabelClick = () => {
890
+ if (disabled || isUploading) return;
891
+ resetError();
892
+ };
893
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "file-input-container cloud-media-upload-container", children: [
894
+ /* @__PURE__ */ jsxRuntime.jsx(
895
+ "input",
896
+ {
897
+ ref: inputRef,
898
+ type: "file",
899
+ accept,
900
+ className: "file-input-hidden",
901
+ id,
902
+ onChange: handleFileChange,
903
+ disabled: disabled || isUploading,
904
+ "aria-label": buttonText
905
+ }
906
+ ),
907
+ /* @__PURE__ */ jsxRuntime.jsxs(
908
+ "label",
909
+ {
910
+ htmlFor: id,
911
+ className: className ?? "btn-primary file-input-label",
912
+ onClick: handleLabelClick,
913
+ style: { pointerEvents: disabled || isUploading ? "none" : void 0 },
914
+ children: [
915
+ icon ?? /* @__PURE__ */ jsxRuntime.jsx(Upload, { className: "icon-sm" }),
916
+ isUploading ? `${Math.round(progress)}%` : buttonText
917
+ ]
918
+ }
919
+ ),
920
+ isUploading && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cloud-media-upload-progress", role: "progressbar", "aria-valuenow": progress, "aria-valuemin": 0, "aria-valuemax": 100, children: /* @__PURE__ */ jsxRuntime.jsx(
921
+ "div",
922
+ {
923
+ className: "cloud-media-upload-progress-fill",
924
+ style: { width: `${progress}%` }
925
+ }
926
+ ) }),
927
+ error && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "cloud-media-upload-error", role: "alert", children: error })
928
+ ] });
929
+ };
754
930
  const _MediaManagerSingleton = class _MediaManagerSingleton {
755
931
  constructor() {
756
932
  }
@@ -1308,19 +1484,42 @@ const AudioPanelContainer = (props) => {
1308
1484
  });
1309
1485
  addItem(newItem);
1310
1486
  };
1311
- return /* @__PURE__ */ jsxRuntime.jsx(
1312
- AudioPanel,
1313
- {
1314
- items,
1315
- searchQuery,
1316
- onSearchChange: setSearchQuery,
1317
- onItemSelect: handleSelection,
1318
- onFileUpload: handleFileUpload,
1319
- isLoading,
1320
- acceptFileTypes,
1321
- onUrlAdd
1322
- }
1323
- );
1487
+ const onCloudUploadSuccess = async (url, file) => {
1488
+ var _a;
1489
+ const newItem = await mediaManager.addItem({
1490
+ name: file.name,
1491
+ url,
1492
+ type: "audio",
1493
+ metadata: { source: ((_a = props.uploadConfig) == null ? void 0 : _a.provider) ?? "s3" }
1494
+ });
1495
+ addItem(newItem);
1496
+ };
1497
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1498
+ props.uploadConfig && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1499
+ CloudMediaUpload,
1500
+ {
1501
+ uploadApiUrl: props.uploadConfig.uploadApiUrl,
1502
+ provider: props.uploadConfig.provider,
1503
+ accept: "audio/*",
1504
+ onSuccess: onCloudUploadSuccess,
1505
+ buttonText: "Upload audio",
1506
+ className: "btn-ghost w-full"
1507
+ }
1508
+ ) }),
1509
+ /* @__PURE__ */ jsxRuntime.jsx(
1510
+ AudioPanel,
1511
+ {
1512
+ items,
1513
+ searchQuery,
1514
+ onSearchChange: setSearchQuery,
1515
+ onItemSelect: handleSelection,
1516
+ onFileUpload: handleFileUpload,
1517
+ isLoading,
1518
+ acceptFileTypes,
1519
+ onUrlAdd
1520
+ }
1521
+ )
1522
+ ] });
1324
1523
  };
1325
1524
  function ImagePanel({
1326
1525
  items,
@@ -1406,19 +1605,42 @@ function ImagePanelContainer(props) {
1406
1605
  });
1407
1606
  addItem(newItem);
1408
1607
  };
1409
- return /* @__PURE__ */ jsxRuntime.jsx(
1410
- ImagePanel,
1411
- {
1412
- items,
1413
- searchQuery,
1414
- onSearchChange: setSearchQuery,
1415
- onItemSelect: handleSelection,
1416
- onFileUpload: handleFileUpload,
1417
- isLoading,
1418
- acceptFileTypes,
1419
- onUrlAdd
1420
- }
1421
- );
1608
+ const onCloudUploadSuccess = async (url, file) => {
1609
+ var _a;
1610
+ const newItem = await mediaManager.addItem({
1611
+ name: file.name,
1612
+ url,
1613
+ type: "image",
1614
+ metadata: { source: ((_a = props.uploadConfig) == null ? void 0 : _a.provider) ?? "s3" }
1615
+ });
1616
+ addItem(newItem);
1617
+ };
1618
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1619
+ props.uploadConfig && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1620
+ CloudMediaUpload,
1621
+ {
1622
+ uploadApiUrl: props.uploadConfig.uploadApiUrl,
1623
+ provider: props.uploadConfig.provider,
1624
+ accept: "image/*",
1625
+ onSuccess: onCloudUploadSuccess,
1626
+ buttonText: "Upload image",
1627
+ className: "btn-ghost w-full"
1628
+ }
1629
+ ) }),
1630
+ /* @__PURE__ */ jsxRuntime.jsx(
1631
+ ImagePanel,
1632
+ {
1633
+ items,
1634
+ searchQuery,
1635
+ onSearchChange: setSearchQuery,
1636
+ onItemSelect: handleSelection,
1637
+ onFileUpload: handleFileUpload,
1638
+ isLoading,
1639
+ acceptFileTypes,
1640
+ onUrlAdd
1641
+ }
1642
+ )
1643
+ ] });
1422
1644
  }
1423
1645
  const useVideoPreview = () => {
1424
1646
  const [playingVideo, setPlayingVideo] = react.useState(null);
@@ -1572,17 +1794,40 @@ function VideoPanelContainer(props) {
1572
1794
  });
1573
1795
  addItem(newItem);
1574
1796
  };
1575
- return /* @__PURE__ */ jsxRuntime.jsx(
1576
- VideoPanel,
1577
- {
1578
- items,
1579
- onItemSelect: handleSelection,
1580
- onFileUpload: handleFileUpload,
1581
- isLoading,
1582
- acceptFileTypes,
1583
- onUrlAdd
1584
- }
1585
- );
1797
+ const onCloudUploadSuccess = async (url, file) => {
1798
+ var _a;
1799
+ const newItem = await mediaManager.addItem({
1800
+ name: file.name,
1801
+ url,
1802
+ type: "video",
1803
+ metadata: { source: ((_a = props.uploadConfig) == null ? void 0 : _a.provider) ?? "s3" }
1804
+ });
1805
+ addItem(newItem);
1806
+ };
1807
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1808
+ props.uploadConfig && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1809
+ CloudMediaUpload,
1810
+ {
1811
+ uploadApiUrl: props.uploadConfig.uploadApiUrl,
1812
+ provider: props.uploadConfig.provider,
1813
+ accept: "video/*",
1814
+ onSuccess: onCloudUploadSuccess,
1815
+ buttonText: "Upload video",
1816
+ className: "btn-ghost w-full"
1817
+ }
1818
+ ) }),
1819
+ /* @__PURE__ */ jsxRuntime.jsx(
1820
+ VideoPanel,
1821
+ {
1822
+ items,
1823
+ onItemSelect: handleSelection,
1824
+ onFileUpload: handleFileUpload,
1825
+ isLoading,
1826
+ acceptFileTypes,
1827
+ onUrlAdd
1828
+ }
1829
+ )
1830
+ ] });
1586
1831
  }
1587
1832
  function TextPanel({
1588
1833
  textContent,
@@ -2677,7 +2922,8 @@ const ElementPanelContainer = ({
2677
2922
  videoResolution,
2678
2923
  selectedElement,
2679
2924
  addElement,
2680
- updateElement
2925
+ updateElement,
2926
+ uploadConfig
2681
2927
  }) => {
2682
2928
  const addNewElement = async (element) => {
2683
2929
  await addElement(element);
@@ -2691,7 +2937,8 @@ const ElementPanelContainer = ({
2691
2937
  videoResolution,
2692
2938
  selectedElement,
2693
2939
  addElement: addNewElement,
2694
- updateElement
2940
+ updateElement,
2941
+ uploadConfig
2695
2942
  }
2696
2943
  );
2697
2944
  case "audio":
@@ -2701,7 +2948,8 @@ const ElementPanelContainer = ({
2701
2948
  videoResolution,
2702
2949
  selectedElement,
2703
2950
  addElement: addNewElement,
2704
- updateElement
2951
+ updateElement,
2952
+ uploadConfig
2705
2953
  }
2706
2954
  );
2707
2955
  case "video":
@@ -2711,7 +2959,8 @@ const ElementPanelContainer = ({
2711
2959
  videoResolution,
2712
2960
  selectedElement,
2713
2961
  addElement: addNewElement,
2714
- updateElement
2962
+ updateElement,
2963
+ uploadConfig
2715
2964
  }
2716
2965
  );
2717
2966
  case "text":
@@ -3679,7 +3928,7 @@ function PropertiesPanelContainer({
3679
3928
  return /* @__PURE__ */ jsxRuntime.jsxs("aside", { className: "properties-panel", "aria-label": "Element properties inspector", children: [
3680
3929
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "properties-header", children: [
3681
3930
  !selectedElement && /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "properties-title", children: "Composition" }),
3682
- selectedElement && selectedElement.getType() === "caption" && /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "properties-title", children: "Subtitles are edited from the captions panel" }),
3931
+ selectedElement && selectedElement.getType() === "caption" && /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "properties-title", children: "Edit from the captions panel" }),
3683
3932
  selectedElement && selectedElement.getType() !== "caption" && /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "properties-title", children: title })
3684
3933
  ] }),
3685
3934
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "prop-content", children: [
@@ -3908,7 +4157,8 @@ function TwickStudio({ studioConfig }) {
3908
4157
  setSelectedTool,
3909
4158
  selectedElement,
3910
4159
  addElement,
3911
- updateElement
4160
+ updateElement,
4161
+ uploadConfig: twickStudiConfig.uploadConfig
3912
4162
  }
3913
4163
  ) }),
3914
4164
  /* @__PURE__ */ jsxRuntime.jsx("main", { className: "main-container", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "canvas-wrapper", children: /* @__PURE__ */ jsxRuntime.jsx(
@@ -4207,6 +4457,7 @@ exports.AudioPanel = AudioPanel;
4207
4457
  exports.CAPTION_PROPS = CAPTION_PROPS;
4208
4458
  exports.CaptionsPanel = CaptionsPanel;
4209
4459
  exports.CirclePanel = CirclePanel;
4460
+ exports.CloudMediaUpload = CloudMediaUpload;
4210
4461
  exports.ImagePanel = ImagePanel;
4211
4462
  exports.RectPanel = RectPanel;
4212
4463
  exports.StudioHeader = StudioHeader;
@@ -4215,6 +4466,7 @@ exports.Toolbar = Toolbar;
4215
4466
  exports.TwickStudio = TwickStudio;
4216
4467
  exports.VideoPanel = VideoPanel;
4217
4468
  exports.default = TwickStudio;
4469
+ exports.useCloudMediaUpload = useCloudMediaUpload;
4218
4470
  exports.useGenerateCaptions = useGenerateCaptions;
4219
4471
  exports.useStudioManager = useStudioManager;
4220
4472
  //# sourceMappingURL=index.js.map