@twick/studio 0.14.6 → 0.14.7

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
@@ -34,12 +34,32 @@ export default function App() {
34
34
  initialData={INITIAL_TIMELINE_DATA}
35
35
  contextId={"studio-demo"}
36
36
  >
37
- <TwickStudio studioConfig={{
38
- videoProps: {
39
- width: 720,
40
- height: 1280,
41
- },
42
- }}/>
37
+ <TwickStudio
38
+ studioConfig={{
39
+ videoProps: {
40
+ width: 720,
41
+ height: 1280,
42
+ },
43
+ // Optional: Customize timeline tick marks
44
+ timelineTickConfigs: [
45
+ { durationThreshold: 30, majorInterval: 5, minorTicks: 5 },
46
+ { durationThreshold: 300, majorInterval: 30, minorTicks: 6 }
47
+ ],
48
+ // Optional: Customize zoom behavior
49
+ timelineZoomConfig: {
50
+ min: 0.5, max: 2.0, step: 0.25, default: 1.0
51
+ },
52
+ // Optional: Customize element colors
53
+ elementColors: {
54
+ video: "#8B5FBF",
55
+ audio: "#3D8B8B",
56
+ image: "#D4956C",
57
+ text: "#A78EC8",
58
+ caption: "#9B8ACE",
59
+ fragment: "#1A1A1A"
60
+ }
61
+ }}
62
+ />
43
63
  </TimelineProvider>
44
64
  </LivePlayerProvider>
45
65
  );
@@ -59,6 +79,24 @@ The main studio component that provides a complete video editing interface.
59
79
  width: 1920,
60
80
  height: 1080
61
81
  },
82
+ // Optional: Customize timeline tick marks
83
+ timelineTickConfigs: [
84
+ { durationThreshold: 30, majorInterval: 5, minorTicks: 5 },
85
+ { durationThreshold: 300, majorInterval: 30, minorTicks: 6 }
86
+ ],
87
+ // Optional: Customize zoom behavior
88
+ timelineZoomConfig: {
89
+ min: 0.5, max: 2.0, step: 0.25, default: 1.0
90
+ },
91
+ // Optional: Customize element colors
92
+ elementColors: {
93
+ video: "#8B5FBF",
94
+ audio: "#3D8B8B",
95
+ image: "#D4956C",
96
+ text: "#A78EC8",
97
+ caption: "#9B8ACE",
98
+ fragment: "#1A1A1A"
99
+ },
62
100
  saveProject: async (project, fileName) => {
63
101
  // Custom save logic
64
102
  return { status: true, message: "Project saved" };
@@ -85,10 +123,39 @@ interface StudioConfig {
85
123
  width: number;
86
124
  height: number;
87
125
  };
126
+ // Timeline tick configuration
127
+ timelineTickConfigs?: TimelineTickConfig[];
128
+ // Zoom configuration
129
+ timelineZoomConfig?: TimelineZoomConfig;
130
+ // Element colors
131
+ elementColors?: ElementColors;
132
+ // Project management callbacks
88
133
  saveProject?: (project: ProjectJSON, fileName: string) => Promise<Result>;
89
134
  loadProject?: () => Promise<ProjectJSON>;
90
135
  exportVideo?: (project: ProjectJSON, videoSettings: VideoSettings) => Promise<Result>;
91
136
  }
137
+
138
+ interface TimelineTickConfig {
139
+ durationThreshold: number; // Applies when duration < threshold
140
+ majorInterval: number; // Major tick interval in seconds
141
+ minorTicks: number; // Number of minor ticks between majors
142
+ }
143
+
144
+ interface TimelineZoomConfig {
145
+ min: number; // Minimum zoom level
146
+ max: number; // Maximum zoom level
147
+ step: number; // Zoom step increment/decrement
148
+ default: number; // Default zoom level
149
+ }
150
+
151
+ interface ElementColors {
152
+ video: string;
153
+ audio: string;
154
+ image: string;
155
+ text: string;
156
+ caption: string;
157
+ fragment: string;
158
+ }
92
159
  ```
93
160
 
94
161
  ### Individual Panels
@@ -1,3 +1,3 @@
1
1
  import { AudioPanelProps } from '../../types/media-panel';
2
2
 
3
- export declare const AudioPanel: ({ items, searchQuery, onSearchChange, onItemSelect, onFileUpload, acceptFileTypes, }: AudioPanelProps) => import("react/jsx-runtime").JSX.Element;
3
+ export declare const AudioPanel: ({ items, onItemSelect, onFileUpload, acceptFileTypes, onUrlAdd, }: AudioPanelProps) => import("react/jsx-runtime").JSX.Element;
@@ -1,3 +1,3 @@
1
1
  import { ImagePanelProps } from '../../types/media-panel';
2
2
 
3
- export declare function ImagePanel({ items, searchQuery, onSearchChange, onItemSelect, onFileUpload, acceptFileTypes, }: ImagePanelProps): import("react/jsx-runtime").JSX.Element;
3
+ export declare function ImagePanel({ items, onItemSelect, onFileUpload, acceptFileTypes, onUrlAdd, }: ImagePanelProps): import("react/jsx-runtime").JSX.Element;
@@ -1,3 +1,3 @@
1
1
  import { VideoPanelProps } from '../../types/media-panel';
2
2
 
3
- export declare function VideoPanel({ items, searchQuery, onSearchChange, onItemSelect, onFileUpload, acceptFileTypes, }: VideoPanelProps): import("react/jsx-runtime").JSX.Element;
3
+ export declare function VideoPanel({ items, onItemSelect, onFileUpload, acceptFileTypes, onUrlAdd, }: VideoPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -1,3 +1,4 @@
1
1
  export * from './color-input';
2
2
  export * from './file-input';
3
3
  export * from './media-manager';
4
+ export { default as UrlInput } from './url-input';
@@ -0,0 +1,6 @@
1
+ type MediaType = "video" | "audio" | "image";
2
+ export default function UrlInput({ type, onSubmit, }: {
3
+ type: MediaType;
4
+ onSubmit: (url: string) => void;
5
+ }): import("react/jsx-runtime").JSX.Element;
6
+ export {};
package/dist/index.js CHANGED
@@ -797,6 +797,83 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
797
797
  __publicField(_MediaManagerSingleton, "instance", null);
798
798
  let MediaManagerSingleton = _MediaManagerSingleton;
799
799
  const getMediaManager = () => MediaManagerSingleton.getInstance();
800
+ const EXTENSIONS = {
801
+ video: ["mp4", "webm", "ogg", "mov", "mkv", "m3u8"],
802
+ audio: ["mp3", "wav", "ogg", "m4a", "aac", "flac"],
803
+ image: ["jpg", "jpeg", "png", "gif", "webp", "svg"]
804
+ };
805
+ function isValidUrl(url) {
806
+ try {
807
+ new URL(url);
808
+ return true;
809
+ } catch {
810
+ return false;
811
+ }
812
+ }
813
+ function matchesType(url, type) {
814
+ const pathname = (() => {
815
+ try {
816
+ return new URL(url).pathname.toLowerCase();
817
+ } catch {
818
+ return url.toLowerCase();
819
+ }
820
+ })();
821
+ const ext = pathname.split(".").pop() || "";
822
+ return EXTENSIONS[type].includes(ext);
823
+ }
824
+ function UrlInput({
825
+ type,
826
+ onSubmit
827
+ }) {
828
+ const [url, setUrl] = react.useState("");
829
+ const [error, setError] = react.useState("");
830
+ const tryAdd = async () => {
831
+ const trimmed = url.trim();
832
+ if (!trimmed) return;
833
+ if (!isValidUrl(trimmed)) {
834
+ setError("Enter a valid URL");
835
+ return;
836
+ }
837
+ if (!matchesType(trimmed, type)) {
838
+ setError(`URL must be a ${type} (${EXTENSIONS[type].join(", ")})`);
839
+ return;
840
+ }
841
+ setError("");
842
+ onSubmit(trimmed);
843
+ setUrl("");
844
+ };
845
+ const onKeyDown = (e) => {
846
+ if (e.key === "Enter") {
847
+ e.preventDefault();
848
+ void tryAdd();
849
+ }
850
+ };
851
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { children: [
852
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex-container", children: [
853
+ /* @__PURE__ */ jsxRuntime.jsx(
854
+ "input",
855
+ {
856
+ type: "url",
857
+ placeholder: `Paste ${type} URL...`,
858
+ value: url,
859
+ onChange: (e) => setUrl(e.target.value),
860
+ onKeyDown,
861
+ className: "input w-full"
862
+ }
863
+ ),
864
+ /* @__PURE__ */ jsxRuntime.jsx(
865
+ "button",
866
+ {
867
+ className: "btn-ghost",
868
+ onClick: () => void tryAdd(),
869
+ "aria-label": `Add ${type} by URL`,
870
+ children: /* @__PURE__ */ jsxRuntime.jsx(Plus, { size: 16 })
871
+ }
872
+ )
873
+ ] }),
874
+ error ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-error", children: error }) : null
875
+ ] });
876
+ }
800
877
  const initialMediaState = {
801
878
  items: [],
802
879
  searchQuery: "",
@@ -964,24 +1041,6 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
964
1041
  acceptFileTypes: config.acceptFileTypes
965
1042
  };
966
1043
  };
967
- const SearchInput = ({
968
- searchQuery,
969
- setSearchQuery
970
- }) => {
971
- return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "search-container", children: [
972
- /* @__PURE__ */ jsxRuntime.jsx(
973
- "input",
974
- {
975
- type: "text",
976
- placeholder: "Search media...",
977
- value: searchQuery,
978
- onChange: (e) => setSearchQuery(e.target.value),
979
- className: "input search-input w-full"
980
- }
981
- ),
982
- /* @__PURE__ */ jsxRuntime.jsx(Search, { className: "search-icon" })
983
- ] });
984
- };
985
1044
  const useAudioPreview = () => {
986
1045
  const [playingAudio, setPlayingAudio] = react.useState(null);
987
1046
  const audioRef = react.useRef(null);
@@ -1021,22 +1080,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1021
1080
  };
1022
1081
  const AudioPanel = ({
1023
1082
  items,
1024
- searchQuery,
1025
- onSearchChange,
1026
1083
  onItemSelect,
1027
1084
  onFileUpload,
1028
- acceptFileTypes
1085
+ acceptFileTypes,
1086
+ onUrlAdd
1029
1087
  }) => {
1030
1088
  const { playingAudio, togglePlayPause } = useAudioPreview();
1031
1089
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "panel-container", children: [
1032
1090
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "panel-title", children: "Audio Library" }),
1033
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1034
- SearchInput,
1035
- {
1036
- searchQuery,
1037
- setSearchQuery: onSearchChange
1038
- }
1039
- ) }),
1091
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(UrlInput, { type: "audio", onSubmit: onUrlAdd }) }),
1040
1092
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1041
1093
  FileInput,
1042
1094
  {
@@ -1088,13 +1140,14 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1088
1140
  }) }),
1089
1141
  items.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "empty-state", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "empty-state-content", children: [
1090
1142
  /* @__PURE__ */ jsxRuntime.jsx(WandSparkles, { className: "empty-state-icon" }),
1091
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-text", children: "No audio files found" }),
1092
- searchQuery && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-subtext", children: "Try adjusting your search" })
1143
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-text", children: "No audio files found" })
1093
1144
  ] }) })
1094
1145
  ] })
1095
1146
  ] });
1096
1147
  };
1097
1148
  const AudioPanelContainer = (props) => {
1149
+ const { addItem } = useMedia("audio");
1150
+ const mediaManager = getMediaManager();
1098
1151
  const {
1099
1152
  items,
1100
1153
  searchQuery,
@@ -1112,6 +1165,24 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1112
1165
  },
1113
1166
  props.videoResolution
1114
1167
  );
1168
+ const onUrlAdd = async (url) => {
1169
+ const nameFromUrl = (() => {
1170
+ try {
1171
+ const u = new URL(url);
1172
+ const parts = u.pathname.split("/").filter(Boolean);
1173
+ return decodeURIComponent(parts[parts.length - 1] || url);
1174
+ } catch {
1175
+ return url;
1176
+ }
1177
+ })();
1178
+ const newItem = await mediaManager.addItem({
1179
+ name: nameFromUrl,
1180
+ url,
1181
+ type: "audio",
1182
+ metadata: { source: "url" }
1183
+ });
1184
+ addItem(newItem);
1185
+ };
1115
1186
  return /* @__PURE__ */ jsxRuntime.jsx(
1116
1187
  AudioPanel,
1117
1188
  {
@@ -1121,27 +1192,21 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1121
1192
  onItemSelect: handleSelection,
1122
1193
  onFileUpload: handleFileUpload,
1123
1194
  isLoading,
1124
- acceptFileTypes
1195
+ acceptFileTypes,
1196
+ onUrlAdd
1125
1197
  }
1126
1198
  );
1127
1199
  };
1128
1200
  function ImagePanel({
1129
1201
  items,
1130
- searchQuery,
1131
- onSearchChange,
1132
1202
  onItemSelect,
1133
1203
  onFileUpload,
1134
- acceptFileTypes
1204
+ acceptFileTypes,
1205
+ onUrlAdd
1135
1206
  }) {
1136
1207
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "panel-container", children: [
1137
1208
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "panel-title", children: "Image Library" }),
1138
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1139
- SearchInput,
1140
- {
1141
- searchQuery,
1142
- setSearchQuery: onSearchChange
1143
- }
1144
- ) }),
1209
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(UrlInput, { type: "image", onSubmit: onUrlAdd }) }),
1145
1210
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1146
1211
  FileInput,
1147
1212
  {
@@ -1178,13 +1243,14 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1178
1243
  )) }),
1179
1244
  items.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "empty-state", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "empty-state-content", children: [
1180
1245
  /* @__PURE__ */ jsxRuntime.jsx(WandSparkles, { className: "empty-state-icon" }),
1181
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-text", children: "No images found" }),
1182
- searchQuery && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-subtext", children: "Try adjusting your search" })
1246
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-text", children: "No images found" })
1183
1247
  ] }) })
1184
1248
  ] })
1185
1249
  ] });
1186
1250
  }
1187
1251
  function ImagePanelContainer(props) {
1252
+ const { addItem } = useMedia("image");
1253
+ const mediaManager = getMediaManager();
1188
1254
  const {
1189
1255
  items,
1190
1256
  searchQuery,
@@ -1202,6 +1268,24 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1202
1268
  },
1203
1269
  props.videoResolution
1204
1270
  );
1271
+ const onUrlAdd = async (url) => {
1272
+ const nameFromUrl = (() => {
1273
+ try {
1274
+ const u = new URL(url);
1275
+ const parts = u.pathname.split("/").filter(Boolean);
1276
+ return decodeURIComponent(parts[parts.length - 1] || url);
1277
+ } catch {
1278
+ return url;
1279
+ }
1280
+ })();
1281
+ const newItem = await mediaManager.addItem({
1282
+ name: nameFromUrl,
1283
+ url,
1284
+ type: "image",
1285
+ metadata: { source: "url" }
1286
+ });
1287
+ addItem(newItem);
1288
+ };
1205
1289
  return /* @__PURE__ */ jsxRuntime.jsx(
1206
1290
  ImagePanel,
1207
1291
  {
@@ -1211,7 +1295,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1211
1295
  onItemSelect: handleSelection,
1212
1296
  onFileUpload: handleFileUpload,
1213
1297
  isLoading,
1214
- acceptFileTypes
1298
+ acceptFileTypes,
1299
+ onUrlAdd
1215
1300
  }
1216
1301
  );
1217
1302
  }
@@ -1255,22 +1340,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1255
1340
  };
1256
1341
  function VideoPanel({
1257
1342
  items,
1258
- searchQuery,
1259
- onSearchChange,
1260
1343
  onItemSelect,
1261
1344
  onFileUpload,
1262
- acceptFileTypes
1345
+ acceptFileTypes,
1346
+ onUrlAdd
1263
1347
  }) {
1264
1348
  const { playingVideo, togglePlayPause } = useVideoPreview();
1265
1349
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "panel-container", children: [
1266
1350
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "panel-title", children: "Video Library" }),
1267
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1268
- SearchInput,
1269
- {
1270
- searchQuery,
1271
- setSearchQuery: onSearchChange
1272
- }
1273
- ) }),
1351
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(UrlInput, { type: "video", onSubmit: onUrlAdd }) }),
1274
1352
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex panel-section", children: /* @__PURE__ */ jsxRuntime.jsx(
1275
1353
  FileInput,
1276
1354
  {
@@ -1304,7 +1382,6 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1304
1382
  }
1305
1383
  }
1306
1384
  ),
1307
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "media-duration", children: "0:13" }),
1308
1385
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "media-actions", children: [
1309
1386
  /* @__PURE__ */ jsxRuntime.jsx(
1310
1387
  "button",
@@ -1339,17 +1416,16 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1339
1416
  )) }),
1340
1417
  items.length === 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "empty-state", children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "empty-state-content", children: [
1341
1418
  /* @__PURE__ */ jsxRuntime.jsx(WandSparkles, { className: "empty-state-icon" }),
1342
- /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-text", children: "No videos found" }),
1343
- searchQuery && /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-subtext", children: "Try adjusting your search" })
1419
+ /* @__PURE__ */ jsxRuntime.jsx("p", { className: "empty-state-text", children: "No videos found" })
1344
1420
  ] }) })
1345
1421
  ] })
1346
1422
  ] });
1347
1423
  }
1348
1424
  function VideoPanelContainer(props) {
1425
+ const { addItem } = useMedia("video");
1426
+ const mediaManager = getMediaManager();
1349
1427
  const {
1350
1428
  items,
1351
- searchQuery,
1352
- setSearchQuery,
1353
1429
  handleSelection,
1354
1430
  handleFileUpload,
1355
1431
  isLoading,
@@ -1363,16 +1439,33 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1363
1439
  },
1364
1440
  props.videoResolution
1365
1441
  );
1442
+ const onUrlAdd = async (url) => {
1443
+ const nameFromUrl = (() => {
1444
+ try {
1445
+ const u = new URL(url);
1446
+ const parts = u.pathname.split("/").filter(Boolean);
1447
+ return decodeURIComponent(parts[parts.length - 1] || url);
1448
+ } catch {
1449
+ return url;
1450
+ }
1451
+ })();
1452
+ const newItem = await mediaManager.addItem({
1453
+ name: nameFromUrl,
1454
+ url,
1455
+ type: "video",
1456
+ metadata: { source: "url" }
1457
+ });
1458
+ addItem(newItem);
1459
+ };
1366
1460
  return /* @__PURE__ */ jsxRuntime.jsx(
1367
1461
  VideoPanel,
1368
1462
  {
1369
1463
  items,
1370
- searchQuery,
1371
- onSearchChange: setSearchQuery,
1372
1464
  onItemSelect: handleSelection,
1373
1465
  onFileUpload: handleFileUpload,
1374
1466
  isLoading,
1375
- acceptFileTypes
1467
+ acceptFileTypes,
1468
+ onUrlAdd
1376
1469
  }
1377
1470
  );
1378
1471
  }
@@ -1706,6 +1799,24 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1706
1799
  const textPanelProps = useTextPanel(props);
1707
1800
  return /* @__PURE__ */ jsxRuntime.jsx(TextPanel, { ...textPanelProps });
1708
1801
  }
1802
+ const SearchInput = ({
1803
+ searchQuery,
1804
+ setSearchQuery
1805
+ }) => {
1806
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "search-container", children: [
1807
+ /* @__PURE__ */ jsxRuntime.jsx(
1808
+ "input",
1809
+ {
1810
+ type: "text",
1811
+ placeholder: "Search media...",
1812
+ value: searchQuery,
1813
+ onChange: (e) => setSearchQuery(e.target.value),
1814
+ className: "input search-input w-full"
1815
+ }
1816
+ ),
1817
+ /* @__PURE__ */ jsxRuntime.jsx(Search, { className: "search-icon" })
1818
+ ] });
1819
+ };
1709
1820
  function IconPanel({
1710
1821
  icons,
1711
1822
  loading,