# HG changeset patch # User drewp@bigasterisk.com # Date 1653979567 25200 # Node ID ddbd0fca89ff8797d0243fb9ad04fe1a4d83eac7 # Parent 8516a39eedc92d1aae9e3d3484165c9abbce2b81 file moves for effectSequencer diff -r 8516a39eedc9 -r ddbd0fca89ff bin/effectSequencer --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/bin/effectSequencer Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,5 @@ +#!/bin/sh +pnpx vite -c light9/effect/sequencer/web/vite.config.ts & +pdm run uvicorn light9.effect.sequencer.service:app --host 0.0.0.0 --port 8213 --no-access-log +wait + diff -r 8516a39eedc9 -r ddbd0fca89ff bin/effectsequencer --- a/bin/effectsequencer Mon May 30 23:45:28 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,84 +0,0 @@ -#!bin/python -""" -plays back effect notes from the timeline -""" - -from run_local import log -from twisted.internet import reactor -from light9.metrics import metrics, metricsRoute -from rdfdb.syncedgraph import SyncedGraph -from light9 import networking, showconfig -import optparse, sys, logging -import cyclone.web -from rdflib import URIRef -from light9.effect.sequencer import Sequencer, Updates -from light9.collector.collector_client import sendToCollector - -from light9 import clientsession - - -class App(object): - - def __init__(self, show, session): - self.show = show - self.session = session - - self.graph = SyncedGraph(networking.rdfdb.url, "effectSequencer") - self.graph.initiallySynced.addCallback(self.launch) - - def launch(self, *args): - self.seq = Sequencer( - self.graph, - lambda settings: sendToCollector( - 'effectSequencer', - self.session, - settings, - # This seems to be safe here (and lets us get from - # 20fpx to 40fpx), even though it leads to big stalls - # if I use it on KC. - useZmq=True)) - - self.cycloneApp = cyclone.web.Application(handlers=[ - (r'/()', cyclone.web.StaticFileHandler, { - "path": "light9/effect/", - "default_filename": "sequencer.html" - }), - (r'/updates', Updates), - metricsRoute(), - ], - debug=True, - seq=self.seq, - graph=self.graph, - # stats=self.stats - ) - reactor.listenTCP(networking.effectSequencer.port, self.cycloneApp) - log.info("listening on %s" % networking.effectSequencer.port) - - -if __name__ == "__main__": - parser = optparse.OptionParser() - parser.add_option( - '--show', - help='show URI, like http://light9.bigasterisk.com/show/dance2008', - default=showconfig.showUri()) - parser.add_option("-v", - "--verbose", - action="store_true", - help="logging.DEBUG") - parser.add_option("--twistedlog", - action="store_true", - help="twisted logging") - clientsession.add_option(parser) - (options, args) = parser.parse_args() - log.setLevel(logging.DEBUG if options.verbose else logging.INFO) - - if not options.show: - raise ValueError("missing --show http://...") - - session = clientsession.getUri('effectSequencer', options) - - app = App(URIRef(options.show), session) - if options.twistedlog: - from twisted.python import log as twlog - twlog.startLogging(sys.stderr) - reactor.run() diff -r 8516a39eedc9 -r ddbd0fca89ff light9/effect/sequencer.html --- a/light9/effect/sequencer.html Mon May 30 23:45:28 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,149 +0,0 @@ - - - - effect sequencer - - - - - - - - - - - - - - - - - - - - - - - - - diff -r 8516a39eedc9 -r ddbd0fca89ff light9/effect/sequencer.py --- a/light9/effect/sequencer.py Mon May 30 23:45:28 2022 -0700 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,288 +0,0 @@ -''' -copies from effectloop.py, which this should replace -''' - -from louie import dispatcher -from rdflib import URIRef -from twisted.internet import reactor -from twisted.internet import defer -from twisted.internet.defer import Deferred, inlineCallbacks -from twisted.internet.inotify import INotify -from twisted.python.filepath import FilePath -import cyclone.sse -import logging, bisect, time -import traceback -from decimal import Decimal -from typing import Any, Callable, Dict, List, Tuple, cast, Union - -from light9.ascoltami.musictime_client import MusicTime -from light9.effect import effecteval -from light9.effect.settings import DeviceSettings -from light9.effect.simple_outputs import SimpleOutputs -from light9.namespaces import L9, RDF -from light9.newtypes import DeviceUri, DeviceAttr, NoteUri, Curve, Song -from rdfdb.syncedgraph import SyncedGraph -from light9.metrics import metrics - -import imp - -log = logging.getLogger('sequencer') - - -def pyType(n): - ret = n.toPython() - if isinstance(ret, Decimal): - return float(ret) - return ret - - -class Note(object): - - def __init__(self, graph: SyncedGraph, uri: NoteUri, effectevalModule, - simpleOutputs): - g = self.graph = graph - self.uri = uri - self.effectEval = effectevalModule.EffectEval( - graph, g.value(uri, L9['effectClass']), simpleOutputs) - self.baseEffectSettings: Dict[URIRef, Any] = {} # {effectAttr: value} - for s in g.objects(uri, L9['setting']): - settingValues = dict(g.predicate_objects(s)) - ea = settingValues[L9['effectAttr']] - self.baseEffectSettings[ea] = pyType(settingValues[L9['value']]) - - def floatVal(s, p): - return float(g.value(s, p).toPython()) - - originTime = floatVal(uri, L9['originTime']) - self.points: List[Tuple[float, float]] = [] - for curve in g.objects(uri, L9['curve']): - self.points.extend( - self.getCurvePoints(curve, L9['strength'], originTime)) - self.points.sort() - - def getCurvePoints(self, curve: Curve, attr, - originTime: float) -> List[Tuple[float, float]]: - points = [] - po = list(self.graph.predicate_objects(curve)) - if dict(po).get(L9['attr'], None) != attr: - return [] - for point in [row[1] for row in po if row[0] == L9['point']]: - po2 = dict(self.graph.predicate_objects(point)) - points.append( - (originTime + float(po2[L9['time']]), float(po2[L9['value']]))) - return points - - def activeAt(self, t: float) -> bool: - return self.points[0][0] <= t <= self.points[-1][0] - - def evalCurve(self, t: float) -> float: - i = bisect.bisect_left(self.points, (t, None)) - 1 - - if i == -1: - return self.points[0][1] - if self.points[i][0] > t: - return self.points[i][1] - if i >= len(self.points) - 1: - return self.points[i][1] - - p1, p2 = self.points[i], self.points[i + 1] - frac = (t - p1[0]) / (p2[0] - p1[0]) - y = p1[1] + (p2[1] - p1[1]) * frac - return y - - def outputSettings( - self, - t: float) -> Tuple[List[Tuple[DeviceUri, DeviceAttr, float]], Dict]: - """ - list of (device, attr, value), and a report for web - """ - report = { - 'note': str(self.uri), - 'effectClass': self.effectEval.effect, - } - effectSettings: Dict[DeviceAttr, Union[float, str]] = dict( - (DeviceAttr(da), v) for da, v in self.baseEffectSettings.items()) - effectSettings[L9['strength']] = self.evalCurve(t) - - def prettyFormat(x: Union[float, str]): - if isinstance(x, float): - return round(x, 4) - return x - - report['effectSettings'] = dict( - (str(k), prettyFormat(v)) - for k, v in sorted(effectSettings.items())) - report['nonZero'] = cast(float, effectSettings[L9['strength']]) > 0 - out, evalReport = self.effectEval.outputFromEffect( - list(effectSettings.items()), - songTime=t, - # note: not using origin here since it's going away - noteTime=t - self.points[0][0]) - report['devicesAffected'] = len(out.devices()) - return out, report - - -class CodeWatcher(object): - - def __init__(self, onChange): - self.onChange = onChange - - self.notifier = INotify() - self.notifier.startReading() - self.notifier.watch(FilePath(effecteval.__file__.replace('.pyc', - '.py')), - callbacks=[self.codeChange]) - - def codeChange(self, watch, path, mask): - - def go(): - log.info("reload effecteval") - imp.reload(effecteval) - self.onChange() - - # in case we got an event at the start of the write - reactor.callLater(.1, go) - - -class Sequencer(object): - - def __init__(self, - graph: SyncedGraph, - sendToCollector: Callable[[DeviceSettings], Deferred], - fps=40): - self.graph = graph - self.fps = fps - metrics('update_loop_goal_fps').set(self.fps) - metrics('update_loop_goal_latency').set(1 / self.fps) - self.sendToCollector = sendToCollector - self.music = MusicTime(period=.2, pollCurvecalc=False) - - self.recentUpdateTimes: List[float] = [] - self.lastStatLog = 0.0 - self._compileGraphCall = None - self.notes: Dict[Song, List[Note]] = {} # song: [notes] - self.simpleOutputs = SimpleOutputs(self.graph) - self.graph.addHandler(self.compileGraph) - self.lastLoopSucceeded = False - - self.codeWatcher = CodeWatcher(onChange=self.onCodeChange) - self.updateLoop() - - def onCodeChange(self): - log.debug('seq.onCodeChange') - self.graph.addHandler(self.compileGraph) - #self.updateLoop() - - @metrics('compile_graph').time() - def compileGraph(self) -> None: - """rebuild our data from the graph""" - for song in self.graph.subjects(RDF.type, L9['Song']): - - def compileSong(song: Song = cast(Song, song)) -> None: - self.compileSong(song) - - self.graph.addHandler(compileSong) - - @metrics('compile_song').time() - def compileSong(self, song: Song) -> None: - anyErrors = False - self.notes[song] = [] - for note in self.graph.objects(song, L9['note']): - try: - n = Note(self.graph, NoteUri(note), effecteval, - self.simpleOutputs) - except Exception: - log.warn(f"failed to build Note {note} - skipping") - anyErrors = True - continue - self.notes[song].append(n) - if not anyErrors: - log.info('built all notes') - - @inlineCallbacks - def updateLoop(self) -> None: - frameStart = time.time() - try: - sec = yield self.update() - except Exception as e: - self.lastLoopSucceeded = False - traceback.print_exc() - log.warn('updateLoop: %r', e) - reactor.callLater(1, self.updateLoop) - else: - took = time.time() - frameStart - metrics('update_loop_latency').observe(took) - - if not self.lastLoopSucceeded: - log.info('Sequencer.update is working') - self.lastLoopSucceeded = True - - delay = max(0, 1 / self.fps - took) - reactor.callLater(delay, self.updateLoop) - - @metrics('update_call').time() - @inlineCallbacks - def update(self) -> Deferred: - - with metrics('update_s0_getMusic').time(): - musicState = self.music.getLatest() - if not musicState.get('song') or not isinstance( - musicState.get('t'), float): - return defer.succeed(0.0) - song = Song(URIRef(musicState['song'])) - dispatcher.send('state', - update={ - 'song': str(song), - 't': musicState['t'] - }) - - with metrics('update_s1_eval').time(): - settings = [] - songNotes = sorted(self.notes.get(song, []), key=lambda n: n.uri) - noteReports = [] - for note in songNotes: - try: - s, report = note.outputSettings(musicState['t']) - except Exception: - traceback.print_exc() - raise - noteReports.append(report) - settings.append(s) - devSettings = DeviceSettings.fromList(self.graph, settings) - - dispatcher.send('state', update={'songNotes': noteReports}) - - with metrics('update_s3_send').time(): # our measurement - sendSecs = yield self.sendToCollector(devSettings) - - # sendToCollector's own measurement. - # (sometimes it's None, not sure why, and neither is mypy) - #if isinstance(sendSecs, float): - # metrics('update_s3_send_client').observe(sendSecs) - - -class Updates(cyclone.sse.SSEHandler): - - def __init__(self, application, request, **kwargs) -> None: - cyclone.sse.SSEHandler.__init__(self, application, request, **kwargs) - self.state: Dict = {} - dispatcher.connect(self.updateState, 'state') - self.numConnected = 0 - - def updateState(self, update: Dict): - self.state.update(update) - - def bind(self) -> None: - self.numConnected += 1 - - if self.numConnected == 1: - self.loop() - - def loop(self) -> None: - if self.numConnected == 0: - return - self.sendEvent(self.state) - reactor.callLater(.1, self.loop) - - def unbind(self) -> None: - self.numConnected -= 1 diff -r 8516a39eedc9 -r ddbd0fca89ff light9/effect/sequencer/__init__.py diff -r 8516a39eedc9 -r ddbd0fca89ff light9/effect/sequencer/sequencer.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/sequencer.py Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,288 @@ +''' +copies from effectloop.py, which this should replace +''' + +from louie import dispatcher +from rdflib import URIRef +from twisted.internet import reactor +from twisted.internet import defer +from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.internet.inotify import INotify +from twisted.python.filepath import FilePath +import cyclone.sse +import logging, bisect, time +import traceback +from decimal import Decimal +from typing import Any, Callable, Dict, List, Tuple, cast, Union + +from light9.ascoltami.musictime_client import MusicTime +from light9.effect import effecteval +from light9.effect.settings import DeviceSettings +from light9.effect.simple_outputs import SimpleOutputs +from light9.namespaces import L9, RDF +from light9.newtypes import DeviceUri, DeviceAttr, NoteUri, Curve, Song +from rdfdb.syncedgraph import SyncedGraph +from light9.metrics import metrics + +import imp + +log = logging.getLogger('sequencer') + + +def pyType(n): + ret = n.toPython() + if isinstance(ret, Decimal): + return float(ret) + return ret + + +class Note(object): + + def __init__(self, graph: SyncedGraph, uri: NoteUri, effectevalModule, + simpleOutputs): + g = self.graph = graph + self.uri = uri + self.effectEval = effectevalModule.EffectEval( + graph, g.value(uri, L9['effectClass']), simpleOutputs) + self.baseEffectSettings: Dict[URIRef, Any] = {} # {effectAttr: value} + for s in g.objects(uri, L9['setting']): + settingValues = dict(g.predicate_objects(s)) + ea = settingValues[L9['effectAttr']] + self.baseEffectSettings[ea] = pyType(settingValues[L9['value']]) + + def floatVal(s, p): + return float(g.value(s, p).toPython()) + + originTime = floatVal(uri, L9['originTime']) + self.points: List[Tuple[float, float]] = [] + for curve in g.objects(uri, L9['curve']): + self.points.extend( + self.getCurvePoints(curve, L9['strength'], originTime)) + self.points.sort() + + def getCurvePoints(self, curve: Curve, attr, + originTime: float) -> List[Tuple[float, float]]: + points = [] + po = list(self.graph.predicate_objects(curve)) + if dict(po).get(L9['attr'], None) != attr: + return [] + for point in [row[1] for row in po if row[0] == L9['point']]: + po2 = dict(self.graph.predicate_objects(point)) + points.append( + (originTime + float(po2[L9['time']]), float(po2[L9['value']]))) + return points + + def activeAt(self, t: float) -> bool: + return self.points[0][0] <= t <= self.points[-1][0] + + def evalCurve(self, t: float) -> float: + i = bisect.bisect_left(self.points, (t, None)) - 1 + + if i == -1: + return self.points[0][1] + if self.points[i][0] > t: + return self.points[i][1] + if i >= len(self.points) - 1: + return self.points[i][1] + + p1, p2 = self.points[i], self.points[i + 1] + frac = (t - p1[0]) / (p2[0] - p1[0]) + y = p1[1] + (p2[1] - p1[1]) * frac + return y + + def outputSettings( + self, + t: float) -> Tuple[List[Tuple[DeviceUri, DeviceAttr, float]], Dict]: + """ + list of (device, attr, value), and a report for web + """ + report = { + 'note': str(self.uri), + 'effectClass': self.effectEval.effect, + } + effectSettings: Dict[DeviceAttr, Union[float, str]] = dict( + (DeviceAttr(da), v) for da, v in self.baseEffectSettings.items()) + effectSettings[L9['strength']] = self.evalCurve(t) + + def prettyFormat(x: Union[float, str]): + if isinstance(x, float): + return round(x, 4) + return x + + report['effectSettings'] = dict( + (str(k), prettyFormat(v)) + for k, v in sorted(effectSettings.items())) + report['nonZero'] = cast(float, effectSettings[L9['strength']]) > 0 + out, evalReport = self.effectEval.outputFromEffect( + list(effectSettings.items()), + songTime=t, + # note: not using origin here since it's going away + noteTime=t - self.points[0][0]) + report['devicesAffected'] = len(out.devices()) + return out, report + + +class CodeWatcher(object): + + def __init__(self, onChange): + self.onChange = onChange + + self.notifier = INotify() + self.notifier.startReading() + self.notifier.watch(FilePath(effecteval.__file__.replace('.pyc', + '.py')), + callbacks=[self.codeChange]) + + def codeChange(self, watch, path, mask): + + def go(): + log.info("reload effecteval") + imp.reload(effecteval) + self.onChange() + + # in case we got an event at the start of the write + reactor.callLater(.1, go) + + +class Sequencer(object): + + def __init__(self, + graph: SyncedGraph, + sendToCollector: Callable[[DeviceSettings], Deferred], + fps=40): + self.graph = graph + self.fps = fps + metrics('update_loop_goal_fps').set(self.fps) + metrics('update_loop_goal_latency').set(1 / self.fps) + self.sendToCollector = sendToCollector + self.music = MusicTime(period=.2, pollCurvecalc=False) + + self.recentUpdateTimes: List[float] = [] + self.lastStatLog = 0.0 + self._compileGraphCall = None + self.notes: Dict[Song, List[Note]] = {} # song: [notes] + self.simpleOutputs = SimpleOutputs(self.graph) + self.graph.addHandler(self.compileGraph) + self.lastLoopSucceeded = False + + self.codeWatcher = CodeWatcher(onChange=self.onCodeChange) + self.updateLoop() + + def onCodeChange(self): + log.debug('seq.onCodeChange') + self.graph.addHandler(self.compileGraph) + #self.updateLoop() + + @metrics('compile_graph').time() + def compileGraph(self) -> None: + """rebuild our data from the graph""" + for song in self.graph.subjects(RDF.type, L9['Song']): + + def compileSong(song: Song = cast(Song, song)) -> None: + self.compileSong(song) + + self.graph.addHandler(compileSong) + + @metrics('compile_song').time() + def compileSong(self, song: Song) -> None: + anyErrors = False + self.notes[song] = [] + for note in self.graph.objects(song, L9['note']): + try: + n = Note(self.graph, NoteUri(note), effecteval, + self.simpleOutputs) + except Exception: + log.warn(f"failed to build Note {note} - skipping") + anyErrors = True + continue + self.notes[song].append(n) + if not anyErrors: + log.info('built all notes') + + @inlineCallbacks + def updateLoop(self) -> None: + frameStart = time.time() + try: + sec = yield self.update() + except Exception as e: + self.lastLoopSucceeded = False + traceback.print_exc() + log.warn('updateLoop: %r', e) + reactor.callLater(1, self.updateLoop) + else: + took = time.time() - frameStart + metrics('update_loop_latency').observe(took) + + if not self.lastLoopSucceeded: + log.info('Sequencer.update is working') + self.lastLoopSucceeded = True + + delay = max(0, 1 / self.fps - took) + reactor.callLater(delay, self.updateLoop) + + @metrics('update_call').time() + @inlineCallbacks + def update(self) -> Deferred: + + with metrics('update_s0_getMusic').time(): + musicState = self.music.getLatest() + if not musicState.get('song') or not isinstance( + musicState.get('t'), float): + return defer.succeed(0.0) + song = Song(URIRef(musicState['song'])) + dispatcher.send('state', + update={ + 'song': str(song), + 't': musicState['t'] + }) + + with metrics('update_s1_eval').time(): + settings = [] + songNotes = sorted(self.notes.get(song, []), key=lambda n: n.uri) + noteReports = [] + for note in songNotes: + try: + s, report = note.outputSettings(musicState['t']) + except Exception: + traceback.print_exc() + raise + noteReports.append(report) + settings.append(s) + devSettings = DeviceSettings.fromList(self.graph, settings) + + dispatcher.send('state', update={'songNotes': noteReports}) + + with metrics('update_s3_send').time(): # our measurement + sendSecs = yield self.sendToCollector(devSettings) + + # sendToCollector's own measurement. + # (sometimes it's None, not sure why, and neither is mypy) + #if isinstance(sendSecs, float): + # metrics('update_s3_send_client').observe(sendSecs) + + +class Updates(cyclone.sse.SSEHandler): + + def __init__(self, application, request, **kwargs) -> None: + cyclone.sse.SSEHandler.__init__(self, application, request, **kwargs) + self.state: Dict = {} + dispatcher.connect(self.updateState, 'state') + self.numConnected = 0 + + def updateState(self, update: Dict): + self.state.update(update) + + def bind(self) -> None: + self.numConnected += 1 + + if self.numConnected == 1: + self.loop() + + def loop(self) -> None: + if self.numConnected == 0: + return + self.sendEvent(self.state) + reactor.callLater(.1, self.loop) + + def unbind(self) -> None: + self.numConnected -= 1 diff -r 8516a39eedc9 -r ddbd0fca89ff light9/effect/sequencer/service.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/service.py Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,83 @@ +""" +plays back effect notes from the timeline +""" + +from run_local import log +from twisted.internet import reactor +from light9.metrics import metrics, metricsRoute +from rdfdb.syncedgraph import SyncedGraph +from light9 import networking, showconfig +import optparse, sys, logging +import cyclone.web +from rdflib import URIRef +from light9.effect.sequencer.sequencer import Sequencer, Updates +from light9.collector.collector_client import sendToCollector + +from light9 import clientsession + + +class App(object): + + def __init__(self, show, session): + self.show = show + self.session = session + + self.graph = SyncedGraph(networking.rdfdb.url, "effectSequencer") + self.graph.initiallySynced.addCallback(self.launch) + + def launch(self, *args): + self.seq = Sequencer( + self.graph, + lambda settings: sendToCollector( + 'effectSequencer', + self.session, + settings, + # This seems to be safe here (and lets us get from + # 20fpx to 40fpx), even though it leads to big stalls + # if I use it on KC. + useZmq=True)) + + self.cycloneApp = cyclone.web.Application(handlers=[ + (r'/()', cyclone.web.StaticFileHandler, { + "path": "light9/effect/", + "default_filename": "sequencer.html" + }), + (r'/updates', Updates), + metricsRoute(), + ], + debug=True, + seq=self.seq, + graph=self.graph, + # stats=self.stats + ) + reactor.listenTCP(networking.effectSequencer.port, self.cycloneApp) + log.info("listening on %s" % networking.effectSequencer.port) + + +if __name__ == "__main__": + parser = optparse.OptionParser() + parser.add_option( + '--show', + help='show URI, like http://light9.bigasterisk.com/show/dance2008', + default=showconfig.showUri()) + parser.add_option("-v", + "--verbose", + action="store_true", + help="logging.DEBUG") + parser.add_option("--twistedlog", + action="store_true", + help="twisted logging") + clientsession.add_option(parser) + (options, args) = parser.parse_args() + log.setLevel(logging.DEBUG if options.verbose else logging.INFO) + + if not options.show: + raise ValueError("missing --show http://...") + + session = clientsession.getUri('effectSequencer', options) + + app = App(URIRef(options.show), session) + if options.twistedlog: + from twisted.python import log as twlog + twlog.startLogging(sys.stderr) + reactor.run() diff -r 8516a39eedc9 -r ddbd0fca89ff light9/effect/sequencer/web/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/web/index.html Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,149 @@ + + + + effect sequencer + + + + + + + + + + + + + + + + + + + + + + + + + diff -r 8516a39eedc9 -r ddbd0fca89ff light9/effect/sequencer/web/vite.config.ts --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/light9/effect/sequencer/web/vite.config.ts Mon May 30 23:46:07 2022 -0700 @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; + +const servicePort = 8213; +export default defineConfig({ + base: "/effectSeequencer/", + root: "./light9/effect/sequencer/web", + publicDir: "../web", + server: { + host: "0.0.0.0", + strictPort: true, + port: servicePort + 100, + hmr: { + port: servicePort + 200, + }, + }, + clearScreen: false, + define: { + global: {}, + }, +});