From 95c2a9d5b96b6265151f183e48c67245b09eebbc Mon Sep 17 00:00:00 2001 From: JJ Date: Mon, 16 Mar 2026 03:44:38 +0000 Subject: [PATCH] fix matching time --- config/constants.py | 2 +- nodes/draw_image.py | 100 ++++++++++++++++-- nodes/power_spline_editor.py | 2 +- .../canvas/canvas_box_timeline.js | 33 +++++- .../canvas/canvas_constants.js | 2 +- .../canvas/canvas_paths.js | 2 +- web/power_spline_editor/canvas_top_row.js | 14 ++- web/power_spline_editor/layer_type_box.js | 4 +- .../modules/dimension-manager.js | 59 +++++++++++ .../power_spline_editor.js | 18 ++++ 10 files changed, 216 insertions(+), 20 deletions(-) diff --git a/config/constants.py b/config/constants.py index f88f883..e704273 100644 --- a/config/constants.py +++ b/config/constants.py @@ -7,5 +7,5 @@ This is the single source of truth for all constant values. BOX_BASE_RADIUS = 200 BOX_BASE_SIZE = BOX_BASE_RADIUS * 2 # Side length of box (400) POINT_BASE_RADIUS = 8 -BOX_TIMELINE_MAX_POINTS = 50 +BOX_TIMELINE_MAX_POINTS = 250 BOX_BORDER_BAND = 20 diff --git a/nodes/draw_image.py b/nodes/draw_image.py index 2c3508a..72dc531 100644 --- a/nodes/draw_image.py +++ b/nodes/draw_image.py @@ -14,7 +14,6 @@ from ..config.constants import BOX_BASE_RADIUS # Supersampling factor for smooth scaling SUPERSAMPLE = 4 - class DrawImageOnPath: RETURN_TYPES = ("IMAGE", "MASK",) RETURN_NAMES = ("image", "mask",) @@ -249,6 +248,80 @@ Draws an input image along a coordinate path for each frame, returning the rende padded.append(dict(last)) return padded + def _get_interpolated_point(self, coords, target_frame): + """ + Get interpolated coordinates for a target frame using the 'frame' property. + This matches the Power Spline Editor's box timeline interpolation logic. + """ + if not coords: + return None + + # Filter out coords without a frame property + valid_coords = [c for c in coords if isinstance(c.get("frame"), (int, float))] + if not valid_coords: + # Fall back to first coordinate + return coords[0] if coords else None + + # Sort by frame number + sorted_coords = sorted(valid_coords, key=lambda c: float(c.get("frame", 0))) + + first = sorted_coords[0] + last = sorted_coords[-1] + first_frame = float(first.get("frame", 1)) + last_frame = float(last.get("frame", 1)) + + # Clamp target frame to keyframe range + if target_frame <= first_frame: + return first + if target_frame >= last_frame: + return last + + # Find the two keyframes that bracket our target frame + for i in range(len(sorted_coords) - 1): + curr = sorted_coords[i] + next_c = sorted_coords[i + 1] + curr_frame = float(curr.get("frame", 0)) + next_frame = float(next_c.get("frame", 0)) + + if curr_frame <= target_frame <= next_frame: + # Exact match on current frame + if target_frame == curr_frame: + return curr + + # Interpolate between curr and next_c + if next_frame == curr_frame: + return curr + + t = (target_frame - curr_frame) / (next_frame - curr_frame) + + # Interpolate all numeric properties + result = {} + + # Copy non-numeric properties as-is from curr + for key, value in curr.items(): + if key not in ("x", "y", "scale", "pointScale", "boxScale", "rotation", "boxR", "frame"): + result[key] = value + + # Interpolate numeric properties + for prop in ("x", "y", "scale", "pointScale", "boxScale", "rotation", "boxR"): + curr_val = curr.get(prop) + next_val = next_c.get(prop) + + if isinstance(curr_val, (int, float)) and isinstance(next_val, (int, float)): + result[prop] = curr_val + (next_val - curr_val) * t + elif isinstance(curr_val, (int, float)): + result[prop] = curr_val + elif isinstance(next_val, (int, float)): + result[prop] = next_val + + # Copy frame from target + result["frame"] = target_frame + + return result + + # Shouldn't reach here, but return last as fallback + return last + def create(self, bg_image, ref_images, coordinates, ref_masks=None, use_box_rotation=True, use_box_scale_size=True, fallback_scale=1.0, overlay_opacity=1.0, frames=0, add_shadows=False, mask_fill=0.0, use_gpu=True, gpu_batch=8): try: @@ -376,10 +449,14 @@ Draws an input image along a coordinate path for each frame, returning the rende # Process each layer for this frame (reversed so top layers in list draw on top) for reversed_idx, layer_coords in enumerate(reversed(coords)): - if frame_idx >= len(layer_coords): + if not layer_coords: continue - point = layer_coords[frame_idx] + # Use interpolation to get the correct point for this frame + # Coordinates already have per-frame data, so use frame index directly + point = self._get_interpolated_point(layer_coords, frame_idx + 1) + if point is None: + continue # Get original layer index (before reversal) for ref_selections layer_idx = len(coords) - 1 - reversed_idx @@ -624,7 +701,7 @@ Draws an input image along a coordinate path for each frame, returning the rende return output_tensor, mask_tensor def _create_gpu(self, bg_image, ref_images, coordinates, ref_masks, use_box_rotation, use_box_scale_size, - fallback_scale, overlay_opacity, frames, add_shadows, mask_fill, gpu_batch=8): + fallback_scale, overlay_opacity, frames, add_shadows=False, mask_fill=0.0, gpu_batch=8): """GPU-accelerated rendering using torch operations with batched processing to avoid OOM.""" device = torch.device('cuda') @@ -751,13 +828,18 @@ Draws an input image along a coordinate path for each frame, returning the rende bg_batch = bg_cpu[batch_start:batch_end].to(device) # Extract coordinate batch for each layer + # Use interpolation to get correct coordinates for each frame in the batch coords_batch = [] for layer_coords in coords: - batch_coords = layer_coords[batch_start:batch_end] - # Extend if needed to match batch size - if len(batch_coords) < current_batch_size and layer_coords: - last_coord = dict(layer_coords[-1]) - batch_coords = list(batch_coords) + [dict(last_coord)] * (current_batch_size - len(batch_coords)) + batch_coords = [] + for i in range(current_batch_size): + actual_frame = batch_start + i + 1 # Frames are 1-indexed + # Coordinates already have per-frame data, so use frame directly + point = self._get_interpolated_point(layer_coords, actual_frame) + if point is None: + # Create a default point if interpolation fails + point = {"x": 0.5, "y": 0.5, "scale": 1, "frame": actual_frame} + batch_coords.append(point) coords_batch.append(batch_coords) # Render this batch using the existing GPU method diff --git a/nodes/power_spline_editor.py b/nodes/power_spline_editor.py index 16edadc..5dabd11 100644 --- a/nodes/power_spline_editor.py +++ b/nodes/power_spline_editor.py @@ -416,7 +416,7 @@ class PowerSplineEditor: 'scale': round(snap['scale'], 4), 'boxScale': round(snap['scale'], 4), 'pointScale': round(snap['scale'], 4), - 'frame': idx + 1, + 'frame': round(timeline_frame), 'boxR': round(snap.get('boxR', 0.0), 4), }) return samples diff --git a/web/power_spline_editor/canvas/canvas_box_timeline.js b/web/power_spline_editor/canvas/canvas_box_timeline.js index 37b2ec3..63c7f0b 100644 --- a/web/power_spline_editor/canvas/canvas_box_timeline.js +++ b/web/power_spline_editor/canvas/canvas_box_timeline.js @@ -4,9 +4,31 @@ import { applyBoxTimelineFrame } from './canvas_scrub.js'; import { transformMouseToVideoSpace, transformVideoToCanvasSpace } from '../spline_utils.js'; // Added transformVideoToCanvasSpace export function attachBoxTimelineHelpers(editor) { - // Helper to get max frames dynamically (can be overridden by editor._maxFrames) + // Helper to get max frames dynamically + // Returns the highest of: explicitly set _maxFrames, highest keyframe across all box widgets, or default editor._getMaxFrames = () => { - return editor._maxFrames || BOX_TIMELINE_MAX_POINTS; + if (editor._maxFrames) { + return editor._maxFrames; + } + // Dynamically calculate based on existing keyframes in all box layer widgets + const layerManager = editor.node?.layerManager; + if (layerManager) { + const widgets = layerManager.getSplineWidgets(); + let maxKeyframeFrame = 0; + for (const widget of widgets) { + if (editor._isBoxLayerWidget(widget) && Array.isArray(widget.value.box_keys)) { + for (const key of widget.value.box_keys) { + if (key && typeof key.frame === 'number') { + maxKeyframeFrame = Math.max(maxKeyframeFrame, key.frame); + } + } + } + } + if (maxKeyframeFrame > 0) { + return Math.max(BOX_TIMELINE_MAX_POINTS, maxKeyframeFrame); + } + } + return BOX_TIMELINE_MAX_POINTS; }; editor._isBoxLayerWidget = (widget) => widget?.value?.type === 'box_layer'; @@ -27,8 +49,9 @@ export function attachBoxTimelineHelpers(editor) { const normY = Math.abs(rawY) >= 10 ? rawY / height : rawY; const scaleVal = (typeof key.scale === 'number' && !Number.isNaN(key.scale)) ? key.scale : 1; const rotationVal = (typeof key.rotation === 'number' && !Number.isNaN(key.rotation)) ? key.rotation : 0; + // Don't clamp keyframe frames - allow them to extend beyond default max return { - frame: Math.max(1, Math.min(editor._getMaxFrames(), Math.round(key.frame || 1))), + frame: Math.max(1, Math.round(key.frame || 1)), x: normX, y: normY, scale: editor.clampScaleValue ? editor.clampScaleValue(scaleVal) : Math.max(0.2, Math.min(6, scaleVal)), @@ -38,7 +61,9 @@ export function attachBoxTimelineHelpers(editor) { .filter(Boolean) .sort((a, b) => (a.frame || 0) - (b.frame || 0)); const current = Number(widget.value.box_timeline_point) || 1; - widget.value.box_timeline_point = Math.max(1, Math.min(editor._getMaxFrames(), Math.round(current))); + // Clamp current timeline point but allow it to extend if keyframes go further + const maxFrames = editor._getMaxFrames(); + widget.value.box_timeline_point = Math.max(1, Math.min(maxFrames, Math.round(current))); return widget.value.box_keys; }; diff --git a/web/power_spline_editor/canvas/canvas_constants.js b/web/power_spline_editor/canvas/canvas_constants.js index 74bf8f0..9e8260f 100644 --- a/web/power_spline_editor/canvas/canvas_constants.js +++ b/web/power_spline_editor/canvas/canvas_constants.js @@ -1 +1 @@ -export const BOX_TIMELINE_MAX_POINTS = 50; +export const BOX_TIMELINE_MAX_POINTS = 250; diff --git a/web/power_spline_editor/canvas/canvas_paths.js b/web/power_spline_editor/canvas/canvas_paths.js index 63eafd9..39af7f4 100644 --- a/web/power_spline_editor/canvas/canvas_paths.js +++ b/web/power_spline_editor/canvas/canvas_paths.js @@ -224,7 +224,7 @@ export function attachPathHelpers(editor) { if (isBoxLayer) { const normalizedKeys = Array.isArray(widget.value.box_keys) ? widget.value.box_keys.map(k => ({ - frame: Math.max(1, Math.min(editor._getMaxFrames(), Math.round(k.frame || 1))), + frame: Math.max(1, Math.round(k.frame || 1)), x: typeof k.x === 'number' ? k.x : 0.5, y: typeof k.y === 'number' ? k.y : 0.5, scale: editor.clampScaleValue(k.scale ?? 1), diff --git a/web/power_spline_editor/canvas_top_row.js b/web/power_spline_editor/canvas_top_row.js index c99b238..7e759af 100644 --- a/web/power_spline_editor/canvas_top_row.js +++ b/web/power_spline_editor/canvas_top_row.js @@ -424,6 +424,15 @@ export async function handleCanvasRefresh(node) { if (!node.editor) { const SplineEditor2 = (await import('./canvas/canvas_main.js')).default; node.editor = new SplineEditor2(node); + + // Restore max frames from session storage or properties + if (node.dimensionManager) { + const restoredFrames = node.dimensionManager.restoreMaxFrames(); + // If no saved frames, initialize from frames input + if (!restoredFrames) { + node.dimensionManager.initializeMaxFramesFromInput(); + } + } } // Refresh the background image to trigger canvas render @@ -570,10 +579,13 @@ export class TopRowWidget extends RgthreeBaseWidget { // Draw Refresh Frames button if (this.visibility.refreshFramesButton) { + // Get current max frames for display + const currentMaxFrames = node.editor?._getMaxFrames?.() ?? node.properties?.box_max_frames ?? '?'; + const framesLabel = `🕞 ${currentMaxFrames}`; drawWidgetButton( ctx, { size: [refreshFramesWidth, height], pos: [posX, posY] }, - "🕞 Frames", + framesLabel, this.framesButtonMouseDown ); } diff --git a/web/power_spline_editor/layer_type_box.js b/web/power_spline_editor/layer_type_box.js index ab3e1f7..ebba47b 100644 --- a/web/power_spline_editor/layer_type_box.js +++ b/web/power_spline_editor/layer_type_box.js @@ -3,7 +3,7 @@ import { initializeDrivenConfig, initializeEasingConfig, initializeScaleConfig, import { showCustomEasingMenu, showCustomDrivenToggleMenu, showInterpolationMenu, showRefSelectionMenu } from './context_menu.js'; import { app } from '../../../scripts/app.js'; -const BOX_TIMELINE_MAX_POINTS = 50; +const BOX_TIMELINE_MAX_POINTS = 250; export class BoxLayerWidget extends RgthreeBaseWidget { constructor(name) { @@ -1064,7 +1064,7 @@ export class BoxLayerWidget extends RgthreeBaseWidget { this.stopBoxPlayback(node); } const editor = node?.editor; - const fps = 16; + const fps = 25; const intervalMs = Math.max(10, Math.round(1000 / fps)); const advanceFrame = () => { const max = this._getMaxFrames(node); diff --git a/web/power_spline_editor/modules/dimension-manager.js b/web/power_spline_editor/modules/dimension-manager.js index 1178a8d..fc456a6 100644 --- a/web/power_spline_editor/modules/dimension-manager.js +++ b/web/power_spline_editor/modules/dimension-manager.js @@ -406,6 +406,65 @@ export function createDimensionManager(node) { ); } } catch {} + }, + + /** + * Restore max frames from session storage or properties + */ + restoreMaxFrames() { + try { + // First check node properties + if (node.properties?.box_max_frames) { + const savedFrames = Number(node.properties.box_max_frames); + if (!Number.isNaN(savedFrames) && savedFrames > 0 && node.editor) { + node.editor._maxFrames = savedFrames; + console.log(`[DimensionManager] Restored max frames from properties: ${savedFrames}`); + return savedFrames; + } + } + + // Then check session storage + if (node.uuid) { + const sessionValue = sessionStorage.getItem(`spline-editor-maxframes-${node.uuid}`); + if (sessionValue) { + const savedFrames = Number(sessionValue); + if (!Number.isNaN(savedFrames) && savedFrames > 0 && node.editor) { + node.editor._maxFrames = savedFrames; + // Also update properties for persistence + node.properties = node.properties || {}; + node.properties.box_max_frames = savedFrames; + console.log(`[DimensionManager] Restored max frames from session: ${savedFrames}`); + return savedFrames; + } + } + } + } catch (error) { + console.warn('[DimensionManager] Error restoring max frames:', error); + } + return null; + }, + + /** + * Initialize max frames from the frames input/property + * This sets the initial max frames based on what's connected to the frames input + */ + initializeMaxFramesFromInput() { + try { + const targetFrames = this._getTargetFrames(); + if (targetFrames && node.editor) { + node.editor._maxFrames = targetFrames; + node.properties = node.properties || {}; + node.properties.box_max_frames = targetFrames; + if (node.uuid) { + safeSetSessionItem(`spline-editor-maxframes-${node.uuid}`, String(targetFrames)); + } + console.log(`[DimensionManager] Initialized max frames from input: ${targetFrames}`); + return targetFrames; + } + } catch (error) { + console.warn('[DimensionManager] Error initializing max frames:', error); + } + return null; } }; } \ No newline at end of file diff --git a/web/power_spline_editor/power_spline_editor.js b/web/power_spline_editor/power_spline_editor.js index 9d42edc..25dd822 100644 --- a/web/power_spline_editor/power_spline_editor.js +++ b/web/power_spline_editor/power_spline_editor.js @@ -751,6 +751,15 @@ app.registerExtension({ this.editor = new SplineEditor2(this); } + // Restore max frames from session storage or properties + if (this.dimensionManager) { + const restoredFrames = this.dimensionManager.restoreMaxFrames(); + // If no saved frames, initialize from frames input + if (!restoredFrames) { + this.dimensionManager.initializeMaxFramesFromInput(); + } + } + // Restore video background if (this.videoData && !this.imgData && this.editor) { console.log('[PowerSplineEditor onConfigure] Restoring video background'); @@ -871,6 +880,15 @@ app.registerExtension({ this.editor = new SplineEditor2(this, false); } + // Restore max frames from session storage or properties + if (this.dimensionManager) { + const restoredFrames = this.dimensionManager.restoreMaxFrames(); + // If no saved frames, initialize from frames input + if (!restoredFrames) { + this.dimensionManager.initializeMaxFramesFromInput(); + } + } + const finishEditorSetup = async () => { if (coord_in) { const coord_in_str = Array.isArray(coord_in)