/** * Layer Drawer Module * * Orchestrates layer rendering for all layer types (normal, handdraw, box) and states (active, inactive). * This is the largest module handling the complexity of drawing different layer types. * * This module handles: * - Active layer rendering (drawActiveLayer) * - Inactive layer rendering (drawInactiveLayer) * - Box layer visualizations and keyframe paths * - Line and dot rendering for all layer types * - Preview states and rotation handles */ import { BOX_BASE_RADIUS, POINT_BASE_RADIUS, transformVideoToCanvasSpace } from '../spline_utils.js'; import { BOX_TIMELINE_MAX_POINTS } from '../canvas/canvas_constants.js'; /** * Draws the active layer with lines, dots, and box-specific visualizations * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the layer */ export function drawActiveLayer(context, widget) { const points = Array.isArray(context.splineEditor.points) ? context.splineEditor.points : []; const hasPoints = points.length > 0; const layerType = context._getLayerType(widget); const isBoxLayer = layerType === 'box'; const safePoints = hasPoints ? points.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y)) : []; // For non-box layers, we need points to render anything if (!safePoints.length && !isBoxLayer) return; // Strictly determine interpolation based on widget type to prevent bleeding const interpolation = isBoxLayer ? (widget.value.interpolation || 'linear') : context._normalizeInterpolation(widget.value.interpolation, layerType); const isPointMode = layerType === 'box' ? true : interpolation === 'points'; const styles = context._getLayerStyles(layerType, 'active', isPointMode); // Draw lines and dots for regular spline layers (not box layers) if (hasPoints && !isBoxLayer) { drawActiveLine(context, interpolation, isPointMode, layerType, styles); drawActiveDots(context, interpolation, isPointMode, layerType, styles, points); } if (isBoxLayer) { // First: Draw the current interpolated box visualization // We pass the widget, but we WON'T pass safePoints anymore. // The function will fetch them fresh. drawCurrentBoxVisualization(context, widget); // Second: Draw the box layer keyframe timeline visualization (red dots and connecting lines) drawBoxKeyPath(context, context.activeLayerPanel, widget, true); // Third: Get the actual keyframe points from box_keys for rotation handles and green center dots const sortedKeys = context.splineEditor._ensureBoxLayerData(widget); if (sortedKeys.length > 0) { const keyframeNormPoints = sortedKeys.map(k => ({ x: k.x, y: k.y })); const keyframeDenormPoints = safeDenormalizePoints(context, keyframeNormPoints); if (keyframeDenormPoints && keyframeDenormPoints.length > 0) { const validKeyframePoints = keyframeDenormPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y) ); if (validKeyframePoints.length > 0) { // Temporarily removed rotation handles for all keyframes to avoid visual duplication // with orange keyframe indicators. Will be added back with hover-based display. // drawBoxRotationHandles(context, context.activeLayerPanel, validKeyframePoints, sortedKeys); // Note: Removed duplicate green center dots as they conflict with orange keyframe indicators } } } // Fourth: Draw the preview hover state drawBoxPreview(context, widget); } } /** * Draws the current (active frame) box visualization with squares, rotation handles, and reference images * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the box layer */ export function drawCurrentBoxVisualization(context, widget) { const styles = context._getLayerStyles('box', 'active', true, false); // HELPER: Fetch fresh points directly from the source const getFreshBoxPoints = () => { const rawPoints = context.splineEditor.points || []; return rawPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y)); }; const pts = getFreshBoxPoints(); const sortedPoints = [...pts].sort((a, b) => context.splineEditor.getBoxPointRadius(b) - context.splineEditor.getBoxPointRadius(a) ); // 1. Queue the GREEN SQUARES const attachment = context._getSelectedRefAttachment(widget); // During preview mode (_boxRefOnlyMode), don't render green boxes or rotation controls if (!context._boxRefOnlyMode) { sortedPoints.forEach((dot, index) => { const radius = context.getScaledBoxRadius(dot); const rotation = context._getBoxRotationValue(dot); const svgStyles = { radius: radius, stroke: styles.pointStroke || "#139613", strokeWidth: styles.lineWidth || 2, fill: styles.pointFill || "rgba(19, 150, 19, 0.1)", cursor: "default" }; const element = context._createSVGPath('square', dot, svgStyles, rotation); if (element) { // Add event handlers element.addEventListener('mousedown', (e) => { if (context.splineEditor.handlePointPointerDown) { context.splineEditor.handlePointPointerDown(dot, e); } }); element.addEventListener('mouseover', (e) => { if (context.splineEditor.mouseOverHandler) { context.splineEditor.mouseOverHandler.call(dot); } }); element.addEventListener('mouseout', (e) => { if (context.splineEditor.mouseOutHandler) { context.splineEditor.mouseOutHandler.call(dot); } }); // Queue for rendering after vis.render() context._pendingShapes.activeBoxDots.push(element); } // Add rotation text display (green with 60% opacity) - only for first box if (index === 0) { const rotationRad = context._getBoxRotationValue(dot); const rotationDeg = rotationRad * (180 / Math.PI); // Create a group to hold the text so we can position it correctly const textGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); textGroup.setAttribute('data-rotation-text', 'true'); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); // Position at top-left corner of the box (relative to box center) const offsetX = -radius; const offsetY = -radius - 5; text.setAttribute('x', offsetX); text.setAttribute('y', offsetY); text.setAttribute('fill', 'rgba(19, 150, 19, 0.6)'); // Green with 60% opacity text.setAttribute('font-size', '14'); text.setAttribute('font-family', 'monospace'); text.setAttribute('font-weight', 'bold'); text.setAttribute('text-anchor', 'start'); text.setAttribute('pointer-events', 'none'); text.style.userSelect = 'none'; text.textContent = `${rotationDeg.toFixed(1)}°`; textGroup.appendChild(text); const transformedPoint = transformVideoToCanvasSpace(context.splineEditor, dot.x, dot.y); textGroup.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y})`); textGroup.style.pointerEvents = 'none'; context._pendingShapes.activeBoxDots.push(textGroup); } }); // Draw rotation handle and center dot when not in ref-only mode drawCurrentBoxRotationHandle(context, () => { const pts = getFreshBoxPoints(); return pts.length > 0 ? pts[0] : null; }, styles); drawCurrentBoxCenterDot(context, () => { const pts = getFreshBoxPoints(); return pts.length > 0 ? pts[0] : null; }, styles); } // Queue attached reference image (fit inside box, preserve aspect) // Get fresh points to ensure image follows box during dragging/rotation const freshPoints = getFreshBoxPoints(); const freshSortedPoints = [...freshPoints].sort((a, b) => context.splineEditor.getBoxPointRadius(b) - context.splineEditor.getBoxPointRadius(a) ); if (attachment && (attachment.base64 || attachment.path) && freshSortedPoints.length > 0) { const point = freshSortedPoints[0]; const boxRadius = context.getScaledBoxRadius(point); const boxSize = boxRadius * 2; const imgW = Math.max(1, attachment.width || boxSize); const imgH = Math.max(1, attachment.height || boxSize); const scale = Math.min(boxSize / imgW, boxSize / imgH); const renderW = imgW * scale; const renderH = imgH * scale; const rotationDeg = context._getBoxRotationValue(point) * (180 / Math.PI); const image = document.createElementNS('http://www.w3.org/2000/svg', 'image'); // Use cached URL to prevent blinking during timeline scrub const imageHref = context._getRefImageUrl(widget, attachment); if (!imageHref) return; // Skip if no valid image source // Force image reload by setting href to empty first, then to actual URL // This prevents browser from using stale cached image data image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', ''); image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', imageHref); image.setAttribute('width', renderW); image.setAttribute('height', renderH); image.setAttribute('x', -renderW / 2); image.setAttribute('y', -renderH / 2); image.setAttribute('preserveAspectRatio', 'xMidYMid meet'); const transformedPoint = transformVideoToCanvasSpace(context.splineEditor, point.x, point.y); image.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); image.style.pointerEvents = 'none'; image.style.opacity = '0.9'; // Store cache bust version to track if image needs updating image.dataset.cacheBustVersion = context._cacheBustCounter; context._pendingShapes.activeBoxImages.push(image); } } /** * Draws the rotation handle (stem and tip circle) for the active box * * @param {Object} context - The LayerRenderer instance (this) * @param {Function} pointGetter - Function that returns the current point * @param {Object} styles - Style configuration object */ export function drawCurrentBoxRotationHandle(context, pointGetter, styles = {}) { // 1. Draw handle line (Stem) context.activeLayerPanel.add(pv.Line) .data(() => { // Only render if current layer is still a box layer const activeWidget = context.node.layerManager.getActiveWidget(); if (context._getLayerType(activeWidget) !== 'box') return []; const point = pointGetter(); // <--- Dynamic lookup const geom = context._computeBoxHandleGeometry(point); return geom ? [geom.base, geom.tip] : []; }) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).y) // 2. Draw handle tip (Circle) context.activeLayerPanel.add(pv.Dot) .data(() => { // Only render if current layer is still a box layer const activeWidget = context.node.layerManager.getActiveWidget(); if (context._getLayerType(activeWidget) !== 'box') return []; const point = pointGetter(); // <--- Dynamic lookup const geom = context._computeBoxHandleGeometry(point); return geom ? [{ point, tip: geom.tip }] : []; }) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.tip.x, d.tip.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.tip.x, d.tip.y).y) .shape('circle') .radius(5) .fillStyle(styles.pointFill || 'rgba(45, 242, 109, 0.9)') .strokeStyle(styles.pointStroke || '#064f1c') .lineWidth(1.5) .cursor('grab') .event("mousedown", (d) => { const idx = context.splineEditor.resolvePointIndex?.(d.point) ?? context.splineEditor.points.indexOf(d.point); if (idx >= 0 && context.splineEditor.startBoxRotationDrag) { context.splineEditor.startBoxRotationDrag(idx, pv.event); } }); } /** * Draws the center dot for the active box * * @param {Object} context - The LayerRenderer instance (this) * @param {Function} pointGetter - Function that returns the current point * @param {Object} styles - Style configuration object */ export function drawCurrentBoxCenterDot(context, pointGetter, styles = {}) { context.activeLayerPanel.add(pv.Dot) .data(() => { // Only render if current layer is still a box layer const activeWidget = context.node.layerManager.getActiveWidget(); if (context._getLayerType(activeWidget) !== 'box') return []; const point = pointGetter(); // <--- Dynamic lookup return point ? [point] : []; }) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).y) .radius(3) .fillStyle(styles.pointFill || '#2df26d') .strokeStyle(styles.pointStroke || '#064f1c') .lineWidth(1) .events('none'); } /** * Queues a box manipulator for rendering at the current timeline point * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the box layer */ export function queueBoxManipulator(context, widget) { if (!widget || widget?.value?.type !== 'box_layer') return; const editor = context.splineEditor; const maxFrames = editor?._getMaxFrames ? editor._getMaxFrames() : BOX_TIMELINE_MAX_POINTS; const frame = Math.max(1, Math.min(maxFrames, Math.round(widget.value?.box_timeline_point || 1))); const normalized = editor?._computeBoxLayerPosition ? editor._computeBoxLayerPosition(widget, frame) : { x: 0.5, y: 0.5, scale: 1, rotation: 0 }; const denorm = editor?.denormalizePoints ? editor.denormalizePoints([{ x: normalized.x ?? 0.5, y: normalized.y ?? 0.5 }]) : [{ x: 0, y: 0 }]; const point = denorm[0] || { x: 0, y: 0 }; point.boxScale = context.clampPointScale(normalized.scale ?? 1); point.scale = point.boxScale; point.boxRotation = (typeof normalized.rotation === 'number' && !Number.isNaN(normalized.rotation)) ? normalized.rotation : 0; const styles = context._getLayerStyles('box', 'active', true, false); const radius = context.getScaledBoxRadius(point); const svgStyles = { radius, stroke: styles.pointStroke || "#139613", strokeWidth: styles.lineWidth || 2, fill: styles.pointFill || "rgba(19, 150, 19, 0.1)", cursor: "default" }; const attachment = context._getSelectedRefAttachment(widget); // During preview mode (_boxRefOnlyMode), don't render green boxes or rotation text if (!context._boxRefOnlyMode) { const element = context._createSVGPath('square', point, svgStyles, context._getBoxRotationValue(point)); if (element) { element.style.pointerEvents = 'none'; context._pendingShapes.activeBoxDots.push(element); } // Add rotation text display (green with 60% opacity) const rotationRad = context._getBoxRotationValue(point); const rotationDeg = rotationRad * (180 / Math.PI); // Create a group to hold the text so we can position it correctly const textGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); textGroup.setAttribute('data-rotation-text', 'true'); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); // Position at top-left corner of the box (relative to box center) const offsetX = -radius; const offsetY = -radius - 5; text.setAttribute('x', offsetX); text.setAttribute('y', offsetY); text.setAttribute('fill', 'rgba(19, 150, 19, 0.6)'); // Green with 60% opacity text.setAttribute('font-size', '14'); text.setAttribute('font-family', 'monospace'); text.setAttribute('font-weight', 'bold'); text.setAttribute('text-anchor', 'start'); text.setAttribute('pointer-events', 'none'); text.style.userSelect = 'none'; text.textContent = `${rotationDeg.toFixed(1)}°`; textGroup.appendChild(text); const transformedPoint = transformVideoToCanvasSpace(context.splineEditor, point.x, point.y); textGroup.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y})`); textGroup.style.pointerEvents = 'none'; context._pendingShapes.activeBoxDots.push(textGroup); } // Render attached reference image if it exists if (attachment && (attachment.base64 || attachment.path)) { const boxSize = radius * 2; const imgW = Math.max(1, attachment.width || boxSize); const imgH = Math.max(1, attachment.height || boxSize); const scale = Math.min(boxSize / imgW, boxSize / imgH); const renderW = imgW * scale; const renderH = imgH * scale; const rotationDeg = context._getBoxRotationValue(point) * (180 / Math.PI); const image = document.createElementNS('http://www.w3.org/2000/svg', 'image'); // Use cached URL to prevent blinking during timeline scrub const imageHref = context._getRefImageUrl(widget, attachment); if (!imageHref) return; // Skip if no valid image source // Force image reload by setting href to empty first, then to actual URL // This prevents browser from using stale cached image data image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', ''); image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', imageHref); image.setAttribute('width', renderW); image.setAttribute('height', renderH); image.setAttribute('x', -renderW / 2); image.setAttribute('y', -renderH / 2); image.setAttribute('preserveAspectRatio', 'xMidYMid meet'); const transformedPoint = transformVideoToCanvasSpace(context.splineEditor, point.x, point.y); image.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); image.style.pointerEvents = 'none'; image.style.opacity = '0.9'; // Store cache bust version to track if image needs updating image.dataset.cacheBustVersion = context._cacheBustCounter; context._pendingShapes.activeBoxImages.push(image); } } /** * Draws the line connecting active layer points * * @param {Object} context - The LayerRenderer instance (this) * @param {string} interpolation - Interpolation mode (linear, cardinal, basis, points) * @param {boolean} isPointMode - Whether in point mode * @param {string} layerType - Type of layer (normal, handdraw, box) * @param {Object} styles - Style configuration object */ export function drawActiveLine(context, interpolation, isPointMode, layerType, styles) { if (layerType === 'box') return; // Box layers render via box helpers only // We ignore the passed 'safePoints' and fetch fresh ones inside .data() context.activeLayerPanel.add(pv.Line) .data(() => { const currentPoints = context.splineEditor.points || []; const validPoints = currentPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y)); // If we don't have points, or if this is actually a box layer // (but the renderer got confused), return empty to hide the line. const activeWidget = context.node.layerManager.getActiveWidget(); const activeType = context._getLayerType(activeWidget); if (activeType === 'box') return []; return prepareLineData(validPoints, interpolation, isPointMode); }) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).y) .interpolate(() => isPointMode ? 'linear' : interpolation) .strokeStyle(() => styles.lineStroke) .lineWidth(() => styles.lineWidth); } /** * Prepares line data by inserting helper points around highlighted points for linear segments * * @param {Array} safePoints - Array of valid points * @param {string} interpolation - Interpolation mode * @param {boolean} isPointMode - Whether in point mode * @returns {Array} Prepared points for line rendering */ export function prepareLineData(safePoints, interpolation, isPointMode) { if (isPointMode || interpolation === 'linear') return safePoints; const hasHighlighted = safePoints.some(p => p.highlighted); if (!hasHighlighted) return safePoints; // Insert helper points around highlighted points for linear segments const result = []; for (let i = 0; i < safePoints.length; i++) { const point = safePoints[i]; if (point.highlighted) { if (i > 0) { const prev = safePoints[i - 1]; const dx = point.x - prev.x, dy = point.y - prev.y; const dist = Math.sqrt(dx * dx + dy * dy); const offset = Math.min(0.01, 1 / dist); result.push({ x: prev.x + dx * (1 - offset), y: prev.y + dy * (1 - offset) }); } result.push(point); if (i < safePoints.length - 1) { const next = safePoints[i + 1]; const dx = next.x - point.x, dy = next.y - point.y; const dist = Math.sqrt(dx * dx + dy * dy); const offset = Math.min(0.01, 1 / dist); result.push({ x: point.x + dx * offset, y: point.y + dy * offset }); } } else { result.push(point); } } return result; } /** * Draws dots (circles, triangles, squares) for active layer points * * @param {Object} context - The LayerRenderer instance (this) * @param {string} interpolation - Interpolation mode * @param {boolean} isPointMode - Whether in point mode * @param {string} layerType - Type of layer * @param {Object} styles - Style configuration object * @param {Array} allPoints - All points in the layer */ export function drawActiveDots(context, interpolation, isPointMode, layerType, styles, allPoints) { if (layerType === 'box') return; // Box layers use box-specific drawing // Get current points const currentPoints = context.splineEditor.points || []; const validPoints = currentPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y)); // Queue SVG elements for each point (will be rendered after vis.render()) validPoints.forEach((point, idx) => { const shape = context._getDotShape(point, interpolation, layerType, idx); const strokeStyle = context._getDotStrokeStyle(point, interpolation, isPointMode, layerType, styles, validPoints); const fillStyle = context._getDotFillStyle(point, interpolation, isPointMode, layerType, styles, validPoints); const svgStyles = { radius: styles.pointRadius || 6, stroke: strokeStyle, strokeWidth: styles.strokeWidth || styles.lineWidth || 3, fill: fillStyle || 'none', cursor: isPointMode ? 'default' : 'move' }; let element; if (shape === 'circle') { element = context._createSVGCircle(point, svgStyles); } else if (shape === 'triangle' || shape === 'square') { const rotation = context._getDotAngle(point, interpolation, layerType); element = context._createSVGPath(shape, point, svgStyles, rotation); } if (element) { // Add event handlers element.addEventListener('mousedown', (e) => { if (context.splineEditor.handlePointPointerDown) { context.splineEditor.handlePointPointerDown(point, e); } }); element.addEventListener('mouseover', (e) => { if (context.splineEditor.mouseOverHandler) { context.splineEditor.mouseOverHandler.call(context.splineEditor, point); } }); element.addEventListener('mouseout', (e) => { if (context.splineEditor.mouseOutHandler) { context.splineEditor.mouseOutHandler.call(context.splineEditor, point); } }); // Queue for rendering after vis.render() context._pendingShapes.activeDots.push(element); } }); } /** * Draws a preview dot showing hover state on active box layer * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the box layer */ export function drawBoxPreview(context, widget) { const previewState = context.splineEditor._boxPreviewState; if (!previewState || previewState.widget !== widget || !Number.isFinite(previewState.x) || !Number.isFinite(previewState.y)) return; context.activeLayerPanel.add(pv.Dot) .data(() => { // Only render if current layer is still a box layer const activeWidget = context.node.layerManager.getActiveWidget(); if (context._getLayerType(activeWidget) !== 'box') return []; return [previewState]; }) .left(d => d.x) .top(d => d.y) .radius(() => POINT_BASE_RADIUS) .fillStyle('rgba(44, 198, 255, 0.85)') .strokeStyle('#0b3b4b') .lineWidth(1.5) .events('none'); } /** * Draws an inactive layer with lines and dots * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the layer */ export function drawInactiveLayer(context, widget) { const layerType = context._getLayerType(widget); const isOff = !widget.value.on; const isBoxLayer = layerType === 'box'; let points; try { points = isBoxLayer ? getBoxLayerPoints(context, widget) : getRegularLayerPoints(context, widget); } catch (e) { console.error("Error parsing points for inactive layer:", e); return; } if (!points?.length) return; const layerPanel = context.vis.add(pv.Panel); context.inactiveLayerPanels.push(layerPanel); context.inactiveLayerMetadata.push({ widget, points, panel: layerPanel }); if (isBoxLayer) { const boxStyles = context._getLayerStyles('box', 'inactive', true, isOff); drawInactiveBoxLayer(context, layerPanel, points, widget, boxStyles); return; } const interpolation = context._normalizeInterpolation(widget.value.interpolation, layerType); const isPointMode = (interpolation === 'points'); const styles = context._getLayerStyles(layerType, 'inactive', isPointMode, isOff); drawInactiveLine(context, layerPanel, points, interpolation, isPointMode, styles, widget); if (layerType !== 'handdraw') { drawInactiveDots(context, layerPanel, points, interpolation, isPointMode, styles, layerType, widget); } } /** * Gets points for a box layer from its keyframe data * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the box layer * @returns {Array} Array of denormalized points */ export function getBoxLayerPoints(context, widget) { const boxKeys = Array.isArray(widget.value.box_keys) ? widget.value.box_keys : []; if (!boxKeys.length) return null; const sortedKeys = boxKeys .filter(k => typeof k?.x === 'number' && typeof k?.y === 'number') .slice() .sort((a, b) => (a.frame || 0) - (b.frame || 0)); if (!sortedKeys.length) return null; const interpolationMode = widget.value.box_interpolation || 'linear'; let curvePoints; if (interpolationMode === 'basis' && sortedKeys.length >= 3) { curvePoints = generateBasisCurvePoints(sortedKeys); } else { curvePoints = sortedKeys.map(k => ({ x: k.x, y: k.y })); } return context.splineEditor.denormalizePoints(curvePoints); } /** * Gets points for a regular (non-box) layer from stored points * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the layer * @returns {Array} Array of denormalized points */ export function getRegularLayerPoints(context, widget) { const storedPoints = JSON.parse(widget.value.points_store || '[]'); return context.splineEditor.denormalizePoints(storedPoints); } /** * Draws a line for an inactive layer * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} layerPanel - The Protovis panel for the layer * @param {Array} points - Array of points to draw * @param {string} interpolation - Interpolation mode * @param {boolean} isPointMode - Whether in point mode * @param {Object} styles - Style configuration object */ export function drawInactiveLine(context, layerPanel, points, interpolation, isPointMode, styles, widget) { const renderPoints = prepareLineData(points, interpolation, isPointMode); const lineWidth = styles.lineWidth ?? (isPointMode ? 1 : 3); layerPanel.add(pv.Line).events("none") .data(renderPoints) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).y) .interpolate(isPointMode ? 'linear' : interpolation) .strokeStyle(styles.lineStroke) .lineWidth(lineWidth); // Add a second, wider, transparent line for hit detection layerPanel.add(pv.Line) .data(renderPoints) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).y) .interpolate(isPointMode ? 'linear' : interpolation) .strokeStyle("rgba(0,0,0,0)") // transparent .lineWidth(Math.max(lineWidth, 10)) // wider .event("dblclick", () => { if (context.splineEditor?.node?.layerManager && widget) { context.splineEditor.node.layerManager.setActiveWidget(widget); } }) .cursor("default"); } /** * Draws dots for an inactive layer * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} layerPanel - The Protovis panel for the layer * @param {Array} points - Array of points to draw * @param {string} interpolation - Interpolation mode * @param {boolean} isPointMode - Whether in point mode * @param {Object} styles - Style configuration object * @param {string} layerType - Type of layer * @param {Object} widget - The widget representing the layer */ export function drawInactiveDots(context, layerPanel, points, interpolation, isPointMode, styles, layerType, widget) { if (layerType === 'box') return; // Create deep copy to prevent sharing with active layers const orderedDots = [...points].map(p => ({...p})).sort((a, b) => context.splineEditor.getBoxPointRadius ? context.splineEditor.getBoxPointRadius(b) - context.splineEditor.getBoxPointRadius(a) : 0 ); const firstPointOriginal = points.length > 0 ? points[0] : null; orderedDots.forEach((dot) => { let shape = 'circle'; let isFirstPoint = false; // Determine shape based on layer type and position if (layerType === 'normal' && !isPointMode) { // Check if this dot is the first point by comparing coordinates if (firstPointOriginal && Math.abs(dot.x - firstPointOriginal.x) < 0.001 && Math.abs(dot.y - firstPointOriginal.y) < 0.001) { shape = 'triangle'; isFirstPoint = true; } else { shape = 'circle'; } } else if (layerType === 'handdraw') { shape = 'circle'; } const pointRadius = isPointMode ? POINT_BASE_RADIUS * 0.6 : 4.5; const svgStyles = { radius: pointRadius, stroke: styles.pointStroke || 'rgb(31,119,180)', strokeWidth: styles.strokeWidth || 3, fill: styles.pointFill || 'none', cursor: 'default' }; let element; if (shape === 'circle') { element = context._createSVGCircle(dot, svgStyles); } else if (shape === 'triangle') { // Calculate rotation for the first point triangle let rotationRad = 0; if (isFirstPoint && points.length > 1) { const dx = points[1].x - points[0].x; const dy = points[1].y - points[0].y; rotationRad = (dx !== 0 || dy !== 0) ? (Math.atan2(dy, dx) - Math.PI / 2 + Math.PI + 2 * Math.PI) % (2 * Math.PI) : 0; } element = context._createSVGPath(shape, dot, svgStyles, rotationRad); } if (element) { // Inactive dots need to be clickable for double-click layer switching element.style.pointerEvents = 'auto'; element.style.cursor = 'default'; // Add double-click event handler to switch to this layer element.addEventListener('dblclick', (e) => { e.preventDefault(); e.stopPropagation(); if (context.splineEditor?.node?.layerManager && widget) { context.splineEditor.node.layerManager.setActiveWidget(widget); } }); // Queue for rendering after vis.render() context._pendingShapes.inactiveDots.push(element); } }); return; // Disable old Protovis code } /** * Draws an inactive box layer with keyframe path and reference image * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} layerPanel - The Protovis panel for the layer * @param {Array} points - Array of points to draw * @param {Object} widget - The widget representing the box layer * @param {Object} styles - Style configuration object */ export function drawInactiveBoxLayer(context, layerPanel, points, widget, styles) { // Always render ref image even if no keyframes exist const hasPoints = points && points.length > 0; if (hasPoints) { // Only use the main keyframe points for the inactive circles const sanitizedKeys = context.splineEditor._ensureBoxLayerData(widget); const keyframeNormPoints = sanitizedKeys.map(k => ({ x: k.x, y: k.y })); const keyframeDenormPoints = safeDenormalizePoints(context, keyframeNormPoints) || []; const orderedDots = [...keyframeDenormPoints].map(p => ({ ...p })).sort((a, b) => context.splineEditor.getBoxPointRadius ? context.splineEditor.getBoxPointRadius(b) - context.splineEditor.getBoxPointRadius(a) : 0 ); drawInactiveLine(context, layerPanel, points, 'linear', true, styles, widget); // Keyframe markers (uses Protovis) - relying solely on this for keyframe circles drawBoxKeyPath(context, layerPanel, widget, false); } // Render reference image for inactive box layer at 50% opacity (even without keyframes) renderInactiveBoxRefImage(context, widget, 0.5); } /** * Renders the reference image for an inactive box layer * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} widget - The widget representing the box layer * @param {number} opacity - Opacity for the image (0-1) */ export function renderInactiveBoxRefImage(context, widget, opacity = 0.5) { // Get selected ref attachment for this widget const attachment = context._getSelectedRefAttachment(widget); if (!attachment || (!attachment.base64 && !attachment.path)) return; if (widget.value.ref_selection === 'no_ref') return; // Get current frame from timeline const maxFrames = context.splineEditor._getMaxFrames ? context.splineEditor._getMaxFrames() : 250; const frame = Math.max(1, Math.min(maxFrames, Math.round(widget.value?.box_timeline_point || 1))); // Compute box position at current frame const normalized = context.splineEditor._computeBoxLayerPosition ? context.splineEditor._computeBoxLayerPosition(widget, frame) : { x: 0.5, y: 0.5, scale: 1, rotation: 0 }; // Denormalize coordinates to canvas space const denorm = context.splineEditor.denormalizePoints ? context.splineEditor.denormalizePoints([{ x: normalized.x ?? 0.5, y: normalized.y ?? 0.5 }]) : [{ x: 0, y: 0 }]; const point = denorm[0] || { x: 0, y: 0 }; point.boxScale = context.clampPointScale ? context.clampPointScale(normalized.scale ?? 1) : (normalized.scale ?? 1); point.scale = point.boxScale; point.boxRotation = (typeof normalized.rotation === 'number' && !Number.isNaN(normalized.rotation)) ? normalized.rotation : 0; // Calculate box size const boxRadius = context.getScaledBoxRadius(point); const boxSize = boxRadius * 2; const imgW = Math.max(1, attachment.width || boxSize); const imgH = Math.max(1, attachment.height || boxSize); // Calculate image dimensions (preserve aspect ratio) const scale = Math.min(boxSize / imgW, boxSize / imgH); const renderW = imgW * scale; const renderH = imgH * scale; const rotationDeg = point.boxRotation * (180 / Math.PI); // Create SVG image element const image = document.createElementNS('http://www.w3.org/2000/svg', 'image'); // Get cached image URL const imageHref = context._getRefImageUrl(widget, attachment); if (!imageHref) return; // Skip if no valid image source // Force image reload by setting href to empty first, then to actual URL image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', ''); image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', imageHref); image.setAttribute('width', renderW); image.setAttribute('height', renderH); image.setAttribute('x', -renderW / 2); image.setAttribute('y', -renderH / 2); image.setAttribute('preserveAspectRatio', 'xMidYMid meet'); // Transform to canvas space and apply rotation const transformedPoint = transformVideoToCanvasSpace(context.splineEditor, point.x, point.y); image.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); // Set opacity and pointer events image.style.pointerEvents = 'none'; image.style.opacity = String(opacity); // Store cache bust version to track if image needs updating image.dataset.cacheBustVersion = context._cacheBustCounter; // Push to inactive box images queue context._pendingShapes.inactiveBoxImages.push(image); } /** * Draws the keyframe path for a box layer (connecting line and keyframe indicators) * * @param {Object} context - The LayerRenderer instance (this) * @param {Object} panel - The Protovis panel for the layer * @param {Object} widget - The widget representing the box layer * @param {boolean} isActive - Whether this is the active layer */ export function drawBoxKeyPath(context, panel, widget, isActive) { const sortedKeys = context.splineEditor._ensureBoxLayerData(widget); if (!sortedKeys.length) return; // Check if we're currently manipulating box keyframes const isManipulating = context.splineEditor._boxKeyframePoints && context.splineEditor._manipulatingBoxKeyframe && context.splineEditor._manipulatingBoxKeyframe.widget === widget; let keyframeDenormPoints; let keyframeNormPoints; if (isManipulating) { // Use the working points being manipulated for real-time updates keyframeDenormPoints = context.splineEditor._boxKeyframePoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y) ); // Also create normalized points for path generation keyframeNormPoints = context.splineEditor.normalizePoints ? context.splineEditor.normalizePoints(keyframeDenormPoints) : sortedKeys.map(k => ({ x: k.x, y: k.y })); } else { // Use the stored box_keys keyframeNormPoints = sortedKeys.map(k => ({ x: k.x, y: k.y })); keyframeDenormPoints = safeDenormalizePoints(context, keyframeNormPoints); } if (!keyframeDenormPoints || !keyframeDenormPoints.length) return; const interpolationMode = widget.value.box_interpolation || 'linear'; // Validate denormalized points to prevent drawing artifacts const validKeyframeDenormPoints = keyframeDenormPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y) ); if (!validKeyframeDenormPoints.length) return; // Generate path points for connecting line let pathNormPoints = keyframeNormPoints; if (interpolationMode === 'basis' && sortedKeys.length >= 3) { pathNormPoints = generateBasisCurvePoints(sortedKeys); } let pathDenormPoints = validKeyframeDenormPoints; if (pathNormPoints !== keyframeNormPoints) { const tempPath = safeDenormalizePoints(context, pathNormPoints); if (tempPath && tempPath.length >= 2) { pathDenormPoints = tempPath; } } const activeColor = '#2df26d'; const inactiveColor = '#f04d3a'; const color = isActive ? activeColor : inactiveColor; const fillColor = isActive ? 'rgba(45, 242, 109, 0.3)' : 'rgba(240,77,58,0.25)'; // Manually draw connecting path for active box layer to fix render bug if (isActive && sortedKeys.length >= 2) { const targetWidget = widget; const currentManipulating = context.splineEditor._boxKeyframePoints && context.splineEditor._manipulatingBoxKeyframe && context.splineEditor._manipulatingBoxKeyframe.widget === targetWidget; let pathPoints; if (currentManipulating) { pathPoints = context.splineEditor._boxKeyframePoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y) ); } else { const currentSortedKeys = context.splineEditor._ensureBoxLayerData(targetWidget); const interpolationMode = targetWidget.value.box_interpolation || 'linear'; let normPoints = currentSortedKeys.map(k => ({ x: k.x, y: k.y })); if (interpolationMode === 'basis' && currentSortedKeys.length >= 3) { normPoints = generateBasisCurvePoints(currentSortedKeys); } pathPoints = safeDenormalizePoints(context, normPoints); } if (pathPoints && pathPoints.length >= 2) { const canvasPoints = pathPoints.map(p => transformVideoToCanvasSpace(context.splineEditor, p.x, p.y)); const d = canvasPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', d); path.setAttribute('stroke', color); path.setAttribute('stroke-width', String(isActive ? 2 : 1.5)); path.setAttribute('fill', 'none'); path.style.pointerEvents = 'none'; context._pendingShapes.activeLines.push(path); } } // Draw keyframe indicator circles with interaction support // These show where keyframes exist on the timeline and can be manipulated validKeyframeDenormPoints.forEach((point, index) => { // Add frame info to the point so we can identify which keyframe it belongs to const keyframeData = { ...point, frame: sortedKeys[index]?.frame, isBoxKeyframe: true, // Mark this as a box keyframe for special handling boxKeyIndex: index, widget: widget }; if (isActive) { // Active layer: make keyframes interactive with dynamic positioning panel.add(pv.Dot) .data(() => { // Get real-time position during manipulation const currentManipulating = context.splineEditor._boxKeyframePoints && context.splineEditor._manipulatingBoxKeyframe && context.splineEditor._manipulatingBoxKeyframe.widget === widget; if (currentManipulating && context.splineEditor._boxKeyframePoints[index]) { // Use working point position return [{ ...keyframeData, x: context.splineEditor._boxKeyframePoints[index].x, y: context.splineEditor._boxKeyframePoints[index].y }]; } // Use original position return [keyframeData]; }) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).y) .shape('circle') .radius(4) .strokeStyle(color) .lineWidth(2) .fillStyle(fillColor) // Semi-transparent fill for better visibility .event("mousedown", (d) => { if (context.splineEditor.handleBoxKeyframePointerDown) { context.splineEditor.handleBoxKeyframePointerDown(d, pv.event); } }) .event("mouseover", (d) => { if (context.splineEditor.mouseOverHandler) { context.splineEditor.mouseOverHandler(d); } }) .event("mouseout", (d) => { if (context.splineEditor.mouseOutHandler) { context.splineEditor.mouseOutHandler(); } }); } else { // Inactive layer: non-interactive panel.add(pv.Dot) .data([keyframeData]) .left(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).x) .top(d => transformVideoToCanvasSpace(context.splineEditor, d.x, d.y).y) .shape('circle') .radius(3) .strokeStyle(color) .lineWidth(2) .fillStyle(fillColor) .event("dblclick", (d) => { if (context.splineEditor?.node?.layerManager && d.widget) { context.splineEditor.node.layerManager.setActiveWidget(d.widget); } }) .cursor("default"); } }); } /** * Generates smooth basis spline curve points from keyframes * * @param {Array} sortedKeys - Array of keyframe data sorted by frame * @returns {Array} Array of points along the smooth curve */ export function generateBasisCurvePoints(sortedKeys) { if (sortedKeys.length < 3) { return sortedKeys.map(k => ({ x: k.x, y: k.y })); } const points = []; const stepsPerSegment = 20; const basisInterpolate = (p0, p1, p2, p3, t) => { const t2 = t * t, t3 = t2 * t; const b0 = (1 - t3 + 3 * t2 - 3 * t) / 6; const b1 = (4 - 6 * t2 + 3 * t3) / 6; const b2 = (1 + 3 * t + 3 * t2 - 3 * t3) / 6; const b3 = t3 / 6; return { x: b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x, y: b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y }; }; for (let i = 0; i < sortedKeys.length - 1; i++) { const p0 = sortedKeys[Math.max(0, i - 1)]; const p1 = sortedKeys[i]; const p2 = sortedKeys[i + 1]; const p3 = sortedKeys[Math.min(sortedKeys.length - 1, i + 2)]; if (i === 0) points.push({ x: p1.x, y: p1.y }); for (let step = 1; step < stepsPerSegment; step++) { const t = step / stepsPerSegment; points.push(basisInterpolate(p0, p1, p2, p3, t)); } if (i === sortedKeys.length - 2) points.push({ x: p2.x, y: p2.y }); } return points; } /** * Safely denormalizes points, returning null if denormalization fails * * @param {Object} context - The LayerRenderer instance (this) * @param {Array} points - Array of normalized points * @returns {Array|null} Denormalized points or null on error */ function safeDenormalizePoints(context, points) { if (!points?.length) return null; try { return context.splineEditor.denormalizePoints(points); } catch { return null; } }