feat: use canvas to render png

This commit is contained in:
Emerald 2023-10-11 19:46:33 -04:00
parent 1a27172cc9
commit 5f51d0b1d6
Signed by: emerald
GPG Key ID: 420C9E1863CCB30F
18 changed files with 2863 additions and 3755 deletions

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
node 20
yarn latest

82
cliff.toml Normal file
View File

@ -0,0 +1,82 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# remove the leading and trailing whitespace from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"}, # replace issue numbers
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^doc", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^style", group = "Styling" },
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|ci", group = "Miscellaneous Tasks" },
{ body = ".*security", group = "Security" },
{ message = "^revert", group = "Revert" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "v[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"
# regex for ignoring tags
ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
# limit the number of commits included in the changelog.
# limit_commits = 42

2706
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,12 +12,15 @@
},
"dependencies": {
"@neodrag/svelte": "^1.2.3",
"@tauri-apps/api": "^1.0.2"
"@tauri-apps/api": "^1.5.0",
"animejs": "^3.2.1",
"image-js": "^0.35.4"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.1",
"@tauri-apps/cli": "^1.1.1",
"@tauri-apps/cli": "^1.5.2",
"@tsconfig/svelte": "^3.0.0",
"@types/animejs": "^3.1.8",
"@types/node": "^18.7.10",
"sass": "^1.54.8",
"svelte": "^3.49.0",

2147
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
[package]
name = "cathode-tube"
version = "0.1.0"
description = "A Tauri App"
description = "A small PNGTubing app"
authors = ["AnActualEmerald"]
license = "GPL-3.0-or-later"
repository = "https://github.com/AnActualEmerald/cathode"
@ -32,12 +32,12 @@ lto = true
codegen-units = 1
[build-dependencies]
tauri-build = { version = "1.2.0", features = [] }
tauri-build = { version = "1", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.2.0", features = ["cli", "dialog-all", "fs-create-dir", "fs-read-dir", "fs-read-file", "fs-write-file", "macos-private-api", "window-minimize", "window-set-max-size", "window-set-min-size", "window-unminimize"] }
tauri = { version = "1", features = [ "protocol-asset", "window-center", "cli", "dialog-all", "fs-create-dir", "fs-read-dir", "fs-read-file", "fs-write-file", "macos-private-api", "window-minimize", "window-set-max-size", "window-set-min-size", "window-unminimize"] }
cpal = { version = "0.14.1", features = ["jack"] }
ray_format = {path = "../ray_format", version = "~0.1.0"}
anyhow = "1.0.66"
@ -46,9 +46,10 @@ env_logger = "0.9.3"
rand = "0.8.5"
tokio = { version = "1.21.2", features = ["full"] }
image = "0.24.4"
base64-url = "1.4.13"
toml = "0.5.9"
notify = "5.0.0"
figment = { version = "0.10.11", features = ["toml", "env"] }
base64 = "0.21.4"
[features]
# by default Tauri runs in production mode

View File

@ -12,12 +12,12 @@ use cpal::InputCallbackInfo;
use log::debug;
use tauri::Window;
pub async fn monitor(
pub fn monitor(
window: Window,
threshold: Arc<Mutex<f32>>,
level: Arc<Mutex<f32>>,
sens: Arc<Mutex<f32>>,
) {
) -> ! {
let device = initialize().expect("Unable to init audio");
debug!("Using device {}", device.name().unwrap());
let config = device.default_input_config().unwrap();
@ -48,13 +48,14 @@ pub async fn monitor(
stream.play().expect("Error creating input stream");
// The stream will end if it goes out of scope, so just dwell here
// The stream is closed when dropped, so just dwell here
loop {
sleep(Duration::from_secs(5000));
sleep(Duration::from_millis(10000));
}
}
fn initialize() -> Result<Device> {
debug!("Hey there");
let host = cpal::default_host();
host.default_input_device()
.ok_or_else(|| anyhow!("No default output device"))

View File

@ -1,6 +1,10 @@
use std::{fmt::Display, path::PathBuf};
use anyhow::Result;
use figment::{
providers::{Env, Format, Toml},
Figment,
};
use log::{debug, error, trace, warn};
use serde::{Deserialize, Serialize};
use tauri::api::path::config_dir;
@ -55,56 +59,33 @@ impl Display for BGColor {
}
#[inline]
pub fn get_config_dir() -> Option<PathBuf> {
Some(config_dir()?.join("cathode"))
pub fn get_config_dir() -> PathBuf {
config_dir()
.expect("Unable to get config directory")
.join("cathode")
}
pub fn load_config() -> Config {
if let Some(path) = get_config_dir() {
use std::fs;
if let Ok(true) = path.try_exists() {
let raw = fs::read_to_string(path.join("config.toml")).unwrap_or_else(|f| {
warn!("Failed to load config file: {}", f);
String::new()
});
trace!("Using config: {}", raw);
match toml::from_str::<Config>(&raw) {
Ok(c) => {
trace!("parsed config: {:#?}", c);
c
}
Err(e) => {
error!("Unable to parse config file: {}", e);
Config::default()
}
}
} else {
if let Err(e) = fs::create_dir_all(&path) {
debug!("{:?}", e);
error!("Failed to create config directory");
error!("{}", e);
} else {
debug!("Created config dir at {}", path.display())
}
Figment::new()
.merge(Toml::file(get_config_dir().join("config.toml")))
.merge(Env::prefixed("CATHODE_"))
.extract()
.unwrap_or_else(|e| {
error!("Error while loading config: {e}");
Config::default()
}
} else {
debug!("Using default config");
Config::default()
}
})
}
pub fn save_config(config: &Config) -> Result<()> {
use std::fs;
let raw = toml::to_string_pretty(config)?;
if let Some(dir) = get_config_dir() {
use std::fs;
if !dir.exists() {
fs::create_dir_all(&dir)?;
}
debug!("Writing config");
trace!("Config: {}", raw);
let path = dir.join("config.toml");
fs::write(&path, raw)?;
let dir = get_config_dir();
if !dir.exists() {
fs::create_dir_all(&dir)?;
}
debug!("Writing config");
trace!("Config: {}", raw);
let path = dir.join("config.toml");
fs::write(&path, raw)?;
Ok(())
}

View File

@ -1,18 +1,18 @@
use std::collections::HashMap;
use std::fs;
use std::io::Cursor;
use std::ffi::OsStr;
use std::fs::{self, File};
use std::path::Path;
use base64_url as base64;
use anyhow::Result;
use base64::prelude::*;
use image::ImageFormat;
use log::{debug, trace};
use log::{debug, error};
use ray_format::Ray;
use serde::{Deserialize, Serialize};
use tauri::api::dialog::blocking::FileDialogBuilder;
use tauri::api::path::{cache_dir, home_dir, picture_dir};
const OBJ_URL: &'static str = "data:image/png;base64,";
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct WebRay {
frames: [String; 4],
@ -24,16 +24,25 @@ pub(crate) async fn open_image() -> Option<String> {
debug!("Opening iamge dialog...");
let path = FileDialogBuilder::new()
.add_filter("Images", &["png", "jpg"])
.set_directory(picture_dir().unwrap_or_else(|| home_dir().unwrap()))
.set_directory(
picture_dir().unwrap_or_else(|| home_dir().expect("No home directory for user")),
)
.set_title("Select an image")
.pick_file()?;
if let Ok(b) = image::open(path) {
let mut buf = Cursor::new(vec![]);
b.write_to(&mut buf, ImageFormat::Png).unwrap();
let encoded = base64::encode(buf.get_ref());
trace!("Encoded: {:?}", encoded);
if let Ok(mut b) = image::open(&path) {
if b.width() > 600 || b.height() > 400 {
b = b.resize(600, 400, image::imageops::FilterType::Lanczos3);
}
let mut loaded = cache_dir()
.expect("Unable to get cache dir")
.join("cathode")
.join("loaded")
.join(path.file_stem().unwrap_or_else(|| OsStr::new("image")));
loaded.set_extension("png");
let mut file = File::create(&loaded).expect("Unable to create file");
b.write_to(&mut file, ImageFormat::Png).unwrap();
Some(format!("{}{}", OBJ_URL, encoded))
loaded.to_str().map(|v| v.to_string())
} else {
None
}
@ -62,9 +71,9 @@ pub(crate) fn load_ray(path: impl AsRef<Path>) -> Option<WebRay> {
debug!("Frame {} was empty", i);
continue;
}
let encoded = base64::encode(&f);
let encoded = BASE64_STANDARD.encode(f);
frames[i as usize] = format!("{}{}", OBJ_URL, encoded);
frames[i as usize] = encoded;
}
}
@ -74,16 +83,10 @@ pub(crate) fn load_ray(path: impl AsRef<Path>) -> Option<WebRay> {
}
}
fs::write(
cache_dir().unwrap().join("cathode").join("last_selected"),
path.as_ref()
.canonicalize()
.unwrap()
.to_str()
.unwrap()
.as_bytes(),
)
.unwrap();
if let Err(e) = cache_loaded_ray(path.as_ref()) {
error!("Error caching loaded ray: {e}");
}
Some(WebRay { frames, meta })
}
@ -99,18 +102,42 @@ pub(crate) async fn save_ray(ray: WebRay) -> Result<(), String> {
let mut res = Ray::default();
for (i, f) in ray.frames.iter().enumerate() {
let stripped = f.strip_prefix(OBJ_URL).unwrap_or(f);
let decoded = base64::decode(stripped).unwrap();
res.set_frame(i as usize, decoded);
if f == "" {
continue;
}
match BASE64_STANDARD.decode(f) {
Ok(decoded) => {
res.set_frame(i as usize, decoded);
}
Err(e) => {
error!("{e:?}");
}
}
}
for (k, v) in ray.meta {
res.add_meta(k, v);
}
cache_loaded_ray(&path).map_err(|e| e.to_string())?;
res.save(&path)
.map_err(|e| format!("Failed to save ray file: {}", e))
} else {
Ok(())
}
}
pub fn cache_loaded_ray(path: impl AsRef<Path>) -> Result<()> {
fs::write(
cache_dir().unwrap().join("cathode").join("last_selected"),
path.as_ref()
.canonicalize()
.unwrap()
.to_str()
.unwrap()
.as_bytes(),
)?;
Ok(())
}

View File

@ -44,20 +44,18 @@ fn main() {
if let Some(d) = cache_dir() {
use std::fs;
debug!("Ensuring cache directories exist");
fs::create_dir_all(d.join("cathode").join("loaded")).expect("Unable to create cache dir");
if let Ok(s) = fs::read_to_string(d.join("cathode").join("last_selected")) {
debug!("Found selected ray in cache");
*ray.lock().unwrap() = Some(Path::new(&s).to_path_buf());
}
}
// Create the config directory if needed
debug!("Loading existing config");
let config = config::load_config();
{
*blink_interval.lock().unwrap() = config.blink_interval;
*sens.lock().unwrap() = config.mic_sens;
}
let current_conf = Arc::new(Mutex::new(config));
let config = current_conf.clone();
@ -80,11 +78,17 @@ fn main() {
})
.setup(move |app| {
let window = app.get_window("main").unwrap();
tauri::async_runtime::spawn(async move {
monitor(window, threshold, level, sens).await;
monitor(window, threshold, level, sens);
});
let window = app.get_window("main").unwrap();
window
.emit_all("reload-config", "")
.expect("Failed to send window event");
#[cfg(debug_assertions)]
window.open_devtools();
tauri::async_runtime::spawn(async move {
loop {
if rand::random() {

View File

@ -28,18 +28,27 @@
"fs": {
"scope": [
"$PUBLIC/*",
"$CONFIG/*"
"$CONFIG/*",
"$CACHE/cathode/*",
"$CACHE/cathode/loaded/*"
],
"readFile": true,
"readDir": true,
"createDir": true,
"writeFile": true
},
"protocol": {
"assetScope": [
"$CACHE/cathode/loaded/*"
],
"asset": true
},
"window": {
"setMinSize": true,
"setMaxSize": true,
"minimize": true,
"unminimize": true
"unminimize": true,
"center": true
}
},
"bundle": {
@ -80,7 +89,7 @@
}
},
"security": {
"csp": null
"csp": "default-src 'self'; img-src 'self'; asset: https://asset.localhost"
},
"updater": {
"active": false

View File

@ -11,30 +11,32 @@
<script lang="ts">
import { frames } from "../store";
import { invoke } from "@tauri-apps/api";
import { fs, invoke } from "@tauri-apps/api";
import { fade } from "svelte/transition";
import Context from "./context.svelte";
import LoadingSVG from "/src/loading.svg";
import Image from "image-js";
export let index: number;
let menuTimeout: NodeJS.Timeout | null = null;
let showMenu = false;
let src = "";
let src: string | null;
$: {
src = $frames[index];
src = $frames[index] ? $frames[index].toDataURL() : null;
}
const openImage = async () => {
loading[index] = true;
const path = (await invoke("open_image")) as string;
if (path) {
$frames[index] = path;
const data = await fs.readBinaryFile(path);
$frames[index] = await Image.load(data);
}
loading[index] = false;
};
const clearImage = () => {
$frames[index] = "";
$frames[index] = null;
};
</script>
@ -108,18 +110,20 @@
background-color: darken($color: $bg, $amount: 5%);
}
display: flex;
align-content: center;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 2px solid black;
width: 100%;
height: 100%;
background-color: $bg;
width: 15vh;
height: 15vh;
user-select: none;
cursor: pointer;
}
.box {
width: 15vh;
height: 15vh;
user-select: none;
gap: 10px;
display: flex;

View File

@ -55,14 +55,19 @@
id="sens"
step="0.01"
min="0.01"
max="1.0"
value={$config.mic_sens}
on:change={(e) => {
$config.mic_sens = e.currentTarget.valueAsNumber;
}}
max="5.0"
bind:value={$config.mic_sens}
/>
<span>{Math.trunc($config.mic_sens * 100)}%</span>
</div>
<button
id="save"
on:click={async () => {
await invoke("save_current_config");
onClose();
}}>Save</button
>
</div>
<style lang="scss">
@ -110,4 +115,12 @@
justify-content: space-between;
width: 50%;
}
#save {
position: absolute;
right: 0.25rem;
bottom: 0.25rem;
width: 25%;
height: 5rem;
}
</style>

View File

@ -2,15 +2,20 @@
import { frames } from "../store";
import { onMount } from "svelte";
import { appWindow } from "@tauri-apps/api/window";
import type { Image } from "image-js";
import anime from "animejs";
let src = "";
export let buf = 0;
export let open = false;
export let threshold = 50;
let closed = false;
let blink = false;
let inAnim = "jump-in";
let outAnim = "none";
$: bitMaps = [null, null, null, null];
$: currFrame = 0;
$: src = bitMaps[currFrame];
let pos = { x: 0, y: 0 };
$: {
if (buf < threshold) {
@ -21,19 +26,58 @@
$: {
if (closed) {
src = $frames[0];
currFrame = 0;
} else if (open) {
src = $frames[1];
currFrame = 1;
}
if (blink && closed) {
src = $frames[2] ? $frames[2] : $frames[0];
currFrame = $frames[2] ? 2 : 0;
} else if (blink && open) {
src = $frames[3] ? $frames[3] : $frames[1];
currFrame = $frames[3] ? 3 : 1;
}
}
$: {
if (open) {
anime({
targets: pos,
y: [
{ value: 50, duration: 200 },
{ value: 0, duration: 200 },
],
autoplay: true,
easing: "easeOutCubic",
});
}
}
// $: {
// if (closed)
// anime({
// targets: pos,
// y: [
// { value: 100, duration: 100 },
// { value: 0, duration: 100 },
// ],
// autoplay: true,
// easing: "easeInOutCubic",
// });
// }
const createBitmaps = async (f: Array<Image | null>) => {
return await Promise.all(
f.map(async (v) => (v ? await createImageBitmap(await v.toBlob()) : null))
);
};
onMount(async () => {
bitMaps = await createBitmaps($frames);
console.log("bitMaps: ", bitMaps);
frames.subscribe(async (f) => {
bitMaps = await createBitmaps(f);
});
await appWindow.listen("mouth-open", () => {
buf = 100;
open = true;
@ -49,57 +93,43 @@
blink = true;
setTimeout(() => (blink = false), 100);
});
await appWindow.onResized(updateCanvasSize);
updateCanvasSize();
const ctx = canvas.getContext("2d");
const update = async () => {
try {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(
src,
canvas.width / 2 - pos.x - src.width / 2,
canvas.height / 2 - pos.y - src.height / 2
);
} catch {}
requestAnimationFrame(update);
};
requestAnimationFrame(update);
});
const updateCanvasSize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
let canvas: HTMLCanvasElement;
</script>
{#if src}
<img {src} alt="tuber" class:open class:closed class="{inAnim} {outAnim}" />
{/if}
<canvas bind:this={canvas} />
<style lang="scss">
@keyframes jump-out {
0% {
transform: translateY(-50%);
}
50% {
transform: translateY(-52%);
}
100% {
transform: translateY(-50%);
}
}
@keyframes jump-in {
0% {
transform: translateY(-50%);
}
50% {
transform: translateY(-52%);
}
100% {
transform: translateY(-50%);
}
}
img {
canvas {
position: absolute;
transform: translateY(-50%);
top: 50vh;
left: calc(50vw - 200px);
width: 400px;
}
.closed.jump-out {
animation: jump-out;
animation-duration: 0.2s;
}
.open.jump-in {
animation: jump-in;
animation-duration: 0.2s;
translate: -50% -50%;
top: 50%;
left: 50%;
}
</style>

View File

@ -1,16 +1,18 @@
import {writable} from "svelte/store";
import type { Image } from "image-js";
export type BGColor ="transparent" | "blue" | "green" | "pink" | {custom: string} ;
export type Meta = {
export type Meta = {
threshold: string | null;
closeThreshold: string | null;
};
export class WebRay {
meta: Meta;
frames: string[];
frames: Array<string | null>;
public constructor(
frames: string[] = [],
frames: Array<string | null> = [null, null, null, null],
meta: Meta = { threshold: null, closeThreshold: null }
) {
this.frames = frames;
@ -33,5 +35,5 @@ export class Config {
export let frames = writable(new Array<string>(4));
export let frames = writable<Array<Image | null>>([null, null, null, null]);
export let config = writable(new Config());

View File

@ -8,13 +8,13 @@
<script lang="ts">
import { appWindow, PhysicalSize } from "@tauri-apps/api/window";
// import { listen } from "@tauri-apps/api/event";
import { onMount, onDestroy } from "svelte";
import { fly } from "svelte/transition";
import { invoke } from "@tauri-apps/api/tauri";
import { tick } from "svelte";
import { frames, WebRay } from "../store";
import { quintInOut } from "svelte/easing";
import { Image } from "image-js";
import type { UnlistenFn } from "@tauri-apps/api/event";
//components
@ -24,7 +24,7 @@
import Settings from "../components/settings.svelte";
// import Devices from "../components/devices.svelte";
let monitorTimer: NodeJS.Timer;
let monitorTimer: string | number | NodeJS.Timeout;
let active = false;
let activation = 0;
@ -52,9 +52,20 @@
});
}
const openRay = (ray: WebRay) => {
const openRay = async (ray: WebRay) => {
for (let i = 0; i < 4; i++) {
$frames[i] = ray.frames[i];
if (!ray.frames[i]) {
$frames[i] = null;
continue;
}
try {
const image = await Image.load(
`data:image/png;base64,${ray.frames[i]}`
);
$frames[i] = image;
} catch (e) {
await invoke("log", { msg: "Error loading blob: " + e.toString() });
}
}
if (ray.meta.threshold) {
$threshold = parseFloat(ray.meta.threshold);
@ -65,8 +76,8 @@
};
onMount(async () => {
await appWindow.center();
await appWindow.setMinSize(new PhysicalSize(720, 600));
focusUnlisten = await appWindow.onFocusChanged(({ payload: focused }) => {
$transparent = !focused;
});
@ -100,26 +111,28 @@
const saveRay = async () => {
let fr = new Array<string>(4);
for (let i = 0; i < 4; i++) {
$frames[i] ? (fr[i] = $frames[i]) : (fr[i] = "");
$frames[i] ? (fr[i] = $frames[i].toBase64() as string) : (fr[i] = "");
}
fr.map((e) => (e ? e : ""));
const ray = {
frames: [fr[0], fr[1], fr[2], fr[3]],
frames: fr,
meta: {
threshold: $threshold.toString(),
closeThreshold: closeThreshold.toString(),
},
};
// await invoke("log", { msg: `Saving ray: ${JSON.stringify(ray)}` });
invoke("save_ray", { ray })
.then()
.catch((e) => invoke("log", { msg: e }).then());
await invoke("log", { msg: `Saving ray: ${JSON.stringify(ray)}` });
try {
await invoke("save_ray", { ray });
} catch (e) {
await invoke("log", { msg: e });
}
};
const loadRay = async () => {
const ray: WebRay = await invoke("open_ray");
openRay(ray);
await openRay(ray);
};
</script>

View File

@ -12,6 +12,7 @@ export default defineConfig({
server: {
port: 1420,
strictPort: true,
cors: true
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand

1248
yarn.lock Normal file

File diff suppressed because it is too large Load Diff