(null);
+ const [previewTime, setPreviewTime] = createSignal(
+ editorState.playbackTime,
+ );
+ const [videoLoaded, setVideoLoaded] = createSignal(false);
+
+ const currentSegment = createMemo(() => {
+ const time = previewTime();
+ let elapsed = 0;
+ for (const seg of project.timeline?.segments ?? []) {
+ const segDuration = (seg.end - seg.start) / seg.timescale;
+ if (time < elapsed + segDuration) {
+ return {
+ index: seg.recordingSegment ?? 0,
+ localTime: seg.start / seg.timescale + (time - elapsed),
+ };
+ }
+ elapsed += segDuration;
+ }
+ return { index: 0, localTime: 0 };
+ });
+
+ const videoSrc = createMemo(() =>
+ convertFileSrc(
+ `${editorInstance.path}/content/segments/segment-${currentSegment().index}/display.mp4`,
+ ),
+ );
+
+ createEffect(
+ on(
+ () => currentSegment().index,
+ () => {
+ setVideoLoaded(false);
+ },
+ { defer: true },
+ ),
+ );
+
+ createEffect(() => {
+ if (videoRef && videoLoaded()) {
+ videoRef.currentTime = currentSegment().localTime;
+ }
+ });
const initialBounds = {
x: dialog().position.x,
@@ -582,16 +638,60 @@ function Dialogs() {
allowLightMode={true}
onContextMenu={(e) => showCropOptionsMenu(e, true)}
>
-
+
+

+
+
+
+ {formatTime(previewTime())}
+
+ setPreviewTime(v)}
+ aria-label="Video timeline"
+ />
+
+ {formatTime(totalDuration())}
+
+
}
>
Crop
diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts
index 7ab29a7ab5..c993c3fcde 100644
--- a/apps/desktop/src/routes/editor/context.ts
+++ b/apps/desktop/src/routes/editor/context.ts
@@ -12,6 +12,7 @@ import {
batch,
createEffect,
createResource,
+ createRoot,
createSignal,
on,
onCleanup,
@@ -39,6 +40,10 @@ import {
type TimelineConfiguration,
type XY,
} from "~/utils/tauri";
+import {
+ cleanup as cleanupCropVideoPreloader,
+ preloadCropVideoMetadata,
+} from "./cropVideoPreloader";
import type { MaskSegment } from "./masks";
import type { TextSegment } from "./text";
import { createProgressBar } from "./utils";
@@ -747,11 +752,22 @@ export const [EditorInstanceContextProvider, useEditorInstanceContext] =
const [canvasControls, setCanvasControls] =
createSignal(null);
+ let disposeWorkerReadyEffect: (() => void) | undefined;
+
+ onCleanup(() => {
+ disposeWorkerReadyEffect?.();
+ cleanupCropVideoPreloader();
+ });
+
const [editorInstance] = createResource(async () => {
console.log("[Editor] Creating editor instance...");
const instance = await commands.createEditorInstance();
console.log("[Editor] Editor instance created, setting up WebSocket");
+ preloadCropVideoMetadata(
+ `${instance.path}/content/segments/segment-0/display.mp4`,
+ );
+
const requestFrame = () => {
events.renderFrameEvent.emit({
frame_number: 0,
@@ -768,8 +784,11 @@ export const [EditorInstanceContextProvider, useEditorInstanceContext] =
setCanvasControls(controls);
- createEffect(() => {
- setIsWorkerReady(workerReady());
+ disposeWorkerReadyEffect = createRoot((dispose) => {
+ createEffect(() => {
+ setIsWorkerReady(workerReady());
+ });
+ return dispose;
});
ws.addEventListener("open", () => {
diff --git a/apps/desktop/src/routes/editor/cropVideoPreloader.ts b/apps/desktop/src/routes/editor/cropVideoPreloader.ts
new file mode 100644
index 0000000000..4f1ee17d08
--- /dev/null
+++ b/apps/desktop/src/routes/editor/cropVideoPreloader.ts
@@ -0,0 +1,47 @@
+import { convertFileSrc } from "@tauri-apps/api/core";
+
+let preloadedVideo: HTMLVideoElement | null = null;
+let preloadState: "idle" | "metadata" | "full" | "ready" = "idle";
+let currentVideoPath: string | null = null;
+
+export function preloadCropVideoMetadata(videoPath: string) {
+ if (preloadState !== "idle") return;
+
+ currentVideoPath = videoPath;
+ preloadedVideo = document.createElement("video");
+ preloadedVideo.preload = "metadata";
+ preloadedVideo.src = convertFileSrc(videoPath);
+ preloadedVideo.muted = true;
+ preloadedVideo.load();
+ preloadState = "metadata";
+}
+
+export function preloadCropVideoFull() {
+ if (!preloadedVideo || preloadState === "full" || preloadState === "ready")
+ return;
+
+ preloadedVideo.preload = "auto";
+ preloadedVideo.load();
+ preloadState = "full";
+
+ preloadedVideo.oncanplaythrough = () => {
+ preloadState = "ready";
+ };
+}
+
+export function getPreloadState() {
+ return preloadState;
+}
+
+export function getPreloadedVideoPath() {
+ return currentVideoPath;
+}
+
+export function cleanup() {
+ if (preloadedVideo) {
+ preloadedVideo.src = "";
+ preloadedVideo = null;
+ }
+ currentVideoPath = null;
+ preloadState = "idle";
+}
diff --git a/crates/frame-converter/examples/benchmark.rs b/crates/frame-converter/examples/benchmark.rs
index 04ef566638..2d457dfece 100644
--- a/crates/frame-converter/examples/benchmark.rs
+++ b/crates/frame-converter/examples/benchmark.rs
@@ -63,18 +63,13 @@ fn benchmark_pool(
pool.submit(frame, i as u64).expect("Submit failed");
}
- let mut received = 0u64;
let deadline = Instant::now() + Duration::from_secs(30);
- while received < frame_count as u64 && Instant::now() < deadline {
- if let Some(_converted) = pool.recv_timeout(Duration::from_millis(100)) {
- received += 1;
- }
+ while Instant::now() < deadline {
+ let _ = pool.recv_timeout(Duration::from_millis(100));
let stats = pool.stats();
if stats.frames_converted >= frame_count as u64 {
- while pool.try_recv().is_some() {
- received += 1;
- }
+ while pool.try_recv().is_some() {}
break;
}
}
diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts
index 9708f6b361..2e7c41dfb8 100644
--- a/packages/ui-solid/src/auto-imports.d.ts
+++ b/packages/ui-solid/src/auto-imports.d.ts
@@ -82,6 +82,7 @@ declare global {
const IconLucideHardDrive: typeof import('~icons/lucide/hard-drive.jsx')['default']
const IconLucideImage: typeof import('~icons/lucide/image.jsx')['default']
const IconLucideLayout: typeof import('~icons/lucide/layout.jsx')['default']
+ const IconLucideLoader2: typeof import('~icons/lucide/loader2.jsx')['default']
const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default']
const IconLucideMaximize: typeof import('~icons/lucide/maximize.jsx')['default']
const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.jsx')['default']