Skip to content

Commit

Permalink
Merge pull request #295 from FAReTek1/sbeditor
Browse files Browse the repository at this point in the history
sbeditor (Rival to project_json_capabilites) @ scratchattach.editor
  • Loading branch information
FAReTek1 authored Dec 24, 2024
2 parents 9432bb1 + 9fe3d68 commit bcae33b
Show file tree
Hide file tree
Showing 34 changed files with 7,019 additions and 129 deletions.
2 changes: 2 additions & 0 deletions scratchattach/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@
from .site.classroom import Classroom, get_classroom
from .site.user import User, get_user
from .site._base import BaseSiteComponent

from . import editor
21 changes: 21 additions & 0 deletions scratchattach/editor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""
scratchattach.editor (sbeditor v2) - a library for all things sb3
"""

from .asset import Asset, Costume, Sound
from .project import Project
from .extension import Extensions, Extension
from .mutation import Mutation, Argument, parse_proc_code
from .meta import Meta, set_meta_platform
from .sprite import Sprite
from .block import Block
from .prim import Prim, PrimTypes
from .backpack_json import load_script as load_script_from_backpack
from .twconfig import TWConfig, is_valid_twconfig
from .inputs import Input, ShadowStatuses
from .field import Field
from .vlb import Variable, List, Broadcast
from .comment import Comment
from .monitor import Monitor

from .build_defaulting import add_chain, add_comment, add_block
198 changes: 198 additions & 0 deletions scratchattach/editor/asset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
from __future__ import annotations

from dataclasses import dataclass, field
from hashlib import md5
import requests

from . import base, commons, sprite, build_defaulting


@dataclass(init=True, repr=True)
class AssetFile:
filename: str
_data: bytes = field(repr=False, default=None)
_md5: str = field(repr=False, default=None)

@property
def data(self):
if self._data is None:
# Download and cache
rq = requests.get(f"https://assets.scratch.mit.edu/internalapi/asset/{self.filename}/get/")
if rq.status_code != 200:
raise ValueError(f"Can't download asset {self.filename}\nIs not uploaded to scratch! Response: {rq.text}")

self._data = rq.content

return self._data

@property
def md5(self):
if self._md5 is None:
self._md5 = md5(self.data).hexdigest()

return self._md5


class Asset(base.SpriteSubComponent):
def __init__(self,
name: str = "costume1",
file_name: str = "b7853f557e4426412e64bb3da6531a99.svg",
_sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT):
"""
Represents a generic asset. Can be a sound or an image.
https://en.scratch-wiki.info/wiki/Scratch_File_Format#Assets
"""
try:
asset_id, data_format = file_name.split('.')
except ValueError:
raise ValueError(f"Invalid file name: {file_name}, # of '.' in {file_name} ({file_name.count('.')}) != 2; "
f"(too many/few values to unpack)")
self.name = name

self.id = asset_id
self.data_format = data_format

super().__init__(_sprite)

def __repr__(self):
return f"Asset<{self.name!r}>"

@property
def folder(self):
return commons.get_folder_name(self.name)

@property
def name_nfldr(self):
return commons.get_name_nofldr(self.name)

@property
def file_name(self):
return f"{self.id}.{self.data_format}"

@property
def md5ext(self):
return self.file_name

@property
def parent(self):
if self.project is None:
return self.sprite
else:
return self.project

@property
def asset_file(self) -> AssetFile:
for asset_file in self.parent.asset_data:
if asset_file.filename == self.file_name:
return asset_file

# No pre-existing asset file object; create one and add it to the project
asset_file = AssetFile(self.file_name)
self.project.asset_data.append(asset_file)
return asset_file

@staticmethod
def from_json(data: dict):
_name = data.get("name")
_file_name = data.get("md5ext")
if _file_name is None:
if "dataFormat" in data and "assetId" in data:
_id = data["assetId"]
_data_format = data["dataFormat"]
_file_name = f"{_id}.{_data_format}"

return Asset(_name, _file_name)

def to_json(self) -> dict:
return {
"name": self.name,

"assetId": self.id,
"md5ext": self.file_name,
"dataFormat": self.data_format,
}

"""
@staticmethod
def from_file(fp: str, name: str = None):
image_types = ("png", "jpg", "jpeg", "svg")
sound_types = ("wav", "mp3")
# Should save data as well so it can be uploaded to scratch if required (add to project asset data)
...
"""


class Costume(Asset):
def __init__(self,
name: str = "Cat",
file_name: str = "b7853f557e4426412e64bb3da6531a99.svg",

bitmap_resolution=None,
rotation_center_x: int | float = 48,
rotation_center_y: int | float = 50,
_sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT):
"""
A costume. An asset with additional properties
https://en.scratch-wiki.info/wiki/Scratch_File_Format#Costumes
"""
super().__init__(name, file_name, _sprite)

self.bitmap_resolution = bitmap_resolution
self.rotation_center_x = rotation_center_x
self.rotation_center_y = rotation_center_y

@staticmethod
def from_json(data):
_asset_load = Asset.from_json(data)

bitmap_resolution = data.get("bitmapResolution")

rotation_center_x = data["rotationCenterX"]
rotation_center_y = data["rotationCenterY"]
return Costume(_asset_load.name, _asset_load.file_name,

bitmap_resolution, rotation_center_x, rotation_center_y)

def to_json(self) -> dict:
_json = super().to_json()
_json.update({
"bitmapResolution": self.bitmap_resolution,
"rotationCenterX": self.rotation_center_x,
"rotationCenterY": self.rotation_center_y
})
return _json


class Sound(Asset):
def __init__(self,
name: str = "pop",
file_name: str = "83a9787d4cb6f3b7632b4ddfebf74367.wav",

rate: int = None,
sample_count: int = None,
_sprite: sprite.Sprite = build_defaulting.SPRITE_DEFAULT):
"""
A sound. An asset with additional properties
https://en.scratch-wiki.info/wiki/Scratch_File_Format#Sounds
"""
super().__init__(name, file_name, _sprite)

self.rate = rate
self.sample_count = sample_count

@staticmethod
def from_json(data):
_asset_load = Asset.from_json(data)

rate = data.get("rate")
sample_count = data.get("sampleCount")
return Sound(_asset_load.name, _asset_load.file_name, rate, sample_count)

def to_json(self) -> dict:
_json = super().to_json()
commons.noneless_update(_json, {
"rate": self.rate,
"sampleCount": self.sample_count
})
return _json
117 changes: 117 additions & 0 deletions scratchattach/editor/backpack_json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""
Module to deal with the backpack's weird JSON format, by overriding with new load methods
"""
from __future__ import annotations

from . import block, prim, field, inputs, mutation, sprite


def parse_prim_fields(_fields: dict[str]) -> tuple[str | None, str | None, str | None]:
"""
Function for reading the fields in a backpack **primitive**
"""
for key, value in _fields.items():
key: str
value: dict[str, str]
prim_value, prim_name, prim_id = (None,) * 3
if key == "NUM":
prim_value = value.get("value")
else:
prim_name = value.get("value")
prim_id = value.get("id")

# There really should only be 1 item, and this function can only return for that item
return prim_value, prim_name, prim_id
return (None,) * 3


class BpField(field.Field):
"""
A normal field but with a different load method
"""

@staticmethod
def from_json(data: dict[str, str]) -> field.Field:
# We can very simply convert it to the regular format
data = [data.get("value"), data.get("id")]
return field.Field.from_json(data)


class BpInput(inputs.Input):
"""
A normal input but with a different load method
"""

@staticmethod
def from_json(data: dict[str, str]) -> inputs.Input:
# The actual data is stored in a separate prim block
_id = data.get("shadow")
_obscurer_id = data.get("block")

if _obscurer_id == _id:
# If both the shadow and obscurer are the same, then there is no actual obscurer
_obscurer_id = None
# We cannot work out the shadow status yet since that is located in the primitive
return inputs.Input(None, _id=_id, _obscurer_id=_obscurer_id)


class BpBlock(block.Block):
"""
A normal block but with a different load method
"""

@staticmethod
def from_json(data: dict) -> prim.Prim | block.Block:
"""
Load a block in the **backpack** JSON format
:param data: A dictionary (not list)
:return: A new block/prim object
"""
_opcode = data["opcode"]

_x, _y = data.get("x"), data.get("y")
if prim.is_prim_opcode(_opcode):
# This is actually a prim
prim_value, prim_name, prim_id = parse_prim_fields(data["fields"])
return prim.Prim(prim.PrimTypes.find(_opcode, "opcode"),
prim_value, prim_name, prim_id)

_next_id = data.get("next")
_parent_id = data.get("parent")

_shadow = data.get("shadow", False)
_top_level = data.get("topLevel", _parent_id is None)

_inputs = {}
for _input_code, _input_data in data.get("inputs", {}).items():
_inputs[_input_code] = BpInput.from_json(_input_data)

_fields = {}
for _field_code, _field_data in data.get("fields", {}).items():
_fields[_field_code] = BpField.from_json(_field_data)

if "mutation" in data:
_mutation = mutation.Mutation.from_json(data["mutation"])
else:
_mutation = None

return block.Block(_opcode, _shadow, _top_level, _mutation, _fields, _inputs, _x, _y, _next_id=_next_id,
_parent_id=_parent_id)


def load_script(_script_data: list[dict]) -> sprite.Sprite:
"""
Loads a script into a sprite from the backpack JSON format
:param _script_data: Backpack script JSON data
:return: a blockchain object containing the script
"""
# Using a sprite since it simplifies things, e.g. local global loading
_blockchain = sprite.Sprite()

for _block_data in _script_data:
_block = BpBlock.from_json(_block_data)
_block.sprite = _blockchain
_blockchain.blocks[_block_data["id"]] = _block

_blockchain.link_subcomponents()
return _blockchain
Loading

0 comments on commit bcae33b

Please sign in to comment.