@hyperframes/studio 0.8.105 → 0.8.106

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.
Files changed (79) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-dl6K6ABp.js} +1 -1
  2. package/dist/assets/{index-DEDhPmPD.js → index-CMiYVGv5.js} +202 -202
  3. package/dist/assets/{index-zpe332hb.js → index-DKT5NF1Y.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-Ca6LZlVb.js → index-ooaZZhKf.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-ICTZJPQQ.js} +2 -1
  7. package/dist/chunk-ICTZJPQQ.js.map +1 -0
  8. package/dist/index.d.ts +15 -4
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +6601 -6022
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/captions/keyboard.test.ts +6 -0
  16. package/src/captions/keyboard.ts +2 -1
  17. package/src/components/StudioHeader.dom.test.tsx +31 -9
  18. package/src/components/StudioHeader.tsx +4 -1
  19. package/src/components/ThemeToggle.tsx +99 -0
  20. package/src/components/editor/DomEditOverlay.tsx +37 -5
  21. package/src/components/editor/SourceEditor.tsx +2 -6
  22. package/src/components/editor/domEditOverlayStartGesture.ts +127 -115
  23. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +275 -0
  24. package/src/components/editor/manualEditsDom.ts +33 -0
  25. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  26. package/src/components/editor/marqueeCommit.ts +6 -1
  27. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  28. package/src/components/editor/overlayFrameLoop.ts +21 -0
  29. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  30. package/src/components/editor/propertyPanelFont.tsx +69 -46
  31. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  32. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  33. package/src/components/editor/studioCodeTheme.ts +99 -0
  34. package/src/components/editor/useDomEditNudge.test.tsx +31 -0
  35. package/src/components/editor/useDomEditNudge.ts +2 -2
  36. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  37. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  38. package/src/components/editor/useMotionPathData.ts +5 -1
  39. package/src/hooks/appHotkeysDispatch.ts +2 -1
  40. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  41. package/src/hooks/useAppHotkeys.test.ts +32 -0
  42. package/src/hooks/useAppHotkeys.ts +1 -2
  43. package/src/hooks/useClipboard.source.test.ts +99 -19
  44. package/src/hooks/useClipboard.ts +145 -124
  45. package/src/hooks/useDomEditSession.test.tsx +76 -72
  46. package/src/hooks/useDomEditSession.ts +7 -43
  47. package/src/hooks/useGsapTweenCache.ts +1 -2
  48. package/src/hooks/useInlineTextEdit.ts +16 -2
  49. package/src/hooks/useLivePreviewIframe.ts +18 -0
  50. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  51. package/src/hooks/usePreviewPersistence.ts +2 -5
  52. package/src/hooks/useRenderClipContent.ts +3 -3
  53. package/src/index.ts +2 -0
  54. package/src/livePreviewIframeExport.test.tsx +33 -0
  55. package/src/main.tsx +4 -1
  56. package/src/player/components/AudioWaveform.test.tsx +36 -15
  57. package/src/player/components/AudioWaveform.tsx +24 -7
  58. package/src/player/components/TimelineClip.test.tsx +38 -0
  59. package/src/player/components/TimelineClip.tsx +14 -6
  60. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  61. package/src/player/components/TimelineClipFades.tsx +368 -137
  62. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  63. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  64. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  65. package/src/player/lib/automationStoreSync.test.ts +18 -1
  66. package/src/player/lib/automationStoreSync.ts +29 -17
  67. package/src/player/sceneSwap.ts +18 -0
  68. package/src/styles/components.css +26 -0
  69. package/src/styles/studio.css +6 -0
  70. package/src/styles/theme.css +10 -0
  71. package/src/utils/clipboardPayload.ts +10 -1
  72. package/src/utils/studioPendingEdits.ts +21 -0
  73. package/src/utils/studioTheme.test.tsx +141 -0
  74. package/src/utils/studioTheme.ts +34 -0
  75. package/src/utils/studioUiPreferences.ts +3 -0
  76. package/src/utils/typingTarget.test.ts +8 -1
  77. package/src/utils/typingTarget.ts +5 -0
  78. package/dist/assets/index-Cre9X4RS.css +0 -1
  79. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -60,6 +60,38 @@ afterEach(() => {
60
60
  });
61
61
  });
62
62
 
63
+ describe("keys on a focused slider", () => {
64
+ const slider = () => {
65
+ const el = document.createElement("div");
66
+ el.setAttribute("role", "slider");
67
+ document.body.append(el);
68
+ return el;
69
+ };
70
+ const from = (target: HTMLElement, init: KeyboardEventInit) => {
71
+ const event = new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true });
72
+ Object.defineProperty(event, "target", { value: target });
73
+ return event;
74
+ };
75
+
76
+ it("leaves Delete to the slider instead of deleting the selected clip", () => {
77
+ usePlayerStore.setState({
78
+ elements: [bgmElement],
79
+ selectedElementId: "bgm",
80
+ selectedElementIds: new Set(["bgm"]),
81
+ });
82
+ const cb = callbacks();
83
+ dispatchPlainKey(from(slider(), { key: "Delete" }), "delete", cb);
84
+ expect(cb.handleTimelineElementsDelete).not.toHaveBeenCalled();
85
+ expect(cb.handleTimelineElementDelete).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("still undoes with Cmd+Z while a slider has focus", () => {
89
+ const cb = callbacks();
90
+ dispatchModifierKey(from(slider(), { key: "z", metaKey: true }), "z", cb);
91
+ expect(cb.handleUndo).toHaveBeenCalledTimes(1);
92
+ });
93
+ });
94
+
63
95
  describe("dispatchPlainKey — select leftward / rightward", () => {
64
96
  const clips = [
65
97
  { ...bgmElement, id: "early", key: "early", start: 0, track: 0 },
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from "react";
2
2
  import { usePlayerStore } from "../player";
3
3
  import type { TimelineElement } from "../player";
4
4
  import type { DomEditSelection } from "../components/editor/domEditing";
5
- import { isTypingTarget } from "../utils/typingTarget";
6
5
  import { useCaptionStore } from "../captions/store";
7
6
  import {
8
7
  applyCaptionModelToIframe,
@@ -220,7 +219,7 @@ export function useAppHotkeys({
220
219
  dispatchModifierKey(event, key, cb);
221
220
  return;
222
221
  }
223
- if (!isTypingTarget(event.target)) dispatchPlainKey(event, key, cb);
222
+ dispatchPlainKey(event, key, cb);
224
223
  }, []);
225
224
 
226
225
  // eslint-disable-next-line no-restricted-syntax
@@ -98,6 +98,7 @@ function mountClipboard(
98
98
  sub = SUB,
99
99
  bundle: (host: Element) => void = () => {},
100
100
  saved = SAVED,
101
+ view = { path: "index.html" },
101
102
  ) {
102
103
  const files: Record<string, string> = { "index.html": saved, "compositions/sub.html": sub };
103
104
  const fail = { on: false };
@@ -115,7 +116,7 @@ function mountClipboard(
115
116
  function Harness() {
116
117
  api = useClipboard({
117
118
  projectId: "p",
118
- activeCompPath: "index.html",
119
+ activeCompPath: view.path,
119
120
  domEditSelectionRef: domSelectionRef,
120
121
  showToast: () => {},
121
122
  writeProjectFile,
@@ -134,7 +135,9 @@ function mountClipboard(
134
135
  root = createRoot(document.createElement("div"));
135
136
  act(() => root?.render(React.createElement(Harness)));
136
137
  const clipboard = () => api as ReturnType<typeof useClipboard>;
138
+ const rerender = () => act(() => root?.render(React.createElement(Harness)));
137
139
  return {
140
+ rerender,
138
141
  clipboard,
139
142
  writes,
140
143
  deleted,
@@ -253,6 +256,17 @@ const SUB_SELECTION = {
253
256
  sourceFile: "compositions/sub.html",
254
257
  } as DomEditSelection;
255
258
 
259
+ const copySubNext = ({ clipboard, domSelectionRef }: ReturnType<typeof mountClipboard>) => {
260
+ clearSelection();
261
+ domSelectionRef.current = SUB_SELECTION;
262
+ clipboard().handleCopy();
263
+ };
264
+
265
+ const expectSubPastedAlone = (writes: string[]) => {
266
+ expect(writes[0]).toContain(">Sub</h2>");
267
+ expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
268
+ };
269
+
256
270
  describe("paste of an element styled by its id", () => {
257
271
  it("gives the copy the look its original has", async () => {
258
272
  clearSelection();
@@ -339,30 +353,24 @@ describe("copy order", () => {
339
353
 
340
354
  it("pastes the second of two copies", async () => {
341
355
  selectTitle();
342
- const { clipboard, writes, domSelectionRef } = mountClipboard();
343
- clipboard().handleCopy();
356
+ const view = mountClipboard();
357
+ view.clipboard().handleCopy();
344
358
  await new Promise((resolve) => setTimeout(resolve, 10));
345
- clearSelection();
346
- domSelectionRef.current = SUB_SELECTION;
347
- clipboard().handleCopy();
348
- await clipboard().handlePaste();
349
- expect(writes[0]).toContain(">Sub</h2>");
350
- expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
359
+ copySubNext(view);
360
+ await view.clipboard().handlePaste();
361
+ expectSubPastedAlone(view.writes);
351
362
  });
352
363
 
353
364
  it("pastes the later copy even when the earlier copy's read lands last", async () => {
354
365
  selectTitle();
355
- const { clipboard, writes, delayMs, domSelectionRef } = mountClipboard();
356
- delayMs["index.html"] = 20;
357
- clipboard().handleCopy();
358
- clearSelection();
359
- domSelectionRef.current = SUB_SELECTION;
360
- clipboard().handleCopy();
366
+ const view = mountClipboard();
367
+ view.delayMs["index.html"] = 20;
368
+ view.clipboard().handleCopy();
369
+ copySubNext(view);
361
370
  await new Promise((resolve) => setTimeout(resolve, 40));
362
- delayMs["index.html"] = 0;
363
- await clipboard().handlePaste();
364
- expect(writes[0]).toContain(">Sub</h2>");
365
- expect(writes[0]?.match(/>Title<\/h1>/g)).toHaveLength(1);
371
+ view.delayMs["index.html"] = 0;
372
+ await view.clipboard().handlePaste();
373
+ expectSubPastedAlone(view.writes);
366
374
  });
367
375
 
368
376
  it("duplicates a hidden clip with its saved markup", async () => {
@@ -374,6 +382,78 @@ describe("copy order", () => {
374
382
  });
375
383
  });
376
384
 
385
+ describe("a copy in place, as the canvas makes one", () => {
386
+ // The copy's start and track: the saved original has no data-hf-id, its copy is minted one.
387
+ const copyAt = (html: string | undefined) => {
388
+ const copy = html?.match(/<h1[^>]*data-hf-id[^>]*>Title<\/h1>/)?.[0] ?? "";
389
+ return [copy.match(/data-start="([^"]*)"/)?.[1], copy.match(/data-track-index="([^"]*)"/)?.[1]];
390
+ };
391
+
392
+ it("duplicates at the clip's own time, on the next free track", async () => {
393
+ selectTitle();
394
+ const { clipboard, writes } = mountClipboard();
395
+ await clipboard().handleDuplicate({ inPlace: true });
396
+ expect(copyAt(writes[0])).toEqual(["2", "1"]);
397
+ });
398
+
399
+ it("pastes at the copied clip's time, not the playhead's", async () => {
400
+ selectTitle();
401
+ const { clipboard, writes } = mountClipboard();
402
+ clipboard().handleCopy();
403
+ await clipboard().handlePaste({ inPlace: true });
404
+ expect(copyAt(writes[0])).toEqual(["2", "1"]);
405
+ });
406
+
407
+ // Host at 2 s: the clip's local 1-3 s shows as a 3-5 s master row, the clock the film's own timeline has.
408
+ const SUB_CLIP: TimelineElement = {
409
+ id: SUB_HF_ID,
410
+ hfId: SUB_HF_ID,
411
+ tag: "h2",
412
+ start: 3,
413
+ duration: 2,
414
+ track: 0,
415
+ authoredTrack: 0,
416
+ sourceFile: "compositions/sub.html",
417
+ parentCompositionStart: 2,
418
+ };
419
+ const pastedSubStarts = (html: string | undefined) =>
420
+ [...(html ?? "").matchAll(/<h2[^>]*data-hf-id[^>]*data-start="([^"]+)"/g)].map((m) => m[1]);
421
+ const mountSubClip = (currentTime = 0, view = { path: "index.html" }) => {
422
+ usePlayerStore.setState({
423
+ elements: [TITLE, SUB_CLIP],
424
+ selectedElementId: SUB_CLIP.id,
425
+ selectedElementIds: new Set([SUB_CLIP.id]),
426
+ currentTime,
427
+ });
428
+ return mountClipboard(null, SUB, (host) => host.setAttribute("data-start", "2"), SAVED, view);
429
+ };
430
+
431
+ it("pastes a sub-composition's clip into the film at the moment the film shows it", async () => {
432
+ const { clipboard, writes } = mountSubClip(7);
433
+ clipboard().handleCopy();
434
+ await clipboard().handlePaste({ inPlace: true });
435
+ expect(pastedSubStarts(writes[0])).toEqual(["3"]);
436
+ });
437
+
438
+ it("duplicates a sub-composition's clip at its own local time, on another track", async () => {
439
+ const { clipboard, writes } = mountSubClip();
440
+ await clipboard().handleDuplicate({ inPlace: true });
441
+ const copy = /<h2[^>]*data-hf-id[^>]*>/.exec(writes[0] ?? "")?.[0] ?? "";
442
+ expect(copy).toContain('data-start="1"');
443
+ expect(copy).toContain('data-track-index="1"');
444
+ });
445
+
446
+ it("pastes at the playhead once the edited composition is another, whose clock differs", async () => {
447
+ const view = { path: "index.html" };
448
+ const { clipboard, writes, rerender } = mountSubClip(0.5, view);
449
+ clipboard().handleCopy();
450
+ view.path = "compositions/sub.html";
451
+ rerender();
452
+ await clipboard().handlePaste({ inPlace: true });
453
+ expect(pastedSubStarts(writes[0])).toEqual(["0.5"]);
454
+ });
455
+ });
456
+
377
457
  describe("duplicate of a clip inside a sub-composition", () => {
378
458
  it("writes the copy at local time right after the original", async () => {
379
459
  // Host at 2 s, so the clip's local 1-3 s shows as a 3-5 s master row.
@@ -30,6 +30,26 @@ import {
30
30
  } from "../utils/authoredSource";
31
31
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
32
32
 
33
+ type InPlace = { inPlace?: boolean };
34
+
35
+ const pasteStart = (
36
+ payload: Extract<ClipboardPayload, { kind: "timeline-clip" }>,
37
+ targetPath: string,
38
+ playhead: number,
39
+ options?: InPlace,
40
+ ) =>
41
+ options?.inPlace && payload.copiedInComposition === targetPath
42
+ ? Math.min(...payload.clips.map((clip) => clip.start))
43
+ : playhead;
44
+
45
+ const pasteWords = (payload: ClipboardPayload) =>
46
+ payload.kind === "timeline-clip"
47
+ ? {
48
+ label: clipLabel("Paste", payload.clips.length),
49
+ toast: clipToast("Pasted", payload.clips.length),
50
+ }
51
+ : { label: "Paste element", toast: "Pasted element" };
52
+
33
53
  interface RecordEditInput {
34
54
  label: string;
35
55
  coalesceKey?: string;
@@ -313,6 +333,7 @@ export function useClipboard({
313
333
  clips,
314
334
  sourceFile,
315
335
  projectId: projectIdRef.current ?? undefined,
336
+ copiedInComposition: activeCompPath || "index.html",
316
337
  };
317
338
  });
318
339
  }, [activeCompPath, findSelectedClips, readClips, showToast]);
@@ -369,136 +390,136 @@ export function useClipboard({
369
390
 
370
391
  // Two independent paste modes (timeline clip vs DOM element) behind one guarded save.
371
392
  // fallow-ignore-next-line complexity
372
- const handlePaste = useCallback(async () => {
373
- const payload = await clipboardRef.current;
374
- if (!payload) {
375
- showToast("Nothing to paste.", "info");
376
- return;
377
- }
378
- const pid = projectIdRef.current;
379
- if (!pid) return;
380
-
381
- const targetPath = activeCompPath || "index.html";
382
- try {
383
- let pastedIds: string[] = [];
384
- const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
385
- const paste = (originalContent: string) => {
386
- if (payload.kind !== "timeline-clip")
387
- return pasteElementHtml(originalContent, payload, fromThisFile);
388
- const { currentTime, elements } = usePlayerStore.getState();
389
- const pasted = pasteTimelineClips(
390
- originalContent,
391
- payload.clips,
392
- currentTime,
393
- elements,
394
- fromThisFile,
395
- );
396
- pastedIds = pasted.ids;
397
- // A clip pasted past the current composition end would exist in the
398
- // file but never appear on the timeline or in playback/export (the
399
- // root's data-duration is what actually bounds the render).
400
- return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
401
- };
402
-
403
- const label =
404
- payload.kind === "timeline-clip"
405
- ? clipLabel("Paste", payload.clips.length)
406
- : "Paste element";
407
-
408
- await saveProjectFilesWithHistory({
409
- projectId: pid,
410
- label,
411
- files: { [targetPath]: paste },
412
- readFile: (path) => readFileContent(pid, path),
413
- writeFile: writeProjectFile,
414
- recordEdit,
415
- });
416
-
417
- // CapCut: the pasted clip(s) become the selection; the playhead does not
418
- // move. reloadPreview is a bare refresh-key bump with no selection
419
- // snapshot of its own; calling setSelection before it is what makes the
420
- // post-reload store state land on the pasted ids instead of stale ones.
421
- if (pastedIds.length > 0) {
422
- usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
393
+ const handlePaste = useCallback(
394
+ async (options?: InPlace) => {
395
+ const payload = await clipboardRef.current;
396
+ if (!payload) {
397
+ showToast("Nothing to paste.", "info");
398
+ return;
423
399
  }
424
- reloadPreview();
425
- showToast(
426
- payload.kind === "timeline-clip"
427
- ? clipToast("Pasted", payload.clips.length)
428
- : "Pasted element",
429
- "info",
430
- );
431
- } catch (error) {
432
- const message = error instanceof Error ? error.message : "Failed to paste";
433
- showToast(message);
434
- }
435
- }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]);
400
+ const pid = projectIdRef.current;
401
+ if (!pid) return;
402
+
403
+ const targetPath = activeCompPath || "index.html";
404
+ try {
405
+ let pastedIds: string[] = [];
406
+ const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
407
+ const paste = (originalContent: string) => {
408
+ if (payload.kind !== "timeline-clip")
409
+ return pasteElementHtml(originalContent, payload, fromThisFile);
410
+ const { currentTime, elements } = usePlayerStore.getState();
411
+ const pasted = pasteTimelineClips(
412
+ originalContent,
413
+ payload.clips,
414
+ pasteStart(payload, targetPath, currentTime, options),
415
+ elements,
416
+ fromThisFile,
417
+ );
418
+ pastedIds = pasted.ids;
419
+ // A clip pasted past the current composition end would exist in the
420
+ // file but never appear on the timeline or in playback/export (the
421
+ // root's data-duration is what actually bounds the render).
422
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
423
+ };
436
424
 
437
- // Duplicates the current selection in place, immediately after it, without
438
- // touching the clipboard — a pending copy must survive a Cmd+D. Shares
439
- // pasteTimelineClips with handlePaste; only the anchor and clip source
440
- // differ (the selection's own end here, the playhead there).
441
- const handleDuplicate = useCallback(async (): Promise<boolean> => {
442
- const targets = findSelectedClips();
443
- if (!targets) return false;
444
- const pid = projectIdRef.current;
445
- if (!pid) return false;
446
-
447
- const { elements } = targets;
448
- const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
449
- const targetPath = pathOf(elements[0]!);
450
- // The copy lands in targetPath's own clock, so anchor and lane check are local to it.
451
- const anchorTime = toAuthoredStart(
452
- elements[0]!,
453
- Math.max(...elements.map((el) => el.start + el.duration)),
454
- );
425
+ const { label, toast } = pasteWords(payload);
426
+
427
+ await saveProjectFilesWithHistory({
428
+ projectId: pid,
429
+ label,
430
+ files: { [targetPath]: paste },
431
+ readFile: (path) => readFileContent(pid, path),
432
+ writeFile: writeProjectFile,
433
+ recordEdit,
434
+ });
435
+
436
+ // CapCut: the pasted clip(s) become the selection; the playhead does not
437
+ // move. reloadPreview is a bare refresh-key bump with no selection
438
+ // snapshot of its own; calling setSelection before it is what makes the
439
+ // post-reload store state land on the pasted ids instead of stale ones.
440
+ if (pastedIds.length > 0) {
441
+ usePlayerStore.getState().setSelection(pastedIds.map((id) => elementKey(id, targetPath)));
442
+ }
443
+ reloadPreview();
444
+ showToast(toast, "info");
445
+ } catch (error) {
446
+ const message = error instanceof Error ? error.message : "Failed to paste";
447
+ showToast(message);
448
+ }
449
+ },
450
+ [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile],
451
+ );
455
452
 
456
- try {
457
- const clips = await readClips(targets);
458
- let ids: string[] = [];
459
- const duplicate = (originalContent: string) => {
460
- const liveElements = usePlayerStore
461
- .getState()
462
- .elements.filter((el) => pathOf(el) === targetPath)
463
- .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
464
- const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
465
- ids = pasted.ids;
466
- return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
467
- };
453
+ // Duplicates the selection right after it, or with `inPlace` at its own time on a track of its own,
454
+ // without touching the clipboard — a pending copy must survive a Cmd+D. Shares
455
+ // pasteTimelineClips with handlePaste; only the anchor and clip source differ (the
456
+ // selection's end or start here, the playhead or the copy's own time there).
457
+ const handleDuplicate = useCallback(
458
+ async (options?: InPlace): Promise<boolean> => {
459
+ const targets = findSelectedClips();
460
+ if (!targets) return false;
461
+ const pid = projectIdRef.current;
462
+ if (!pid) return false;
463
+
464
+ const { elements } = targets;
465
+ const pathOf = (el: TimelineElement) => el.sourceFile || activeCompPath || "index.html";
466
+ const targetPath = pathOf(elements[0]!);
467
+ // The copy lands in targetPath's own clock, so anchor and lane check are local to it.
468
+ const anchorTime = toAuthoredStart(
469
+ elements[0]!,
470
+ options?.inPlace
471
+ ? Math.min(...elements.map((el) => el.start))
472
+ : Math.max(...elements.map((el) => el.start + el.duration)),
473
+ );
468
474
 
469
- await saveProjectFilesWithHistory({
470
- projectId: pid,
471
- label: clipLabel("Duplicate", clips.length),
472
- files: { [targetPath]: duplicate },
473
- readFile: (path) => readFileContent(pid, path),
474
- writeFile: writeProjectFile,
475
- recordEdit,
476
- });
475
+ try {
476
+ const clips = await readClips(targets);
477
+ let ids: string[] = [];
478
+ const duplicate = (originalContent: string) => {
479
+ const liveElements = usePlayerStore
480
+ .getState()
481
+ .elements.filter((el) => pathOf(el) === targetPath)
482
+ .map((el) => ({ ...el, start: toAuthoredStart(el, el.start) }));
483
+ const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements, true);
484
+ ids = pasted.ids;
485
+ return extendRootDurationInSource(pasted.content, pasted.requiredEnd);
486
+ };
477
487
 
478
- // The duplicate becomes the selection, mirroring CapCut's own paste
479
- // convention — there is no CapCut Duplicate to match directly (Cmd+D is
480
- // a no-op there); this is our own choice for consistency with paste.
481
- // Select before reloading, same reason as handlePaste above.
482
- if (ids.length > 0) {
483
- usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
488
+ await saveProjectFilesWithHistory({
489
+ projectId: pid,
490
+ label: clipLabel("Duplicate", clips.length),
491
+ files: { [targetPath]: duplicate },
492
+ readFile: (path) => readFileContent(pid, path),
493
+ writeFile: writeProjectFile,
494
+ recordEdit,
495
+ });
496
+
497
+ // The duplicate becomes the selection, mirroring CapCut's own paste
498
+ // convention — there is no CapCut Duplicate to match directly (Cmd+D is
499
+ // a no-op there); this is our own choice for consistency with paste.
500
+ // Select before reloading, same reason as handlePaste above.
501
+ if (ids.length > 0) {
502
+ usePlayerStore.getState().setSelection(ids.map((id) => elementKey(id, targetPath)));
503
+ }
504
+ reloadPreview();
505
+ showToast(clipToast("Duplicated", clips.length), "info");
506
+ return true;
507
+ } catch (error) {
508
+ const message = error instanceof Error ? error.message : "Failed to duplicate";
509
+ showToast(message);
510
+ return false;
484
511
  }
485
- reloadPreview();
486
- showToast(clipToast("Duplicated", clips.length), "info");
487
- return true;
488
- } catch (error) {
489
- const message = error instanceof Error ? error.message : "Failed to duplicate";
490
- showToast(message);
491
- return false;
492
- }
493
- }, [
494
- activeCompPath,
495
- findSelectedClips,
496
- readClips,
497
- recordEdit,
498
- reloadPreview,
499
- showToast,
500
- writeProjectFile,
501
- ]);
512
+ },
513
+ [
514
+ activeCompPath,
515
+ findSelectedClips,
516
+ readClips,
517
+ recordEdit,
518
+ reloadPreview,
519
+ showToast,
520
+ writeProjectFile,
521
+ ],
522
+ );
502
523
 
503
524
  const handleCut = useCallback(async (): Promise<boolean> => {
504
525
  const selected = getSelectedElements();