This commit is contained in:
Robin Appelman 2023-11-24 22:42:25 +01:00
commit 189788a1b6
14 changed files with 599 additions and 6 deletions

10
package-lock.json generated
View file

@ -5,7 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@demostf/parser-worker": "^0.1.1", "@demostf/edit": "0.2.0",
"@demostf/tf-demos-viewer": "^0.1.0", "@demostf/tf-demos-viewer": "^0.1.0",
"@solid-primitives/resize-observer": "^2.0.15", "@solid-primitives/resize-observer": "^2.0.15",
"@thisbeyond/solid-select": "^0.13.0", "@thisbeyond/solid-select": "^0.13.0",
@ -19,10 +19,10 @@
"@types/react": "^18.0.35" "@types/react": "^18.0.35"
} }
}, },
"node_modules/@demostf/parser-worker": { "node_modules/@demostf/edit": {
"version": "0.1.1", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/@demostf/parser-worker/-/parser-worker-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@demostf/edit/-/edit-0.2.0.tgz",
"integrity": "sha512-BDCN1G5bZkD46MlIUJz8z/ALP1ZTL4SF8Ygwp739+RqlmPj5k7ptrFHagnIqAvnsrlEwwaMtuOpWRkJLANLDVA==" "integrity": "sha512-s9wk3QVm+aTpMhIyfdGIHRm5qHp7FQ1dq/Jn0fms+lXsB1xY3wgjfWH+5gwRjjo/Dd3UMNM0o3atjO2uh+CxOQ=="
}, },
"node_modules/@demostf/tf-demos-viewer": { "node_modules/@demostf/tf-demos-viewer": {
"version": "0.1.1", "version": "0.1.1",

View file

@ -1,6 +1,6 @@
{ {
"dependencies": { "dependencies": {
"@demostf/parser-worker": "^0.1.1", "@demostf/edit": "0.2.0",
"@demostf/tf-demos-viewer": "^0.1.0", "@demostf/tf-demos-viewer": "^0.1.0",
"@solid-primitives/resize-observer": "^2.0.15", "@solid-primitives/resize-observer": "^2.0.15",
"@thisbeyond/solid-select": "^0.13.0", "@thisbeyond/solid-select": "^0.13.0",

View file

@ -0,0 +1,30 @@
export function formatDuration(input) {
if (!input) {
return '0:00';
}
const hours = Math.floor(input / 3600);
const minutes = Math.floor((input - (hours * 3600)) / 60);
const seconds = Math.floor(input - (hours * 3600) - (minutes * 60));
const hourString = (hours < 10) ? "0" + hours : "" + hours;
const minuteString = (minutes < 10) ? "0" + minutes : "" + minutes;
const secondString = (seconds < 10) ? "0" + seconds : "" + seconds;
if (hourString !== '00') {
return hourString + ':' + minuteString + ':' + secondString;
} else {
return minuteString + ':' + secondString;
}
}
export interface DurationProps {
duration: number;
className?: string;
}
export function Duration(props: DurationProps) {
const duration = formatDuration(props.duration);
return (
<span className={props.className||''}>{duration}</span>
);
}

View file

@ -0,0 +1,110 @@
import {createEffect, createSignal} from "solid-js";
export interface SliderProps {
min: number,
max: number,
onChange: (min: number, max: number) => void,
labelFn?: (value: number) => string,
}
const MultiRangeSlider = (props: SliderProps) => {
if (!props.labelFn) {
props.labelFn = (_val) => '';
}
const [minVal, setMinVal] = createSignal<number>(props.min);
const [maxVal, setMaxVal] = createSignal<number>(props.max);
let range;
// Convert to percentage
const getPercent = (value) => Math.round(((value - props.min) / (props.max - props.min)) * 100);
// Set width of the range to decrease from the left side
createEffect(() => {
const minPercent = getPercent(minVal());
const maxPercent = getPercent(maxVal());
if (range) {
range.style.left = `${minPercent}%`;
range.style.width = `${maxPercent - minPercent}%`;
}
});
// Set width of the range to decrease from the right side
createEffect(() => {
const minPercent = getPercent(minVal());
const maxPercent = getPercent(maxVal());
if (range) {
range.style.width = `${maxPercent - minPercent}%`;
}
});
// Get min and max values when their state changes
createEffect(() => {
props.onChange(minVal(), maxVal());
});
return (
<span className="container">
<input
type="range"
min={props.min}
max={props.max}
value={minVal()}
onChange={(event) => {
const value = Math.min(Number(event.target.value), maxVal() - 1);
if (value != minVal()) {
setMinVal(value);
}
}}
className="thumb thumb--left"
style={{ zIndex: (minVal() > props.max - 100) ? "5" : undefined }}
/>
<input
type="range"
min={props.min}
max={props.max}
value={maxVal()}
onChange={(event) => {
const value = Math.max(Number(event.target.value), minVal() + 1);
if (value != maxVal()) {
setMaxVal(value);
}
}}
className="thumb thumb--right"
/>
<span className="slider">
<span className="slider__left-input">
<input type="number" value={minVal()} onInput={(event) => {
const value = Math.max(Math.min(Number(event.currentTarget.value), maxVal - 1), props.min);
console.log(event.currentTarget.value, value);
if (value != minVal()) {
setMinVal(value);
}
}}/>
</span>
<span className="slider__right-input">
<input type="number" value={maxVal()} onInput={(event) => {
console.log(event);
const value = Math.min(Math.max(Number(event.currentTarget.value), minVal + 1), props.max);
console.log(event.currentTarget.value, value);
if (value != maxVal()) {
setMaxVal(value);
}
}}/>
</span>
<span className="slider__track" />
<span ref={range} className="slider__range" />
<span className="slider__left-value">
{() => props.labelFn(minVal())}
</span>
<span className="slider__right-value">
{() => props.labelFn(maxVal())}
</span>
</span>
</span>
);
};
export default MultiRangeSlider;

View file

@ -0,0 +1,20 @@
import * as React from 'react';
export interface SectionProps {
title: string;
children: any[] | any;
className?: string;
}
export function Section(props: SectionProps) {
return (
<section key={props.title} className={props.title + (props.className ? ' ' + props.className : '')}>
<div className="title">
<h3>
{props.title}
</h3>
</div>
{props.children}
</section>
);
}

106
script/edit/EditPage.tsx Normal file
View file

@ -0,0 +1,106 @@
import {Duration, formatDuration} from '../components/Duration';
import Element = JSX.Element;
import {Section} from "../components/Section";
import MultiRangeSlider from "../components/MultiSlider";
import {DemoHead} from "../header";
import {createSignal} from "solid-js";
import {downloadBuffer, edit} from "./tools";
export interface EditPageProps {
header: DemoHead;
demoData: ArrayBuffer;
setDropText: (string) => void,
name: string,
}
export const Editor = (props: EditPageProps) => {
let demoInfo: any[] | Element = [];
demoInfo = (
<div className="demo-info">
{props.header.map}
<Duration className="time"
duration={Math.floor(props.header.duration)}/>
</div>
);
const [loading, setLoading] = createSignal(false);
const [cutFrom, setCutFrom] = createSignal(0);
const [cutTo, setCutTo] = createSignal(props.header.ticks);
const intervalPerTick = props.header.duration / props.header.ticks;
const [unlockPov, setUnlockPov] = createSignal(false);
const editCb = async () => {
try {
setLoading(true);
(props.setDropText)("Editing...");
await doEdit(props.name, props.demoData, props.header, unlockPov(), cutFrom(), cutTo());
(props.setDropText)(props.name);
setLoading(false);
} catch (e) {
(props.setDropText)("Error: " + e.toString());
setLoading(false);
}
}
return (
<div>
{demoInfo}
<Section title="Unlock camera">
<ul key={1}>
<li key={1}>Unlocks the camera in pov demos, allowing free movement as if it were an stv demo.</li>
<li key={2}>When the player respawns the camera will be moved.</li>
<li key={3}>As pov demos only contain data near the player, far away players might freeze, teleport or otherwise behave weirdly.</li>
</ul>
<p key={2}>
<input type="checkbox" id="pov-unlock"
checked={unlockPov() ? "checked" : null}
onChange={() => {
setUnlockPov(!unlockPov())
}}/>
<label htmlFor="pov-unlock">Unlock camara for pov demo</label>
</p>
</Section>
<Section title="Cut demo">
<ul key={1}>
<li key={1}>Cuts the demo file to the selected tick range.</li>
<li key={2}>Cutting demos is experimental, resulting demo files might crash, have broken animations or have other issues.</li>
<li key={3}>Changing the specific cut range can sometimes work around issues with broken demos.</li>
</ul>
<p key={2}>
<MultiRangeSlider
min={0}
max={props.header.ticks}
onChange={(min, max) => {
if (min !== cutFrom() || max !== cutTo()) {
setCutFrom(min);
setCutTo(max);
}
}}
labelFn={(ticks) => formatDuration(ticks * intervalPerTick)}
/>
</p>
</Section>
<Section title="Process">
<p key={1}>
<button onClick={editCb}
className="pure-button pure-button-primary"
disabled={loading() ? "disabled" : null}>
{() => loading() ? 'Processing...' : 'Edit'}
</button>
</p>
</Section>
</div>
);
}
async function doEdit(name: string, data: ArrayBuffer, header: DemoHead, unlockPov: boolean, from: number, to: number) {
let options = {
unlock_pov: unlockPov,
cut: (from > 0 || to < header.ticks) ? {from, to} : undefined,
}
console.log(options);
const edited = await edit(data, options);
downloadBuffer(edited, name.replace('.dem', '_edited.dem'));
}

45
script/edit/EditWorker.ts Normal file
View file

@ -0,0 +1,45 @@
import init from "@demostf/edit";
import {count_ticks, edit_js} from "@demostf/edit";
declare function postMessage(message: any, transfer?: any[]): void;
function getCacheBuster(): string {
const url = self.location.href;
return url.substring(url.indexOf('?'));
}
/**
* @global postMessage
* @param event
*/
onmessage = async (event: MessageEvent) => {
await init(`/tf-demo-editor.wasm${getCacheBuster()}`);
if (event.data.count) {
const buffer: ArrayBuffer = event.data.buffer;
const bytes = new Uint8Array(buffer);
try {
const ticks = count_ticks(bytes);
postMessage({
ticks: ticks
});
} catch(e) {
postMessage({
error: e
});
}
} else {
const buffer: ArrayBuffer = event.data.buffer;
const options = event.data.options;
const bytes = new Uint8Array(buffer);
try {
const edited = edit_js(bytes, options);
postMessage({
buffer: edited.buffer
}, [edited.buffer]);
} catch (e) {
postMessage({
error: e
});
}
}
};

59
script/edit/tools.ts Normal file
View file

@ -0,0 +1,59 @@
export interface EditOptions {
unlock_pov: boolean,
cut?: TickRange,
}
export interface TickRange {
from: number,
to: number,
}
function getCacheBuster(): string {
const url = document.querySelector('script[src*="editor"]').attributes.src.value;
return url.substring("/editor.js".length);
}
export function edit(data: ArrayBuffer, options: EditOptions): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const worker = new Worker(`/edit-worker.js${getCacheBuster()}`);
worker.postMessage({
buffer: data,
options
});
worker.onmessage = (event) => {
if (event.data.error) {
reject(event.data.error);
return;
} else if (event.data.buffer) {
resolve(event.data.buffer);
}
}
});
}
export function count_ticks(data: ArrayBuffer): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker(`/edit-worker.js${getCacheBuster()}`);
worker.postMessage({
buffer: data,
count: true
});
worker.onmessage = (event) => {
if (event.data.error) {
reject(event.data.error);
return;
} else if (event.data.ticks) {
resolve(event.data.ticks);
}
}
});
}
export function downloadBuffer(arrayBuffer: ArrayBuffer, fileName: string) {
const a = document.createElement('a')
a.href = URL.createObjectURL(new Blob(
[arrayBuffer],
))
a.download = fileName
a.click()
}

37
script/editor.tsx Normal file
View file

@ -0,0 +1,37 @@
import {ready} from './ready';
import {DemoHead, parseHeaderFromBuffer, readFile} from './header';
import {render} from "solid-js/web";
import {count_ticks} from "./edit/tools";
import {Editor} from "./edit/EditPage";
ready(async () => {
const fileInput: HTMLInputElement | null = document.querySelector(`.dropzone input[type="file"]`);
const drop_text = document.querySelector(`.dropzone .text`);
fileInput.addEventListener("change", async (event: InputEvent) => {
let file = (event.target as HTMLInputElement).files[0];
drop_text.textContent = `processing ${file.name}...`;
const data = await readFile(file);
const header = parseHeaderFromBuffer(data);
if (header.type === "HL2DEMO" && header.game === "tf") {
if (header.ticks < 100) {
header.ticks = await count_ticks(data);
}
drop_text.textContent = file.name;
editor(file.name, data, header, text => {
drop_text.textContent = text;
});
} else {
drop_text.textContent = "Malformed demo or not a TF2 demo";
}
});
})
const editor = async (name: string, data: ArrayBuffer, header: DemoHead, setDropText: (string) => void) => {
console.log(header);
const page = document.querySelector('.edit-page .placeholder');
render(() => <Editor name={name} demoData={data} header={header} setDropText={setDropText} />, page);
}

View file

@ -17,6 +17,7 @@ use crate::fragments::demo_list::DemoList;
use crate::pages::about::AboutPage; use crate::pages::about::AboutPage;
use crate::pages::api::ApiPage; use crate::pages::api::ApiPage;
use crate::pages::demo::{ClassIconsStyle, DemoPage}; use crate::pages::demo::{ClassIconsStyle, DemoPage};
use crate::pages::edit::{EditWasm, EditWorkerScript, EditorPage, EditorScript, EditorStyle};
use crate::pages::index::{DemoListScript, Index}; use crate::pages::index::{DemoListScript, Index};
use crate::pages::profile::Profile; use crate::pages::profile::Profile;
use crate::pages::upload::{UploadPage, UploadScript}; use crate::pages::upload::{UploadPage, UploadScript};
@ -111,6 +112,13 @@ async fn main() -> Result<()> {
get(serve_asset::<ParseWorkerScript>), get(serve_asset::<ParseWorkerScript>),
) )
.route(ParserWasm::route(), get(serve_asset::<ParserWasm>)) .route(ParserWasm::route(), get(serve_asset::<ParserWasm>))
.route(EditorScript::route(), get(serve_asset::<EditorScript>))
.route(EditorStyle::route(), get(serve_asset::<EditorStyle>))
.route(
EditWorkerScript::route(),
get(serve_asset::<EditWorkerScript>),
)
.route(EditWasm::route(), get(serve_asset::<EditWasm>))
.route(LogoPng::route(), get(serve_asset::<LogoPng>)) .route(LogoPng::route(), get(serve_asset::<LogoPng>))
.route(LogoSvg::route(), get(serve_asset::<LogoSvg>)) .route(LogoSvg::route(), get(serve_asset::<LogoSvg>))
.route("/fragments/demo-list", get(demo_list)) .route("/fragments/demo-list", get(demo_list))
@ -121,6 +129,7 @@ async fn main() -> Result<()> {
.route("/logout", get(logout)) .route("/logout", get(logout))
.route("/upload", get(upload)) .route("/upload", get(upload))
.route("/viewer", get(viewer)) .route("/viewer", get(viewer))
.route("/edit", get(edit))
.route("/viewer/:id", get(viewer)) .route("/viewer/:id", get(viewer))
.route("/:id", get(demo)) .route("/:id", get(demo))
.route("/images/kill_icons/:icon", get(kill_icons)) .route("/images/kill_icons/:icon", get(kill_icons))
@ -386,6 +395,11 @@ async fn viewer(
session, session,
)) ))
} }
async fn edit(session: SessionData) -> Result<Markup> {
Ok(render(EditorPage, session))
}
async fn handler_404() -> impl IntoResponse { async fn handler_404() -> impl IntoResponse {
Error::NotFound Error::NotFound
} }

53
src/pages/edit.rs Normal file
View file

@ -0,0 +1,53 @@
use crate::pages::Page;
use demostf_build::Asset;
use maud::{html, Markup};
use std::borrow::Cow;
pub struct EditorPage;
#[derive(Asset)]
#[asset(source = "script/editor.tsx", url = "/editor.js")]
pub struct EditorScript;
#[derive(Asset)]
#[asset(source = "script/edit/EditWorker.ts", url = "/edit-worker.js")]
pub struct EditWorkerScript;
#[derive(Asset)]
#[asset(source = "style/pages/editor.css", url = "/editor.css")]
pub struct EditorStyle;
#[derive(Asset)]
#[asset(
source = "node_modules/@demostf/edit/edit_bg.wasm",
url = "/tf-demo-editor.wasm"
)]
pub struct EditWasm;
impl Page for EditorPage {
fn title(&self) -> Cow<'static, str> {
"Edit - demos.tf".into()
}
fn render(&self) -> Markup {
let script = EditorScript::url();
let style_url = EditorStyle::url();
html! {
.edit-page {
p.page-note {
"To edit a demo, select a file on your computer, select the desired options and press the \"edit\" button."
}
.dropzone role = "button" {
noscript {
"Javascript is required to view a demo."
}
span.text { "Drop files or click to view" }
input type = "file" {}
}
.placeholder {}
}
script module src = (script) type = "text/javascript" {}
link rel="stylesheet" type="text/css" href=(style_url);
}
}
}

View file

@ -1,6 +1,7 @@
pub mod about; pub mod about;
pub mod api; pub mod api;
pub mod demo; pub mod demo;
pub mod edit;
pub mod index; pub mod index;
mod plugin_section; mod plugin_section;
pub mod profile; pub mod profile;

View file

@ -0,0 +1,113 @@
.container {
padding-top: 30px;
display: flex;
height: 35px;
}
.slider {
position: relative;
width: 400px;
}
.slider__track,
.slider__range,
.slider__left-value,
.slider__right-value,
.slider__left-input,
.slider__right-input {
position: absolute;
}
.slider__track,
.slider__range {
border-radius: 3px;
height: 5px;
}
.slider__track {
background-color: #ced4da;
width: 100%;
z-index: 1;
}
.slider__range {
background-color: #9fe5e1;
z-index: 2;
}
.slider__left-value,
.slider__right-value {
font-size: 12px;
margin-top: 20px;
}
.slider__left-input,
.slider__right-input {
font-size: 12px;
margin-top: -36px;
}
.slider__left-input input,
.slider__right-input input {
width: 80px;
}
.slider__left-value, .slider__left-input {
left: 6px;
}
.slider__right-value, .slider__right-input {
right: -4px;
}
/* Removing the default appearance */
.thumb,
.thumb::-webkit-slider-thumb {
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
}
.thumb {
pointer-events: none;
position: absolute;
height: 0;
width: 400px;
outline: none;
}
.thumb--left {
z-index: 3;
}
.thumb--right {
z-index: 4;
}
/* For Chrome browsers */
.thumb::-webkit-slider-thumb {
background-color: #f1f5f7;
border: none;
border-radius: 50%;
box-shadow: 0 0 1px 1px #ced4da;
cursor: pointer;
height: 18px;
width: 18px;
margin-top: 4px;
pointer-events: all;
position: relative;
}
/* For Firefox browsers */
.thumb::-moz-range-thumb {
background-color: #f1f5f7;
border: none;
border-radius: 50%;
box-shadow: 0 0 1px 1px #ced4da;
cursor: pointer;
height: 18px;
width: 18px;
margin-top: 4px;
pointer-events: all;
position: relative;
}

5
style/pages/editor.css Normal file
View file

@ -0,0 +1,5 @@
@import 'edit/multislider.css';
.edit-page .edit-disabled {
opacity: 0.5;
}