workshop rework
This commit is contained in:
1 parent
a3a80dfa16
commit
fb39e290c0
115 files changed
+1865
-497
No files matched your search
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user