Skip to content

Commit 5f6863e

Browse files
authored
Merge pull request #126 from walterlow/develop
Auto-keyframe arming, export blit, and editor improvements
2 parents 7b18542 + 5197490 commit 5f6863e

9 files changed

Lines changed: 243 additions & 75 deletions

File tree

src/features/editor/components/properties-sidebar/clip-panel/audio-section.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,21 +108,21 @@ export function AudioSection({ items }: AudioSectionProps) {
108108
// Commit volume (on mouse up, with auto-keyframe support)
109109
const handleVolumeChange = useCallback(
110110
(value: number) => {
111-
let allHandled = true;
112111
const autoOps: AutoKeyframeOperation[] = [];
112+
const fallbackItemIds: string[] = [];
113113
for (const itemId of itemIds) {
114114
const operation = autoKeyframeVolume(itemId, value);
115115
if (operation) {
116116
autoOps.push(operation);
117117
} else {
118-
allHandled = false;
118+
fallbackItemIds.push(itemId);
119119
}
120120
}
121121
if (autoOps.length > 0) {
122122
applyAutoKeyframeOperations(autoOps);
123123
}
124-
if (!allHandled) {
125-
itemIds.forEach((id) => updateItem(id, { volume: value }));
124+
if (fallbackItemIds.length > 0) {
125+
fallbackItemIds.forEach((id) => updateItem(id, { volume: value }));
126126
}
127127
// Defer preview clear to next microtask so store update propagates first
128128
queueMicrotask(() => clearPreview());
@@ -294,4 +294,3 @@ export function AudioSection({ items }: AudioSectionProps) {
294294
</PropertySection>
295295
);
296296
}
297-

src/features/editor/components/properties-sidebar/clip-panel/fill-section.tsx

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -144,21 +144,21 @@ export const FillSection = memo(function FillSection({
144144
(value: number) => {
145145
const opacityValue = value / 100; // Convert from 0-100 to 0-1
146146

147-
let allHandled = true;
148147
const autoOps: AutoKeyframeOperation[] = [];
148+
const fallbackItemIds: string[] = [];
149149
for (const itemId of itemIds) {
150150
const operation = autoKeyframeOpacity(itemId, opacityValue);
151151
if (operation) {
152152
autoOps.push(operation);
153153
} else {
154-
allHandled = false;
154+
fallbackItemIds.push(itemId);
155155
}
156156
}
157157
if (autoOps.length > 0) {
158158
applyAutoKeyframeOperations(autoOps);
159159
}
160-
if (!allHandled) {
161-
onTransformChange(itemIds, { opacity: opacityValue });
160+
if (fallbackItemIds.length > 0) {
161+
onTransformChange(fallbackItemIds, { opacity: opacityValue });
162162
}
163163
queueMicrotask(() => clearPreview());
164164
},
@@ -180,21 +180,21 @@ export const FillSection = memo(function FillSection({
180180
// Commit corner radius (on mouse up, with auto-keyframe support)
181181
const handleCornerRadiusChange = useCallback(
182182
(value: number) => {
183-
let allHandled = true;
184183
const autoOps: AutoKeyframeOperation[] = [];
184+
const fallbackItemIds: string[] = [];
185185
for (const itemId of itemIds) {
186186
const operation = autoKeyframeCornerRadius(itemId, value);
187187
if (operation) {
188188
autoOps.push(operation);
189189
} else {
190-
allHandled = false;
190+
fallbackItemIds.push(itemId);
191191
}
192192
}
193193
if (autoOps.length > 0) {
194194
applyAutoKeyframeOperations(autoOps);
195195
}
196-
if (!allHandled) {
197-
onTransformChange(itemIds, { cornerRadius: value });
196+
if (fallbackItemIds.length > 0) {
197+
onTransformChange(fallbackItemIds, { cornerRadius: value });
198198
}
199199
queueMicrotask(() => clearPreview());
200200
},
@@ -333,4 +333,3 @@ export const FillSection = memo(function FillSection({
333333
</PropertySection>
334334
);
335335
});
336-

src/features/editor/components/properties-sidebar/clip-panel/layout-section.tsx

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -179,23 +179,21 @@ export const LayoutSection = memo(function LayoutSection({
179179
// Commit X position (with auto-keyframe support)
180180
const handleXChange = useCallback(
181181
(value: number) => {
182-
// Try auto-keyframe first for items with keyframes
183-
let allHandled = true;
184182
const autoOps: AutoKeyframeOperation[] = [];
183+
const fallbackItemIds: string[] = [];
185184
for (const itemId of itemIds) {
186185
const operation = getAutoKeyframeOperation(itemId, 'x', value);
187186
if (operation) {
188187
autoOps.push(operation);
189188
} else {
190-
allHandled = false;
189+
fallbackItemIds.push(itemId);
191190
}
192191
}
193192
if (autoOps.length > 0) {
194193
applyAutoKeyframeOperations(autoOps);
195194
}
196-
// Fall back to base transform for items without keyframes
197-
if (!allHandled) {
198-
onTransformChange(itemIds, { x: value });
195+
if (fallbackItemIds.length > 0) {
196+
onTransformChange(fallbackItemIds, { x: value });
199197
}
200198
queueMicrotask(() => clearPreview());
201199
},
@@ -217,21 +215,21 @@ export const LayoutSection = memo(function LayoutSection({
217215
// Commit Y position (with auto-keyframe support)
218216
const handleYChange = useCallback(
219217
(value: number) => {
220-
let allHandled = true;
221218
const autoOps: AutoKeyframeOperation[] = [];
219+
const fallbackItemIds: string[] = [];
222220
for (const itemId of itemIds) {
223221
const operation = getAutoKeyframeOperation(itemId, 'y', value);
224222
if (operation) {
225223
autoOps.push(operation);
226224
} else {
227-
allHandled = false;
225+
fallbackItemIds.push(itemId);
228226
}
229227
}
230228
if (autoOps.length > 0) {
231229
applyAutoKeyframeOperations(autoOps);
232230
}
233-
if (!allHandled) {
234-
onTransformChange(itemIds, { y: value });
231+
if (fallbackItemIds.length > 0) {
232+
onTransformChange(fallbackItemIds, { y: value });
235233
}
236234
queueMicrotask(() => clearPreview());
237235
},
@@ -259,29 +257,29 @@ export const LayoutSection = memo(function LayoutSection({
259257
const handleWidthChange = useCallback(
260258
(value: number) => {
261259
const newHeight = aspectLocked && height !== 'mixed' ? Math.round(value / currentAspectRatio) : null;
262-
263-
let allHandled = true;
264260
const autoOps: AutoKeyframeOperation[] = [];
261+
const fallbackUpdates = new Map<string, Partial<TransformProperties>>();
265262
for (const itemId of itemIds) {
266263
const widthOperation = getAutoKeyframeOperation(itemId, 'width', value);
267264
const heightOperation = newHeight !== null ? getAutoKeyframeOperation(itemId, 'height', newHeight) : null;
268-
const widthHandled = Boolean(widthOperation);
269-
const heightHandled = newHeight !== null ? Boolean(heightOperation) : true;
270265
if (widthOperation) autoOps.push(widthOperation);
271266
if (heightOperation) autoOps.push(heightOperation);
272-
if (!widthHandled || !heightHandled) {
273-
allHandled = false;
267+
const updates: Partial<TransformProperties> = {};
268+
if (!widthOperation) {
269+
updates.width = value;
270+
}
271+
if (newHeight !== null && !heightOperation) {
272+
updates.height = newHeight;
273+
}
274+
if (Object.keys(updates).length > 0) {
275+
fallbackUpdates.set(itemId, updates);
274276
}
275277
}
276278
if (autoOps.length > 0) {
277279
applyAutoKeyframeOperations(autoOps);
278280
}
279-
if (!allHandled) {
280-
if (newHeight !== null) {
281-
onTransformChange(itemIds, { width: value, height: newHeight });
282-
} else {
283-
onTransformChange(itemIds, { width: value });
284-
}
281+
if (fallbackUpdates.size > 0) {
282+
updateItemsTransformMap(fallbackUpdates, { operation: 'resize' });
285283
}
286284
queueMicrotask(() => clearPreview());
287285
},
@@ -294,6 +292,7 @@ export const LayoutSection = memo(function LayoutSection({
294292
currentAspectRatio,
295293
getAutoKeyframeOperation,
296294
applyAutoKeyframeOperations,
295+
updateItemsTransformMap,
297296
]
298297
);
299298

@@ -318,29 +317,29 @@ export const LayoutSection = memo(function LayoutSection({
318317
const handleHeightChange = useCallback(
319318
(value: number) => {
320319
const newWidth = aspectLocked && width !== 'mixed' ? Math.round(value * currentAspectRatio) : null;
321-
322-
let allHandled = true;
323320
const autoOps: AutoKeyframeOperation[] = [];
321+
const fallbackUpdates = new Map<string, Partial<TransformProperties>>();
324322
for (const itemId of itemIds) {
325323
const heightOperation = getAutoKeyframeOperation(itemId, 'height', value);
326324
const widthOperation = newWidth !== null ? getAutoKeyframeOperation(itemId, 'width', newWidth) : null;
327-
const heightHandled = Boolean(heightOperation);
328-
const widthHandled = newWidth !== null ? Boolean(widthOperation) : true;
329325
if (heightOperation) autoOps.push(heightOperation);
330326
if (widthOperation) autoOps.push(widthOperation);
331-
if (!heightHandled || !widthHandled) {
332-
allHandled = false;
327+
const updates: Partial<TransformProperties> = {};
328+
if (!heightOperation) {
329+
updates.height = value;
330+
}
331+
if (newWidth !== null && !widthOperation) {
332+
updates.width = newWidth;
333+
}
334+
if (Object.keys(updates).length > 0) {
335+
fallbackUpdates.set(itemId, updates);
333336
}
334337
}
335338
if (autoOps.length > 0) {
336339
applyAutoKeyframeOperations(autoOps);
337340
}
338-
if (!allHandled) {
339-
if (newWidth !== null) {
340-
onTransformChange(itemIds, { width: newWidth, height: value });
341-
} else {
342-
onTransformChange(itemIds, { height: value });
343-
}
341+
if (fallbackUpdates.size > 0) {
342+
updateItemsTransformMap(fallbackUpdates, { operation: 'resize' });
344343
}
345344
queueMicrotask(() => clearPreview());
346345
},
@@ -353,6 +352,7 @@ export const LayoutSection = memo(function LayoutSection({
353352
currentAspectRatio,
354353
getAutoKeyframeOperation,
355354
applyAutoKeyframeOperations,
355+
updateItemsTransformMap,
356356
]
357357
);
358358

@@ -371,21 +371,21 @@ export const LayoutSection = memo(function LayoutSection({
371371
// Commit rotation (on mouse up, with auto-keyframe support)
372372
const handleRotationChange = useCallback(
373373
(value: number) => {
374-
let allHandled = true;
375374
const autoOps: AutoKeyframeOperation[] = [];
375+
const fallbackItemIds: string[] = [];
376376
for (const itemId of itemIds) {
377377
const operation = getAutoKeyframeOperation(itemId, 'rotation', value);
378378
if (operation) {
379379
autoOps.push(operation);
380380
} else {
381-
allHandled = false;
381+
fallbackItemIds.push(itemId);
382382
}
383383
}
384384
if (autoOps.length > 0) {
385385
applyAutoKeyframeOperations(autoOps);
386386
}
387-
if (!allHandled) {
388-
onTransformChange(itemIds, { rotation: value });
387+
if (fallbackItemIds.length > 0) {
388+
onTransformChange(fallbackItemIds, { rotation: value });
389389
}
390390
queueMicrotask(() => clearPreview());
391391
},

src/features/keyframes/components/dopesheet-editor/index.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { HOTKEY_OPTIONS } from '@/config/hotkeys';
2828
import { getVisibleKeyframeX } from './layout';
2929
import { getDopesheetRowControlState } from './row-controls';
3030
import { PROPERTY_VALUE_RANGES } from '@/features/keyframes/property-value-ranges';
31+
import { useAutoKeyframeStore } from '../../stores/auto-keyframe-store';
3132

3233
interface DopesheetEditorProps {
3334
/** Shared time viewport when split mode needs synchronized frame zoom/pan */
@@ -133,6 +134,7 @@ const ZOOM_OUT_FACTOR = 1.25;
133134
const DRAG_THRESHOLD = 2;
134135
const MARQUEE_SCROLL_EDGE_PX = 24;
135136
const MARQUEE_SCROLL_MAX_SPEED = 16;
137+
const EMPTY_AUTO_KEY_ENABLED_BY_PROPERTY: Partial<Record<AnimatableProperty, boolean>> = {};
136138

137139
function clampFrame(frame: number, totalFrames: number): number {
138140
if (totalFrames <= 0) return 0;
@@ -224,7 +226,13 @@ export const DopesheetEditor = memo(function DopesheetEditor({
224226
const [marqueeRect, setMarqueeRect] = useState<KeyframeMarqueeRect | null>(null);
225227
const [valueDrafts, setValueDrafts] = useState<Partial<Record<AnimatableProperty, string>>>({});
226228
const [editingValueProperty, setEditingValueProperty] = useState<AnimatableProperty | null>(null);
227-
const [autoKeyEnabledByProperty, setAutoKeyEnabledByProperty] = useState<Partial<Record<AnimatableProperty, boolean>>>({});
229+
const autoKeyEnabledByProperty = useAutoKeyframeStore(
230+
useCallback(
231+
(state) => state.enabledByItem[itemId] ?? EMPTY_AUTO_KEY_ENABLED_BY_PROPERTY,
232+
[itemId]
233+
)
234+
);
235+
const toggleAutoKeyframeEnabled = useAutoKeyframeStore((state) => state.toggleAutoKeyframeEnabled);
228236
const [localFrameInputValue, setLocalFrameInputValue] = useState('');
229237
const [globalFrameInputValue, setGlobalFrameInputValue] = useState('');
230238
const skipNextBlurCommitPropertyRef = useRef<AnimatableProperty | null>(null);
@@ -903,11 +911,8 @@ export const DopesheetEditor = memo(function DopesheetEditor({
903911

904912
const handleRowAutoKeyToggle = useCallback((property: AnimatableProperty) => {
905913
onActivePropertyChange?.(property);
906-
setAutoKeyEnabledByProperty((prev) => ({
907-
...prev,
908-
[property]: !prev[property],
909-
}));
910-
}, [onActivePropertyChange]);
914+
toggleAutoKeyframeEnabled(itemId, property);
915+
}, [itemId, onActivePropertyChange, toggleAutoKeyframeEnabled]);
911916

912917
const handleRowValueCommit = useCallback(
913918
(property: AnimatableProperty, options?: { allowCreate?: boolean }) => {
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { create } from 'zustand';
2+
import type { AnimatableProperty } from '@/types/keyframe';
3+
4+
type AutoKeyframeEnabledByProperty = Partial<Record<AnimatableProperty, boolean>>;
5+
type AutoKeyframeEnabledByItem = Record<string, AutoKeyframeEnabledByProperty>;
6+
7+
interface AutoKeyframeState {
8+
enabledByItem: AutoKeyframeEnabledByItem;
9+
}
10+
11+
interface AutoKeyframeActions {
12+
setAutoKeyframeEnabled: (
13+
itemId: string,
14+
property: AnimatableProperty,
15+
enabled: boolean
16+
) => void;
17+
toggleAutoKeyframeEnabled: (itemId: string, property: AnimatableProperty) => void;
18+
isAutoKeyframeEnabled: (itemId: string, property: AnimatableProperty) => boolean;
19+
reset: () => void;
20+
}
21+
22+
function setItemPropertyEnabled(
23+
enabledByItem: AutoKeyframeEnabledByItem,
24+
itemId: string,
25+
property: AnimatableProperty,
26+
enabled: boolean
27+
): AutoKeyframeEnabledByItem {
28+
const currentItemState = enabledByItem[itemId] ?? {};
29+
30+
if (enabled) {
31+
return {
32+
...enabledByItem,
33+
[itemId]: {
34+
...currentItemState,
35+
[property]: true,
36+
},
37+
};
38+
}
39+
40+
const remainingItemState = { ...currentItemState };
41+
delete remainingItemState[property];
42+
if (Object.keys(remainingItemState).length === 0) {
43+
const remainingItems = { ...enabledByItem };
44+
delete remainingItems[itemId];
45+
return remainingItems;
46+
}
47+
48+
return {
49+
...enabledByItem,
50+
[itemId]: remainingItemState,
51+
};
52+
}
53+
54+
export const useAutoKeyframeStore = create<AutoKeyframeState & AutoKeyframeActions>()(
55+
(set, get) => ({
56+
enabledByItem: {},
57+
58+
setAutoKeyframeEnabled: (itemId, property, enabled) =>
59+
set((state) => ({
60+
enabledByItem: setItemPropertyEnabled(state.enabledByItem, itemId, property, enabled),
61+
})),
62+
63+
toggleAutoKeyframeEnabled: (itemId, property) =>
64+
set((state) => ({
65+
enabledByItem: setItemPropertyEnabled(
66+
state.enabledByItem,
67+
itemId,
68+
property,
69+
!state.enabledByItem[itemId]?.[property]
70+
),
71+
})),
72+
73+
isAutoKeyframeEnabled: (itemId, property) => Boolean(get().enabledByItem[itemId]?.[property]),
74+
75+
reset: () => set({ enabledByItem: {} }),
76+
})
77+
);
78+
79+
export function isAutoKeyframeEnabled(itemId: string, property: AnimatableProperty): boolean {
80+
return useAutoKeyframeStore.getState().isAutoKeyframeEnabled(itemId, property);
81+
}
82+
83+
export function resetAutoKeyframeStore(): void {
84+
useAutoKeyframeStore.getState().reset();
85+
}

0 commit comments

Comments
 (0)