diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 14c973e735..0bcaf6b25e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2259,15 +2259,14 @@ export function initSandboxRuntimeModular(): void { bindMediaMetadataListeners(); } - // Keep clock duration in sync with the resolved timeline duration. - // Catches async timeline rebinds that happen outside the 60-tick - // branch (metadata hydration, deferred setTimeout). Note: this reads - // the DOM each tick (duration floors query authored windows + the - // root's declared data-duration), which also keeps live edits to - // data-duration in the studio reflected without a rebind. + // Sync clock duration with the resolved timeline each tick (catches async + // rebinds, live data-duration edits). Never shrink while playing — transient + // short reads cause reachedEnd() → playhead jumps to end (#1636). if (state.capturedTimeline) { const dur = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); - if (dur > 0) clock.setDuration(dur); + if (dur > 0 && (!clock.isPlaying() || dur >= clock.getDuration())) { + clock.setDuration(dur); + } } // Audio-master clock: three tiers of timing precision. diff --git a/packages/producer/src/services/distributed/renderChunk.ts b/packages/producer/src/services/distributed/renderChunk.ts index c6effc1fdb..b1cc2588d9 100644 --- a/packages/producer/src/services/distributed/renderChunk.ts +++ b/packages/producer/src/services/distributed/renderChunk.ts @@ -707,7 +707,7 @@ export async function renderChunk( // Clean up only after the hash + perf sidecar landed. Any failure above // leaves the framesDir in place for inspection. try { - rmSync(workDir, { recursive: true, force: true }); + rmSync(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } catch (err) { log.warn("[renderChunk] failed to remove work dir", { workDir, diff --git a/packages/producer/src/services/render/cleanup.ts b/packages/producer/src/services/render/cleanup.ts index cd5de79399..ce3f7aa677 100644 --- a/packages/producer/src/services/render/cleanup.ts +++ b/packages/producer/src/services/render/cleanup.ts @@ -65,7 +65,7 @@ export async function cleanupRenderResources(input: { // `force: true` swallows ENOENT, so no need to existsSync first. await safeCleanup( `remove workDir (${label})`, - () => rmSync(workDir, { recursive: true, force: true }), + () => rmSync(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }), log, ); } diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index b43d2198be..5670de9ab2 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1699,7 +1699,7 @@ export async function executeRenderJob( await safeCleanup( "remove workDir", () => { - rmSync(workDir, { recursive: true, force: true }); + rmSync(workDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }, log, ); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index 020de0b853..785eaa7727 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -73,7 +73,7 @@ export const STUDIO_COLOR_GRADING_ENABLED = resolveStudioBooleanEnvFlag( export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_ENABLE_KEYFRAMES", "VITE_STUDIO_KEYFRAMES_ENABLED"], - false, + true, ); export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag( diff --git a/packages/studio/src/components/panels/SlideshowPanel.tsx b/packages/studio/src/components/panels/SlideshowPanel.tsx index 9f5a999033..c4a30f0f37 100644 --- a/packages/studio/src/components/panels/SlideshowPanel.tsx +++ b/packages/studio/src/components/panels/SlideshowPanel.tsx @@ -20,6 +20,7 @@ import type { SlideshowManifest, SlideHotspot } from "@hyperframes/core/slidesho import { usePlayerStore } from "../../player"; import { useDomEditSelectionContext } from "../../contexts/DomEditContext"; import { useFileManagerContext } from "../../contexts/FileManagerContext"; +import { generateId } from "../../utils/generateId"; import { SectionHeader, SlideList, @@ -325,7 +326,7 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP const handleCreateSequence = useCallback( (label: string) => { - const id = `seq-${crypto.randomUUID()}`; + const id = `seq-${generateId()}`; applyManifest(createSequence(manifestRef.current, id, label)).catch(() => {}); }, [applyManifest], diff --git a/packages/studio/src/components/panels/SlideshowSubPanels.tsx b/packages/studio/src/components/panels/SlideshowSubPanels.tsx index 39aaa11fdc..580e75e9c2 100644 --- a/packages/studio/src/components/panels/SlideshowSubPanels.tsx +++ b/packages/studio/src/components/panels/SlideshowSubPanels.tsx @@ -7,6 +7,7 @@ import { useState, useCallback, useId } from "react"; import type { SlideRef, SlideHotspot, SlideSequence } from "@hyperframes/core/slideshow"; import type { DomEditSelection } from "../editor/domEditing"; import type { SceneInfo } from "./slideshowPanelHelpers"; +import { generateId } from "../../utils/generateId"; // ── Section header (accordion toggle) ──────────────────────────────────── @@ -425,7 +426,7 @@ export function HotspotTool({ // fallow-ignore-next-line complexity const handleMakeHotspot = useCallback(() => { if (!selectedSceneId || !targetSequenceId || !elementKey) return; - const id = `hotspot-${elementKey}-${crypto.randomUUID()}`; + const id = `hotspot-${elementKey}-${generateId()}`; const label = hotspotLabel.trim() || elementKey; onAddHotspot(selectedSceneId, { id, label, target: targetSequenceId }); setHotspotLabel(""); diff --git a/packages/studio/src/components/renders/useRenderQueue.ts b/packages/studio/src/components/renders/useRenderQueue.ts index 79188da579..d135e4a6e8 100644 --- a/packages/studio/src/components/renders/useRenderQueue.ts +++ b/packages/studio/src/components/renders/useRenderQueue.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { trackStudioRenderStart } from "../../telemetry/events"; import { getAnonymousId } from "../../telemetry/config"; +import { generateId } from "../../utils/generateId"; export interface RenderJob { id: string; @@ -131,7 +132,7 @@ export function useRenderQueue(projectId: string | null) { }); } catch { const failedJob: RenderJob = { - id: crypto.randomUUID(), + id: generateId(), status: "failed", progress: 0, error: "Could not reach render server. Use `hyperframes render` from the CLI instead.", @@ -143,7 +144,7 @@ export function useRenderQueue(projectId: string | null) { } if (!res.ok) { const failedJob: RenderJob = { - id: crypto.randomUUID(), + id: generateId(), status: "failed", progress: 0, error: `Server error (${res.status}). Check the terminal for details.`, diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts index cc0cd4479c..06461d2879 100644 --- a/packages/studio/src/hooks/gsapDragCommit.test.ts +++ b/packages/studio/src/hooks/gsapDragCommit.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, beforeEach } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit"; import { commitStaticGsapPosition, commitStaticGsapRotation, parkPlayheadOnKeyframe, type GsapDragCommitCallbacks, } from "./gsapDragCommit"; -import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit"; import { usePlayerStore } from "../player/store/playerStore"; // Minimal selection whose element has no drag-baseline attributes (origX/Y = 0). diff --git a/packages/studio/src/telemetry/config.ts b/packages/studio/src/telemetry/config.ts index 658cb2b3b0..2e26813230 100644 --- a/packages/studio/src/telemetry/config.ts +++ b/packages/studio/src/telemetry/config.ts @@ -5,6 +5,8 @@ // localStorage.setItem('hyperframes-studio:telemetryDisabled','1') // --------------------------------------------------------------------------- +import { generateId } from "../utils/generateId"; + const ANON_ID_KEY = "hyperframes-studio:anonymousId"; const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled"; const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown"; @@ -18,8 +20,7 @@ function safeLocalStorage(): Storage | null { } function newAnonymousId(): string { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); - return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + return generateId(); } export function getAnonymousId(): string { diff --git a/packages/studio/src/utils/generateId.ts b/packages/studio/src/utils/generateId.ts new file mode 100644 index 0000000000..bd22e466d7 --- /dev/null +++ b/packages/studio/src/utils/generateId.ts @@ -0,0 +1,7 @@ +// ponytail: crypto.randomUUID is undefined on plain HTTP non-localhost origins +export function generateId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} diff --git a/packages/studio/src/utils/studioTelemetry.ts b/packages/studio/src/utils/studioTelemetry.ts index a3e789de98..b736919c39 100644 --- a/packages/studio/src/utils/studioTelemetry.ts +++ b/packages/studio/src/utils/studioTelemetry.ts @@ -1,3 +1,5 @@ +import { generateId } from "./generateId"; + // PostHog public ingest key — write-only, safe to ship in the client bundle const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx"; const POSTHOG_HOST = "https://us.i.posthog.com"; @@ -29,7 +31,7 @@ function getDistinctId(): string { } catch { // localStorage may be unavailable } - distinctId = crypto.randomUUID(); + distinctId = generateId(); try { localStorage.setItem("hf-studio-anon-id", distinctId); } catch {