mirror of
https://codeberg.org/demostf/frontend.git
synced 2026-06-03 18:24:12 +02:00
edit wip
This commit is contained in:
parent
0ab24ead47
commit
189788a1b6
14 changed files with 599 additions and 6 deletions
30
script/components/Duration.tsx
Normal file
30
script/components/Duration.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
script/components/MultiSlider.tsx
Normal file
110
script/components/MultiSlider.tsx
Normal 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;
|
||||
20
script/components/Section.tsx
Normal file
20
script/components/Section.tsx
Normal 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
106
script/edit/EditPage.tsx
Normal 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
45
script/edit/EditWorker.ts
Normal 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
59
script/edit/tools.ts
Normal 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
37
script/editor.tsx
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue