workshop rework

This commit is contained in:
Vysyr committed 2026-10-31 17:03:41 +01:00
1 parent a3a80dfa16
commit fb39e290c0
115 files changed
+1865 -497

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
"""Executed by Blender, never by Unreal. Export an evaluated, joined static mesh."""
import argparse
from pathlib import Path
import sys
import bpy
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output", required=True)
parser.add_argument("--collection", default="Character")
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:])
collection = bpy.data.collections.get(args.collection)
if collection is None:
raise RuntimeError(f"Brak kolekcji {args.collection!r} w zapisanym pliku blend.")
meshes = [obj for obj in collection.all_objects if obj.type == "MESH"]
if not meshes:
raise RuntimeError("Kolekcja Character nie zawiera siatek.")
bpy.ops.object.select_all(action="DESELECT")
depsgraph = bpy.context.evaluated_depsgraph_get()
copies = []
for obj in meshes:
evaluated = obj.evaluated_get(depsgraph)
data = bpy.data.meshes.new_from_object(evaluated, depsgraph=depsgraph)
duplicate = bpy.data.objects.new(obj.name + "_export", data)
bpy.context.scene.collection.objects.link(duplicate)
duplicate.matrix_world = obj.matrix_world.copy()
duplicate.select_set(True)
copies.append(duplicate)
bpy.context.view_layer.objects.active = copies[0]
bpy.ops.object.join()
joined = bpy.context.object
joined.name = "Character"
# Origin at world zero: stable pivot, independent of which body part is first.
bpy.context.scene.cursor.location = (0, 0, 0)
bpy.ops.object.origin_set(type="ORIGIN_CURSOR")
output = Path(args.output).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(output), export_format="GLB", use_selection=True,
export_animations=False, export_materials="EXPORT", export_yup=True)
print("WORKSHOP_EXPORTED=" + str(output))
if __name__ == "__main__":
main()
+22
View File
@@ -0,0 +1,22 @@
"""OSC 8 file links, with plain paths for unsupported or redirected consoles."""
import os
from pathlib import Path
import sys
def file_link(path, mode="auto", stream=None):
target = Path(path).resolve()
stream = sys.stdout if stream is None else stream
supported = (
stream.isatty()
and os.environ.get("TERM") != "dumb"
and (
os.environ.get("WT_SESSION")
or os.environ.get("TERM_PROGRAM") in {"vscode", "iTerm.app", "WezTerm", "ghostty"}
or os.environ.get("VTE_VERSION")
or os.environ.get("KONSOLE_VERSION")
)
)
if mode == "always" or (mode == "auto" and supported):
return f"\033]8;;{target.as_uri()}\033\\{target}\033]8;;\033\\"
return str(target)
+58
View File
@@ -0,0 +1,58 @@
"""Blender --background --python tools/create_character.py (does not need UE)."""
from pathlib import Path
import bpy
root = Path(__file__).resolve().parents[1]
target = root / "source_assets/character/character.blend"
if target.exists():
raise RuntimeError(f"Plik juz istnieje: {target}. Generator nie nadpisuje Twojego modelu.")
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete(use_global=False)
collection = bpy.data.collections.new("Character")
bpy.context.scene.collection.children.link(collection)
def material(name, color):
mat = bpy.data.materials.new(name)
mat.use_nodes = True
bsdf = mat.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = (*color, 1)
bsdf.inputs["Roughness"].default_value = 0.5
mat.diffuse_color = (*color, 1)
return mat
blue = material("Body_Blue", (0.03, 0.3, 0.8))
orange = material("Head_Orange", (0.9, 0.22, 0.04))
dark = material("Eyes", (0.01, 0.01, 0.015))
def part(name, position, scale, mat, sphere=False):
if sphere:
bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=16, radius=0.5, location=position)
else:
bpy.ops.mesh.primitive_cube_add(size=1, location=position)
obj = bpy.context.object
obj.name = name
obj.scale = scale
for owner in list(obj.users_collection):
owner.objects.unlink(obj)
collection.objects.link(obj)
obj.data.materials.append(mat)
if sphere:
for polygon in obj.data.polygons:
polygon.use_smooth = True
return obj
part("Body", (0, 0, 0.9), (0.55, 0.8, 0.9), blue)
part("Head", (0, 0, 1.75), (0.7, 0.7, 0.7), orange, True)
part("LeftLeg", (0, -0.23, 0.25), (0.35, 0.3, 0.5), blue)
part("RightLeg", (0, 0.23, 0.25), (0.35, 0.3, 0.5), blue)
part("Eye_L", (0.31, -0.14, 1.82), (0.12, 0.12, 0.12), dark, True)
part("Eye_R", (0.31, 0.14, 1.82), (0.12, 0.12, 0.12), dark, True)
bpy.context.scene.unit_settings.system = "METRIC"
target.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.save_as_mainfile(filepath=str(target))
print("Gotowy model: " + str(target))
+63
View File
@@ -0,0 +1,63 @@
"""Concise console progress; does not depend on Unreal or its log format."""
import json
from pathlib import Path
import time
class ProgressDisplay:
LABELS = {
"starting": "Uruchamianie UE...",
"preparing": "Przygotowywanie sceny i sekwencji...",
"preparing_render": "Przygotowywanie renderu...",
"rendering": "Renderowanie...",
"encoding": "Render zakonczony. Skladanie MP4...",
"closing": "Zamykanie UE...",
}
def __init__(self, events, clock=time.monotonic, emit=print):
self.events = Path(events)
self.clock, self.emit = clock, emit
self.started = self.last_print = clock()
self.position = 0
self.pending = b""
self.state = "starting"
self.details = {}
self.last_count = -1
self.show(self.LABELS[self.state])
def show(self, message):
seconds = int(self.clock() - self.started)
self.emit(f"[{seconds // 60:02d}:{seconds % 60:02d}] {message}", flush=True)
self.last_print = self.clock()
def update(self):
try:
with self.events.open("rb") as stream:
stream.seek(self.position)
self.pending += stream.read()
self.position = stream.tell()
except FileNotFoundError:
pass
while b"\n" in self.pending:
line, self.pending = self.pending.split(b"\n", 1)
try:
event = json.loads(line)
except (ValueError, UnicodeError):
continue
state = event.get("state")
if state in self.LABELS:
self.state, self.details = state, event
self.last_count = -1
self.show(self.LABELS[state])
elapsed = self.clock() - self.last_print
if self.state == "rendering" and elapsed >= 2:
output = self.details.get("output")
total = self.details.get("frames")
if output and total:
count = len(list((Path(output) / "frames").glob("frame_*.png")))
if count != self.last_count and count > 0:
self.last_count = count
self.show(f"Renderowanie: {count}/{total} klatek zapisanych.")
return
if elapsed >= 10:
self.show(self.LABELS[self.state] + " Nadal pracuje.")
+127
View File
@@ -0,0 +1,127 @@
"""Plain Python launcher. Does not import unreal; works on Windows and Linux."""
import argparse
from contextlib import nullcontext
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time
import uuid
from progress_display import ProgressDisplay
from console_links import file_link
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "Content/Python"))
from ue_workshop.paths import executable
def editor_path(explicit=None):
value = explicit or os.environ.get("WORKSHOP_UNREAL") or shutil.which("UnrealEditor")
if not value and os.name == "nt":
value = r"C:\Program Files\Epic Games\UE_5.7\Engine\Binaries\Win64\UnrealEditor-Cmd.exe"
if value and Path(value).is_file():
return str(Path(value).resolve())
raise FileNotFoundError("Ustaw WORKSHOP_UNREAL na plik UnrealEditor-Cmd.exe / UnrealEditor (UE 5.7).")
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("script", nargs="?", default="exercises/my_shot.py")
parser.add_argument("--editor", help="Pelna sciezka do UE 5.7")
parser.add_argument("--check", action="store_true", help="Sprawdz srodowisko bez otwierania UE")
parser.add_argument("--blender", action="store_true", help="Przy --check wymagaj tez Blendera")
parser.add_argument("--timeout", type=int, default=900)
parser.add_argument("--render", action="store_true", help="Renderuj zmienna shot ze skryptu, nawet gdy RENDER=False")
parser.add_argument("--single-frame", type=int, help="Renderuj tylko wskazana klatke (bez MP4)")
parser.add_argument("--verbose", action="store_true", help="Pokazuj pelne logi UE w konsoli")
parser.add_argument("--links", choices=("auto", "always", "never"), default="auto",
help="Klikalne sciezki w terminalu (domyslnie: auto)")
args = parser.parse_args(argv)
def link(path, stream=None):
return file_link(path, args.links, stream)
editor = editor_path(args.editor)
if args.check:
print("UE:", editor)
print("FFmpeg:", executable("ffmpeg"))
if args.blender:
print("Blender:", executable("blender"))
print("Projekt:", ROOT / "ExampleProject.uproject")
return 0
script = (ROOT / args.script).resolve()
if not script.is_file():
raise FileNotFoundError(script)
status = ROOT / "Saved/Workshop" / (uuid.uuid4().hex + ".json")
log_path = status.with_suffix(".log")
progress_path = status.with_suffix(".progress.jsonl")
status.parent.mkdir(parents=True, exist_ok=True)
env = dict(os.environ, WORKSHOP_SCRIPT=str(script), WORKSHOP_STATUS=str(status), WORKSHOP_BATCH="1")
env["WORKSHOP_PROGRESS"] = str(progress_path)
env["WORKSHOP_RENDER"] = "1" if args.render else "0"
env["WORKSHOP_SINGLE_FRAME"] = "" if args.single_frame is None else str(args.single_frame)
command = [editor, str(ROOT / "ExampleProject.uproject"),
"-ExecutePythonScript=" + (ROOT / "tools/ue_bootstrap.py").as_posix(),
"-RenderOffscreen", "-unattended", "-nosplash", "-nosound", "-stdout",
"-FullStdOutLogOutput", "-NoLoadingScreen"]
if args.verbose:
# UE writes its own log when console output is streamed directly.
command.append("-abslog=" + log_path.as_posix())
print("Uruchamiam:", script, flush=True)
progress = ProgressDisplay(progress_path)
try:
# Keep native stderr (including third-party plugins) out of the terminal too.
with nullcontext(None) if args.verbose else log_path.open("wb") as output:
with subprocess.Popen(command, env=env, cwd=ROOT, stdout=output,
stderr=subprocess.STDOUT) as process:
deadline = time.monotonic() + args.timeout
try:
while True:
progress.update()
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(command, args.timeout)
try:
exit_code = process.wait(timeout=min(0.5, remaining))
except subprocess.TimeoutExpired:
continue
progress.update()
break
finally:
if process.poll() is None:
process.kill()
process.wait()
except subprocess.TimeoutExpired:
print(f"Przekroczono limit czasu UE. Log: {link(log_path, sys.stderr)}", file=sys.stderr)
return 1
if not status.is_file():
print(f"UE nie zapisalo wyniku (kod {exit_code}). Log: {link(log_path, sys.stderr)}", file=sys.stderr)
return 1
try:
result = json.loads(status.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
print(f"Nie mozna odczytac wyniku: {exc}. Log: {link(log_path, sys.stderr)}", file=sys.stderr)
return 1
result["editor_exit_code"] = exit_code
result["log"] = str(log_path)
status.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
success = exit_code == 0 and result.get("success")
if success:
print("Gotowe:", link(result.get("video") or result.get("output") or script))
else:
print("Blad:", result.get("error") or f"UE zakonczyl prace z kodem {exit_code}.", file=sys.stderr)
print("Log:", link(log_path))
if args.verbose:
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if success else 1
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError) as exc:
print(str(exc), file=sys.stderr)
raise SystemExit(1)
+69
View File
@@ -0,0 +1,69 @@
"""Integration check: export/import, changed geometry/material, stable asset paths, MRQ."""
import os
from pathlib import Path
import subprocess
import unreal
from ue_workshop import Scene, Sequence, blender, camera, lighting, render
from ue_workshop.paths import PROJECT, executable
source = PROJECT / "source_assets/character/character.blend"
if not source.is_file():
raise RuntimeError("Wygeneruj przykladowa postac: blender --background --python tools/create_character.py")
mesh = blender.import_character(source, name="SmokeCharacterV1")
asset_path = mesh.get_path_name()
old_bounds = mesh.get_bounds().box_extent.z
old_materials = {str(item.material_slot_name): item.material_interface.get_path_name()
for item in mesh.static_materials}
assert len(old_materials) == 3, old_materials
assert all(old_materials.values())
# Change a COPY of the source, never the student's .blend.
variant = PROJECT / "build/smoke/character_variant.blend"
variant.parent.mkdir(parents=True, exist_ok=True)
code = ("import bpy; bpy.data.objects['Head'].scale *= 2; "
"bpy.data.materials['Head_Orange'].node_tree.nodes.get('Principled BSDF')."
"inputs['Base Color'].default_value = (0.02, 0.8, 0.05, 1); "
"image = bpy.data.images.new('BodyPattern', width=2, height=2); "
"image.pixels = [0.1, 0.3, 0.8, 1] * 4; image.pack(); "
"tree = bpy.data.materials['Body_Blue'].node_tree; "
"node = tree.nodes.new('ShaderNodeTexImage'); node.image = image; "
"tree.links.new(node.outputs['Color'], tree.nodes.get('Principled BSDF').inputs['Base Color']); "
f"bpy.ops.wm.save_as_mainfile(filepath={str(variant)!r})")
completed = subprocess.run([executable("blender"), "--background", str(source),
"--python-exit-code", "1", "--python-expr", code],
capture_output=True, text=True, timeout=120)
if completed.returncode:
raise RuntimeError((completed.stdout + completed.stderr)[-4000:])
updated = blender.import_character(variant, name="SmokeCharacterV1")
assert updated.get_path_name() == asset_path, "Import created a different mesh asset"
assert updated.get_bounds().box_extent.z > old_bounds + 5, "Geometry was not updated"
new_materials = {str(item.material_slot_name): item.material_interface.get_path_name()
for item in updated.static_materials}
assert new_materials == old_materials, "Material identities changed"
assert all(unreal.load_asset(path) for path in new_materials.values())
green_found = False
texture_found = False
for path in new_materials.values():
material = unreal.load_asset(path)
for parameter in unreal.MaterialEditingLibrary.get_vector_parameter_names(material):
color = unreal.MaterialEditingLibrary.get_material_instance_vector_parameter_value(material, parameter)
if color.g > 0.7 and color.r < 0.1 and color.b < 0.1:
green_found = True
for parameter in unreal.MaterialEditingLibrary.get_texture_parameter_names(material):
texture = unreal.MaterialEditingLibrary.get_material_instance_texture_parameter_value(material, parameter)
if texture and texture.get_path_name().startswith("/Game/Workshop/Generated/Imports/SmokeCharacterV1/"):
texture_found = True
assert green_found, "Updated green material was not imported"
assert texture_found, "Embedded Blender texture was not imported and assigned"
scene = Scene("BlenderSmoke")
actor = scene.mesh(updated, name="UpdatedCharacter")
lighting.studio(scene)
cam = camera.create(scene)
shot = Sequence(scene, frames=2)
shot.camera(cam)
shot.transform_key(actor, 0, rotation=(0, 0, 0))
shot.transform_key(actor, 1, rotation=(0, 0, 30))
render.start(shot)
+62
View File
@@ -0,0 +1,62 @@
"""Exercise real Sequencer UI ticks, rebuild, render, then render again in one editor."""
import json
import os
import traceback
import unreal
from ue_workshop import render
from ue_workshop.paths import PROJECT
unreal.EditorPythonScripting.set_keep_python_script_alive(True)
# The test owns shutdown; allow multiple renders in the same editor process.
os.environ["WORKSHOP_BATCH"] = "0"
code = (PROJECT / "exercises/my_shot.py").read_text(encoding="utf-8")
code = code.replace("RENDER = True", "RENDER = False")
state = {"stage": 0, "ticks": 0, "outputs": []}
def build():
namespace = {"__name__": "__main__"}
exec(compile(code, "my_shot.py", "exec"), namespace)
return namespace
namespace = build()
def tick(delta):
global namespace
state["ticks"] += 1
try:
if state["stage"] in {0, 2} and state["ticks"] >= 30:
# The previous sequence editor has ticked before its actors and
# bindings are replaced, matching repeated Execute Python Script.
next_stage = state["stage"] + 1
# Asset saves can pump Slate recursively. Do not re-enter the test.
state["stage"] = -1
namespace = build()
output = render.start(namespace["shot"], mp4=True)
state["outputs"].append(output)
state["stage"] = next_stage
elif state["stage"] in {1, 3} and not render.is_rendering():
result = json.loads((state["outputs"][-1] / "result.json").read_text())
assert result["success"], result
if state["stage"] == 3:
finish({"success": True, "renders": [str(path) for path in state["outputs"]]})
else:
unreal.LevelSequenceEditorBlueprintLibrary.open_level_sequence(namespace["shot"].asset)
state["stage"] = 2
state["ticks"] = 0
except Exception:
finish({"success": False, "error": traceback.format_exc()})
def finish(result):
unreal.unregister_slate_post_tick_callback(handle)
render._write_status(result)
unreal.log("[Workshop UI test] " + str(result))
unreal.EditorPythonScripting.set_keep_python_script_alive(False)
handle = unreal.register_slate_post_tick_callback(tick)
+41
View File
@@ -0,0 +1,41 @@
"""Verify custom settings, preset overrides and PNG dimensions inside UE."""
import struct
import unreal
from ue_workshop import Renderer, render
from ue_workshop.paths import PROJECT
for kwargs in ({"width": 0}, {"height": -1}, {"samples": 0}, {"warmup": -1}, {"width": 1.5}, {"samples": True}):
try:
Renderer(**kwargs)
except ValueError:
pass
else:
raise AssertionError(f"Accepted invalid settings: {kwargs}")
preset = Renderer.from_preset("final", width=800)
assert (preset.width, preset.height, preset.samples, preset.warmup) == (800, 720, 4, 32)
code = (PROJECT / "exercises/my_shot.py").read_text(encoding="utf-8")
code = code.replace('Scene("MyShot")', 'Scene("RendererSmoke")')
code = code.replace('materials.color("Character"', 'materials.color("RendererSmokeCharacter"')
code = code.replace('materials.color("Floor"', 'materials.color("RendererSmokeFloor"')
namespace = {"__name__": "__main__"}
exec(compile(code.replace("RENDER = True", "RENDER = False"), "my_shot.py", "exec"), namespace)
renderer = Renderer(width=320, height=180, samples=2, warmup=0)
output = renderer.render(namespace["shot"], single_frame=24)
config = render._active["queue"].get_jobs()[0].get_configuration()
aa = config.find_setting_by_class(unreal.MoviePipelineAntiAliasingSetting)
assert aa.spatial_sample_count == 2
assert aa.engine_warm_up_count == 0
def verify(executor, success):
if not success:
return
frames = list((output / "frames").glob("*.png"))
assert len(frames) == 1
assert struct.unpack(">II", frames[0].read_bytes()[16:24]) == (320, 180)
unreal.log("[Workshop] Custom renderer: PNG dimensions 320x180 verified.")
render._active["executor"].on_executor_finished_delegate.add_callable_unique(verify)
+27
View File
@@ -0,0 +1,27 @@
"""Integration check run inside a full UE editor by run_workshop.py."""
import unreal
from ue_workshop import Sequence, render
from ue_workshop.paths import PROJECT
def build():
# Respect student edits on disk, but let this test own the render.
code = (PROJECT / "exercises/my_shot.py").read_text(encoding="utf-8")
namespace = {"__name__": "__main__"}
exec(compile(code.replace("RENDER = True", "RENDER = False"), "my_shot.py", "exec"), namespace)
return namespace
# Execute twice: no duplicate actors, bindings or transform tracks.
first = build()
actor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
count = len(actor_subsystem.get_all_level_actors())
second = build()
assert count == len(actor_subsystem.get_all_level_actors()), "Duplicate actors after rerun"
assert len(second["shot"].asset.get_bindings()) == 2
assert len(second["shot"].asset.find_tracks_by_type(unreal.MovieSceneCameraCutTrack)) == 1
short = Sequence(second["scene"], "Smoke", fps=24, frames=2)
short.camera(second["cam"])
short.transform_key(second["character"], 0, rotation=(0, 0, 0))
short.transform_key(second["character"], 1, rotation=(0, 0, 30))
render.start(short, mp4=True)
+38
View File
@@ -0,0 +1,38 @@
"""Entry point for -ExecutePythonScript; preserve the editor until MRQ finishes."""
import json
import os
from pathlib import Path
import runpy
import sys
import traceback
import unreal
root = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(root / "Content/Python"))
from ue_workshop.progress import report
try:
unreal.EditorPythonScripting.set_keep_python_script_alive(True)
report("preparing")
namespace = runpy.run_path(os.environ["WORKSHOP_SCRIPT"], run_name="__main__")
from ue_workshop import render
if not render.is_rendering() and (os.environ.get("WORKSHOP_RENDER") == "1" or
os.environ.get("WORKSHOP_SINGLE_FRAME")):
if "shot" not in namespace:
raise RuntimeError("Opcja --render wymaga zmiennej shot w skrypcie.")
frame = os.environ.get("WORKSHOP_SINGLE_FRAME")
renderer = namespace.get("renderer") or render.Renderer()
renderer.render(namespace["shot"], single_frame=int(frame) if frame else None)
if not render.is_rendering():
render._write_status({"success": True, "state": "script_finished"})
report("closing")
unreal.EditorPythonScripting.set_keep_python_script_alive(False)
except Exception:
error = traceback.format_exc()
unreal.log_error(error)
status = os.environ.get("WORKSHOP_STATUS")
if status:
Path(status).write_text(json.dumps({"success": False, "error": error}), encoding="utf-8")
report("closing")
unreal.EditorPythonScripting.set_keep_python_script_alive(False)