Working on integrating replay system with backend, store beatmap file in backend (w/ migration from file system)

This commit is contained in:
nise.moe 2024-03-02 17:51:54 +01:00
parent 1b390969c0
commit 6bba41623e
31 changed files with 566 additions and 158 deletions

View File

@ -16,7 +16,7 @@ import org.jooq.ForeignKey
import org.jooq.Name
import org.jooq.Record
import org.jooq.Records
import org.jooq.Row10
import org.jooq.Row11
import org.jooq.Schema
import org.jooq.SelectField
import org.jooq.Table
@ -112,6 +112,11 @@ open class Beatmaps(
*/
val LAST_REPLAY_CHECK: TableField<BeatmapsRecord, String?> = createField(DSL.name("last_replay_check"), SQLDataType.CLOB, this, "")
/**
* The column <code>public.beatmaps.beatmap_file</code>.
*/
val BEATMAP_FILE: TableField<BeatmapsRecord, String?> = createField(DSL.name("beatmap_file"), SQLDataType.CLOB, this, "")
private constructor(alias: Name, aliased: Table<BeatmapsRecord>?): this(alias, null, null, aliased, null)
private constructor(alias: Name, aliased: Table<BeatmapsRecord>?, parameters: Array<Field<*>?>?): this(alias, null, null, aliased, parameters)
@ -153,18 +158,18 @@ open class Beatmaps(
override fun rename(name: Table<*>): Beatmaps = Beatmaps(name.getQualifiedName(), null)
// -------------------------------------------------------------------------
// Row10 type methods
// Row11 type methods
// -------------------------------------------------------------------------
override fun fieldsRow(): Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> = super.fieldsRow() as Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?>
override fun fieldsRow(): Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> = super.fieldsRow() as Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?>
/**
* Convenience mapping calling {@link SelectField#convertFrom(Function)}.
*/
fun <U> mapping(from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?) -> U): SelectField<U> = convertFrom(Records.mapping(from))
fun <U> mapping(from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?) -> U): SelectField<U> = convertFrom(Records.mapping(from))
/**
* Convenience mapping calling {@link SelectField#convertFrom(Class,
* Function)}.
*/
fun <U> mapping(toType: Class<U>, from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?) -> U): SelectField<U> = convertFrom(toType, Records.mapping(from))
fun <U> mapping(toType: Class<U>, from: (Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?) -> U): SelectField<U> = convertFrom(toType, Records.mapping(from))
}

View File

@ -10,8 +10,8 @@ import java.time.OffsetDateTime
import org.jooq.Field
import org.jooq.Record1
import org.jooq.Record10
import org.jooq.Row10
import org.jooq.Record11
import org.jooq.Row11
import org.jooq.impl.UpdatableRecordImpl
@ -19,7 +19,7 @@ import org.jooq.impl.UpdatableRecordImpl
* This class is generated by jOOQ.
*/
@Suppress("UNCHECKED_CAST")
open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRecord>(Beatmaps.BEATMAPS), Record10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> {
open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRecord>(Beatmaps.BEATMAPS), Record11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> {
open var beatmapId: Int?
set(value): Unit = set(0, value)
@ -61,6 +61,10 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
set(value): Unit = set(9, value)
get(): String? = get(9) as String?
open var beatmapFile: String?
set(value): Unit = set(10, value)
get(): String? = get(10) as String?
// -------------------------------------------------------------------------
// Primary key information
// -------------------------------------------------------------------------
@ -68,11 +72,11 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun key(): Record1<Int?> = super.key() as Record1<Int?>
// -------------------------------------------------------------------------
// Record10 type implementation
// Record11 type implementation
// -------------------------------------------------------------------------
override fun fieldsRow(): Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> = super.fieldsRow() as Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?>
override fun valuesRow(): Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?> = super.valuesRow() as Row10<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?>
override fun fieldsRow(): Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> = super.fieldsRow() as Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?>
override fun valuesRow(): Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?> = super.valuesRow() as Row11<Int?, String?, Int?, String?, String?, Double?, String?, String?, OffsetDateTime?, String?, String?>
override fun field1(): Field<Int?> = Beatmaps.BEATMAPS.BEATMAP_ID
override fun field2(): Field<String?> = Beatmaps.BEATMAPS.ARTIST
override fun field3(): Field<Int?> = Beatmaps.BEATMAPS.BEATMAPSET_ID
@ -83,6 +87,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun field8(): Field<String?> = Beatmaps.BEATMAPS.VERSION
override fun field9(): Field<OffsetDateTime?> = Beatmaps.BEATMAPS.SYS_LAST_UPDATE
override fun field10(): Field<String?> = Beatmaps.BEATMAPS.LAST_REPLAY_CHECK
override fun field11(): Field<String?> = Beatmaps.BEATMAPS.BEATMAP_FILE
override fun component1(): Int? = beatmapId
override fun component2(): String? = artist
override fun component3(): Int? = beatmapsetId
@ -93,6 +98,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun component8(): String? = version
override fun component9(): OffsetDateTime? = sysLastUpdate
override fun component10(): String? = lastReplayCheck
override fun component11(): String? = beatmapFile
override fun value1(): Int? = beatmapId
override fun value2(): String? = artist
override fun value3(): Int? = beatmapsetId
@ -103,6 +109,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
override fun value8(): String? = version
override fun value9(): OffsetDateTime? = sysLastUpdate
override fun value10(): String? = lastReplayCheck
override fun value11(): String? = beatmapFile
override fun value1(value: Int?): BeatmapsRecord {
set(0, value)
@ -154,7 +161,12 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
return this
}
override fun values(value1: Int?, value2: String?, value3: Int?, value4: String?, value5: String?, value6: Double?, value7: String?, value8: String?, value9: OffsetDateTime?, value10: String?): BeatmapsRecord {
override fun value11(value: String?): BeatmapsRecord {
set(10, value)
return this
}
override fun values(value1: Int?, value2: String?, value3: Int?, value4: String?, value5: String?, value6: Double?, value7: String?, value8: String?, value9: OffsetDateTime?, value10: String?, value11: String?): BeatmapsRecord {
this.value1(value1)
this.value2(value2)
this.value3(value3)
@ -165,13 +177,14 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
this.value8(value8)
this.value9(value9)
this.value10(value10)
this.value11(value11)
return this
}
/**
* Create a detached, initialised BeatmapsRecord
*/
constructor(beatmapId: Int? = null, artist: String? = null, beatmapsetId: Int? = null, creator: String? = null, source: String? = null, starRating: Double? = null, title: String? = null, version: String? = null, sysLastUpdate: OffsetDateTime? = null, lastReplayCheck: String? = null): this() {
constructor(beatmapId: Int? = null, artist: String? = null, beatmapsetId: Int? = null, creator: String? = null, source: String? = null, starRating: Double? = null, title: String? = null, version: String? = null, sysLastUpdate: OffsetDateTime? = null, lastReplayCheck: String? = null, beatmapFile: String? = null): this() {
this.beatmapId = beatmapId
this.artist = artist
this.beatmapsetId = beatmapsetId
@ -182,6 +195,7 @@ open class BeatmapsRecord private constructor() : UpdatableRecordImpl<BeatmapsRe
this.version = version
this.sysLastUpdate = sysLastUpdate
this.lastReplayCheck = lastReplayCheck
this.beatmapFile = beatmapFile
resetChangedOnNotNull()
}
}

View File

@ -94,6 +94,11 @@ data class ReplayDataSimilarScore(
val correlation: Double
)
data class ReplayViewerData(
val replay: String,
val beatmap: String,
)
data class ReplayData(
val replay_id: Long,
val user_id: Int,

View File

@ -0,0 +1,15 @@
package com.nisemoe.nise.config
import com.nisemoe.nise.controller.NiseApiInterceptor
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class WebConfig : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(NiseApiInterceptor())
}
}

View File

@ -0,0 +1,23 @@
package com.nisemoe.nise.controller
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
@Component
class NiseApiInterceptor : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
request.getHeader("X-NISE-API")?.let {
if(it.toInt() < 20240218) {
response.sendError(403, "Forbidden")
return false
}
}
return true
}
}

View File

@ -3,6 +3,7 @@ package com.nisemoe.nise.controller
import com.nisemoe.nise.Format
import com.nisemoe.nise.ReplayData
import com.nisemoe.nise.ReplayPair
import com.nisemoe.nise.ReplayViewerData
import com.nisemoe.nise.database.ScoreService
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
@ -14,6 +15,14 @@ class ScoreController(
private val scoreService: ScoreService
) {
@GetMapping("score/{replayId}/replay")
fun getReplay(@PathVariable replayId: Long): ResponseEntity<ReplayViewerData> {
val replay = this.scoreService.getReplayViewerData(replayId)
?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(replay)
}
@GetMapping("score/{replayId}")
fun getScoreDetails(@PathVariable replayId: Long): ResponseEntity<ReplayData> {
val replayData = this.scoreService.getReplayData(replayId)

View File

@ -7,6 +7,7 @@ import com.nisemoe.nise.*
import com.nisemoe.nise.osu.Mod
import com.nisemoe.nise.service.AuthService
import com.nisemoe.nise.service.CompressJudgements
import org.apache.commons.compress.compressors.lzma.LZMACompressorInputStream
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Record
@ -14,6 +15,7 @@ import org.jooq.impl.DSL
import org.jooq.impl.DSL.avg
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.*
import kotlin.math.roundToInt
@Service
@ -40,6 +42,31 @@ class ScoreService(
.mapNotNull { (scoreType, title) -> db.get(scoreType)?.let { ReplayDataChart(title, it.filterNotNull()) } }
}
fun getReplayViewerData(replayId: Long): ReplayViewerData? {
val result = dslContext.select(
SCORES.REPLAY,
BEATMAPS.BEATMAP_FILE
)
.from(SCORES)
.join(BEATMAPS).on(SCORES.BEATMAP_ID.eq(BEATMAPS.BEATMAP_ID))
.where(SCORES.REPLAY_ID.eq(replayId))
.fetchOne() ?: return null
var replayData = result.get(SCORES.REPLAY, String::class.java) ?: return null
val decompressedReplay = Base64.getDecoder().decode(replayData).inputStream().use { byteStream ->
LZMACompressorInputStream(byteStream).readBytes()
}
replayData = String(decompressedReplay, Charsets.UTF_8).trimEnd(',')
if(result.get(BEATMAPS.BEATMAP_FILE, String::class.java) == null) return null
return ReplayViewerData(
replay = replayData,
beatmap = result.get(BEATMAPS.BEATMAP_FILE, String::class.java)
)
}
fun getReplayData(replayId: Long): ReplayData? {
val result = dslContext.select(
SCORES.ID,

View File

@ -28,8 +28,8 @@ class CircleguardService {
@Serializable
data class ReplayRequest(
val replay_data: String,
val mods: Int,
val beatmap_id: Int
val beatmap_data: String,
val mods: Int
)
@Serializable
@ -106,13 +106,13 @@ class CircleguardService {
replayResponse.error_skewness = replayResponse.error_skewness?.times(conversionFactor)
}
fun processReplay(replayData: String, beatmapId: Int, mods: Int = 0): CompletableFuture<ReplayResponse> {
fun processReplay(replayData: String, beatmapData: String, mods: Int = 0): CompletableFuture<ReplayResponse> {
val requestUri = "$apiUrl/replay"
val request = ReplayRequest(
replay_data = replayData,
beatmap_data = beatmapData,
mods = mods,
beatmap_id = beatmapId
)
// Serialize the request object to JSON

View File

@ -100,7 +100,26 @@ class OsuApi(
}
/**
* Retrieves the replay data for a given score ID from the Osu API.
* Retrieves the beatmap file for a given beatmap ID from the osu!api
*
* @param beatmapId The ID of the beatmap
*/
fun getBeatmapFile(beatmapId: Int): String? {
val response = this.doRequest("https://osu.ppy.sh/osu/${beatmapId}", emptyMap(), authorized = false)
if(response == null) {
this.logger.info("Error loading beatmap file")
return null
}
return if (response.statusCode() == 200) {
response.body()
} else {
null
}
}
/**
* Retrieves the replay data for a given score ID from the osu!api.
* Efficiently cycles through the API keys to avoid rate limiting.
* It's limited to 10 requests per minute according to @ https://github.com/ppy/osu-api/wiki#get-replay-data
*

View File

@ -0,0 +1,53 @@
package com.nisemoe.nise.scheduler
import com.nisemoe.generated.tables.references.BEATMAPS
import org.jooq.DSLContext
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Profile
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.io.File
@Profile("fix:migrate")
@Service
class FixMigration(
private val dslContext: DSLContext
){
@Value("\${BEATMAPS_PATH}")
private lateinit var beatmapPath: String
private val logger = LoggerFactory.getLogger(javaClass)
@Scheduled(fixedDelay = 120000, initialDelay = 0)
fun fixStuff() {
val dir = File(beatmapPath)
val regex = Regex("(.+) - (.+) \\((.+)\\)\\[(.+)\\]\\.osu")
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
val matchResult = regex.matchEntire(file.name)
if (matchResult != null) {
val (artist, title, creator, version) = matchResult.destructured
val content = file.readText()
val result = dslContext.update(BEATMAPS)
.set(BEATMAPS.BEATMAP_FILE, content)
.where(BEATMAPS.ARTIST.eq(artist))
.and(BEATMAPS.TITLE.eq(title))
.and(BEATMAPS.CREATOR.eq(creator))
.and(BEATMAPS.VERSION.eq(version))
.execute()
this.logger.info("Artist: $artist, Title: $title, Creator: $creator, Version: $version")
this.logger.info("Affected rows: $result")
}
}
} else {
this.logger.error("Directory does not exist or is not a directory")
}
}
}

View File

@ -1,8 +1,10 @@
package com.nisemoe.nise.scheduler
import com.nisemoe.generated.tables.records.ScoresRecord
import com.nisemoe.generated.tables.references.BEATMAPS
import com.nisemoe.generated.tables.references.SCORES
import com.nisemoe.nise.integrations.CircleguardService
import com.nisemoe.nise.osu.OsuApi
import com.nisemoe.nise.service.CompressJudgements
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
@ -20,6 +22,7 @@ import org.springframework.stereotype.Service
@Service
class FixOldScores(
private val dslContext: DSLContext,
private val osuApi: OsuApi,
private val circleguardService: CircleguardService,
private val compressJudgements: CompressJudgements
){
@ -101,9 +104,26 @@ class FixOldScores(
fun processScore(score: ScoresRecord) {
// Fetch the beatmap file from database
var beatmapFile = dslContext.select(BEATMAPS.BEATMAP_FILE)
.from(BEATMAPS)
.where(BEATMAPS.BEATMAP_ID.eq(score.beatmapId))
.fetchOneInto(String::class.java)
if(beatmapFile == null) {
this.logger.warn("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from database")
beatmapFile = this.osuApi.getBeatmapFile(beatmapId = score.beatmapId!!)
if(beatmapFile == null) {
this.logger.error("Failed to fetch beatmap file for beatmap_id = ${score.beatmapId} from osu!api")
return
}
}
val processedReplay: CircleguardService.ReplayResponse? = try {
this.circleguardService.processReplay(
replayData = score.replay!!.decodeToString(), beatmapId = score.beatmapId!!, mods = score.mods ?: 0
replayData = score.replay!!.decodeToString(), beatmapData = beatmapFile, mods = score.mods ?: 0
).get()
} catch (e: Exception) {
this.logger.error("Circleguard failed to process replay with score_id: ${score.id}")

View File

@ -207,6 +207,7 @@ class ImportScores(
for(topScore in allUserScores) {
val beatmapExists = dslContext.fetchExists(BEATMAPS, BEATMAPS.BEATMAP_ID.eq(topScore.beatmap!!.id))
if (!beatmapExists) {
val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id)
dslContext.insertInto(BEATMAPS)
.set(BEATMAPS.BEATMAP_ID, topScore.beatmap.id)
.set(BEATMAPS.BEATMAPSET_ID, topScore.beatmapset!!.id)
@ -217,6 +218,7 @@ class ImportScores(
.set(BEATMAPS.TITLE, topScore.beatmapset.title)
.set(BEATMAPS.SOURCE, topScore.beatmapset.source)
.set(BEATMAPS.CREATOR, topScore.beatmapset.creator)
.set(BEATMAPS.BEATMAP_FILE, beatmapFile)
.execute()
this.statistics.beatmapsAddedToDatabase++
}
@ -354,6 +356,7 @@ class ImportScores(
}
if (!beatmapExists) {
val beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmap.id)
dslContext.insertInto(BEATMAPS)
.set(BEATMAPS.BEATMAP_ID, beatmap.id)
.set(BEATMAPS.BEATMAPSET_ID, beatmapset.id.toInt())
@ -364,6 +367,7 @@ class ImportScores(
.set(BEATMAPS.TITLE, beatmapset.title)
.set(BEATMAPS.SOURCE, beatmapset.source)
.set(BEATMAPS.CREATOR, beatmapset.creator)
.set(BEATMAPS.BEATMAP_FILE, beatmapFile)
.execute()
this.statistics.beatmapsAddedToDatabase++
}
@ -611,10 +615,27 @@ class ImportScores(
return
}
// Fetch the beatmap file from database
var beatmapFile = dslContext.select(BEATMAPS.BEATMAP_FILE)
.from(BEATMAPS)
.where(BEATMAPS.BEATMAP_ID.eq(beatmapId))
.fetchOneInto(String::class.java)
if(beatmapFile == null) {
this.logger.warn("Failed to fetch beatmap file for beatmap_id = $beatmapId from database")
beatmapFile = this.osuApi.getBeatmapFile(beatmapId = beatmapId)
if(beatmapFile == null) {
this.logger.error("Failed to fetch beatmap file for beatmap_id = $beatmapId from osu!api")
return
}
}
// Calculate UR
val processedReplay: CircleguardService.ReplayResponse? = try {
this.circleguardService.processReplay(
replayData = scoreReplay.content, beatmapId = beatmapId, mods = Mod.combineModStrings(score.mods)
replayData = scoreReplay.content, beatmapData = beatmapFile, mods = Mod.combineModStrings(score.mods)
).get()
} catch (e: Exception) {
this.logger.error("Circleguard failed to process replay with score_id: ${score.id}")

View File

@ -0,0 +1,2 @@
ALTER TABLE public.beatmaps
ADD COLUMN beatmap_file text;

View File

@ -12,6 +12,7 @@ from brparser import Replay, BeatmapOsu, Mod
from circleguard import Circleguard, ReplayString, Hit
from circleguard.utils import filter_outliers
from flask import Flask, request, jsonify, abort
from slider import Beatmap
from src.WriteStreamWrapper import WriteStreamWrapper
from src.keypresses import get_kp_sliders
@ -45,16 +46,16 @@ def my_filter_outliers(arr, bias=1.5):
@dataclass
class ReplayRequest:
replay_data: str
beatmap_data: str
mods: int
beatmap_id: int
@staticmethod
def from_dict(data):
try:
return ReplayRequest(
replay_data=data['replay_data'],
mods=data['mods'],
beatmap_id=int(data['beatmap_id'])
beatmap_data=data['beatmap_data'],
mods=data['mods']
)
except (ValueError, KeyError, TypeError) as e:
raise ValueError(f"Invalid data format: {e}")
@ -128,7 +129,7 @@ def process_replay():
result_bytes1 = memory_stream1.getvalue()
replay1 = ReplayString(result_bytes1)
cg_beatmap = cg.library.lookup_by_id(beatmap_id=replay_request.beatmap_id, download=True, save=True)
cg_beatmap = Beatmap.parse(replay_request.beatmap_data)
ur = cg.ur(replay=replay1, beatmap=cg_beatmap)
adjusted_ur = cg.ur(replay=replay1, beatmap=cg_beatmap, adjusted=True)
@ -144,14 +145,11 @@ def process_replay():
replay = Replay(decoded_data, pure_lzma=True)
replay.mods = Mod(replay_request.mods)
filename = (f'{cg_beatmap.artist} - {cg_beatmap.title} ({cg_beatmap.creator})[{cg_beatmap.version}].osu'
.replace('/', ''))
beatmap_file = f'dbs/{filename}'
if not os.path.exists(beatmap_file):
print(f'Map not found @ {beatmap_file}', flush=True)
return 400, "Map not found"
beatmap = BeatmapOsu(None)
beatmap._process_headers(replay_request.beatmap_data.splitlines())
beatmap._parse(replay_request.beatmap_data.splitlines())
beatmap._sort_objects()
beatmap = BeatmapOsu(beatmap_file)
kp, se = get_kp_sliders(replay, beatmap)
hits: Iterable[Hit] = cg.hits(replay=replay1, beatmap=cg_beatmap)

View File

@ -21,7 +21,6 @@
"chart.js": "^4.4.1",
"date-fns": "^3.3.1",
"lz-string": "^1.5.0",
"lzma-web": "^3.0.1",
"ng2-charts": "^5.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
@ -8082,11 +8081,6 @@
"lz-string": "bin/bin.js"
}
},
"node_modules/lzma-web": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/lzma-web/-/lzma-web-3.0.1.tgz",
"integrity": "sha512-sb5cdfd+PLNljK/HUgYzvnz4G7r0GFK8sonyGrqJS0FVyUQjFYcnmU2LqTWFi6r48lH1ZBstnxyLWepKM/t7QA=="
},
"node_modules/magic-string": {
"version": "0.30.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",

View File

@ -24,7 +24,6 @@
"chart.js": "^4.4.1",
"date-fns": "^3.3.1",
"lz-string": "^1.5.0",
"lzma-web": "^3.0.1",
"ng2-charts": "^5.0.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",

View File

@ -13,6 +13,11 @@ export interface ReplayDataSimilarScore {
correlation: number;
}
export interface ReplayViewerData {
replay: string;
beatmap: string;
}
export interface ReplayData {
replay_id: number;
user_id: number;

View File

@ -12,9 +12,6 @@
</div>
</div>
</ng-container>
<div class="main term mb-2">
<app-replay-viewer></app-replay-viewer>
</div>
<ng-container *ngIf="this.replayData && !this.isLoading && !this.error">
<div class="main term mb-2">
<div class="fade-stuff">
@ -193,7 +190,7 @@
<app-chart [title]="chart.title" [data]="chart.data"></app-chart>
</ng-container>
<div class="main term" *ngIf="this.replayData.error_distribution && Object.keys(this.replayData.error_distribution).length > 0">
<div class="main term mb-2" *ngIf="this.replayData.error_distribution && Object.keys(this.replayData.error_distribution).length > 0">
<h1># hit distribution</h1>
<canvas baseChart
[data]="barChartData"
@ -204,4 +201,9 @@
class="chart">
</canvas>
</div>
<div class="main term mb-2" *ngIf="this.replayViewerData">
<h1># replay viewer <small>(experimental)</small></h1>
<app-replay-viewer [replayViewerData]="this.replayViewerData"></app-replay-viewer>
</div>
</ng-container>

View File

@ -6,7 +6,7 @@ import {environment} from "../../environments/environment";
import {DecimalPipe, JsonPipe, NgForOf, NgIf, NgOptimizedImage} from "@angular/common";
import {ActivatedRoute, RouterLink} from "@angular/router";
import {catchError, throwError} from "rxjs";
import {DistributionEntry, ReplayData} from "../replays";
import {DistributionEntry, ReplayData, ReplayViewerData} from "../replays";
import {calculateAccuracy} from "../format";
import {Title} from "@angular/platform-browser";
import {OsuGradeComponent} from "../../corelib/components/osu-grade/osu-grade.component";
@ -39,6 +39,7 @@ export class ViewScoreComponent implements OnInit {
isLoading = false;
error: string | null = null;
replayData: ReplayData | null = null;
replayViewerData: ReplayViewerData | null = null;
replayId: number | null = null;
public barChartLegend = true;
@ -76,6 +77,10 @@ export class ViewScoreComponent implements OnInit {
this.replayId = params['replayId'];
if (this.replayId) {
this.loadScoreData();
if(this.activatedRoute.snapshot.queryParams['viewer'] === 'true') {
this.loadReplayViewerData();
}
}
});
}
@ -94,6 +99,15 @@ export class ViewScoreComponent implements OnInit {
return url;
}
private loadReplayViewerData(): void {
this.http.get<ReplayViewerData>(`${environment.apiUrl}/score/${this.replayId}/replay`)
.subscribe({
next: (response) => {
this.replayViewerData = response;
}
});
}
private loadScoreData(): void {
this.isLoading = true;
this.http.get<ReplayData>(`${environment.apiUrl}/score/${this.replayId}`).pipe(

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@ -12,12 +12,66 @@ export type HitObject = {
hitSound: number;
objectParams: string;
hitSample: string;
currentCombo: number;
};
export interface BeatmapDifficulty {
hpDrainRate: number;
circleSize: number;
overralDifficulty: number;
approachRate: number;
sliderMultiplier: number;
sliderTickRate: number;
}
export function parseBeatmapDifficulty(beatmap: string): BeatmapDifficulty {
const lines = beatmap.split('\n');
let recording = false;
const difficulty: Partial<BeatmapDifficulty> = {};
for (const line of lines) {
if (line.trim() === '[Difficulty]') {
recording = true;
continue;
}
if (!recording) continue;
if (line.startsWith('[')) break;
const parts = line.split(':');
if (parts.length < 2) continue;
switch (parts[0]) {
case 'HPDrainRate':
difficulty.hpDrainRate = parseFloat(parts[1]);
break;
case 'CircleSize':
difficulty.circleSize = parseFloat(parts[1]);
break;
case 'OverallDifficulty':
difficulty.overralDifficulty = parseFloat(parts[1]);
break;
case 'ApproachRate':
difficulty.approachRate = parseFloat(parts[1]);
break;
case 'SliderMultiplier':
difficulty.sliderMultiplier = parseFloat(parts[1]);
break;
case 'SliderTickRate':
difficulty.sliderTickRate = parseFloat(parts[1]);
break;
}
}
return difficulty as BeatmapDifficulty;
}
export function parseHitObjects(beatmap: string): any[] {
const lines = beatmap.split('\n');
let recording = false;
const hitObjects = [];
let currentCombo = 1;
for (const line of lines) {
if (line.trim() === '[HitObjects]') {
@ -33,6 +87,14 @@ export function parseHitObjects(beatmap: string): any[] {
if (parts.length < 5) continue;
const type = parseInt(parts[3], 10);
const isNewCombo = type & (1 << 2); // Bit at index 2 for new combo
if (isNewCombo) {
currentCombo = 1; // Reset combo
} else {
// If not the start of a new combo, increment the current combo
currentCombo++;
}
const hitObject = {
x: parseInt(parts[0], 10),
y: parseInt(parts[1], 10),
@ -40,9 +102,15 @@ export function parseHitObjects(beatmap: string): any[] {
type: getTypeFromFlag(type),
hitSound: parseInt(parts[4], 10),
objectParams: parts[5],
hitSample: parts.length > 6 ? parts[6] : '0:0:0:0:'
hitSample: parts.length > 6 ? parts[6] : '0:0:0:0:',
currentCombo: currentCombo
};
if (isNewCombo) {
// Reset currentCombo after assigning to hitObject if it's the start of a new combo
currentCombo = 1;
}
hitObjects.push(hitObject);
}

View File

@ -1,10 +1,7 @@
import {KeyPress, ReplayEvent} from "./replay-viewer.component";
import LZMA from 'lzma-web'
export async function getEvents(replayString: string): Promise<ReplayEvent[]> {
const decompressedData = await decompressData(replayString);
const replayDataStr = new TextDecoder("utf-8").decode(decompressedData);
const trimmedReplayDataStr = replayDataStr.endsWith(',') ? replayDataStr.slice(0, -1) : replayDataStr;
export function getEvents(replayString: string): ReplayEvent[] {
const trimmedReplayDataStr = replayString.endsWith(',') ? replayString.slice(0, -1) : replayString;
return processEvents(trimmedReplayDataStr);
}
@ -19,7 +16,6 @@ function processEvents(replayDataStr: string): ReplayEvent[] {
}
function createReplayEvent(eventParts: string[], index: number, totalEvents: number): ReplayEvent | null {
const timeDelta = parseInt(eventParts[0], 10);
const x = parseFloat(eventParts[1]);
const y = parseFloat(eventParts[2]);
@ -32,39 +28,18 @@ function createReplayEvent(eventParts: string[], index: number, totalEvents: num
return null;
}
// Safely cast the integer value to the KeyPress enum
let keyPress = KeyPress[rawKey as unknown as keyof typeof KeyPress];
if (keyPress === undefined) {
// TODO: Fix
console.error("Unknown key press:", rawKey);
keyPress = KeyPress.Smoke;
let keys: KeyPress[] = [];
Object.keys(KeyPress).forEach(key => {
const keyPress = KeyPress[key as keyof typeof KeyPress];
if ((rawKey & keyPress) === keyPress) {
keys.push(keyPress);
}
});
return {
timeDelta,
x,
y,
key: keyPress
keys
};
}
async function decompressData(base64Data: string): Promise<Uint8Array> {
const lzma = new LZMA();
const binaryString = atob(base64Data);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const result = await lzma.decompress(bytes);
if (typeof result === 'string') {
return new TextEncoder().encode(result);
} else {
return result;
}
}

View File

@ -3,14 +3,14 @@ import {KeyPress, ReplayEvent} from "./replay-viewer.component";
export class ReplayEventProcessed {
x: number;
y: number;
t: number; // Time
key: KeyPress;
t: number;
keys: KeyPress[];
constructor(x: number, y: number, t: number, key: KeyPress) {
constructor(x: number, y: number, t: number, keys: KeyPress[]) {
this.x = x;
this.y = y;
this.t = t;
this.key = key;
this.keys = keys;
}
}
@ -47,7 +47,7 @@ export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] {
const interpolatedX = lastPositiveFrame.x + ratio * (currentFrame.x - lastPositiveFrame.x);
const interpolatedY = lastPositiveFrame.y + ratio * (currentFrame.y - lastPositiveFrame.y);
pEvents.push(new ReplayEventProcessed(interpolatedX, interpolatedY, lastPositiveTime, lastPositiveFrame.key));
pEvents.push(new ReplayEventProcessed(interpolatedX, interpolatedY, lastPositiveTime, lastPositiveFrame.keys));
}
wasInNegativeSection = false;
}
@ -55,7 +55,7 @@ export function processReplay(events: ReplayEvent[]): ReplayEventProcessed[] {
wasInNegativeSection = isInNegativeSection;
if (!isInNegativeSection) {
pEvents.push(new ReplayEventProcessed(currentFrame.x, currentFrame.y, cumulativeTimeDelta, currentFrame.key));
pEvents.push(new ReplayEventProcessed(currentFrame.x, currentFrame.y, cumulativeTimeDelta, currentFrame.keys));
}
if (!isInNegativeSection) {

View File

@ -1,6 +1,8 @@
import { ElementRef, Injectable } from '@angular/core';
import {HitObject, HitObjectType} from "./decode-beatmap";
import {ReplayEventProcessed} from "./process-replay";
import {BeatmapDifficulty, HitObject, HitObjectType, parseBeatmapDifficulty, parseHitObjects} from "./decode-beatmap";
import {processReplay, ReplayEventProcessed} from "./process-replay";
import {ReplayViewerData} from "../../../app/replays";
import {getEvents} from "./decode-replay";
@Injectable({
providedIn: 'root',
@ -9,8 +11,11 @@ export class ReplayService {
private hitObjects: HitObject[] = [];
private replayEvents: ReplayEventProcessed[] = [];
private difficulty: BeatmapDifficulty | null = null;
currentTime = 0;
speedFactor: number = 1;
private totalDuration = 0;
private lastRenderTime = 0;
@ -20,7 +25,17 @@ export class ReplayService {
private replayCanvas: ElementRef<HTMLCanvasElement> | null = null;
private ctx: CanvasRenderingContext2D | null = null;
constructor() {}
private hitCircleImage = new Image();
private hitCircleOverlay = new Image();
private approachCircleImage = new Image();
private cursorImage = new Image();
constructor() {
this.hitCircleImage.src = 'assets/replay-viewer/hitcircle.png';
this.hitCircleOverlay.src = 'assets/replay-viewer/hitcircleoverlay.png';
this.approachCircleImage.src = 'assets/replay-viewer/approachcircle.png';
this.cursorImage.src = 'assets/replay-viewer/cursor.png';
}
setCanvasElement(canvas: ElementRef<HTMLCanvasElement>) {
this.replayCanvas = canvas;
@ -30,15 +45,13 @@ export class ReplayService {
this.ctx = ctx;
}
setHitObjects(hitObjects: HitObject[]) {
this.hitObjects = hitObjects;
console.log('Hit objects:', this.hitObjects);
}
loadReplay(replayViewerData: ReplayViewerData): void {
this.replayEvents = processReplay(getEvents(replayViewerData.replay))
setEvents(events: ReplayEventProcessed[]) {
this.replayEvents = events;
this.hitObjects = parseHitObjects(replayViewerData.beatmap)
this.calculateTotalDuration();
console.log('Replay events:', this.replayEvents);
this.difficulty = parseBeatmapDifficulty(replayViewerData.beatmap)
}
private calculateTotalDuration() {
@ -72,7 +85,6 @@ export class ReplayService {
this.isPlaying = true;
this.animate();
this.isPlaying = false;
console.log('Seeking to:', this.currentTime);
}
private animate(currentTimestamp: number = 0) {
@ -82,6 +94,11 @@ export class ReplayService {
this.lastRenderTime = currentTimestamp;
}
if(!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
return;
}
const elapsedTime = currentTimestamp - this.lastRenderTime;
// Check if enough time has passed for the next frame (approximately 16.67ms for 60 FPS)
@ -92,7 +109,7 @@ export class ReplayService {
}
// Assuming elapsedTime is sufficient for 1 frame, update currentTime for real-time playback
this.currentTime += elapsedTime;
this.currentTime += elapsedTime * this.speedFactor;
if (this.currentTime > this.totalDuration) {
this.currentTime = this.totalDuration;
@ -100,9 +117,10 @@ export class ReplayService {
return;
}
this.drawCurrentEventPosition();
this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height);
this.drawHitCircles();
this.drawSliders();
this.drawCursor();
this.lastRenderTime = currentTimestamp;
@ -122,10 +140,15 @@ export class ReplayService {
return currentEvent;
}
private drawCurrentEventPosition() {
private drawCursor() {
const currentEvent = this.getCurrentReplayEvent();
if (currentEvent) {
this.updateCanvas(currentEvent.x, currentEvent.y);
if (!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
return;
}
this.ctx.drawImage(this.cursorImage, currentEvent.x - 32, currentEvent.y - 32, 64, 64);
}
}
@ -143,10 +166,93 @@ export class ReplayService {
visibleHitCircles.forEach(hitCircle => {
const opacity = this.calculateOpacity(hitCircle.time);
this.drawHitCircle(hitCircle.x, hitCircle.y, opacity);
this.drawHitCircle(hitCircle.x, hitCircle.y, opacity, hitCircle.currentCombo);
this.drawApproachCircle(hitCircle.x, hitCircle.y, 1, hitCircle.time)
});
}
private drawApproachCircle(x: number, y: number, opacity: number, hitTime: number) {
let {fadeIn, totalDisplayTime} = this.calculatePreempt();
let baseSize = 54.4 - 4.48 * this.difficulty!.circleSize;
// Calculate scale using the provided formula
let scale = Math.max(1, ((hitTime - this.currentTime) / totalDisplayTime) * 3 + 1);
// Adjust baseSize according to the scale
baseSize *= scale;
this.ctx!.drawImage(this.approachCircleImage, x - baseSize, y - baseSize, baseSize * 2, baseSize * 2);
}
private calculateOpacity(circleTime: number): number {
const timeDifference = circleTime - this.currentTime;
if (timeDifference < 0) {
return 0; // Circle time has passed, so it should be fully transparent
}
let {fadeIn, totalDisplayTime} = this.calculatePreempt();
// Adjust the opacity calculation based on fadeIn and totalDisplayTime
if (timeDifference > totalDisplayTime) {
return 0; // Circle is not visible yet
} else if (timeDifference > fadeIn) {
return 1; // Circle is fully visible (opaque)
} else {
return (fadeIn - timeDifference) / fadeIn;
}
}
private calculatePreempt() {
const AR = this.difficulty!.approachRate;
let fadeIn = 0;
if (AR === 5) {
fadeIn = 800;
} else if (AR > 5) {
fadeIn = 800 - 500 * (AR - 5) / 5;
} else if (AR < 5) {
fadeIn = 800 + 400 * (5 - AR) / 5;
}
let stayVisibleTime: number;
if (AR > 5) {
stayVisibleTime = 1200 + 600 * (5 - AR) / 5;
} else if (AR < 5) {
stayVisibleTime = 1200 - 750 * (AR - 5) / 5;
} else {
stayVisibleTime = 1200;
}
let totalDisplayTime = fadeIn + stayVisibleTime;
return {fadeIn, totalDisplayTime};
}
private drawHitCircle(x: number, y: number, opacity: number, combo: number) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
let radius = 54.4 - 4.48 * this.difficulty!.circleSize;
this.ctx!.drawImage(this.hitCircleImage, x - radius, y - radius, radius * 2, radius * 2);
this.ctx!.drawImage(this.hitCircleOverlay, x - radius, y - radius, radius * 2, radius * 2);
// Draw combo
this.ctx!.font = "32px monospace";
const measure = this.ctx!.measureText(combo.toString());
this.ctx!.fillText(combo.toString(), x - measure.width / 2, y + 10);
}
private drawSlider(x: number, y: number, opacity: number, combo: number) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
let radius = 54.4 - 4.48 * this.difficulty!.circleSize;
this.ctx!.drawImage(this.hitCircleImage, x - radius, y - radius, radius * 2, radius * 2);
this.ctx!.drawImage(this.hitCircleOverlay, x - radius, y - radius, radius * 2, radius * 2);
// Draw combo
this.ctx!.font = "32px monospace";
const measure = this.ctx!.measureText(combo.toString());
this.ctx!.fillText(combo.toString(), x - measure.width / 2, y + 10);
}
private drawSliders() {
if (!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
@ -161,47 +267,11 @@ export class ReplayService {
visibleSliders.forEach(slider => {
const opacity = this.calculateOpacity(slider.time);
this.drawSlider(slider, opacity);
this.drawSlider(slider.x, slider.y, opacity, slider.currentCombo);
this.drawApproachCircle(slider.x, slider.y, 1, slider.time)
});
}
private drawSlider(slider: HitObject, opacity: number) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
this.ctx!.beginPath();
this.ctx!.arc(slider.x, slider.y, 25, 0, 2 * Math.PI); // Assuming a radius of 5px
this.ctx!.fill();
}
private calculateOpacity(circleTime: number): number {
const timeDifference = circleTime - this.currentTime;
if (timeDifference < 0) {
return 0; // Circle time has passed
}
// Calculate fade-in effect (0 to 1 over 200ms)
return Math.min(1, (200 - timeDifference) / 200);
}
private drawHitCircle(x: number, y: number, opacity: number) {
this.ctx!.fillStyle = `rgba(255, 255, 255, ${opacity})`; // Set opacity for fade-in effect
this.ctx!.beginPath();
this.ctx!.arc(x, y, 25, 0, 2 * Math.PI); // Assuming a radius of 50px
this.ctx!.fill();
}
private updateCanvas(x: number, y: number) {
if (!this.ctx || !this.replayCanvas) {
console.error('Canvas context not initialized');
return;
}
this.ctx.clearRect(0, 0, this.replayCanvas.nativeElement.width, this.replayCanvas.nativeElement.height);
this.ctx.fillStyle = '#FFFFFF';
this.ctx.beginPath();
this.ctx.arc(x, y, 5, 0, 2 * Math.PI);
this.ctx.fill();
}
getTotalDuration() {
return this.totalDuration;
}

View File

@ -0,0 +1,39 @@
.row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
}
.column {
display: flex;
flex-direction: column;
flex-basis: 100%;
flex: 1;
}
ul {
list-style: none;
}
/* Flex container */
.flex-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px; /* Adjust the gap between items as needed */
}
/* Flex items - default to full width to stack on smaller screens */
.flex-container > div {
flex: 0 0 100%;
box-sizing: border-box; /* To include padding and border in the element's total width and height */
}
/* Responsive columns */
@media (min-width: 768px) { /* Adjust the breakpoint as needed */
.flex-container > div {
flex: 0 0 40%;
max-width: 50%;
}
}

View File

@ -1,5 +1,40 @@
<div class="text-center">
<canvas #replayCanvas width="600" height="400"></canvas>
<div>Current Time: {{ replayService.currentTime | number: '1.0-0' }}</div>
<div>Total Duration: {{ replayService.getTotalDuration() | number: '1.0-0' }}</div>
<button (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
<input type="range" min="0" [max]="replayService.getTotalDuration()" [(ngModel)]="replayService.currentTime" (input)="seek(replayService.currentTime)">
</div>
<div class="text-center mb-2">
<button style="font-size: 20px" (click)="togglePlayPause()">{{ replayService.getIsPlaying() ? 'Pause' : 'Play' }}</button>
</div>
<div class="some-page-wrapper text-center">
<div class="row">
<div class="column">
<fieldset>
<label for="currentTime">Current Time: {{ replayService.currentTime | number: '1.0-0' }}</label>
<div>
<input id="currentTime" type="range" min="0" [max]="replayService.getTotalDuration()" [(ngModel)]="replayService.currentTime" (input)="seek(replayService.currentTime)">
</div>
</fieldset>
</div>
<div class="column">
<fieldset>
<label for="speedFactor">Speed Factor: {{ replayService.speedFactor }}</label>
<div>
<input type="range" min="0.1" max="2" step="0.1" id="speedFactor" [(ngModel)]="replayService.speedFactor">
</div>
</fieldset>
</div>
</div>
</div>

View File

@ -1,11 +1,11 @@
import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import {AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core';
import {getEvents} from "./decode-replay";
import {DecimalPipe, JsonPipe} from "@angular/common";
import {beatmapData, replayData} from "./sample-replay";
import {DecimalPipe, JsonPipe, NgForOf} from "@angular/common";
import {ReplayService} from "./replay-service";
import {FormsModule} from "@angular/forms";
import {parseHitObjects} from "./decode-beatmap";
import {processReplay} from "./process-replay";
import {ReplayViewerData} from "../../../app/replays";
export enum KeyPress {
M1 = 1,
@ -32,9 +32,9 @@ export interface ReplayEvent {
y: number;
/**
* Key being pressed.
* Keys being pressed.
*/
key: KeyPress;
keys: KeyPress[];
}
@ -44,7 +44,8 @@ export interface ReplayEvent {
imports: [
JsonPipe,
FormsModule,
DecimalPipe
DecimalPipe,
NgForOf
],
templateUrl: './replay-viewer.component.html',
styleUrl: './replay-viewer.component.css'
@ -54,15 +55,14 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit {
@ViewChild('replayCanvas') replayCanvas!: ElementRef<HTMLCanvasElement>;
private ctx!: CanvasRenderingContext2D;
@Input() replayViewerData!: ReplayViewerData;
// TODO: Calculate AudioLeadIn
// TODO: Calculate circle size (CS)
// TODO: Hard-Rock, DT, Easy
// TODO: Cursor trail and where keys are pressed
// TODO: Button for -100 ms, +100 ms, etc (precise seeking) (or keyboard shortcuts)
// TODO: Way to obtain replay+beatmap info from the backend
// TODO: UR bar
// Todo: Customizable speed
// TODO: Customizable zoom
@ -75,11 +75,7 @@ export class ReplayViewerComponent implements OnInit, AfterViewInit {
constructor(public replayService: ReplayService) { }
ngOnInit() {
// Assume getEvents() method returns a promise of ReplayEvent[]
getEvents(replayData).then(events => {
this.replayService.setEvents(processReplay(events));
this.replayService.setHitObjects(parseHitObjects(beatmapData))
});
this.replayService.loadReplay(this.replayViewerData);
}
ngAfterViewInit() {