cutscene extraction tool

This commit is contained in:
Skillbert
2023-10-13 20:26:45 +02:00
parent 873dc99f7c
commit 53d53f1891
6 changed files with 263 additions and 14 deletions

View File

@@ -13,25 +13,25 @@ export type cutscenes = {
end: number,
flag0: number,
graphics: {
img: string,
spritename: string,
height: number,
width: number,
unk: number,
unk2: number,
matrix0: [
spriteid: number,
opacityframes: [
number,
number,
][],
matrix1: [
rotateframes: [
number,
number,
][],
matrix2: [
translateframes: [
number,
number,
number,
][],
matrix3: [
scaleframes: [
number,
number,
number,
@@ -41,7 +41,8 @@ export type cutscenes = {
sound: string | null,
flag2: number,
subtitle: string | null,
unkbytes: Uint8Array | null,
unkbyte: number | null,
soundid: number | null,
extraflags: (number|0),
extra_01: {
start: number,

View File

@@ -9,21 +9,22 @@
["end","float"],
["flag0","ubyte"],
["graphics",["array",["ref","flag0"],["struct",
["img","paddedstring"],
["spritename","paddedstring"],
["height","ushort"],
["width","ushort"],
["unk","ushort"],
["unk2","ushort"],
["matrix0",["array","ubyte",["tuple","float","float"]]],
["matrix1",["array","ubyte",["tuple","float","float"]]],
["matrix2",["array","ubyte",["tuple","float","float","float"]]],
["matrix3",["array","ubyte",["tuple","float","float","float"]]]
["spriteid","ushort"],
["opacityframes",["array","ubyte",["tuple","float","float"]]],
["rotateframes",["array","ubyte",["tuple","float","float"]]],
["translateframes",["array","ubyte",["tuple","float","float","float"]]],
["scaleframes",["array","ubyte",["tuple","float","float","float"]]]
]]],
["flag1","ubyte"],
["sound",["opt","flag1!=0","paddedstring"]],
["flag2","ubyte"],
["subtitle",["opt","flag2!=0","paddedstring"]],
["unkbytes",["opt","flag0==0",["buffer",3,"hex"]]],
["unkbyte",["opt","flag0==0","ubyte"]],
["soundid",["opt","flag0==0","ushort"]],
["extraflags",["match",["ref","flag0"],{"0":"ubyte","other":0}]],
["extra_01",["opt","extraflags!=0",["struct",["start","float"],["end","float"]]]]
]]],

View File

@@ -14,6 +14,7 @@ import { ParsedTexture } from "../3d/textures";
import { parseMusic } from "./musictrack";
import { legacyGroups, legacyMajors } from "../cache/legacycache";
import { classicGroups } from "../cache/classicloader";
import { renderCutscene } from "./rendercutscene";
type CacheFileId = {
@@ -373,6 +374,18 @@ const decodeSound = (major: number): DecodeModeFactory => () => {
}
}
const decodeCutscene: DecodeModeFactory = () => {
return {
ext: "html",
...noArchiveIndex(cacheMajors.cutscenes),
...throwOnNonSimple,
async read(buf, fileid, source) {
let res = await renderCutscene(source, buf);
return res.doc;
}
}
}
const decodeOldProcTexture: DecodeModeFactory = () => {
return {
ext: "png",
@@ -563,6 +576,7 @@ export const cacheFileDecodeModes = constrainedMap<DecodeModeFactory>()({
sounds: decodeSound(cacheMajors.sounds),
musicfragments: decodeSound(cacheMajors.music),
music: decodeMusic,
cutscenehtml: decodeCutscene,
npcmodels: npcmodels,

View File

@@ -0,0 +1,222 @@
import { cutscenes } from "../../generated/cutscenes";
import { parseSprite } from "../3d/sprite";
import { CacheFileSource } from "../cache";
import { cacheMajors } from "../constants";
import { pixelsToDataUrl } from "../imgutils";
import { crc32 } from "../libs/crc32util";
import { parse } from "../opdecoder";
import { escapeHTML } from "../utils";
import { parseMusic } from "./musictrack";
export async function renderCutscene(engine: CacheFileSource, file: Buffer) {
let obj = parse.cutscenes.read(file, engine);
let root = document.createElement("div");
root.style.width = `${obj.width}px`;
root.style.height = `${obj.height}px`;
console.log(obj);
// let uuid = "";
// for (let i = 0; i < 8; i++) { uuid += String.fromCharCode("A".charCodeAt(0) + Math.random() * 26 | 0); }
let uuid = `cutscene-${crc32(file) >>> 0}`;
let css = "";
let html = "";
let endtime = obj.elements.reduce((a, v) => Math.max(a, v.end), 0);
let timetopercent = (t: number) => `${Math.max(0, t / endtime * 100).toFixed(2)}%`;
let imgcache = new Map<number, string>();
let anim = function <T extends number[][]>(el: cutscenes["elements"][number], animname: string, frames: T, stylefn: (v: T[number]) => string) {
css += `@keyframes ${animname}{\n`
css += ` from{${stylefn(frames[0])}}\n`;
css += frames.map(q => ` ${timetopercent(el.start + q[0])}{${stylefn(q)}}\n`).join("");
css += ` to{${stylefn(frames.at(-1)!)}}\n`;
css += `}\n`;
return `${endtime}s infinite ${animname} linear`;
}
css += `.subtitle{\n`;
css += ` position: absolute;\n`
css += ` font-size: 50px;\n`
css += ` bottom: 20px;\n`
css += ` text-align: center;\n`
css += ` color: white;\n`
css += ` padding: 5px;\n`
css += ` left: 20px;\n`
css += ` right: 20px;\n`
css += ` font-family: sans-serif;\n`;
css += ` display:flex;\n`;
css += `}\n`;
css += `.subtitle>div{\n`;
css += ` background:rgba(0,0,0,0.3);\n`;
css += ` margin:0px auto;\n`;
css += ` padding:12px;\n`;
css += ` border-radius:20px;\n`;
css += `}\n`;
for (let i = obj.elements.length - 1; i >= 0; i--) {
let el = obj.elements[i];
let visibilityanim = `${uuid}-${i}-visibility`;
css += `@keyframes ${visibilityanim}{\n`
css += ` 0%{visibility:hidden}\n`;
css += ` ${timetopercent(el.start)}{visibility:visible}\n`
css += ` ${timetopercent(el.end)}{visibility:hidden}\n`
css += `}\n`;
html += `<div style="animation:${endtime}s step-end infinite ${visibilityanim}">\n`;
if (el.subtitle) {
html += `<div class="subtitle"><div>${escapeHTML(el.subtitle)}</div></div>\n`;
}
if (el.soundid) {
try {
let file = await parseMusic(engine, cacheMajors.sounds, el.soundid, null);
html += `<audio src="data:audio/ogg;base64,${file.toString("base64")}" data-timestart="${el.start}" data-timeend="${el.end}"></audio>\n`;
} catch (e) {
console.warn(`missing sound ${el.soundid} ${el.sound}`);
}
}
if (el.graphics) {
if (el.graphics.length != 0) {
for (let imgindex = el.graphics.length - 1; imgindex >= 0; imgindex--) {
let img = el.graphics[imgindex]
let pngfile = imgcache.get(img.spriteid);
if (!pngfile) {
let spritebuf = await engine.getFileById(cacheMajors.sprites, img.spriteid);
pngfile = await pixelsToDataUrl(parseSprite(spritebuf)[0].img);
imgcache.set(img.spriteid, pngfile);
}
let anims: string[] = [];
let translateanim = "";
let rotateanim = "";
if (img.opacityframes.length != 0) {
let animname = `${uuid}-${i}-${imgindex}-opacity`;
anims.push(anim(el, animname, img.opacityframes, v => `opacity:${v[1].toFixed(2)}`));
}
if (img.rotateframes.length != 0) {
let animname = `${uuid}-${i}-${imgindex}-rotate`;
rotateanim = `animation:${anim(el, animname, img.rotateframes, v => `transform:rotate(${v[1].toFixed(2)}deg);`)};`;
}
if (img.translateframes.length != 0) {
let animname = `${uuid}-${i}-${imgindex}-translate`;
translateanim = `animation:${anim(el, animname, img.translateframes, v => `transform:translate(${v[1].toFixed(2)}px,${v[2].toFixed(2)}px)`)};`;
}
if (img.scaleframes.length != 0) {
let animname = `${uuid}-${i}-${imgindex}-scale`;
anims.push(anim(el, animname, img.scaleframes, v => `transform:scale(${v[1].toFixed(3)},${v[2].toFixed(2)});`));
}
let positionstyle = `position:absolute; top:0px;left:0px;`;
let countermargin = `margin-left:${-img.width / 2}px; margin-top:${-img.height / 2}px;`;
html += `<div style="${positionstyle} ${translateanim}">\n`;
html += `<div style="${rotateanim} transform-origin:center;">\n`;
html += `<img src="${pngfile}" width="${img.width}" height="${img.height}" style="${countermargin} animation:${anims.join()};">\n`;
html += `</div>\n`;
html += `</div>\n`;
}
}
html += "</div>";
}
}
function embeddedModule(endtime: number) {
console.log("module init");
let lastseektime = 0;
let lastseektimestamp = Date.now();
let lastplayrate = 1;
let endtimeout = 0;
function getTime() {
return lastseektime + (Date.now() - lastseektimestamp) / 1000 * lastplayrate;
}
function onRangeChange(e: InputEvent) {
let time = (e.currentTarget as HTMLInputElement).valueAsNumber;
seek(time, 0);
}
function play() {
seek(getTime(), 1);
}
function pause() {
seek(getTime(), 0);
}
function seek(time: number, playbackRate = 1) {
lastseektime = time;
lastplayrate = playbackRate;
lastseektimestamp = Date.now();
if (endtimeout) {
clearTimeout(endtimeout);
endtimeout = 0;
}
if (playbackRate != 0) {
let timeleft = (endtime - time / playbackRate) * 1000;
endtimeout = +setTimeout(() => { seek(0, playbackRate); }, timeleft)
}
//fix css anims
let anims = document.getAnimations();
for (let anim of anims) {
anim.currentTime = 1000 * time;
anim.playbackRate = playbackRate;
if (playbackRate != 0) {
anim.play();
} else {
anim.pause();
}
}
//fix audio
let audios = Array.from(document.querySelectorAll("audio"));
for (let audio of audios) {
let reltime = time - +(audio.dataset.timestart ?? 0);
if (audio.dataset.delaytimer) {
clearTimeout(+audio.dataset.delaytimer);
audio.dataset.delaytimer = undefined;
}
if (playbackRate != 0) {
audio.playbackRate = playbackRate;
if (reltime < 0) {
audio.dataset.delaytimer = "" + +setTimeout(() => { audio.currentTime = 0; audio.play() }, -reltime / playbackRate * 1000);
} else {
audio.currentTime = reltime;
audio.play();
}
} else {
audio.pause();
}
}
}
return { seek, play, pause, onRangeChange };
}
let doc = `<!DOCTYPE html>\n`;
doc += `<html>\n`
doc += `<head>\n`
doc += `<style>\n`
doc += css;
doc += `</style>\n`
doc += `</head>\n`
doc += `<body>\n`
doc += `<input type="range" min="0" max="${endtime}" step="0.01" style="width:400px;" oninput="controls.onRangeChange(event)">\n`
doc += `<input type="button" value="play" onclick="controls.play()">\n`;
doc += `<input type="button" value="pause" onclick="controls.pause()">\n`;
doc += `<div style="position:relative; width:${obj.width}px; height:${obj.height}px; overflow:hidden; zoom:0.5;">\n`
doc += html;
doc += `</div>\n`
doc += `<script>\n`
doc += `var controls=(${embeddedModule})(${endtime});\n`;
doc += `controls.play()\n`;
doc += `</script>\n`
// doc += `<script>initAudio();</script>\n`;
doc += `</body>\n`
doc += `</html>\n`
return { html, css, doc };
}

View File

@@ -88,6 +88,15 @@ export function posmod(x: number, n: number) {
return ((x % n) + n) % n;
}
export function escapeHTML(str: string) {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* used to get an array with enum typing
*/

View File

@@ -633,6 +633,8 @@ export function FileDisplay(p: { file: UIScriptFile }) {
if (typeof filedata == "string") {
if (ext == "hexerr.json") {
el = <FileDecodeErrorViewer file={filedata} />;
} else if (ext == "html") {
el = <iframe srcDoc={filedata} sandbox="allow-scripts" style={{ width: "95%", height: "95%" }} />;
} else {
//TODO make this not depend on wether file is Buffer or string
//string types so far: txt, json, batch.json