hyperframes 0.8.78 → 0.8.79

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 (34) hide show
  1. package/dist/cli.js +1071 -523
  2. package/dist/fontLocalizeCli.js +99 -33
  3. package/dist/hyperframe-runtime.js +54 -54
  4. package/dist/hyperframe.manifest.json +1 -1
  5. package/dist/hyperframe.runtime.iife.js +54 -54
  6. package/dist/hyperframes-player.global.js +3 -3
  7. package/dist/skills/media-use/scripts/compatibility.test.mjs +2 -0
  8. package/dist/skills/media-use/scripts/lib/cache.mjs +6 -8
  9. package/dist/skills/media-use/scripts/lib/candidates.test.mjs +2 -2
  10. package/dist/skills/media-use/scripts/lib/manifest.test.mjs +2 -0
  11. package/dist/skills/media-use/scripts/lib/media-home.mjs +20 -0
  12. package/dist/skills/media-use/scripts/lib/media-home.test.mjs +61 -0
  13. package/dist/skills/media-use/scripts/lib/misses.mjs +6 -6
  14. package/dist/skills/media-use/scripts/lib/misses.test.mjs +12 -1
  15. package/dist/skills/media-use/scripts/lib/prefs-store.mjs +2 -2
  16. package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +1 -1
  17. package/dist/skills/media-use/scripts/lib/recipe-store.mjs +2 -2
  18. package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +1 -1
  19. package/dist/skills/media-use/scripts/lib/stats.test.mjs +1 -1
  20. package/dist/skills/media-use/scripts/lib/telemetry.mjs +2 -1
  21. package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +1 -0
  22. package/dist/studio/assets/{hyperframes-player-BdnQGwG5.js → hyperframes-player-Bx_Kw4Ey.js} +3 -3
  23. package/dist/studio/assets/{index-C_n__Uh3.js → index-CJP92F4-.js} +1 -1
  24. package/dist/studio/assets/{index-CX_4MYbk.js → index-DBKySIo7.js} +187 -187
  25. package/dist/studio/assets/{index-DdpuHaGN.js → index-ORZXYB0k.js} +1 -1
  26. package/dist/studio/{chunk-5VANA2TG.js → chunk-I7XNAIOF.js} +2 -1
  27. package/dist/studio/{domEditingLayers-IGPLTI5E.js → domEditingLayers-MHFDKAQ7.js} +2 -2
  28. package/dist/studio/index.d.ts +22 -15
  29. package/dist/studio/index.html +1 -1
  30. package/dist/studio/index.js +452 -256
  31. package/dist/studio/index.js.map +1 -1
  32. package/package.json +1 -1
  33. /package/dist/studio/{chunk-5VANA2TG.js.map → chunk-I7XNAIOF.js.map} +0 -0
  34. /package/dist/studio/{domEditingLayers-IGPLTI5E.js.map → domEditingLayers-MHFDKAQ7.js.map} +0 -0
@@ -21,6 +21,7 @@ import {
21
21
  getDomEditTargetKey,
22
22
  getDomLayerPatchTarget,
23
23
  getSelectorIndex,
24
+ getSourceFileForElement,
24
25
  getSourceScopedSelectorIndex,
25
26
  isElementComputedVisible,
26
27
  isElementVisibleThroughAncestors,
@@ -34,7 +35,7 @@ import {
34
35
  resolveDomEditSelection,
35
36
  serializeDomEditTextFields,
36
37
  setCompositionSourceMap
37
- } from "./chunk-5VANA2TG.js";
38
+ } from "./chunk-I7XNAIOF.js";
38
39
  import {
39
40
  __export
40
41
  } from "./chunk-PZ5AY32C.js";
@@ -4179,6 +4180,7 @@ var Player = forwardRef3(
4179
4180
  }
4180
4181
  player.setAttribute("shader-capture-scale", "1");
4181
4182
  player.setAttribute("shader-loading", "player");
4183
+ player.setAttribute("low-power-idle", "");
4182
4184
  player.setAttribute("width", String(portrait ? 1080 : 1920));
4183
4185
  player.setAttribute("height", String(portrait ? 1920 : 1080));
4184
4186
  player.style.width = "100%";
@@ -22638,6 +22640,85 @@ async function saveProjectFilesWithHistory({
22638
22640
  // src/utils/gsapSoftReload.ts
22639
22641
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR as COLOR_GRADING_SOURCE_HIDDEN_ATTR2 } from "@hyperframes/core/color-grading";
22640
22642
 
22643
+ // src/utils/authoredSource.ts
22644
+ import { ensureHfIds, isCompositionTemplate } from "@hyperframes/parsers/hf-ids";
22645
+ function parseSavedSource(html2) {
22646
+ return new DOMParser().parseFromString(ensureHfIds(html2), "text/html");
22647
+ }
22648
+ var rootsByDoc = /* @__PURE__ */ new WeakMap();
22649
+ function searchRoots(root) {
22650
+ const templates = Array.from(root.querySelectorAll("template")).filter(isCompositionTemplate);
22651
+ return [root, ...templates.flatMap((template) => searchRoots(template.content))];
22652
+ }
22653
+ function findByAttribute(doc, name, value) {
22654
+ let roots = rootsByDoc.get(doc);
22655
+ if (!roots) rootsByDoc.set(doc, roots = searchRoots(doc));
22656
+ const selector = `[${name}="${value.replace(/["\\]/g, "\\$&")}"]`;
22657
+ for (const root of roots) {
22658
+ const match = root.querySelector(selector);
22659
+ if (match) return match;
22660
+ }
22661
+ return null;
22662
+ }
22663
+ function findAuthoredElement(doc, live) {
22664
+ const hfId = live.getAttribute("data-hf-id");
22665
+ if (hfId) return findByAttribute(doc, "data-hf-id", hfId);
22666
+ return live.id ? findByAttribute(doc, "id", live.id) : null;
22667
+ }
22668
+ function findAuthoredElementById(doc, live) {
22669
+ return live.id ? findByAttribute(doc, "id", live.id) : null;
22670
+ }
22671
+ var PATH_ATTRS = ["src", "href"];
22672
+ var CSS_URL = /\burl\(\s*(["']?)([^)"'\s](?:[^)"']*[^)"'\s])?)\1\s*\)/g;
22673
+ function isRelative(path) {
22674
+ return !!path && !/^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(path);
22675
+ }
22676
+ function toProjectPath(sourceFile, path) {
22677
+ const parts = sourceFile.split("/").slice(0, -1);
22678
+ for (const part of path.split("/")) {
22679
+ if (part === "..") parts.pop();
22680
+ else if (part !== "." && part !== "") parts.push(part);
22681
+ }
22682
+ return parts.join("/");
22683
+ }
22684
+ function livePathsOf(live) {
22685
+ const paths = /* @__PURE__ */ new Set();
22686
+ for (const el of [live, ...Array.from(live.querySelectorAll("*"))]) {
22687
+ for (const attr of PATH_ATTRS) paths.add(el.getAttribute(attr) ?? "");
22688
+ for (const match of (el.getAttribute("style") ?? "").matchAll(CSS_URL)) paths.add(match[2]);
22689
+ }
22690
+ return paths;
22691
+ }
22692
+ function authoredMarkup(authored, live, sourceFile) {
22693
+ if (!sourceFile.includes("/")) return authored.outerHTML;
22694
+ const livePaths = livePathsOf(live);
22695
+ const rebase = (path) => {
22696
+ if (!isRelative(path)) return path;
22697
+ const cut = path.search(/[?#]/);
22698
+ const suffix = cut < 0 ? "" : path.slice(cut);
22699
+ const rebased = toProjectPath(sourceFile, cut < 0 ? path : path.slice(0, cut)) + suffix;
22700
+ return path.startsWith("../") || livePaths.has(rebased) ? rebased : path;
22701
+ };
22702
+ const copy = authored.cloneNode(true);
22703
+ for (const el of [copy, ...Array.from(copy.querySelectorAll("*"))]) {
22704
+ for (const attr of PATH_ATTRS) {
22705
+ const value = el.getAttribute(attr);
22706
+ if (value) el.setAttribute(attr, rebase(value.trim()));
22707
+ }
22708
+ const style = el.getAttribute("style");
22709
+ if (style) {
22710
+ el.setAttribute(
22711
+ "style",
22712
+ style.replace(
22713
+ CSS_URL,
22714
+ (_, quote, url) => `url(${quote}${rebase(url)}${quote})`
22715
+ )
22716
+ );
22717
+ }
22718
+ }
22719
+ return copy.outerHTML;
22720
+ }
22721
+
22641
22722
  // src/utils/authoredOpacity.ts
22642
22723
  import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading";
22643
22724
  function readStampedAuthoredOpacity(element) {
@@ -22751,15 +22832,12 @@ function applySoftReload(iframe, scriptText, options = {}) {
22751
22832
  const findAuthoredSource = (el) => {
22752
22833
  if (authoredDoc === void 0) {
22753
22834
  try {
22754
- authoredDoc = authoredHtml ? new DOMParser().parseFromString(authoredHtml, "text/html") : null;
22835
+ authoredDoc = authoredHtml ? parseSavedSource(authoredHtml) : null;
22755
22836
  } catch {
22756
22837
  authoredDoc = null;
22757
22838
  }
22758
22839
  }
22759
- if (!authoredDoc) return null;
22760
- const hfId = el.getAttribute("data-hf-id");
22761
- if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
22762
- return el.id ? authoredDoc.getElementById(el.id) : null;
22840
+ return authoredDoc ? findAuthoredElement(authoredDoc, el) : null;
22763
22841
  };
22764
22842
  const readAuthoredOpacity = (el) => {
22765
22843
  const source = findAuthoredSource(el);
@@ -26155,6 +26233,7 @@ async function decodeFrames(sink, timestamps, signal, resources) {
26155
26233
  throwIfAborted(signal);
26156
26234
  resources.urls.push(URL.createObjectURL(blob));
26157
26235
  }
26236
+ throwIfAborted(signal);
26158
26237
  }
26159
26238
  function loadedResult(resources, aspect, width, height) {
26160
26239
  const firstUrl = resources.urls[0];
@@ -26188,8 +26267,12 @@ async function decodeVideoThumbnail(request, signal, budgets = TIMELINE_VIEWPORT
26188
26267
  }
26189
26268
  const metadataDuration = await track2.getDurationFromMetadata({ skipLiveWait: true });
26190
26269
  throwIfAborted(signal);
26191
- const sourceDuration = Math.max(0, metadataDuration ?? request.sourceRangeDuration ?? 0);
26192
- const sourceStart = Math.min(Math.max(0, request.sourceStart ?? 0), sourceDuration);
26270
+ const requestedStart = Math.max(0, request.sourceStart ?? 0);
26271
+ const sourceDuration = Math.max(
26272
+ 0,
26273
+ metadataDuration ?? requestedStart + (request.sourceRangeDuration ?? 0)
26274
+ );
26275
+ const sourceStart = Math.min(requestedStart, sourceDuration);
26193
26276
  const requestedDuration = request.sourceRangeDuration ?? Math.max(0, sourceDuration - sourceStart);
26194
26277
  const duration = Math.min(
26195
26278
  Math.max(0, requestedDuration),
@@ -26200,6 +26283,16 @@ async function decodeVideoThumbnail(request, signal, budgets = TIMELINE_VIEWPORT
26200
26283
  duration,
26201
26284
  Math.min(request.frameCount, budgets.richPreviewFrameCount)
26202
26285
  );
26286
+ const keys = new mediabunny.EncodedPacketSink(track2);
26287
+ const maxKeyframeLead = duration / Math.max(2, timestamps.length - 1) / 2;
26288
+ async function* decodeTimesAtNearbyKeyframes() {
26289
+ for (const time of timestamps) {
26290
+ const key2 = await keys.getKeyPacket(time, { metadataOnly: true });
26291
+ if (signal.aborted) return;
26292
+ const near2 = key2 && key2.timestamp >= sourceStart && time - key2.timestamp <= maxKeyframeLead;
26293
+ yield near2 ? key2.timestamp : time;
26294
+ }
26295
+ }
26203
26296
  const aspect = displayWidth / displayHeight;
26204
26297
  const target = targetDimensions(aspect, budgets);
26205
26298
  const sink = new mediabunny.CanvasSink(track2, {
@@ -26208,7 +26301,7 @@ async function decodeVideoThumbnail(request, signal, budgets = TIMELINE_VIEWPORT
26208
26301
  fit: request.fit ?? "cover",
26209
26302
  poolSize: 1
26210
26303
  });
26211
- await decodeFrames(sink, timestamps, signal, resources);
26304
+ await decodeFrames(sink, decodeTimesAtNearbyKeyframes(), signal, resources);
26212
26305
  return loadedResult(resources, aspect, target.width, target.height);
26213
26306
  } catch (error) {
26214
26307
  releaseDecodedResources(resources);
@@ -26398,7 +26491,7 @@ var VideoThumbnail = memo17(function VideoThumbnail2({
26398
26491
  useMountEffect(() => () => observerRef.current?.disconnect());
26399
26492
  return /* @__PURE__ */ jsxs59("div", { ref: setContainerRef, className: "absolute inset-0 overflow-hidden", children: [
26400
26493
  urls.length > 0 && /* @__PURE__ */ jsx84("div", { className: "absolute inset-0 flex", children: Array.from({ length: frameCount }, (_, index) => {
26401
- const src = urls[index % urls.length];
26494
+ const src = urls[Math.round(index * (urls.length - 1) / Math.max(1, frameCount - 1))];
26402
26495
  return /* @__PURE__ */ jsx84(
26403
26496
  "div",
26404
26497
  {
@@ -28236,24 +28329,32 @@ function useTimelineSyncCallbacks({
28236
28329
  if (initializeAdapter(context)) return;
28237
28330
  const iframe = iframeRef.current;
28238
28331
  let settled = false;
28332
+ let retryFrame = 0;
28333
+ const stopWaiting = () => {
28334
+ window.removeEventListener("message", onMessage);
28335
+ cancelAnimationFrame(retryFrame);
28336
+ };
28239
28337
  const trySettle = () => {
28240
28338
  if (settled) return;
28241
28339
  if (initializeAdapter(context)) {
28242
28340
  settled = true;
28243
- window.removeEventListener("message", onMessage);
28341
+ stopWaiting();
28244
28342
  if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
28343
+ return;
28245
28344
  }
28345
+ cancelAnimationFrame(retryFrame);
28346
+ retryFrame = requestAnimationFrame(trySettle);
28246
28347
  };
28247
28348
  const onMessage = (e) => {
28248
28349
  if (isPreviewReadinessMessage(e, iframe)) trySettle();
28249
28350
  };
28250
28351
  window.addEventListener("message", onMessage);
28251
- stopWaitingRef.current = () => window.removeEventListener("message", onMessage);
28352
+ stopWaitingRef.current = stopWaiting;
28252
28353
  probeIntervalRef.current = setTimeout(() => {
28253
28354
  if (!settled) {
28254
28355
  trySettle();
28255
28356
  }
28256
- window.removeEventListener("message", onMessage);
28357
+ stopWaiting();
28257
28358
  if (!settled) onLoadGiveUp?.(context);
28258
28359
  revealIframe(iframeRef.current);
28259
28360
  }, 5e3);
@@ -29218,7 +29319,12 @@ function useTimelinePlayer({
29218
29319
  pause,
29219
29320
  seek
29220
29321
  });
29221
- const { processTimelineMessageRef, enrichMissingCompositionsRef, onIframeLoad } = useTimelineSyncCallbacks({
29322
+ const {
29323
+ processTimelineMessageRef,
29324
+ enrichMissingCompositionsRef,
29325
+ onIframeLoad,
29326
+ cancelPendingLoad
29327
+ } = useTimelineSyncCallbacks({
29222
29328
  iframeRef,
29223
29329
  probeIntervalRef,
29224
29330
  pendingSeekRef,
@@ -29289,8 +29395,8 @@ function useTimelinePlayer({
29289
29395
  applyPreviewVariablesToUrl(url);
29290
29396
  beginShadowReload(url.toString());
29291
29397
  }, [saveSeekPosition, getAdapter, beginShadowReload]);
29292
- const getAdapterRef = useRef53(getAdapter);
29293
- getAdapterRef.current = getAdapter;
29398
+ const pauseRef = useRef53(pause);
29399
+ pauseRef.current = pause;
29294
29400
  useMountEffect(() => {
29295
29401
  const handleWindowKeyDown = (e) => playbackKeyDownRef.current(e);
29296
29402
  const handleWindowKeyUp = (e) => playbackKeyUpRef.current(e);
@@ -29303,14 +29409,7 @@ function useTimelinePlayer({
29303
29409
  syncTimelineElements
29304
29410
  });
29305
29411
  const handleVisibilityChange = () => {
29306
- if (document.hidden && usePlayerStore.getState().isPlaying) {
29307
- const adapter = getAdapterRef.current?.();
29308
- if (adapter) {
29309
- adapter.pause();
29310
- setIsPlaying(false);
29311
- stopRAFLoop();
29312
- }
29313
- }
29412
+ if (document.hidden && usePlayerStore.getState().isPlaying) pauseRef.current();
29314
29413
  };
29315
29414
  window.addEventListener("keydown", handleWindowKeyDown, true);
29316
29415
  window.addEventListener("keyup", handleWindowKeyUp, true);
@@ -29327,15 +29426,15 @@ function useTimelinePlayer({
29327
29426
  stopReverseLoop();
29328
29427
  stopScrubPreviewAudio();
29329
29428
  releaseStaticSeekCache(staticSeekAdapterRef, staticSeekWarnedRef);
29330
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
29429
+ cancelPendingLoad();
29331
29430
  };
29332
29431
  });
29333
29432
  const resetPlayer = useCallback41(() => {
29334
29433
  stopRAFLoop();
29335
29434
  stopReverseLoop();
29336
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
29435
+ cancelPendingLoad();
29337
29436
  usePlayerStore.getState().reset();
29338
- }, [stopRAFLoop, stopReverseLoop]);
29437
+ }, [stopRAFLoop, stopReverseLoop, cancelPendingLoad]);
29339
29438
  useEffect36(() => {
29340
29439
  return usePlayerStore.subscribe((state, prev) => {
29341
29440
  const playbackRateChanged = state.playbackRate !== prev.playbackRate;
@@ -34111,7 +34210,7 @@ function OffCanvasIndicators({
34111
34210
  const selectOffCanvas = async () => {
34112
34211
  const el = elements.current.get(r.key);
34113
34212
  if (!el) return;
34114
- const { resolveDomEditSelection: resolveDomEditSelection2 } = await import("./domEditingLayers-IGPLTI5E.js");
34213
+ const { resolveDomEditSelection: resolveDomEditSelection2 } = await import("./domEditingLayers-MHFDKAQ7.js");
34115
34214
  const acp = activeCompositionPathRef.current ?? "index.html";
34116
34215
  const sel = await resolveDomEditSelection2(el, {
34117
34216
  activeCompositionPath: acp,
@@ -71667,7 +71766,43 @@ function useTimelineAssetDropOps({
71667
71766
  }
71668
71767
 
71669
71768
  // src/hooks/timelineAudioGroupVolume.ts
71670
- import { useCallback as useCallback106, useRef as useRef139 } from "react";
71769
+ import { useCallback as useCallback107, useRef as useRef139 } from "react";
71770
+
71771
+ // src/hooks/timelineEditPermission.ts
71772
+ import { useCallback as useCallback106 } from "react";
71773
+ function projectForTimelineSave(isRecording, projectId, showToast) {
71774
+ if (isRecording) {
71775
+ showToast("Cannot edit timeline while recording", "error");
71776
+ return { status: "refused", reason: "Cannot edit timeline while recording" };
71777
+ }
71778
+ return projectId ?? { status: "failed", reason: "No project is open" };
71779
+ }
71780
+ function timelineEditRefusal(canEdit, targets) {
71781
+ for (const element of targets) {
71782
+ const verdict = canEdit?.(element) ?? true;
71783
+ if (verdict !== true) return verdict.reason;
71784
+ }
71785
+ return null;
71786
+ }
71787
+ function useTimelineEditRefusal(canEdit, showToast) {
71788
+ return useCallback106(
71789
+ (targets) => {
71790
+ const reason = timelineEditRefusal(canEdit, targets);
71791
+ if (reason !== null) showToast(reason, "error");
71792
+ return reason;
71793
+ },
71794
+ [canEdit, showToast]
71795
+ );
71796
+ }
71797
+ function useTimelineEditGate(canEdit, showToast) {
71798
+ const refuse = useTimelineEditRefusal(canEdit, showToast);
71799
+ return useCallback106(
71800
+ (targets) => refuse(targets) === null,
71801
+ [refuse]
71802
+ );
71803
+ }
71804
+
71805
+ // src/hooks/timelineAudioGroupVolume.ts
71671
71806
  import { HF_AUDIO_FX_ATTR as HF_AUDIO_FX_ATTR8 } from "@hyperframes/core/audio-fx";
71672
71807
  import { HF_AUDIO_AUTOMATION_ATTR as HF_AUDIO_AUTOMATION_ATTR5 } from "@hyperframes/core/audio-automation";
71673
71808
  function patchLiveGroupAttribute(iframe, groupId, attr, value) {
@@ -71734,7 +71869,7 @@ async function setAudioGroupAttribute({
71734
71869
  const groupEl = previewIframe?.contentDocument?.getElementById(groupId) ?? null;
71735
71870
  const targetPath = resolveGroupSourceFile(groupEl) || activeCompPath || "index.html";
71736
71871
  const patchTarget = buildPatchTarget({ domId: groupId });
71737
- if (!patchTarget) return [];
71872
+ if (!patchTarget) return null;
71738
71873
  return persistElementAttribute({
71739
71874
  projectId,
71740
71875
  targetPath,
@@ -71759,7 +71894,7 @@ function useSetAudioGroupAttribute({
71759
71894
  isRecordingRef
71760
71895
  }) {
71761
71896
  const liveBeforeRef = useRef139(/* @__PURE__ */ new Map());
71762
- const setLive = useCallback106(
71897
+ const setLive = useCallback107(
71763
71898
  (groupId, attr, value) => {
71764
71899
  const key2 = audioGroupAttributeLiveKey(groupId, attr);
71765
71900
  const target = previewIframeRef.current?.contentDocument?.getElementById(groupId);
@@ -71771,7 +71906,7 @@ function useSetAudioGroupAttribute({
71771
71906
  },
71772
71907
  [previewIframeRef]
71773
71908
  );
71774
- const revertLive = useCallback106(
71909
+ const revertLive = useCallback107(
71775
71910
  (groupId, attr) => {
71776
71911
  const key2 = audioGroupAttributeLiveKey(groupId, attr);
71777
71912
  if (!liveBeforeRef.current.has(key2)) return;
@@ -71790,16 +71925,12 @@ function useSetAudioGroupAttribute({
71790
71925
  },
71791
71926
  [previewIframeRef]
71792
71927
  );
71793
- const setQuiet = useCallback106(
71928
+ const setQuiet = useCallback107(
71794
71929
  async (groupId, attr, value, label) => {
71795
- if (isRecordingRef?.current) {
71796
- showToast("Cannot edit timeline while recording", "error");
71797
- return;
71798
- }
71799
- const pid = projectIdRef.current;
71800
- if (!pid) return;
71930
+ const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
71931
+ if (typeof pid !== "string") return pid;
71801
71932
  try {
71802
- await setAudioGroupAttribute({
71933
+ const written = await setAudioGroupAttribute({
71803
71934
  projectId: pid,
71804
71935
  activeCompPath,
71805
71936
  groupId,
@@ -71812,7 +71943,9 @@ function useSetAudioGroupAttribute({
71812
71943
  pendingTimelineEditPathRef
71813
71944
  });
71814
71945
  liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
71946
+ if (!written) return { status: "failed", reason: "This group has no id to save it by" };
71815
71947
  syncStoredGroupAttribute(groupId, attr, value);
71948
+ return { status: "saved" };
71816
71949
  } catch (error) {
71817
71950
  const live = previewIframeRef.current?.contentDocument?.getElementById(groupId);
71818
71951
  syncStoredGroupAttribute(groupId, attr, live?.getAttribute(attr) ?? null);
@@ -71820,6 +71953,7 @@ function useSetAudioGroupAttribute({
71820
71953
  const message = error instanceof Error ? error.message : "Failed to update group";
71821
71954
  showToast(message);
71822
71955
  liveBeforeRef.current.delete(audioGroupAttributeLiveKey(groupId, attr));
71956
+ return { status: "failed", reason: message };
71823
71957
  }
71824
71958
  },
71825
71959
  [
@@ -71837,7 +71971,7 @@ function useSetAudioGroupAttribute({
71837
71971
  }
71838
71972
 
71839
71973
  // src/hooks/timelineElementFxAttribute.ts
71840
- import { useCallback as useCallback107, useRef as useRef140 } from "react";
71974
+ import { useCallback as useCallback108, useRef as useRef140 } from "react";
71841
71975
  function patchLiveElementAttribute(iframe, element, attr, value, activeCompPath) {
71842
71976
  const target = findTimelineElementInIframe(iframe, element, activeCompPath);
71843
71977
  if (!target) return;
@@ -71861,7 +71995,7 @@ async function setElementAttribute({
71861
71995
  }) {
71862
71996
  const targetPath = element.sourceFile || activeCompPath || "index.html";
71863
71997
  const patchTarget = buildPatchTarget(element);
71864
- if (!patchTarget) return [];
71998
+ if (!patchTarget) return null;
71865
71999
  return persistElementAttribute({
71866
72000
  projectId,
71867
72001
  targetPath,
@@ -71886,7 +72020,7 @@ function useSetElementAttribute({
71886
72020
  isRecordingRef
71887
72021
  }) {
71888
72022
  const liveBeforeRef = useRef140(/* @__PURE__ */ new Map());
71889
- const setLive = useCallback107(
72023
+ const setLive = useCallback108(
71890
72024
  (element, attr, value) => {
71891
72025
  const key2 = elementAttributeLiveKey(element, activeCompPath, attr);
71892
72026
  const target = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
@@ -71897,7 +72031,7 @@ function useSetElementAttribute({
71897
72031
  },
71898
72032
  [previewIframeRef, activeCompPath]
71899
72033
  );
71900
- const revertLive = useCallback107(
72034
+ const revertLive = useCallback108(
71901
72035
  (element, attr) => {
71902
72036
  const key2 = elementAttributeLiveKey(element, activeCompPath, attr);
71903
72037
  if (!liveBeforeRef.current.has(key2)) return;
@@ -71912,16 +72046,13 @@ function useSetElementAttribute({
71912
72046
  },
71913
72047
  [previewIframeRef, activeCompPath]
71914
72048
  );
71915
- const setQuiet = useCallback107(
72049
+ const setQuiet = useCallback108(
71916
72050
  async (element, attr, value, label) => {
71917
- if (isRecordingRef?.current) {
71918
- showToast("Cannot edit timeline while recording", "error");
71919
- return;
71920
- }
71921
- const pid = projectIdRef.current;
71922
- if (!pid) return;
72051
+ const pid = projectForTimelineSave(isRecordingRef?.current, projectIdRef.current, showToast);
72052
+ if (typeof pid !== "string") return pid;
72053
+ const liveKey = elementAttributeLiveKey(element, activeCompPath, attr);
71923
72054
  try {
71924
- await setElementAttribute({
72055
+ const written = await setElementAttribute({
71925
72056
  projectId: pid,
71926
72057
  activeCompPath,
71927
72058
  element,
@@ -71933,12 +72064,15 @@ function useSetElementAttribute({
71933
72064
  recordEdit,
71934
72065
  pendingTimelineEditPathRef
71935
72066
  });
71936
- liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
72067
+ liveBeforeRef.current.delete(liveKey);
72068
+ if (written) return { status: "saved" };
72069
+ return { status: "failed", reason: "This clip has no id to save it by" };
71937
72070
  } catch (error) {
71938
72071
  console.error("[Timeline] Failed to set element attribute", error);
71939
72072
  const message = error instanceof Error ? error.message : "Failed to update effect";
71940
72073
  showToast(message);
71941
- liveBeforeRef.current.delete(elementAttributeLiveKey(element, activeCompPath, attr));
72074
+ liveBeforeRef.current.delete(liveKey);
72075
+ return { status: "failed", reason: message };
71942
72076
  }
71943
72077
  },
71944
72078
  [
@@ -71956,7 +72090,7 @@ function useSetElementAttribute({
71956
72090
  }
71957
72091
 
71958
72092
  // src/hooks/useTimelineDeleteOps.ts
71959
- import { useCallback as useCallback108, useRef as useRef141 } from "react";
72093
+ import { useCallback as useCallback109, useRef as useRef141 } from "react";
71960
72094
  function applyRippleShifts(survivors, changes) {
71961
72095
  if (!changes) return survivors;
71962
72096
  const newStartByElement = new Map(changes.map((c) => [c.element, c.start]));
@@ -71980,7 +72114,7 @@ function useTimelineDeleteOps({
71980
72114
  handleTimelineGroupMove
71981
72115
  }) {
71982
72116
  const rippleNoticeShownRef = useRef141(false);
71983
- const handleTimelineElementsDelete = useCallback108(
72117
+ const handleTimelineElementsDelete = useCallback109(
71984
72118
  // fallow-ignore-next-line complexity
71985
72119
  async (selection) => {
71986
72120
  if (isRecordingRef?.current) {
@@ -72113,7 +72247,7 @@ function useTimelineDeleteOps({
72113
72247
  handleTimelineGroupMove
72114
72248
  ]
72115
72249
  );
72116
- const handleTimelineElementDelete = useCallback108(
72250
+ const handleTimelineElementDelete = useCallback109(
72117
72251
  async (element) => {
72118
72252
  await handleTimelineElementsDelete([element]);
72119
72253
  },
@@ -72123,10 +72257,10 @@ function useTimelineDeleteOps({
72123
72257
  }
72124
72258
 
72125
72259
  // src/hooks/useTrackPendingTimelineEdit.ts
72126
- import { useCallback as useCallback109, useRef as useRef142 } from "react";
72260
+ import { useCallback as useCallback110, useRef as useRef142 } from "react";
72127
72261
  function useTrackPendingTimelineEdit() {
72128
72262
  const wrappedRef = useRef142(/* @__PURE__ */ new WeakMap());
72129
- return useCallback109((fn) => {
72263
+ return useCallback110((fn) => {
72130
72264
  const key2 = fn;
72131
72265
  const cached5 = wrappedRef.current.get(key2);
72132
72266
  if (cached5) return cached5;
@@ -72141,11 +72275,11 @@ function useTrackPendingTimelineEdit() {
72141
72275
  }
72142
72276
 
72143
72277
  // src/hooks/timelineAudioGroupCreate.ts
72144
- import { useCallback as useCallback111 } from "react";
72278
+ import { useCallback as useCallback112 } from "react";
72145
72279
  import { HF_AUDIO_GROUP_ATTR as HF_AUDIO_GROUP_ATTR4, HF_AUDIO_GROUP_TAG as HF_AUDIO_GROUP_TAG6 } from "@hyperframes/core/audio-groups";
72146
72280
 
72147
72281
  // src/hooks/timelineTrackVisibility.ts
72148
- import { useCallback as useCallback110 } from "react";
72282
+ import { useCallback as useCallback111 } from "react";
72149
72283
  function getTimelineElementTargetPath(element, activeCompPath) {
72150
72284
  return element.sourceFile || activeCompPath || "index.html";
72151
72285
  }
@@ -72305,7 +72439,7 @@ function useTimelineTrackVisibilityEditing({
72305
72439
  forceReloadSdkSession
72306
72440
  }) {
72307
72441
  const timelineElements = usePlayerStore((state) => state.elements);
72308
- return useCallback110(
72442
+ return useCallback111(
72309
72443
  async (track2, hidden, displayNumber) => {
72310
72444
  if (isRecordingRef?.current) {
72311
72445
  showToast("Cannot edit timeline while recording", "error");
@@ -72359,7 +72493,7 @@ function useTimelineElementVisibilityEditing({
72359
72493
  forceReloadSdkSession
72360
72494
  }) {
72361
72495
  const timelineElements = usePlayerStore((state) => state.elements);
72362
- return useCallback110(
72496
+ return useCallback111(
72363
72497
  async (elementKey3, hidden) => {
72364
72498
  if (isRecordingRef?.current) {
72365
72499
  showToast("Cannot edit timeline while recording", "error");
@@ -72552,7 +72686,7 @@ function useAudioGroupCarveAssignment({
72552
72686
  checkEditable
72553
72687
  }) {
72554
72688
  const timelineElements = usePlayerStore((state) => state.elements);
72555
- return useCallback111(
72689
+ return useCallback112(
72556
72690
  async (clipIds, groupId, groupLabel2) => {
72557
72691
  if (isRecordingRef?.current) {
72558
72692
  showToast("Cannot edit timeline while recording", "error");
@@ -72607,7 +72741,7 @@ function useAudioGroupCarveAssignment({
72607
72741
  }
72608
72742
 
72609
72743
  // src/hooks/useTimelineGroupEditing.ts
72610
- import { useCallback as useCallback112 } from "react";
72744
+ import { useCallback as useCallback113 } from "react";
72611
72745
  function targetPathFor(element, activeCompPath) {
72612
72746
  return element.sourceFile || activeCompPath || "index.html";
72613
72747
  }
@@ -72646,7 +72780,7 @@ function useTimelineGroupEditing({
72646
72780
  showToast,
72647
72781
  writeProjectFile
72648
72782
  }) {
72649
- const enqueueGroupOperation = useCallback112(
72783
+ const enqueueGroupOperation = useCallback113(
72650
72784
  (label, operation) => {
72651
72785
  if (isRecordingRef?.current) {
72652
72786
  showToast("Cannot edit timeline while recording", "error");
@@ -72665,7 +72799,7 @@ function useTimelineGroupEditing({
72665
72799
  },
72666
72800
  [editQueueRef, isRecordingRef, projectIdRef, showToast]
72667
72801
  );
72668
- const persistServerBatch = useCallback112(
72802
+ const persistServerBatch = useCallback113(
72669
72803
  async (projectId, label, batchChanges, coalesceKey, coalesceMs) => {
72670
72804
  await persistTimelineBatchEdit({
72671
72805
  projectId,
@@ -72688,7 +72822,7 @@ function useTimelineGroupEditing({
72688
72822
  writeProjectFile
72689
72823
  ]
72690
72824
  );
72691
- const trySdkBatchPersist = useCallback112(
72825
+ const trySdkBatchPersist = useCallback113(
72692
72826
  async (input) => {
72693
72827
  const sharedPath = allChangesSharePath(input.changes, activeCompPath);
72694
72828
  const canUseSdk = !input.needsExtension && sharedPath !== null && input.eligible && input.sdkChanges.every((change) => change !== null);
@@ -72724,7 +72858,7 @@ function useTimelineGroupEditing({
72724
72858
  writeProjectFile
72725
72859
  ]
72726
72860
  );
72727
- const handleTimelineGroupMove = useCallback112(
72861
+ const handleTimelineGroupMove = useCallback113(
72728
72862
  (changes, options) => {
72729
72863
  if (changes.length === 0) return Promise.resolve();
72730
72864
  for (const change of changes) {
@@ -72818,7 +72952,7 @@ function useTimelineGroupEditing({
72818
72952
  invalidateGsapCache
72819
72953
  ]
72820
72954
  );
72821
- const handleTimelineGroupResize = useCallback112(
72955
+ const handleTimelineGroupResize = useCallback113(
72822
72956
  (changes, options) => {
72823
72957
  if (changes.length === 0) return Promise.resolve();
72824
72958
  for (const change of changes) {
@@ -72920,11 +73054,11 @@ function useTimelineGroupEditing({
72920
73054
  }
72921
73055
 
72922
73056
  // src/hooks/useBlockedTimelineEditToast.ts
72923
- import { useCallback as useCallback113, useRef as useRef143 } from "react";
73057
+ import { useCallback as useCallback114, useRef as useRef143 } from "react";
72924
73058
  var BLOCKED_TOAST_INTERVAL_MS = 1500;
72925
73059
  function useBlockedTimelineEditToast(showToast) {
72926
73060
  const lastAtRef = useRef143(0);
72927
- return useCallback113(
73061
+ return useCallback114(
72928
73062
  (_element) => {
72929
73063
  const now2 = Date.now();
72930
73064
  if (now2 - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return;
@@ -72935,25 +73069,6 @@ function useBlockedTimelineEditToast(showToast) {
72935
73069
  );
72936
73070
  }
72937
73071
 
72938
- // src/hooks/timelineEditPermission.ts
72939
- import { useCallback as useCallback114 } from "react";
72940
- function useTimelineEditGate(canEdit, showToast) {
72941
- return useCallback114(
72942
- (targets) => {
72943
- if (!canEdit) return true;
72944
- for (const element of targets) {
72945
- const verdict = canEdit(element);
72946
- if (verdict !== true) {
72947
- showToast(verdict.reason, "error");
72948
- return false;
72949
- }
72950
- }
72951
- return true;
72952
- },
72953
- [canEdit, showToast]
72954
- );
72955
- }
72956
-
72957
73072
  // src/hooks/useTimelineEditing.ts
72958
73073
  function useTimelineEditing({
72959
73074
  projectId,
@@ -72980,8 +73095,9 @@ function useTimelineEditing({
72980
73095
  const editQueueRef = useRef144(Promise.resolve());
72981
73096
  const track2 = useTrackPendingTimelineEdit();
72982
73097
  const checkEditable = useTimelineEditGate(canEdit, showToast);
72983
- const checkEditableRef = useRef144(checkEditable);
72984
- checkEditableRef.current = checkEditable;
73098
+ const refuseEdit = useTimelineEditRefusal(canEdit, showToast);
73099
+ const refuseEditRef = useRef144(refuseEdit);
73100
+ refuseEditRef.current = refuseEdit;
72985
73101
  const guardedRef = useRef144(/* @__PURE__ */ new WeakMap());
72986
73102
  const guard = useCallback115(
72987
73103
  (resolveTargets, fn, onRefused) => {
@@ -72996,10 +73112,9 @@ function useTimelineEditing({
72996
73112
  entry2.resolveTargets = resolveTargets;
72997
73113
  entry2.onRefused = onRefused;
72998
73114
  entry2.wrapped = ((...args) => {
72999
- if (!checkEditableRef.current(entry2.resolveTargets(...args))) {
73000
- entry2.onRefused?.(...args);
73001
- return Promise.resolve();
73002
- }
73115
+ const reason = refuseEditRef.current(entry2.resolveTargets(...args));
73116
+ if (reason !== null)
73117
+ return Promise.resolve(entry2.onRefused?.(reason, ...args));
73003
73118
  return fn(...args);
73004
73119
  });
73005
73120
  guardedRef.current.set(key2, entry2);
@@ -73340,6 +73455,22 @@ function useTimelineEditing({
73340
73455
  isRecordingRef,
73341
73456
  forceReloadSdkSession
73342
73457
  });
73458
+ const refused = (reason) => ({ status: "refused", reason });
73459
+ const audioGroupMembers = (groupId) => {
73460
+ const state = usePlayerStore.getState();
73461
+ const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
73462
+ const domMembers = state.domClipChildren.filter((child) => child.audioGroup === groupId).map(
73463
+ (child) => ({
73464
+ id: child.id,
73465
+ domId: child.id,
73466
+ tag: "div",
73467
+ start: 0,
73468
+ duration: 0,
73469
+ track: -1
73470
+ })
73471
+ );
73472
+ return [...flatMembers, ...domMembers];
73473
+ };
73343
73474
  const trackedRazorSplit = track2(guard((element) => [element], handleRazorSplit));
73344
73475
  return {
73345
73476
  handleTimelineElementMove: track2(guard((element) => [element], handleTimelineElementMove)),
@@ -73363,25 +73494,10 @@ function useTimelineEditing({
73363
73494
  // (timelineAudioGroupVolume.ts): a sub-composition's group members have
73364
73495
  // no flat twin, only a domClipChildren entry, so both are checked.
73365
73496
  setQuiet: track2(
73366
- guard(
73367
- (groupId) => {
73368
- const state = usePlayerStore.getState();
73369
- const flatMembers = state.elements.filter((el) => el.audioGroup === groupId);
73370
- const domMembers = state.domClipChildren.filter((child) => child.audioGroup === groupId).map(
73371
- (child) => ({
73372
- id: child.id,
73373
- domId: child.id,
73374
- tag: "div",
73375
- start: 0,
73376
- duration: 0,
73377
- track: -1
73378
- })
73379
- );
73380
- return [...flatMembers, ...domMembers];
73381
- },
73382
- setAudioGroupAttribute2.setQuiet,
73383
- (groupId, attr) => revertAudioGroupLive(groupId, attr)
73384
- )
73497
+ guard(audioGroupMembers, setAudioGroupAttribute2.setQuiet, (reason, groupId, attr) => {
73498
+ revertAudioGroupLive(groupId, attr);
73499
+ return refused(reason);
73500
+ })
73385
73501
  )
73386
73502
  },
73387
73503
  setElementFxAttribute: {
@@ -73390,7 +73506,10 @@ function useTimelineEditing({
73390
73506
  guard(
73391
73507
  (element) => [element],
73392
73508
  setElementFxAttribute.setQuiet,
73393
- (element, attr) => revertElementFxLive(element, attr)
73509
+ (reason, element, attr) => {
73510
+ revertElementFxLive(element, attr);
73511
+ return refused(reason);
73512
+ }
73394
73513
  )
73395
73514
  )
73396
73515
  },
@@ -81603,7 +81722,7 @@ function clipLabel(verb, clipCount) {
81603
81722
  function clipToast(verbPast, clipCount) {
81604
81723
  return clipCount > 1 ? `${verbPast} ${clipCount} clips` : `${verbPast} clip`;
81605
81724
  }
81606
- function getElementOuterHtml(iframeRef, selection, activeCompositionPath) {
81725
+ function getSelectedDomElement(iframeRef, selection, activeCompositionPath) {
81607
81726
  let doc = null;
81608
81727
  try {
81609
81728
  doc = iframeRef.current?.contentDocument ?? null;
@@ -81611,7 +81730,21 @@ function getElementOuterHtml(iframeRef, selection, activeCompositionPath) {
81611
81730
  return null;
81612
81731
  }
81613
81732
  if (!doc) return null;
81614
- return findElementForSelection(doc, selection, activeCompositionPath)?.outerHTML ?? null;
81733
+ return findElementForSelection(doc, selection, activeCompositionPath);
81734
+ }
81735
+ function savedMarkupElseLive(saved2, live, sourceFile) {
81736
+ const authored = findAuthoredElement(saved2, live) ?? findAuthoredElementById(saved2, live);
81737
+ return authored ? authoredMarkup(authored, live, sourceFile) : live.outerHTML;
81738
+ }
81739
+ async function readSavedMarkup(readSaved, paths, lives) {
81740
+ const sources = /* @__PURE__ */ new Map();
81741
+ return Promise.all(
81742
+ paths.map(async (path, index) => {
81743
+ if (!sources.has(path)) sources.set(path, readSaved(path).then(parseSavedSource));
81744
+ const saved2 = await sources.get(path);
81745
+ return savedMarkupElseLive(saved2, lives[index], path);
81746
+ })
81747
+ );
81615
81748
  }
81616
81749
  function tracksOverlap(a, b) {
81617
81750
  return a.track === b.track && timeRangesOverlap(a.start, a.start + a.duration, b.start, b.start + b.duration);
@@ -81685,68 +81818,111 @@ function useClipboard({
81685
81818
  reloadPreview,
81686
81819
  handleTimelineElementsDelete,
81687
81820
  handleDomEditElementDelete,
81688
- previewIframeRef
81821
+ previewIframeRef,
81822
+ waitForPendingDomEditSaves
81689
81823
  }) {
81690
81824
  const clipboardRef = useRef161(null);
81691
81825
  const projectIdRef = useRef161(projectId);
81692
81826
  projectIdRef.current = projectId;
81693
- const collectSelectedClips = useCallback145(() => {
81827
+ const readSaved = useCallback145(
81828
+ async (path) => {
81829
+ const pid = projectIdRef.current;
81830
+ if (!pid) throw new Error("No project is open.");
81831
+ await waitForPendingDomEditSaves().catch(() => {
81832
+ });
81833
+ return serializeStudioFileMutations(
81834
+ writeProjectFile,
81835
+ [path],
81836
+ () => readFileContent(pid, path)
81837
+ );
81838
+ },
81839
+ [waitForPendingDomEditSaves, writeProjectFile]
81840
+ );
81841
+ const findSelectedClips = useCallback145(() => {
81694
81842
  const selected = getSelectedElements();
81695
81843
  if (selected.length === 0) return null;
81696
- const clips = [];
81844
+ const lives = [];
81697
81845
  for (const element of selected) {
81698
- const html2 = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath)?.outerHTML ?? null;
81699
- if (!html2) {
81846
+ const live = findTimelineElementInIframe(previewIframeRef.current, element, activeCompPath);
81847
+ if (!live) {
81700
81848
  showToast(`Unable to copy "${element.label ?? element.id}".`, "info");
81701
81849
  return null;
81702
81850
  }
81703
- clips.push({
81704
- html: html2,
81851
+ lives.push(live);
81852
+ }
81853
+ return { elements: selected, lives };
81854
+ }, [activeCompPath, previewIframeRef, showToast]);
81855
+ const readClips = useCallback145(
81856
+ async (targets) => {
81857
+ const paths = targets.elements.map((el) => el.sourceFile || activeCompPath || "index.html");
81858
+ const markup = await readSavedMarkup(readSaved, paths, targets.lives);
81859
+ return targets.elements.map((element, index) => ({
81860
+ html: markup[index],
81705
81861
  start: element.start,
81706
81862
  duration: element.duration,
81707
81863
  // authoredTrack, not track: `track` can be a display-lane number
81708
81864
  // remapped by normalizeToZones, and writing THAT into data-track-index
81709
81865
  // re-targets the wrong track in the sparse authored file.
81710
81866
  track: element.authoredTrack ?? element.track
81711
- });
81712
- }
81713
- return { elements: selected, clips };
81714
- }, [activeCompPath, previewIframeRef, showToast]);
81715
- const handleCopy2 = useCallback145(() => {
81716
- if (usePlayerStore.getState().selectedElementId) {
81717
- const result = collectSelectedClips();
81718
- if (!result) return false;
81719
- const targetPath = result.elements[0]?.sourceFile || activeCompPath || "index.html";
81720
- clipboardRef.current = { kind: "timeline-clip", clips: result.clips, sourceFile: targetPath };
81721
- showToast(
81722
- result.clips.length > 1 ? `Copied ${result.clips.length} clips` : "Copied clip",
81723
- "info"
81724
- );
81725
- return true;
81726
- }
81727
- const domSelection = domEditSelectionRef.current;
81728
- if (domSelection) {
81729
- const html2 = getElementOuterHtml(previewIframeRef, domSelection, activeCompPath);
81730
- if (!html2) {
81867
+ }));
81868
+ },
81869
+ [activeCompPath, readSaved]
81870
+ );
81871
+ const copyTimelineSelection = useCallback145(() => {
81872
+ const targets = findSelectedClips();
81873
+ if (!targets) return null;
81874
+ const sourceFile = targets.elements[0]?.sourceFile || activeCompPath || "index.html";
81875
+ return readClips(targets).then((clips) => {
81876
+ showToast(clips.length > 1 ? `Copied ${clips.length} clips` : "Copied clip", "info");
81877
+ return { kind: "timeline-clip", clips, sourceFile };
81878
+ });
81879
+ }, [activeCompPath, findSelectedClips, readClips, showToast]);
81880
+ const copyDomSelection = useCallback145(
81881
+ (domSelection) => {
81882
+ const live = getSelectedDomElement(previewIframeRef, domSelection, activeCompPath);
81883
+ if (!live) {
81731
81884
  showToast("Unable to copy this element.", "info");
81732
- return false;
81885
+ return null;
81733
81886
  }
81734
- const targetPath = domSelection.sourceFile || activeCompPath || "index.html";
81735
- clipboardRef.current = {
81736
- kind: "dom-element",
81737
- html: html2,
81738
- sourceFile: targetPath,
81739
- originSelector: domSelection.selector,
81740
- originSelectorIndex: domSelection.selectorIndex
81741
- };
81742
- showToast("Copied element", "info");
81743
- return true;
81887
+ const sourceFile = domSelection.sourceFile || activeCompPath || "index.html";
81888
+ return readSaved(sourceFile).then((content) => {
81889
+ showToast("Copied element", "info");
81890
+ return {
81891
+ kind: "dom-element",
81892
+ html: savedMarkupElseLive(parseSavedSource(content), live, sourceFile),
81893
+ sourceFile,
81894
+ originSelector: domSelection.selector,
81895
+ originSelectorIndex: domSelection.selectorIndex
81896
+ };
81897
+ });
81898
+ },
81899
+ [activeCompPath, previewIframeRef, readSaved, showToast]
81900
+ );
81901
+ const copyToClipboard = useCallback145(() => {
81902
+ const domSelection = domEditSelectionRef.current;
81903
+ let pending2;
81904
+ if (usePlayerStore.getState().selectedElementId) pending2 = copyTimelineSelection();
81905
+ else if (domSelection) pending2 = copyDomSelection(domSelection);
81906
+ else {
81907
+ showToast("Nothing selected to copy.", "info");
81908
+ return null;
81744
81909
  }
81745
- showToast("Nothing selected to copy.", "info");
81746
- return false;
81747
- }, [activeCompPath, collectSelectedClips, domEditSelectionRef, previewIframeRef, showToast]);
81910
+ if (!pending2) return null;
81911
+ const own = pending2.catch((error) => {
81912
+ showToast(error instanceof Error ? error.message : "Failed to copy", "error");
81913
+ return null;
81914
+ });
81915
+ const previous = clipboardRef.current;
81916
+ const settled = own.then((payload) => payload ?? previous);
81917
+ clipboardRef.current = settled;
81918
+ void settled.then((payload) => {
81919
+ if (!payload && clipboardRef.current === settled) clipboardRef.current = null;
81920
+ });
81921
+ return own;
81922
+ }, [copyDomSelection, copyTimelineSelection, domEditSelectionRef, showToast]);
81923
+ const handleCopy2 = useCallback145(() => copyToClipboard() !== null, [copyToClipboard]);
81748
81924
  const handlePaste2 = useCallback145(async () => {
81749
- const payload = clipboardRef.current;
81925
+ const payload = await clipboardRef.current;
81750
81926
  if (!payload) {
81751
81927
  showToast("Nothing to paste.", "info");
81752
81928
  return;
@@ -81789,14 +81965,15 @@ function useClipboard({
81789
81965
  }
81790
81966
  }, [activeCompPath, recordEdit, reloadPreview, showToast, writeProjectFile]);
81791
81967
  const handleDuplicate = useCallback145(async () => {
81792
- const result = collectSelectedClips();
81793
- if (!result) return false;
81968
+ const targets = findSelectedClips();
81969
+ if (!targets) return false;
81794
81970
  const pid = projectIdRef.current;
81795
81971
  if (!pid) return false;
81796
- const { elements, clips } = result;
81972
+ const { elements } = targets;
81797
81973
  const targetPath = elements[0]?.sourceFile || activeCompPath || "index.html";
81798
81974
  const anchorTime = Math.max(...elements.map((el) => el.start + el.duration));
81799
81975
  try {
81976
+ const clips = await readClips(targets);
81800
81977
  const originalContent = await readFileContent(pid, targetPath);
81801
81978
  const liveElements = usePlayerStore.getState().elements;
81802
81979
  const pasted = pasteTimelineClips(originalContent, clips, anchorTime, liveElements);
@@ -81823,7 +82000,8 @@ function useClipboard({
81823
82000
  }
81824
82001
  }, [
81825
82002
  activeCompPath,
81826
- collectSelectedClips,
82003
+ findSelectedClips,
82004
+ readClips,
81827
82005
  recordEdit,
81828
82006
  reloadPreview,
81829
82007
  showToast,
@@ -81831,19 +82009,24 @@ function useClipboard({
81831
82009
  ]);
81832
82010
  const handleCut = useCallback145(async () => {
81833
82011
  const selected = getSelectedElements();
81834
- const copied = handleCopy2();
81835
- if (!copied) return false;
82012
+ const domSelection = domEditSelectionRef.current;
82013
+ const copied = copyToClipboard();
82014
+ if (!copied || !await copied) return false;
81836
82015
  if (selected.length > 0) {
81837
82016
  await handleTimelineElementsDelete(selected);
81838
82017
  return true;
81839
82018
  }
81840
- const domSelection = domEditSelectionRef.current;
81841
82019
  if (domSelection) {
81842
82020
  await handleDomEditElementDelete(domSelection);
81843
82021
  return true;
81844
82022
  }
81845
82023
  return true;
81846
- }, [handleCopy2, domEditSelectionRef, handleTimelineElementsDelete, handleDomEditElementDelete]);
82024
+ }, [
82025
+ copyToClipboard,
82026
+ domEditSelectionRef,
82027
+ handleTimelineElementsDelete,
82028
+ handleDomEditElementDelete
82029
+ ]);
81847
82030
  const canPaste = useCallback145(() => clipboardRef.current !== null, []);
81848
82031
  return { handleCopy: handleCopy2, handlePaste: handlePaste2, handleCut, handleDuplicate, canPaste };
81849
82032
  }
@@ -82283,16 +82466,24 @@ function useFrameCapture({
82283
82466
  showToast,
82284
82467
  waitForPendingDomEditSaves
82285
82468
  }) {
82286
- const [captureFrameTime, setCaptureFrameTime] = useState122(0);
82469
+ const [captureFrameTime, setCaptureFrameTime] = useState122(
82470
+ () => usePlayerStore.getState().currentTime
82471
+ );
82287
82472
  const [capturing, setCapturing] = useState122(false);
82288
82473
  const capturingRef = useRef164(false);
82289
- useMountEffect(() => {
82290
- setCaptureFrameTime(usePlayerStore.getState().currentTime);
82291
- return liveTime.subscribe(setCaptureFrameTime);
82292
- });
82293
- const refreshCaptureFrameTime = useCallback147(() => {
82294
- setCaptureFrameTime(usePlayerStore.getState().currentTime);
82474
+ const livePlayheadRef = useRef164(captureFrameTime);
82475
+ useMountEffect(
82476
+ () => liveTime.subscribe((time) => {
82477
+ livePlayheadRef.current = time;
82478
+ })
82479
+ );
82480
+ const playheadTime = useCallback147(() => {
82481
+ const { isPlaying, currentTime } = usePlayerStore.getState();
82482
+ return isPlaying ? livePlayheadRef.current : currentTime;
82295
82483
  }, []);
82484
+ const refreshCaptureFrameTime = useCallback147(() => {
82485
+ setCaptureFrameTime(playheadTime());
82486
+ }, [playheadTime]);
82296
82487
  const handleCaptureFrameClick = useCallback147(
82297
82488
  async (event) => {
82298
82489
  if (!projectId) return;
@@ -82301,7 +82492,7 @@ function useFrameCapture({
82301
82492
  capturingRef.current = true;
82302
82493
  setCapturing(true);
82303
82494
  try {
82304
- const time = usePlayerStore.getState().currentTime;
82495
+ const time = playheadTime();
82305
82496
  setCaptureFrameTime(time);
82306
82497
  await Promise.race([
82307
82498
  waitForPendingDomEditSaves(),
@@ -82352,7 +82543,7 @@ function useFrameCapture({
82352
82543
  setCapturing(false);
82353
82544
  }
82354
82545
  },
82355
- [activeCompPath, projectId, showToast, waitForPendingDomEditSaves]
82546
+ [activeCompPath, playheadTime, projectId, showToast, waitForPendingDomEditSaves]
82356
82547
  );
82357
82548
  const captureFrameHref = projectId ? buildFrameCaptureUrl({
82358
82549
  projectId,
@@ -92593,7 +92784,8 @@ function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason } = {}) {
92593
92784
  reloadPreview,
92594
92785
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
92595
92786
  handleDomEditElementDelete: domEditDeleteBridge,
92596
- previewIframeRef
92787
+ previewIframeRef,
92788
+ waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves
92597
92789
  });
92598
92790
  const appHotkeys = useAppHotkeys({
92599
92791
  handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
@@ -92978,39 +93170,12 @@ function useElementPicker(iframeRef, options) {
92978
93170
  if (!activeIframe) return;
92979
93171
  if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
92980
93172
  return;
92981
- if (data.type === "element-picked") {
92982
- const el = data.elementInfo;
92983
- if (el) {
92984
- const styles = readComputedStyles(activeIframe, el.selector);
92985
- setPickedElement({
92986
- id: el.id ?? null,
92987
- tagName: el.tagName ?? "div",
92988
- selector: el.selector ?? "",
92989
- label: el.label ?? el.tagName ?? "Element",
92990
- boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
92991
- textContent: el.textContent ?? null,
92992
- src: el.src ?? null,
92993
- dataAttributes: el.dataAttributes ?? {},
92994
- computedStyles: styles
92995
- });
92996
- setIsPickMode(false);
92997
- }
93173
+ if (data.type === "element-picked" && data.elementInfo) {
93174
+ setPickedElement(toPickedElement(data.elementInfo, activeIframe));
93175
+ setIsPickMode(false);
92998
93176
  } else if (data.type === "element-pick-candidates") {
92999
93177
  const el = data.candidates?.[data.selectedIndex ?? 0];
93000
- if (el) {
93001
- const styles = readComputedStyles(activeIframe, el.selector);
93002
- setPickedElement({
93003
- id: el.id ?? null,
93004
- tagName: el.tagName ?? "div",
93005
- selector: el.selector ?? "",
93006
- label: el.label ?? el.tagName ?? "Element",
93007
- boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
93008
- textContent: el.textContent ?? null,
93009
- src: el.src ?? null,
93010
- dataAttributes: el.dataAttributes ?? {},
93011
- computedStyles: styles
93012
- });
93013
- }
93178
+ if (el) setPickedElement(toPickedElement(el, activeIframe));
93014
93179
  }
93015
93180
  if (data.type === "pick-mode-cancelled") {
93016
93181
  setIsPickMode(false);
@@ -93021,19 +93186,19 @@ function useElementPicker(iframeRef, options) {
93021
93186
  });
93022
93187
  const optionsRef = useRef189(options);
93023
93188
  optionsRef.current = options;
93189
+ const pendingWritesRef = useRef189(/* @__PURE__ */ new Map());
93190
+ settlePendingWrites(pendingWritesRef.current, options?.workspaceFiles);
93024
93191
  const syncToSource = useCallback183(
93025
- (elementId2, selector, op) => {
93192
+ (picked, live, iframe, op) => {
93026
93193
  const opts = optionsRef.current;
93027
- if (!opts?.workspaceFiles || !opts.onSyncFiles || !elementId2) return;
93028
- const files = opts.workspaceFiles;
93029
- const sourceFile = resolveSourceFile(elementId2, selector, files);
93030
- if (!sourceFile || !files[sourceFile]) {
93031
- return;
93032
- }
93033
- const patched = applyPatch(files[sourceFile], elementId2, op);
93034
- if (patched !== files[sourceFile]) {
93035
- opts.onSyncFiles({ [sourceFile]: patched });
93036
- }
93194
+ if (!opts?.workspaceFiles || !opts.onSyncFiles) return;
93195
+ const hfId = live.getAttribute("data-hf-id");
93196
+ const pending2 = pendingWritesRef.current;
93197
+ const files = withPendingWrites(opts.workspaceFiles, pending2);
93198
+ const patch = picked.id ? patchById(files, picked.id, picked.selector, op) : hfId ? patchByHfId(files, hfId, ownSourceFile(live, iframe), op) : null;
93199
+ if (!patch || patch.after === patch.before) return;
93200
+ recordPendingWrite(pending2, patch.path, opts.workspaceFiles[patch.path], patch.after);
93201
+ opts.onSyncFiles({ [patch.path]: patch.after });
93037
93202
  },
93038
93203
  []
93039
93204
  );
@@ -93052,27 +93217,11 @@ function useElementPicker(iframeRef, options) {
93052
93217
  computedStyles: { ...prev.computedStyles, [prop]: value }
93053
93218
  } : null
93054
93219
  );
93055
- if (pickedElement.id) {
93056
- syncToSource(pickedElement.id, pickedElement.selector, {
93057
- type: "inline-style",
93058
- property: prop,
93059
- value
93060
- });
93061
- } else {
93062
- try {
93063
- const fullHtml = activeIframe.contentDocument?.documentElement.outerHTML;
93064
- if (fullHtml && optionsRef.current?.onSyncFiles) {
93065
- const src = activeIframe.getAttribute("src") ?? "";
93066
- const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
93067
- const filePath = compMatch ? compMatch[1] : "index.html";
93068
- optionsRef.current.onSyncFiles({
93069
- [filePath]: `<!DOCTYPE html>
93070
- <html>${fullHtml.replace(/<html[^>]*>/, "")}`
93071
- });
93072
- }
93073
- } catch {
93074
- }
93075
- }
93220
+ syncToSource(pickedElement, el, activeIframe, {
93221
+ type: "inline-style",
93222
+ property: prop,
93223
+ value
93224
+ });
93076
93225
  }
93077
93226
  } catch {
93078
93227
  }
@@ -93094,13 +93243,11 @@ function useElementPicker(iframeRef, options) {
93094
93243
  dataAttributes: { ...prev.dataAttributes, [attr]: value }
93095
93244
  } : null
93096
93245
  );
93097
- if (pickedElement.id) {
93098
- syncToSource(pickedElement.id, pickedElement.selector, {
93099
- type: "attribute",
93100
- property: attr,
93101
- value
93102
- });
93103
- }
93246
+ syncToSource(pickedElement, el, activeIframe, {
93247
+ type: "attribute",
93248
+ property: attr,
93249
+ value
93250
+ });
93104
93251
  }
93105
93252
  } catch {
93106
93253
  }
@@ -93117,13 +93264,11 @@ function useElementPicker(iframeRef, options) {
93117
93264
  if (el) {
93118
93265
  el.textContent = text;
93119
93266
  setPickedElement((prev) => prev ? { ...prev, textContent: text } : null);
93120
- if (pickedElement.id) {
93121
- syncToSource(pickedElement.id, pickedElement.selector, {
93122
- type: "text-content",
93123
- property: "textContent",
93124
- value: text
93125
- });
93126
- }
93267
+ syncToSource(pickedElement, el, activeIframe, {
93268
+ type: "text-content",
93269
+ property: "textContent",
93270
+ value: text
93271
+ });
93127
93272
  }
93128
93273
  } catch {
93129
93274
  }
@@ -93146,6 +93291,57 @@ function useElementPicker(iframeRef, options) {
93146
93291
  activeIframeRef
93147
93292
  };
93148
93293
  }
93294
+ function settlePendingWrites(pending2, files) {
93295
+ for (const [path, write] of pending2) {
93296
+ const hostSource = files?.[path];
93297
+ if (hostSource === write.hostSource) continue;
93298
+ const caughtUpTo = hostSource === void 0 ? -1 : write.writes.indexOf(hostSource);
93299
+ if (caughtUpTo < 0 || caughtUpTo === write.writes.length - 1) {
93300
+ pending2.delete(path);
93301
+ } else {
93302
+ write.hostSource = hostSource;
93303
+ write.writes = write.writes.slice(caughtUpTo + 1);
93304
+ }
93305
+ }
93306
+ }
93307
+ function withPendingWrites(files, pending2) {
93308
+ const merged = { ...files };
93309
+ for (const [path, write] of pending2) merged[path] = write.writes.at(-1) ?? merged[path];
93310
+ return merged;
93311
+ }
93312
+ function recordPendingWrite(pending2, path, hostSource, after) {
93313
+ const write = pending2.get(path) ?? { hostSource, writes: [] };
93314
+ write.writes.push(after);
93315
+ pending2.set(path, write);
93316
+ }
93317
+ function toPickedElement(el, iframe) {
93318
+ return {
93319
+ id: el.id ?? null,
93320
+ tagName: el.tagName ?? "div",
93321
+ selector: el.selector ?? "",
93322
+ label: el.label ?? el.tagName ?? "Element",
93323
+ boundingBox: el.boundingBox ?? { x: 0, y: 0, width: 0, height: 0 },
93324
+ textContent: el.textContent ?? null,
93325
+ src: el.src ?? null,
93326
+ dataAttributes: el.dataAttributes ?? {},
93327
+ computedStyles: readComputedStyles(iframe, el.selector ?? "")
93328
+ };
93329
+ }
93330
+ function patchById(files, id, selector, op) {
93331
+ const path = resolveSourceFile(id, selector, files);
93332
+ const before = path ? files[path] : void 0;
93333
+ return path && before ? { path, before, after: applyPatch(before, id, op) } : null;
93334
+ }
93335
+ function ownSourceFile(live, iframe) {
93336
+ const previewed = compositionPathOfPreviewUrl(iframe.getAttribute("src") ?? "");
93337
+ return getSourceFileForElement(live, previewed).sourceFile;
93338
+ }
93339
+ function patchByHfId(files, hfId, ownFile, op) {
93340
+ const matches = Object.keys(files).filter((file) => findTagByTarget(files[file] ?? "", { hfId }));
93341
+ const path = matches.includes(ownFile) ? ownFile : matches.length === 1 ? matches[0] : void 0;
93342
+ const before = path ? files[path] : void 0;
93343
+ return path && before ? { path, before, after: applyPatchByTarget(before, { hfId }, op) } : null;
93344
+ }
93149
93345
  function readComputedStyles(iframe, selector) {
93150
93346
  const styles = {};
93151
93347
  try {