mirror of
https://github.com/skillbert/rsmv.git
synced 2025-12-23 21:47:48 -05:00
cutscene extraction tool
This commit is contained in:
15
generated/cutscenes.d.ts
vendored
15
generated/cutscenes.d.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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"]]]]
|
||||
]]],
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
222
src/scripts/rendercutscene.ts
Normal file
222
src/scripts/rendercutscene.ts
Normal 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 };
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* used to get an array with enum typing
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user