Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,16 @@
"filmGrain": "Film Grain",
"reset": "Reset to defaults"
},
"frameInterpolation": {
"title": "Frame Interpolation",
"hint": "Experimental neural frame interpolation (Framegen WebGPU runtime) that synthesizes extra frames between decoded stream frames for smoother motion. Adds display latency and GPU load. Web client mode only; uses shader-f16 when available.",
"nativeUnavailable": "Frame interpolation does not apply while native streaming renders the video outside the app window.",
"factor": "Factor",
"quality": "Model quality",
"qualityHint": "Internal resolution of interpolated frames. Lower uses less GPU; 480p is a good starting point.",
"weightsNotice": "Runtime code is MIT. Model weights are non-commercial research/personal use only (Framegen weight license).",
"reset": "Reset to defaults"
},
"codecDiagnostics": {
"advanced": "Advanced - Codec Diagnostics",
"title": "Codec Diagnostics",
Expand Down
3 changes: 3 additions & 0 deletions opennow-stable/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ dist
dist-electron
dist-release
*.log

# Framegen weights copied at postinstall (non-commercial; see third_party/framegen)
src/renderer/public/framegen-weights/
3 changes: 3 additions & 0 deletions opennow-stable/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,8 @@ export default defineConfig({
"@shared": resolve("src/shared"),
},
},
optimizeDeps: {
include: ["framegen"],
},
},
});
18 changes: 18 additions & 0 deletions opennow-stable/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion opennow-stable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"node": ">=22.22.0"
},
"scripts": {
"postinstall": "node scripts/ensure-electron-installed.mjs",
"postinstall": "node scripts/ensure-electron-installed.mjs && node scripts/copy-framegen-weights.mjs",
"electron:install": "node scripts/ensure-electron-installed.mjs",
"dev": "electron-vite dev",
"build": "electron-vite build",
Expand All @@ -36,6 +36,7 @@
"dependencies": {
"discord-rpc": "^4.0.1",
"electron-updater": "^6.8.9",
"framegen": "^1.4.0",
"posthog-node": "^5.48.1",
"ws": "^8.21.3"
},
Expand All @@ -50,6 +51,7 @@
"@types/three": "^0.185.4",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react": "^5.2.0",
"@webgpu/types": "^0.1.71",
"cross-env": "^10.1.0",
"electron": "^43.3.0",
"electron-builder": "^26.15.3",
Expand Down
23 changes: 23 additions & 0 deletions opennow-stable/scripts/copy-framegen-weights.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Copy Framegen v7-small weights into the renderer public folder so the
* Electron app can load them offline from the app origin.
*
* Weight license is non-commercial (see third_party/framegen/WEIGHTS_LICENSE.md).
*/
import { cpSync, existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const src = join(root, "node_modules", "framegen", "weights");
const dest = join(root, "src", "renderer", "public", "framegen-weights");

if (!existsSync(src)) {
console.warn(`[copy-framegen-weights] skip: ${src} not found (install framegen first)`);
process.exit(0);
}

mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });

console.log(`[copy-framegen-weights] copied weights to ${dest}`);
7 changes: 7 additions & 0 deletions opennow-stable/src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
normalizeStreamPreferences,
normalizeTransportModeForPlatform,
normalizeVideoShaderSettings,
normalizeFrameInterpolationSettings,
normalizeUpdateChannel,
normalizeRecordingBitrateMbps,
normalizeRecordingFps,
Expand Down Expand Up @@ -326,6 +327,12 @@ export class SettingsManager {
migrated = true;
}

const frameInterpolation = normalizeFrameInterpolationSettings(settings.frameInterpolation);
if (JSON.stringify(settings.frameInterpolation) !== JSON.stringify(frameInterpolation)) {
settings.frameInterpolation = frameInterpolation;
migrated = true;
}

const consentBefore = settings.errorReportingConsent;
settings.errorReportingConsent = normalizeErrorReportingConsent(settings.errorReportingConsent);
if (settings.errorReportingConsent !== consentBefore) {
Expand Down
Empty file.
7 changes: 7 additions & 0 deletions opennow-stable/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ActiveSessionInfo,
AuthSession,
DirectLaunchRequest,
FrameInterpolationSettings,
GameInfo,
LoginProvider,
NativeStreamerShortcutAction,
Expand Down Expand Up @@ -1513,6 +1514,10 @@ export function App(): JSX.Element {
void updateSetting("videoShader", value);
}, [updateSetting]);

const handleFrameInterpolationChange = useCallback((value: FrameInterpolationSettings) => {
void updateSetting("frameInterpolation", value);
}, [updateSetting]);

const handleExitApp = useCallback(() => {
appUnloadingRef.current = true;
persistRuntimeSnapshotNow();
Expand Down Expand Up @@ -3044,6 +3049,8 @@ export function App(): JSX.Element {
allowEscapeToExitFullscreen={settings.allowEscapeToExitFullscreen}
videoShader={settings.videoShader}
onVideoShaderChange={handleVideoShaderChange}
frameInterpolation={settings.frameInterpolation}
onFrameInterpolationChange={handleFrameInterpolationChange}
/>
</m.div>
)}
Expand Down
25 changes: 25 additions & 0 deletions opennow-stable/src/renderer/src/components/StreamView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { getStoreDisplayName, getStoreIconComponent } from "./GameCard";
import { SessionElapsedIndicator } from "./ElapsedSessionIndicators";
import {
videoShaderHasVisibleEffect,
type FrameInterpolationSettings,
type MicrophoneMode,
type RecordingFps,
type RecordingResolution,
Expand All @@ -19,6 +20,7 @@ import {
type VideoShaderSettings,
} from "@shared/gfn";
import { VideoShaderPipeline } from "../platforms/gfn/videoShaderPipeline";
import { FrameInterpolationPipeline } from "../platforms/gfn/frameInterpolationPipeline";
import { formatShortcutForDisplay } from "../shortcuts";
import { useScreenshotGallery } from "../hooks/useScreenshotGallery";
import { useStreamMenuNavigation } from "../hooks/useStreamMenuNavigation";
Expand Down Expand Up @@ -120,6 +122,8 @@ interface StreamViewProps {
allowEscapeToExitFullscreen?: boolean;
videoShader: VideoShaderSettings;
onVideoShaderChange: (value: VideoShaderSettings) => void;
frameInterpolation: FrameInterpolationSettings;
onFrameInterpolationChange: (value: FrameInterpolationSettings) => void;
}

export function StreamView({
Expand Down Expand Up @@ -184,6 +188,8 @@ export function StreamView({
className,
videoShader,
onVideoShaderChange,
frameInterpolation,
onFrameInterpolationChange,
}: StreamViewProps): JSX.Element {
const [showHints, setShowHints] = useState(true);
const [showSessionClock, setShowSessionClock] = useState(false);
Expand All @@ -199,6 +205,7 @@ export function StreamView({
const localVideoRef = useRef<HTMLVideoElement | null>(null);
const localAudioRef = useRef<HTMLAudioElement | null>(null);
const shaderPipelineRef = useRef<VideoShaderPipeline | null>(null);
const frameInterpolationPipelineRef = useRef<FrameInterpolationPipeline | null>(null);
const streamHasVideo = useStreamDiagnosticsSelector(
diagnosticsStore,
(stats) => hasVisibleStreamVideo(stats),
Expand Down Expand Up @@ -447,9 +454,25 @@ export function StreamView({
}
}, [videoShader, gstreamerEnabled, nativeRendererActive]);

useEffect(() => {
const video = localVideoRef.current;
if (!video) return;
const effective = gstreamerEnabled || nativeRendererActive
? { ...frameInterpolation, enabled: false }
: frameInterpolation;
if (!frameInterpolationPipelineRef.current) {
if (!effective.enabled) return;
frameInterpolationPipelineRef.current = new FrameInterpolationPipeline(video, effective);
} else {
frameInterpolationPipelineRef.current.updateSettings(effective);
}
}, [frameInterpolation, gstreamerEnabled, nativeRendererActive]);

useEffect(() => () => {
shaderPipelineRef.current?.dispose();
shaderPipelineRef.current = null;
frameInterpolationPipelineRef.current?.dispose();
frameInterpolationPipelineRef.current = null;
}, []);

const setVideoRef = useCallback((element: HTMLVideoElement | null) => {
Expand Down Expand Up @@ -762,6 +785,8 @@ export function StreamView({
gstreamerEnabled={gstreamerEnabled}
videoShader={videoShader}
onVideoShaderChange={onVideoShaderChange}
frameInterpolation={frameInterpolation}
onFrameInterpolationChange={onFrameInterpolationChange}
microphoneMode={microphoneMode}
onMicrophoneModeChange={onMicrophoneModeChange}
diagnosticsStore={diagnosticsStore}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ export const SETTINGS_SCOPE_SEARCH_TERMS: Record<SettingsSearchScopeId, readonly
"vibrance",
"film grain",
"post processing",
"frame interpolation",
"framegen",
"model quality",
"session proxy",
"community proxy",
"zortos",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { type JSX } from "react";
import type { Settings } from "@shared/gfn";
import {
DEFAULT_FRAME_INTERPOLATION_SETTINGS,
FRAME_INTERPOLATION_FACTOR_OPTIONS,
FRAME_INTERPOLATION_QUALITY_OPTIONS,
} from "@shared/gfn";
import { useTranslation } from "../../../i18n";
import type { SettingsChangeHandler } from "./streamSettingsTypes";

interface FrameInterpolationControlsProps {
settings: Settings;
handleChange: SettingsChangeHandler;
}

export function FrameInterpolationControls({
settings,
handleChange,
}: FrameInterpolationControlsProps): JSX.Element {
const { t } = useTranslation();
const fi = settings.frameInterpolation;
const nativeBlocked = settings.streamClientMode === "native";

return (
<div className="settings-row settings-row--complex">
<div className="settings-row-top settings-row-top--compact">
<label
className="settings-label settings-label--wrap"
htmlFor="settings-stream-frame-interpolation-enabled"
>
<span className="settings-label-title">
{t("settings.frameInterpolation.title")}
<span className="settings-inline-badge settings-inline-badge--beta">
{t("app.labels.experimental")}
</span>
</span>
</label>
<label className="settings-toggle">
<input
id="settings-stream-frame-interpolation-enabled"
type="checkbox"
checked={fi.enabled}
disabled={nativeBlocked}
onChange={(event) => {
handleChange("frameInterpolation", {
...fi,
enabled: event.target.checked,
});
}}
/>
<span className="settings-toggle-track" />
</label>
</div>
<span className="settings-subtle-hint">
{nativeBlocked
? t("settings.frameInterpolation.nativeUnavailable")
: t("settings.frameInterpolation.hint")}
</span>
{fi.enabled && !nativeBlocked && (
<>
<div className="settings-row settings-row--column">
<div className="settings-row-top">
<span className="settings-label">
{t("settings.frameInterpolation.factor")}
</span>
<span className="settings-value-badge">{fi.factor}×</span>
</div>
<div className="settings-chip-row">
{FRAME_INTERPOLATION_FACTOR_OPTIONS.map((factor) => (
<button
key={factor}
type="button"
aria-pressed={fi.factor === factor}
className={`settings-chip${fi.factor === factor ? " settings-chip--active" : ""}`}
onClick={() => {
handleChange("frameInterpolation", { ...fi, factor });
}}
>
<span>{factor}×</span>
</button>
))}
</div>
</div>
<div className="settings-row settings-row--column">
<div className="settings-row-top">
<span className="settings-label">
{t("settings.frameInterpolation.quality")}
</span>
<span className="settings-value-badge">{fi.quality}p</span>
</div>
<div className="settings-chip-row">
{FRAME_INTERPOLATION_QUALITY_OPTIONS.map((quality) => (
<button
key={quality}
type="button"
aria-pressed={fi.quality === quality}
className={`settings-chip${fi.quality === quality ? " settings-chip--active" : ""}`}
onClick={() => {
handleChange("frameInterpolation", { ...fi, quality });
}}
>
<span>{quality}p</span>
</button>
))}
</div>
<span className="settings-subtle-hint">
{t("settings.frameInterpolation.qualityHint")}
</span>
</div>
<p className="settings-subtle-hint">
{t("settings.frameInterpolation.weightsNotice")}
</p>
<div className="settings-chip-row">
<button
type="button"
className="settings-chip"
onClick={() => {
handleChange("frameInterpolation", {
...DEFAULT_FRAME_INTERPOLATION_SETTINGS,
enabled: true,
});
}}
>
<span>{t("settings.frameInterpolation.reset")}</span>
</button>
</div>
</>
)}
</div>
);
}
Loading
Loading