Skip to content

Commit 530980c

Browse files
vanceingallsclaude
andcommitted
fix(studio): restore timeline move/resize fallback parity (review #1466)
The §3.2 sdkTimingPersist rewrite regressed the non-SDK fallback path vs the pre-cutover behavior. Restored, on both fallback entry points (no-session and sdkTimingPersist-returned-unhandled): - Resize live DOM patch dropped the conditional data-playback-start/media-start attr — restored so a start-trim updates the preview's in-point immediately. - Move/resize fallback dropped the GSAP-position sync (shift/scaleGsapPositions) + reloadPreview — restored so server-path edits keep GSAP tweens in sync and refresh the preview (the SDK path folds both into setTiming). - Undo-coalesce drift: fallback enqueueEdit carried no coalesceKey while the SDK branch did — plumbed coalesceKey through persistTimelineEdit so undo granularity is identical on either path. - Documented the hasPbsAdjustment second clause + sdkTimingPersist before-capture transition limitation. Flag-off (dark launch) so this lands as one fix PR at the stack tip rather than restacking the mid-stack §3.2 commit. #1500 review items: parity-harness gap already closed at the tip (arc/unroll recast-vs-acorn parity added); blockRemoveRange flagged 'potential' but verified correct (no comma residue on any block position). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09cefc1 commit 530980c

3 files changed

Lines changed: 77 additions & 9 deletions

File tree

packages/studio/src/hooks/timelineEditingHelpers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export interface PersistTimelineEditInput {
9797
recordEdit: (input: RecordEditInput) => Promise<void>;
9898
domEditSaveTimestampRef: React.MutableRefObject<number>;
9999
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
100+
coalesceKey?: string;
100101
}
101102

102103
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
@@ -119,6 +120,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
119120
projectId: input.projectId,
120121
label: input.label,
121122
kind: "timeline",
123+
coalesceKey: input.coalesceKey,
122124
files: { [targetPath]: patchedContent },
123125
readFile: async () => originalContent,
124126
writeFile: input.writeProjectFile,

packages/studio/src/hooks/useTimelineEditing.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
readFileContent,
3030
applyPatchByTarget,
3131
formatTimelineAttributeNumber,
32+
shiftGsapPositions,
33+
scaleGsapPositions,
3234
} from "./timelineEditingHelpers";
3335
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
3436
import { sdkTimingPersist } from "../utils/sdkCutover";
@@ -91,6 +93,7 @@ export function useTimelineEditing({
9193
element: TimelineElement,
9294
label: string,
9395
buildPatches: PersistTimelineEditInput["buildPatches"],
96+
coalesceKey?: string,
9497
): Promise<void> => {
9598
if (isRecordingRef?.current) {
9699
showToast("Cannot edit timeline while recording", "error");
@@ -110,6 +113,7 @@ export function useTimelineEditing({
110113
recordEdit,
111114
domEditSaveTimestampRef,
112115
pendingTimelineEditPathRef,
116+
coalesceKey,
113117
}),
114118
)
115119
.then(() => {
@@ -154,6 +158,24 @@ export function useTimelineEditing({
154158
value: String(updates.track),
155159
});
156160
};
161+
// Server-path fallback (no SDK session): persist the attr patch, then
162+
// shift GSAP tween positions on the server and reload the preview — the
163+
// SDK path folds both into setTiming, but the fallback must do them
164+
// explicitly or the clip moves while its GSAP tweens stay put + the
165+
// preview never refreshes. coalesceKey mirrors the SDK branch so undo
166+
// granularity is identical on either path.
167+
const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
168+
const moveFallback = () =>
169+
enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => {
170+
const pid = projectIdRef.current;
171+
const delta = updates.start - element.start;
172+
if (delta !== 0 && element.domId && pid) {
173+
return shiftGsapPositions(pid, targetPath, element.domId, delta)
174+
.then(() => reloadPreview())
175+
.catch((err) => console.error("[Timeline] Failed to shift GSAP positions", err));
176+
}
177+
return reloadPreview();
178+
});
157179
if (sdkSession && element.hfId) {
158180
return sdkTimingPersist(
159181
element.hfId,
@@ -167,12 +189,12 @@ export function useTimelineEditing({
167189
domEditSaveTimestampRef,
168190
compositionPath: activeCompPath,
169191
},
170-
{ label: "Move timeline clip", coalesceKey: `timeline-move:${element.hfId}` },
192+
{ label: "Move timeline clip", coalesceKey },
171193
).then((handled) => {
172-
if (!handled) return enqueueEdit(element, "Move timeline clip", buildMovePatches);
194+
if (!handled) return moveFallback();
173195
});
174196
}
175-
return enqueueEdit(element, "Move timeline clip", buildMovePatches);
197+
return moveFallback();
176198
},
177199
[
178200
previewIframeRef,
@@ -193,10 +215,21 @@ export function useTimelineEditing({
193215
element: TimelineElement,
194216
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
195217
) => {
196-
patchIframeDomTiming(previewIframeRef.current, element, [
218+
const liveAttrs: Array<[string, string]> = [
197219
["data-start", formatTimelineAttributeNumber(updates.start)],
198220
["data-duration", formatTimelineAttributeNumber(updates.duration)],
199-
]);
221+
];
222+
// Patch the live playback-start/media-start attr too, or a resize that
223+
// trims the playback start leaves the preview showing the old in-point
224+
// until the next reload (the persisted patch handles it via pbs below).
225+
if (updates.playbackStart != null) {
226+
const liveAttr =
227+
element.playbackStartAttr === "playback-start"
228+
? "data-playback-start"
229+
: "data-media-start";
230+
liveAttrs.push([liveAttr, formatTimelineAttributeNumber(updates.playbackStart)]);
231+
}
232+
patchIframeDomTiming(previewIframeRef.current, element, liveAttrs);
200233
const targetPath = element.sourceFile || activeCompPath || "index.html";
201234
const buildResizePatches: PersistTimelineEditInput["buildPatches"] = (original, target) => {
202235
const pbs = resolveResizePlaybackStart(original, target, element, updates);
@@ -220,10 +253,38 @@ export function useTimelineEditing({
220253
return patched;
221254
};
222255
// SDK path: skip when a playback-start adjustment is needed (setTiming has no pbs field).
223-
// Condition: no explicit pbs override AND (no start change OR element has no pbs attribute).
256+
// The second clause fires because trimming the start of a clip that has a
257+
// playback-start attribute implicitly shifts that in-point — which the SDK
258+
// setTiming op can't express — so those resizes must take the server path.
224259
const hasPbsAdjustment =
225260
updates.playbackStart != null ||
226261
(updates.start !== element.start && element.playbackStart != null);
262+
// Server-path fallback: after persisting the attr patch, scale GSAP tween
263+
// positions/durations on the server and reload the preview. The SDK path
264+
// folds both into setTiming; the fallback must do them explicitly or the
265+
// clip resizes while its GSAP tweens keep their old timing + the preview
266+
// never refreshes. coalesceKey mirrors the SDK branch for undo parity.
267+
const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
268+
const timingChanged =
269+
updates.start !== element.start || updates.duration !== element.duration;
270+
const resizeFallback = () =>
271+
enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => {
272+
const pid = projectIdRef.current;
273+
if (timingChanged && element.domId && pid) {
274+
return scaleGsapPositions(
275+
pid,
276+
targetPath,
277+
element.domId,
278+
element.start,
279+
element.duration,
280+
updates.start,
281+
updates.duration,
282+
)
283+
.then(() => reloadPreview())
284+
.catch((err) => console.error("[Timeline] Failed to scale GSAP positions", err));
285+
}
286+
return reloadPreview();
287+
});
227288
if (sdkSession && element.hfId && !hasPbsAdjustment) {
228289
return sdkTimingPersist(
229290
element.hfId,
@@ -237,12 +298,12 @@ export function useTimelineEditing({
237298
domEditSaveTimestampRef,
238299
compositionPath: activeCompPath,
239300
},
240-
{ label: "Resize timeline clip", coalesceKey: `timeline-resize:${element.hfId}` },
301+
{ label: "Resize timeline clip", coalesceKey },
241302
).then((handled) => {
242-
if (!handled) return enqueueEdit(element, "Resize timeline clip", buildResizePatches);
303+
if (!handled) return resizeFallback();
243304
});
244305
}
245-
return enqueueEdit(element, "Resize timeline clip", buildResizePatches);
306+
return resizeFallback();
246307
},
247308
[
248309
previewIframeRef,

packages/studio/src/utils/sdkCutover.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@ export async function sdkTimingPersist(
174174
if (!sdkSession || !sdkSession.getElement(hfId)) return false;
175175
if (wrongCompositionFile(deps, targetPath)) return false;
176176
try {
177+
// `before` is the SDK's serialized state, which is the true pre-edit
178+
// content only while every edit routes through this session. During the
179+
// dark-launch transition (server still writes some paths) the in-memory
180+
// SDK doc can drift from disk, so this `before` may not match the file's
181+
// actual prior bytes. Acceptable for v1; revisit once cutover is always-on.
177182
const before = sdkSession.serialize();
178183
sdkSession.batch(() => sdkSession.setTiming(hfId, timingUpdate));
179184
const after = sdkSession.serialize();

0 commit comments

Comments
 (0)