Skip to main content

funscript/
funscript.rs

1use mint::Point2;
2use ramer_douglas_peucker::rdp;
3use serde::{Deserialize, Serialize};
4use serde_json::{Error as SerdeError, Value};
5use thiserror::Error;
6
7/// A .funscript action point
8/// x = pos
9/// y = at
10#[derive(Debug, Serialize, Deserialize)]
11#[serde(deny_unknown_fields, rename_all = "camelCase")]
12pub struct FSPoint {
13    pub pos: i32,
14    pub at: i32,
15}
16
17/// properties about a pressure simulator
18/// that can be used to input points in a .funscript
19#[derive(Debug, Serialize, Deserialize)]
20#[serde(deny_unknown_fields, rename_all = "camelCase")]
21pub struct SimulatorPresets {
22    pub name: String,
23    pub full_range: bool,
24    pub direction: i32,
25    pub rotation: f32,
26    pub length: f32,
27    pub width: f32,
28    pub offset: String,
29    pub color: String,
30}
31
32/// extra metadata, specifically for OpenFunscripter (OFS)
33#[derive(Debug, Serialize, Deserialize)]
34#[serde(deny_unknown_fields, rename_all = "camelCase")]
35pub struct OFSMetadata {
36    bookmarks: Vec<i32>,
37    chapters: Vec<String>,
38    creator: String,
39    description: String,
40    duration: i32,
41    license: String,
42    notes: String,
43    performers: Vec<String>,
44    #[serde(rename = "script_url")]
45    script_url: String,
46    tags: Vec<String>,
47    title: String,
48    #[serde(rename = "type")]
49    ofs_type: String,
50    #[serde(rename = "video_url")]
51    video_url: String,
52}
53
54/// a serializable and deserializable .funscript file
55#[derive(Debug, Serialize, Deserialize)]
56#[serde(deny_unknown_fields, rename_all = "camelCase", default)]
57pub struct FScript {
58    pub version: String,
59    pub inverted: bool,
60    pub range: i32,
61    pub bookmark: i32,
62    pub last_position: i64,
63    pub graph_duration: i32,
64    pub speed_ratio: f32,
65    pub injection_speed: i32,
66    pub injection_bias: f32,
67    pub scripting_mode: i32,
68    pub simulator_presets: Vec<SimulatorPresets>,
69    pub active_simulator: i32,
70    pub reduction_tolerance: f32,
71    pub reduction_stretch: f32,
72    pub clips: Vec<Value>,
73    pub actions: Vec<FSPoint>,
74    pub raw_actions: Vec<FSPoint>,
75    pub metadata: OFSMetadata,
76}
77
78impl Default for FScript {
79    fn default() -> Self {
80        Self {
81            version: "".to_string(),
82            inverted: false,
83            range: -1,
84            bookmark: -1,
85            last_position: -1,
86            graph_duration: -1,
87            speed_ratio: -1.0,
88            injection_speed: -1,
89            injection_bias: -1.0,
90            scripting_mode: -1,
91            simulator_presets: Vec::new(),
92            active_simulator: -1,
93            reduction_tolerance: -1.0,
94            reduction_stretch: -1.0,
95            clips: Vec::new(),
96            actions: Vec::new(),
97            raw_actions: Vec::new(),
98            metadata: OFSMetadata {
99                bookmarks: Vec::new(),
100                chapters: Vec::new(),
101                creator: "".to_string(),
102                description: "".to_string(),
103                duration: -1,
104                license: "".to_string(),
105                notes: "".to_string(),
106                performers: Vec::new(),
107                script_url: "".to_string(),
108                tags: Vec::new(),
109                title: "".to_string(),
110                ofs_type: "".to_string(),
111                video_url: "".to_string(),
112            },
113        }
114    }
115}
116
117/// Error types for .funscript file operations
118#[derive(Error, Debug)]
119pub enum FunscriptError {
120    #[error("file read error {0}")]
121    FileReadError(#[from] std::io::Error),
122    #[error("json error {0}")]
123    JsonError(#[from] SerdeError),
124    #[error("failed to {0} point at index {1}")]
125    PointError(String, usize),
126}
127
128/// loads a .funscript file using the provided path
129pub fn load_funscript(path: &str) -> Result<FScript, FunscriptError> {
130    let file = std::fs::read_to_string(path)?;
131    let json = serde_json::from_str::<FScript>(&file)?;
132    Ok(json)
133}
134
135/// saves a .funscript file using the provided path
136pub fn save_funscript(path: &str, script: &FScript) -> Result<(), FunscriptError> {
137    if !path.ends_with(".funscript") {
138        return Err(FunscriptError::FileReadError(std::io::Error::new(
139            std::io::ErrorKind::Other,
140            "invalid file extension",
141        )));
142    }
143
144    let json = serde_json::to_string_pretty(script)?;
145    std::fs::write(path, json)?;
146    Ok(())
147}
148
149/// adds an action point
150pub fn get_pt(script: &mut FScript, idx: usize) -> Result<&mut FSPoint, FunscriptError> {
151    if idx >= script.actions.len() {
152        return Err(FunscriptError::PointError("get".to_string(), idx));
153    }
154    Ok(&mut script.actions[idx])
155}
156
157/// runs the ramer-douglas-peucker algorithm on the script
158/// applies a smooth point reduction to the script by the given epsilon
159/// epsilon > ~10.0 will result in nothing but peaks and valleys
160pub fn apply_rdp(script: &mut FScript, epsilon: f64) {
161    let mut points: Vec<Point2<i32>> = Vec::new();
162    for pt in &script.actions {
163        points.push(Point2 {
164            x: pt.at,
165            y: pt.pos,
166        });
167    }
168
169    // keep points that are in idxs
170    let idxs = rdp(points.as_slice(), epsilon);
171    let mut reduced: Vec<Point2<i32>> = Vec::new();
172    for idx in idxs {
173        reduced.push(points[idx]);
174    }
175
176    script.actions.clear();
177    for pt in reduced {
178        script.actions.push(FSPoint {
179            at: pt.x,
180            pos: pt.y,
181        });
182    }
183}
184
185/// print the .funscript structure
186pub fn print_script(script: &FScript) {
187    println!("{}", serde_json::to_string_pretty(script).unwrap());
188}
189
190// print the .funscript structure
191// fn print_script_diagnostics(s: &FScript) {
192//     println!("# of points: {}", s.actions.len());
193// }
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_jfs_save_load_funscript() {
201        let path = "./test-scripts/joyfunscripter.funscript";
202        let save_path = "./test-scripts/out/joyfunscripter.funscript";
203
204        let mut s = load_funscript(path).unwrap();
205        assert!(s.last_position == 6388388382, "file has defaulted");
206        s.bookmark = 100000;
207        save_funscript(save_path, &s).unwrap();
208        let check = load_funscript(save_path).unwrap();
209        assert_eq!(check.bookmark, 100000);
210    }
211
212    #[test]
213    fn test_ofs_save_load_funscript() {
214        let path = "./test-scripts/openfunscripter.funscript";
215        let save_path = "./test-scripts/out/openfunscripter.funscript";
216
217        let mut s = load_funscript(path).unwrap();
218        assert!(s.metadata.duration == 2610, "file has defaulted");
219        s.bookmark = 100000;
220        save_funscript(save_path, &s).unwrap();
221        let check = load_funscript(save_path).unwrap();
222        assert_eq!(check.bookmark, 100000);
223    }
224
225    #[test]
226    fn test_get_set_pt() {
227        let path = "./test-scripts/openfunscripter.funscript";
228        let mut s = load_funscript(path).unwrap();
229        let pt = get_pt(&mut s, 0).unwrap();
230        assert_eq!(pt.at, 218703);
231        pt.at = 12345678;
232        assert_eq!(pt.at, 12345678);
233    }
234}