Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Class adapted from: https://github.com/LLK/scratch-svg-renderer/blob/develop/src/bitmap-adapter.js
*/
export default class {
private makeImage() { return new Image() }
private makeCanvas() { return document.createElement('canvas') }

/**
* Return a canvas with the resized version of the given image, done using nearest-neighbor interpolation
* @param {CanvasImageSource} image The image to resize
* @param {int} newWidth The desired post-resize width of the image
* @param {int} newHeight The desired post-resize height of the image
* @returns {HTMLCanvasElement} A canvas with the resized image drawn on it.
*/
resize(image, newWidth, newHeight) {
// We want to always resize using nearest-neighbor interpolation. However, canvas implementations are free to
// use linear interpolation (or other "smooth" interpolation methods) when downscaling:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1360415
// It seems we can get around this by resizing in two steps: first width, then height. This will always result
// in nearest-neighbor interpolation, even when downscaling.
const stretchWidthCanvas = this.makeCanvas();
stretchWidthCanvas.width = newWidth;
stretchWidthCanvas.height = image.height;
let context = stretchWidthCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
context.drawImage(image, 0, 0, stretchWidthCanvas.width, stretchWidthCanvas.height);
const stretchHeightCanvas = this.makeCanvas();
stretchHeightCanvas.width = newWidth;
stretchHeightCanvas.height = newHeight;
context = stretchHeightCanvas.getContext('2d');
context.imageSmoothingEnabled = false;
context.drawImage(stretchWidthCanvas, 0, 0, stretchHeightCanvas.width, stretchHeightCanvas.height);
return stretchHeightCanvas;
}

/**
* Scratch 2.0 had resolution 1 and 2 bitmaps. All bitmaps in Scratch 3.0 are equivalent
* to resolution 2 bitmaps. Therefore, converting a resolution 1 bitmap means doubling
* it in width and height.
* @param {!string} dataURI Base 64 encoded image data of the bitmap
* @param {!function} callback Node-style callback that returns updated dataURI if conversion succeeded
*/
convertResolution1Bitmap(dataURI, callback) {
const image = new Image();
image.src = dataURI;
image.onload = () => {
callback(null, this.resize(image, image.width * 2, image.height * 2).toDataURL());
};
image.onerror = () => {
callback('Image load failed');
};
}

/**
* Given width/height of an uploaded item, return width/height the image will be resized
* to in Scratch 3.0
* @param {!number} oldWidth original width
* @param {!number} oldHeight original height
* @return {object} Array of new width, new height
*/
getResizedWidthHeight(oldWidth, oldHeight) {
const STAGE_WIDTH = 480;
const STAGE_HEIGHT = 360;
const STAGE_RATIO = STAGE_WIDTH / STAGE_HEIGHT;

// If both dimensions are smaller than or equal to corresponding stage dimension,
// double both dimensions
if ((oldWidth <= STAGE_WIDTH) && (oldHeight <= STAGE_HEIGHT)) {
return { width: oldWidth * 2, height: oldHeight * 2 };
}

// If neither dimension is larger than 2x corresponding stage dimension,
// this is an in-between image, return it as is
if ((oldWidth <= STAGE_WIDTH * 2) && (oldHeight <= STAGE_HEIGHT * 2)) {
return { width: oldWidth, height: oldHeight };
}

const imageRatio = oldWidth / oldHeight;
// Otherwise, figure out how to resize
if (imageRatio >= STAGE_RATIO) {
// Wide Image
return { width: STAGE_WIDTH * 2, height: STAGE_WIDTH * 2 / imageRatio };
}
// In this case we have either:
// - A wide image, but not with as big a ratio between width and height,
// making it so that fitting the width to double stage size would leave
// the height too big to fit in double the stage height
// - A square image that's still larger than the double at least
// one of the stage dimensions, so pick the smaller of the two dimensions (to fit)
// - A tall image
// In any of these cases, resize the image to fit the height to double the stage height
return { width: STAGE_HEIGHT * 2 * imageRatio, height: STAGE_HEIGHT * 2 };
}

/**
* Given bitmap data, resize as necessary.
* @param {string} fileData Base 64 encoded image data of the bitmap
* @param {string} fileType The MIME type of this file
* @returns {Promise} Resolves to resized image data Uint8Array
*/
importBitmap(dataURI: string): Promise<Uint8Array> {
return new Promise((resolve, reject) => {
const image = this.makeImage();
image.src = dataURI;
image.onload = () => {
const newSize = this.getResizedWidthHeight(image.width, image.height);
if (newSize.width === image.width && newSize.height === image.height) {
// No change
resolve(this.convertDataURIToBinary(dataURI));
} else {
const resizedDataURI = this.resize(image, newSize.width, newSize.height).toDataURL();
resolve(this.convertDataURIToBinary(resizedDataURI));
}
};
image.onerror = () => {
reject('Image load failed');
};
});
}

// TODO consolidate with scratch-vm/src/util/base64-util.js
// From https://gist.github.com/borismus/1032746
convertDataURIToBinary(dataURI) {
const BASE64_MARKER = ';base64,';
const base64Index = dataURI.indexOf(BASE64_MARKER) + BASE64_MARKER.length;
const base64 = dataURI.substring(base64Index);
const raw = window.atob(base64);
const rawLength = raw.length;
const array = new Uint8Array(new ArrayBuffer(rawLength));

for (let i = 0; i < rawLength; i++) {
array[i] = raw.charCodeAt(i);
}
return array;
}
}
112 changes: 112 additions & 0 deletions extensions/src/common/extension/mixins/optional/addCostumes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type RenderedTarget from "$scratch-vm/sprites/rendered-target";
import Target from "$scratch-vm/engine/target";
import { MinimalExtensionConstructor } from "../../required";
import MockBitmapAdapter from "./MockBitmapAdapter";
import { getUrlHelper } from "./utils";

let bitmapAdapter: MockBitmapAdapter;
let urlHelper: ReturnType<typeof getUrlHelper>;

const rendererKey: keyof RenderedTarget = "renderer";
const isRenderedTarget = (target: Target | RenderedTarget): target is RenderedTarget => rendererKey in target;

/**
* Mixin the ability for extensions to add costumes to sprites
* @param Ctor
* @returns
* @see https://www.typescriptlang.org/docs/handbook/mixins.html
*/
export default function <T extends MinimalExtensionConstructor>(Ctor: T) {
abstract class ExtensionWithCustomSupport extends Ctor {

/**
* Add a costume to the current sprite based on some image data
* @param {RenderedTarget} target (e.g. `util.target`)
* @param {ImageData} image What image to use to create the costume
* @param {"add only" | "add and set"} action What action should be applied
* - **_add only_**: generates the costume and append it it to the sprite's costume library
* - **_add and set_**: Both generate the costume (adding it to the sprite's costume library) and set it as the sprite's current costume
* @param {string?} name optional name to attach to the costume
*/
async addCostume(target: Target, image: ImageData, action: "add only" | "add and set", name?: string) {
if (!isRenderedTarget(target)) return console.warn("Costume could not be added as the supplied target wasn't a rendered target");

name ??= `${this.id}_generated_${Date.now()}`;
bitmapAdapter ??= new MockBitmapAdapter();
urlHelper ??= getUrlHelper(image);

// storage is of type: https://github.com/LLK/scratch-storage/blob/develop/src/ScratchStorage.js
const { storage } = this.runtime;
const dataFormat = storage.DataFormat.PNG;
const assetType = storage.AssetType.ImageBitmap;
const dataBuffer = await bitmapAdapter.importBitmap(urlHelper.getDataURL(image));

const asset = storage.createAsset(assetType, dataFormat, dataBuffer, null, true);
const { assetId } = asset;
const costume = { name, dataFormat, asset, md5: `${assetId}.${dataFormat}`, assetId };

await this.runtime.addCostume(costume);

const { length } = target.getCostumes();

target.addCostume(costume, length);
if (action === "add and set") target.setCostume(length);
}

/**
* Add a costume to the current sprite based on a bitmpa input
* @param {RenderedTarget} target (e.g. `util.target`)
* @param {string} bitmapImage What image to use to create the costume
* @param {"add only" | "add and set"} action What action should be applied
* - **_add only_**: generates the costume and append it it to the sprite's costume library
* - **_add and set_**: Both generate the costume (adding it to the sprite's costume library) and set it as the sprite's current costume
* @param {string?} name optional name to attach to the costume
*/
async addCostumeBitmap(target: Target, bitmapImage: string, action: "add only" | "add and set", name?: string) {
if (!isRenderedTarget(target)) return console.warn("Costume could not be added as the supplied target wasn't a rendered target");

name ??= `${this.id}_generated_${Date.now()}`;
bitmapAdapter ??= new MockBitmapAdapter();
//urlHelper ??= getUrlHelper(image);

// storage is of type: https://github.com/LLK/scratch-storage/blob/develop/src/ScratchStorage.js
const { storage } = this.runtime;
const dataFormat = storage.DataFormat.PNG;
const assetType = storage.AssetType.ImageBitmap;
const dataBuffer = await bitmapAdapter.importBitmap(bitmapImage);

const asset = storage.createAsset(assetType, dataFormat, dataBuffer, null, true);
const { assetId } = asset;
const costume = { name, dataFormat, asset, md5: `${assetId}.${dataFormat}`, assetId };

await this.runtime.addCostume(costume);

const { length } = target.getCostumes();

target.addCostume(costume, length);
if (action === "add and set") target.setCostume(length);
}

/**
* Add a costume to the current sprite based on same image data
* @param {RenderedTarget} target (e.g. `util.target`)
* @param {string?} name costume name to look for
*/
setCostumeByName(target: Target, name: string): boolean {
if (!isRenderedTarget(target)) {
console.warn("Costume could not be set as the supplied target wasn't a rendered target");
return false;
}

let costumeIdx = target.getCostumeIndexByName(name);
if (costumeIdx >= 0) {
target.setCostume(costumeIdx);
return true;
}
return false;
}

}

return ExtensionWithCustomSupport;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export const getUrlHelper = (dimensions: { width: number, height: number }) => {
const canvas = document.body.appendChild(document.createElement("canvas"));

const setDimensions = ({ width, height }: Parameters<typeof getUrlHelper>[0]) => {
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
};

setDimensions(dimensions);

canvas.hidden = true;
const context = canvas.getContext("2d");

return {
/**
*
* @param image
* @returns
*/
getDataURL(image: ImageData) {
const { width, height } = image;
setDimensions(image);
context.save();
context.clearRect(0, 0, width, height);
context.putImageData(image, 0, 0);
const url = canvas.toDataURL('image/png');
context.restore();
return url;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { block } from "$common/extension/decorators/blocks";
import { withDependencies } from "../../dependencies";
import { MinimalExtensionConstructor } from "../../required";
import video from "../video";

/**
* Mixin a 'setVideoTransparency' Block to control the transparency of the videofeed
* @param Ctor
* @returns
* @see https://www.typescriptlang.org/docs/handbook/mixins.html
*/
export default function <T extends MinimalExtensionConstructor>(Ctor: T) {
abstract class ExtensionWithSetVideoTransparencyBlock extends withDependencies(Ctor, video) {
/**
* A `command` block that takes a single number argument and uses it to set the transparency of the video feed.
* @param transparency What transparency to set -- a higher number is more transparent (thus '100' is fully invisible)
*/
@block({
type: "command",
text: (transparency) => `Set video to ${transparency}% transparent`,
arg: "number"
})
setVideoTransparencyBlock(transparency: number) {
this.setVideoTransparency(transparency);
}
}

return ExtensionWithSetVideoTransparencyBlock;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { block } from "$common/extension/decorators/blocks";
import { withDependencies } from "../../dependencies";
import { MinimalExtensionConstructor } from "../../required";
import video from "../video";

/**
* Mixin a 'toggleVideo' Block to control whether the video feed is on, off, or flipped
* @param Ctor
* @returns
* @see https://www.typescriptlang.org/docs/handbook/mixins.html
*/
export default function <T extends MinimalExtensionConstructor>(Ctor: T) {
abstract class ExtensionWithToggleVideoBlock extends withDependencies(Ctor, video) {
/**
* A `command` block that sets the current video state
* @param state What state to set ("on", "off", or "on (flipped)")
* @returns
*/
@block({
type: "command",
text: (state) => `Set video feed to ${state}`,
arg: { type: "string", options: ["on", "off", "on (flipped)"] }
})
toggleVideoBlock(state: "off" | "on" | "on (flipped)") {
if (state === "off") return this.disableVideo();
this.enableVideo(state === "on");
}
}

return ExtensionWithToggleVideoBlock;
}
Loading
Loading